源宝导读:数据库死锁是高并发复杂系统都要面临课题,处理死锁问题没有一招制敌的标准方法,需要具体问题具体分析。本文将基于研发协同平台遇到的死锁案例,介绍从监控、分析到处理的完整过程和经验总结。
一、背景
研发协同平台使用的技术栈大体是.NET Core + EFCore + SQLServer, 周边还有一些第三方组件, 如Redis、Jenkins、Gitlab、Sonar。整体技术架构又分为前台服务(rdc_service)、调度服务(rdc_service)、更新服务(rdc_upgrade)。其中更新服务面向所有终端用户的服务器,用于客户产品更新;调度服务作为后台作业,定时对客户的服务器进行巡检,发现异常服务器时通知客户;客户和客户服务器的数量数以万计,接口请求量较大,更新服务和调度服务都是对客户及其服务器数据操作,两者之间难免会有数据上的交际,这样一来,死锁就有了可乘之机,本文重点介绍研发协同平台对死锁的监控和解决方案。
二、死锁监控
在早期SQL Server中,通过跟踪标志1204/1222,可以在数据库错误日志中捕获的死锁信息。跟踪标志1204会报告由死锁所涉及的每个节点设置格式的死锁信息。跟踪标志 1222 会设置死锁信息的格式,顺序为先按进程,然后按资源。可以同时启用这两个跟踪标志,以获取同一个死锁事件的两种表示形式。
下面的示例显示启用跟踪标志 1204 时的输出。在此示例中,节点 1 中的表为没有索引的堆,节点 2 中的表为具有非聚集索引的堆。节点 2 中索引键在发生死锁时正在进行更新。
在工作负载密集型系统上使用跟踪标志1204和1222可能会导致性能,为了解决这一问题,自 SQL Server 2012 (11.x) 起,SQL Server提供了xml_deadlock_report 扩展事件 (xEvent),来帮助研发人员更加便捷高效的监测及排查死锁。下图展示了在扩展事件中可以捕获的deadlock相关事件:
在扩展事件启用后,当数据库发生死锁时,SQL Server会自动将导致死锁的相关会话数据,以XML的形式保存下来,下图展示了研发协同平台近期监控到的死锁记录:
三、死锁分析
随机查看几个死锁日志,所有的记录均属于同一场景:进程1持有表CustomerServer行A的排他(X)锁,需要申请表Customer行B的共享(S)锁,而进程2刚好持有Customer行B的排他(X)锁,且正在向CustomerServer表行A申请更新(U)锁。众所周知,排他(X)锁和更新(U)锁完全互斥,故两个进程陷入了相互等待的僵局。下图1展示了死锁发生时两个进程的相关数据,图2则显示了双方在资源上面的争用情况。
除非某个外部进程断开死锁,否则死锁中的两个事务都将无限期等待下去。SQL Server 数据库引擎死锁监视器定期检查陷入死锁的任务。如果监视器检测到循环依赖关系,将选择其中一个任务作为牺牲品,然后终止其事务并提示错误。其他任务就可以完成其事务。
另外需要注意的是:死锁经常与正常阻塞混淆。事务请求的资源被其他事务锁定时,发出请求的事务一直等到该锁被释放。除非设置了 LOCK_TIMEOUT,否则 SQL Server 事务不会超时。拥有锁的事务完成并释放锁后,发出请求的事务将获取锁并继续执行,所以该事务是被阻塞,而不是陷入了死锁。
默认情况下,SQL Server 数据库引擎选择运行回滚开销最小的事务的会话作为死锁牺牲品。此外,用户也可以使用 SET DEADLOCK_PRIORITY 语句指定死锁情况下会话的优先级。
3.1、死锁业务场景
进一步分析死锁报告,可以直观的看到涉及死锁的两个进程来至3台服务器,这里我们假定是服务器A、B、C。3台服务器分别属于进程1调度服务(服务器A)、进程2更新服务(服务器B、C)。调度服务是一组后台作业任务的集合,其中有同时访问表Customer\CustomerServer的任务只有一个,即客户服务器状态同步作业:扫描系统中所有客户的服务器,只要客户服务器未按照约定上报信息,则认为其离线,将离线状态同步至客户表(Customer)和客户服务器表(CustomerServer),并给客户对应的负责人发送邮件。该作业每分钟执行一次,每次作业任务耗时50s,执行时间过长可能是导致频繁死锁的原因之一。
通过死锁报告中的服务器信息可以快速的定位到进程1的业务场景,那么进程2则需要其他工具来帮助我们定位。进程2更新服务是一组API集合,供用户更新客户环境的相关业务,其中涉及Customer\CustomerServer两张表的操作非常多,如果心跳接口、注册客户信息接口。到这一步,我们所知道的信息如下:
死锁报告的中的SQL脚本是由EFCore生成的,表面上看不出来进程2到底是哪一个接口。这里我们借助SkyWalking 的端点埋点数据,下图1是所有心跳接口的错误记录,时间点与死锁日志中的时间完全匹配,没有例外;图2是错误详情,其中的事务进程Id和死锁牺牲对象与死锁中报告完全匹配。
3.2、主外键对执行计划的影响
SQL Server 数据库引擎提供了访问查询执行计划的运行时信息。出现性能问题时,最重要的操作之一是准确了解正在执行的工作负载以及如何驱动使用资源。为此,访问实际执行计划将很重要。这里的更新服务心跳接口,只有对客户服务器表(CustomerServer)访问,并没有直接对Customer表进行访问,通过SkyWalking的端点数据,以及死锁报告中的SQL脚本,可以拿到心跳接口的估算执行计划。尽管业务场景只需要更新一个Status字段,但由于未开启EFCore的模型跟踪(ChangeTracker),EFCore生成的SQL脚本显示的更新了除主键以外所有字段。其中[CustomerId]和[PluginId]正好是外键字段,分别来自Customer和Plugin表,为了保证主外键的约束,一条简单的单表update操作,将涉及到3张表的资源访问。下图展示了心跳接口在SQL Server中的执行计划:
如果开启的模型跟踪,或者手动写编写SQL,按需更新字段,上面的死锁将不会存在,下图展示了单表单字段场景下的执行计划:
四、业务改进
我们通过简单的SQL改造,在不影响业务的情况下,将进程2的SQL资源访问由3张表降低至单表操作,这里不敢保证说已经消除了死锁,但至少将死锁降低到一个可以忽略不计的范围。为了保险起见,这里继续对进程1(调度服务Job)优化。
上述提到客户服务器状态同步作业每分钟执行一次,每次作业任务耗时50s,这里的耗时有点过长,Job在全天24小时内,有83%(50s/60s)的时间处于作业中,为了保证原子性,这里采用是的方法级事务,事务锁定的范围是整个作业周期,即时50s,并且不论客户的服务器状态是否发生改变,这里都会修改数据,下面是Job相关的代码:
在不改变业务场景的基础上,我们对作业Job的执行策略做了微调:
修改正式环境时间间隔为10分钟
更新方式调整为更新指定字段
每个客户单独开启事务处理
状态未发生变化时不修改数据
客户环境的服务器比较复杂,通常来说偶尔的抖动属于正常现象,心跳的有效期都是3-5分钟,每分钟去检测一次状态有点多余,这里将作业时间由每分钟调整10分钟,可以有效降低后台作业与前端API接口之间的资源冲突;更新方式采用EFCore的模型跟踪(ChangeTracker),按需更新,这里既然只检测状态变更,就只需要更改状态,减少修改的字段,可以避免由主外键引起的额外共享锁申请;第3条和第4条都是为了降低事务的范围,这里的事务原子性控制到单个客户明显比全局性价比高,没必要因为一个客户的失败,导致整个作业失败,当客户服务器状态没有发生变化时,对当前客户的处理应直接跳过,减少修改次数;通过上面4条优化策略,任务的作业时间由之前的50s降低为15s,且不会对业务产生影响,死锁也随时消除。
五、写在最后
尽管死锁不能完全避免,但遵守特定的编码习惯可以将发生死锁的机会降至最低。在这里分享几点研发协同平台团队的开发规范,有助于大家在日常开发中避免死锁和提高数据库性能:
缩小事务的范围,提前计算出需要更新的数据,避免在事务中做额外的业务逻辑计算;
按需更新行字段,EFCore类似的ORM框架,带来了开发上的便捷,同时也会带来性能上的隐患;
外键字段加索引,外键字段索引可以显著降低查询或更新时,降低对数据资源锁定的范围;
定期优化慢SQL ,慢SQL意味着需研发协同平台持续交付2.0架构演进要读取更多索引页或者数据页,加大资源锁定的范围;
作者简介
冯同学: 研发工程师,目前负责研发协同平台的设计与开发工作。
也许您还想看
研发协同平台持续集成Jenkins作业设计演进
研发协同平台持续交付之代理服务实践
研发协同平台持续集成2.0架构演进
研发协同平台持续交付2.0架构演进