共计 5680 个字符,预计需要花费 15 分钟才能阅读完成。
本篇文章给大家分享的是有关如何解决热点更新导致的雪崩效应,丸趣 TV 小编觉得挺实用的,因此分享给大家学习,希望大家阅读完这篇文章后可以有所收获,话不多说,跟着丸趣 TV 小编一起来看看吧。
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 如下:
connectupdate 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) 函数方法:
请求逻辑:
connectupdate 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 字段,请求逻辑:
connectinsert 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:
beginselect max_id from id_allocate where business_tag= test1 for update; # 得到 midupdate id_allocate set max_id=max_id+step where business_tag= test1 select max_id from id_allocate where business_tag= test1 #得到 id1commit
beginupdate id_allocate set max_id=max_id+step where business_tag= test1 select max_id from id_allocate where business_tag= test1 #得到 id2commit
随着第三方请求 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 更新获取最新数据 这种延时毛刺
以上就是如何解决热点更新导致的雪崩效应,丸趣 TV 小编相信有部分知识点可能是我们日常工作会见到或用到的。希望你能通过这篇文章学到更多知识。更多详情敬请关注丸趣 TV 行业资讯频道。