DBbrain诊断日 | 不这么办,数据库敢崩个三天三夜给你看
本期诊断日主要分享内容:如何解决热点更新导致的雪崩效应。
本期分享是一个真实的现网故障案例,而且在最近几个月内多个客户都出现了相似的故障,对于迪B哥来说更是印象深刻,在刚刚从事DBA工作的前几年,也处理过类似的问题,接下来的分享内容将会从真实案例的复盘为切入点,深入剖析故障原因,为大家提供切实有效的应急降级解决方案,最后回归业务实现层面提供几点启发建议。
PartⅠ 案例分析
这个故障的场景比较简单,当时业务出现了大量的请求失败,几乎处于不可用状态。同时对应的MySQL数据库也存在大量的CPU使用率高的告警。
1. 登上数据库,通过show processlist 查看到的现场截图如下:
2. MySQL 版本为5.7,数据库表结构如下:
CREATE TABLE `docid_generator` (
`id` int(4) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2
3. 业务请求Session如下:
connect
update docid_generator set id=last_insert_id(id+1);
select last_insert_id()
exit
通过初步排查,了解到:表中只有一个字段、一行记录,该段业务逻辑是通过mysql中last_insert_id(expr)函数特性实现id分配功能;按照processlist执行耗时倒序查看,耗时最长的sql也是该类update请求;innodb status可以看到大量的事务在等待该条记录的X锁;update的X锁使得请求只能串行进行,导致响应很慢,可是最先到来的一批update请求是什么原因卡住了呢?
通过pref分析,显示lock_deadlock_recursive函数占据了cpu recycle事件的近50%时间。该函数是通过深度优先算法进行递归调用,检测是否满足死锁条件,再进行最小代价的事务回滚。
查看information_schema中innodb_trx事务锁等待队列,发现已经有6100+条锁等待信息。
通过查阅文档发现,InnoDB监控器输出的最近死锁检信息中包含“TOO DEEP OR LONG SEARCH IN THE LOCK TABLE WAITS-FOR GRAPH, WE WILL ROLL BACK FOLLOWING TRANSACTION”,表示处于等待的事务列表长度已达到限制200。超过200个事务的等待列表被视为死锁,并且将回滚尝试检查等待列表的事务。如果锁定线程必须查看等待列表上的事务拥有的超过1,000,000个锁,则也可能发生相同的错误。
每个请求维护自己的锁队列,在这个案例中,业务的并发为200个,因为单条记录X锁,只能串行执行,按照先后顺序依次维护自己的锁队列,极限情况记录阻塞的锁队列长度为(1+199)*200/2!所以这一阶段耗时较长。
知道耗时长的原因就好办了。因为业务场景是单一的id分配,只有一条记录,逻辑上不会出现死锁情况,所以完全可以关闭死锁检测功能。很幸运,5.7版本innodb_deadlock_detect可以关闭死锁检测。关闭后,我们再次200并发测试,从原来的10s降低到0.2s,性能提升50倍。
分析到这里,相信大家对这个故障案例也一定有了比较深刻的了解。在之前到的介绍里为了不打断故障分析的连贯性,略过了一些数据库概念的介绍,下面挑选几个给大家详细介绍下。
“死锁”可以理解为两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
在数据库中我们可以形象的理解为:
如上图所示,事务A在等待事务B释放id=2的锁,事务B在等待事务A释放id=1的锁。
这种情况就是死锁,发生死锁有两种方法解决:
1)直接进入等待,直到超时。这个超时时间可以通过参数innodb_lock_wait_timeout来设置
2)发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以执行。将参数innodb_deadlock_detect设置为on,表示开启这个逻辑。
innodb_deadlock_detect=on,该选项使用了禁用MySQL的死锁检测功能的。在高并发系统上,当许多线程等待同一个锁时,死锁检测可能导致速度减慢。当发生死锁时,如果禁用了死锁检测则可能会更有效,这样可以依赖innodb_lock_wait_timeout的设置进行事务回滚。
MySQL默认情况下是开启了死锁检测的,InnoDB自动检测发送死锁的事务,并回滚其中的一个事务或所有导致死锁的事务。InnoDB会在导致死锁的事务中选择一个权重比较小的事务来回滚,这个权重值可能是由该事务insert, updated, deleted的行数决定的。
如果innodb_table_locks = 1(默认值)并且autocommit = 0,则InnoDB能感知到表锁的存在,并且上层的MySQL层知道行级锁。否则,InnoDB无法检测到由MySQL LOCK TABLES语句设置的表锁或由除InnoDB之外的存储引擎设置的锁定的死锁。通过设置innodb_lock_wait_timeout系统变量的值来解决这些情况。
Part Ⅱ 切实有效的应急降级解决方案
如果电商业务在大促和秒杀场景、在线教育业务在报名和签到、游戏业务开服等高并发场景中遇到了类似的热点更新故障,相信大家一定不会有太多时间理性的梳理和挖掘问题的根因,在较短做出最合理优化方案的难度也较大。而此时用户或者业务方对数据库的要求必然是不管用什么方法,先让业务跑起来(恢复)再说。
那么对于热点更新类的故障,DBA常用的应急预案:重启、切换、kill(不论是使用pt-kill还是自己的kill脚本,显然都很难解决,而且会加剧阻塞)、权限控制(极可能误伤一些正常的核心业务逻辑,导致业务依然失败),大概率无法完成业务恢复。即使有损降低如果不依靠业务侧介入都很难完成。
腾讯云数据库智能管家DBbrain,为了防止在热点更新时,用户数据库不被大压力打挂,提供了“SQL限流”和“热点数据防护”这两大功能,帮助用户可以在数据库端实现切实有效的降级和防护,保障用户核心业务能正常运行。
1. SQL限流
DBbrain提供了“SQL限流”功能,能够帮助用户在数据库侧实现优雅的临时降级。通过在SQL进入数据库内核之前拒绝的方式,能解决更多高并发故障中,通过kill无法快速恢复的场景,除了上文介绍的“热点更新引发死锁检测阻塞的场景”之外,还适用于:
- 某类SQL并发急剧上升,影响正常业务,比如缓存穿透或者异常调用,造成原来并发不大的SQL语句突然上升。
- 有数据倾斜SQL,影响正常业务,比如大促时拉取某个特别大的数据,造成整体系统繁忙。
- 未创建索引SQL,影响正常业务,比如新上线SQL调用量特别大,又没有创建索引,造成整体系统繁忙。
用户可以通过在DBbrain控制台中,设置目标SQL的特性。
- SQL类型:select、update、delete、insert、replace
- 最大并发数:同一时刻并发数超过设置的阈值的SQL将被拒绝
- 限流时间:支持设定规则持续时间,超时后不再生效
- SQL关键词:关键字的匹配是无序的,匹配时遍历关键字,看SQL中是否有这个关键字,有几个关键字就匹配几遍
DBbrain会根据SQL样本的关键字自动拒绝请求,保证业务核心服务的正常运行,并且统计在开启“SQL限流”时间段内被拒绝的SQL请求数量。
2. 热点更新保护
DBbrain针对于秒杀场景,大幅度优化对于单行数据的update操作的性能。当开启热点更新自动探测时,系统会自动探测是否有单行的热点更新(同一数据行上面等待的行锁数量超过32个后续的事务就会开始等待),如果有,则会让大量的并发update排队执行,以减少大量行锁或触发大量死锁检测造成的并发性能下降。
DBbrain提供的“热点更新保护”功能,支持自动结束和手动关闭两种模式,设置自动结束时间可实现灵活控制。
3. 热点更新优化建议
在上面的案例中,5.7.15以上的版本可以通过关闭死锁检测方式提升性能,也可以通过 腾讯云数据库智能管家DBbrain提供的“SQL限流”和“热点更新保护”来缓解大量热点更新对数据库带来的负载压力。那么接下来的章节将从业务实现的角度分享一些启发建议。
3.1)基于MySQL实现
表结构如下:
CREATE TABLE `id_allocate` (
`id` bigint NOT NULL AUTO_INCREMENT,
business_tag varchar(20) not null,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (business_tag)
) ENGINE=InnoDB AUTO_INCREMENT=2;
3.1.1)类似上文例子,通过mysql last_insert_id(expr)函数方法:
请求逻辑:
connect
update id_allocate set id=last_insert_id(id+1) where business_tag='test1';
select last_insert_id()
exit
注意点:5.7以上关闭死锁检测innodb_deadlock_detect;
3.1.2)通过mysql auto_increment字段,去掉business_tag字段,只保留id字段,请求逻辑:
connect
insert into id_allocate value(null);
select last_insert_id()
exit
注意点:数据量会持续增大,可以定期低峰删除或者创建为分区表,定期删除历史数据
纯依赖MySQL实现,第一种方法更简单易用。高可用上,常见的思路是存在2个MySQL实例中,设置自增的步长和起始值,比如两个数据库,设置auto-increment-increment=2,分别设置auto-increment-offset为1和2,业务请求这两个DB依次获取到1,3,5,7和2,4,6,8。该方法可避免单MySQL故障的影响,但同时系统的严格单调递增也变成了趋势递增(若单机故障,可能还有id变小的情况)。
3.2)基于Redis实现
利用redis的incr和incrby方式,能支撑的qps更高。同样若担心高可用问题,可以设置两个key分别存储在两个redis实例上,通过控制初始值和incrby的offset来保障。这里显著的弊端是 redis数据不能持久化,但目前腾讯云redis支持了主备同步、双机房容灾和备份功能,对于项目开发紧急,性能要求高的场景也可以尝试使用。
3.3)服务化实现
表结构:
CREATE TABLE `id_allocate` (
`id` bigint NOT NULL AUTO_INCREMENT,
business_tag varchar(20) not null,
max_id bigint not null,
step int not null,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (business_tag)
) ENGINE=InnoDB AUTO_INCREMENT=2;
business_tag标识业务;
max_id标识目前分配出去的最大id;
step标识每次idallocate-server访问数据库时候一次拉走的id区间大小。
实现思路:第三方通过调用idallocate-server服务获取id 。idallocate-server内存至少包含三个值:当前的mid,最大能发的id1,最大能发的id2;id2和id1相差一个step。初始时候,idallocate-server服务从数据库中更新两次,分别得到初始值mid、id1和id2:
begin
select max_id from id_allocate where business_tag='test1' for update; #得到mid
update id_allocate set max_id=max_id+step where business_tag='test1';
select max_id from id_allocate where business_tag='test1';#得到id1
commit
begin
update id_allocate set max_id=max_id+step where business_tag='test1';
select max_id from id_allocate where business_tag='test1';#得到id2
commit
随着第三方请求idallocate-server获取id,mid一直增大,当达到id1的90%时候,需检测id2是否已经存在,若不存在则访问数据库进行获取。若存在则mid达到id1大小后,分配id2部分,当mid达到id2的90%时候,需检测id1是否存在。依次循环保证idallocate-server内存中至少有一个step大小的buffer号段存在。
上述方案中:
1. 可用性:idallocate-server服务可以横向扩展,避免单点;MySQL层面可以通过主备集群半同步或者强一致性同步来保证,且短时间内MySQL故障也不会影响服务。
2. 性能:将更新MySQL的请求降低为纯MySQL id分配方式的 1/step(没step个id大小 更新一次db),降低数据库的压力;同时通过id2和id1双号段的设计,避免了当单独id1分配完全,需等待idallocate-server实时去db更新获取最新数据 这种延时毛刺。
文章转自公众号:腾讯云数据库