大家好,我是张丁予,一名MES系统运维人员。
最近一段时间,生产现场频繁反馈系统卡顿,一开始我以为是正常的负载高峰或者网络抖动,没太放在心上。但随着抱怨声越来越密集,甚至到了每天都有操作员来问“今天系统怎么又卡了”的地步,我知道事情没那么简单。
于是,我登录数据库服务器,准备看看是不是又有哪个倒霉的长查询把表锁了。不看不知道,一看吓一跳——死锁日志每小时都在产生。而且,数据库的自动死锁检测和回滚机制一直在默默“擦屁股”,以至于业务表面上看起来还能运行,但性能损耗已经非常严重。
这篇文章,我就来复盘一下这次针对最影响业务的模块进行的深度排查过程。
现象:诡异的“偶发”卡顿
我们的MES系统中,有一个核心流程:质检员在录入产品关键参数(比如厚度、面密度)时,偶尔会遇到保存按钮点了半天没反应,或者直接弹出一个“事务死锁,请重试”的错误。
由于操作员不止一个人,他们录入的是不同产品的参数,按理说互不干扰。为什么会出现死锁?
第一步:抓取“案发现场”
既然怀疑是死锁,那就得拿到第一手证据。SQL Server 的死锁日志(Deadlock Graph)是最好的突破口。通过系统函数或扩展事件,我成功捕获到了死锁的XML文件。
打开XML,我看到了一个典型的死锁环。为了让大家看懂,我把关键信息提炼一下:
冲突对象:一张用于存储产品质检结果的业务主表。
锁资源:数据页(PAGE),而不是我们期望的数据行(ROW)。
锁模式:更新锁(U锁)。
冲突SQL:两个几乎一模一样的 UPDATE 语句,只是更新的 out_code(唯一标识一个产品的编码)不同。
-- 客户端A执行的语句
UPDATE [业务主表] SET qc_result='1', qc_time='...' WHERE out_code='PRODUCT-A'
-- 客户端B执行的语句
UPDATE [业务主表] SET qc_result='1', qc_time='...' WHERE out_code='PRODUCT-B'
看到这里,我第一反应是:这不科学啊!A和B是不同的产品,out_code 不一样,理论上是更新不同的行,怎么会锁到一起?
第二步:抽丝剥茧,真相浮出水面
仔细分析XML文件中的进程列表和资源列表,我发现三个关键线索:
线索一:并行计划惹的祸
在死锁进程列表中,我看到了很多 ecid(子执行上下文ID)大于0的进程。这意味着,SQL Server 为这两个看似简单的单行 UPDATE 语句生成了并行执行计划。
简单来说,数据库优化器觉得:“这活儿一个人干太慢,我派一群小弟(线程)一起去扫表找数据吧。”
线索二:锁粒度被放大
正常情况下,我们应该希望锁住的是某一行数据(行锁)。但日志里明确写着 <pagelock ...>,表明锁的级别是页锁。当一个并行计划的多个线程分别锁住了不同的数据页,然后又试图去获取对方手上的数据页时,死锁就发生了。
线索三:U锁的自相矛盾
U锁(更新锁)是一种介于共享锁和排他锁之间的锁,它允许读取,但警告其他事务“我可能要改这里”。然而,在页级别上,U锁和U锁是互斥的。这就导致了两个并行的UPDATE任务,各自的子线程拿着不同页的U锁,互相等待对方的页,形成了循环等待。
完整过程还原
T0时刻:客户端A发起更新
PRODUCT-A的请求。SQL Server 优化器认为需要并行,派出N个线程去扫描。这些线程各自锁住了包含目标数据的几个数据页(U锁)。T1时刻:客户端B发起更新
PRODUCT-B的请求。同样,优化器也给它派了N个线程去扫描。这些线程也需要访问刚才被A线程锁住的那几个数据页。T2时刻:死锁形成。比如A的线程1锁住了页P1,想要页P2;而B的线程2锁住了页P2,想要页P1。双方僵持不下。
T3时刻:SQL Server 的锁监视器(Lock Monitor)介入,挑选了一个“代价最小”的事务作为牺牲品进行回滚。
T4时刻:被选中的事务收到错误1205,回滚。另一个事务继续执行,业务看起来一切正常。
这就是为什么业务数据没有出错,但用户感觉卡顿的原因——因为总有一个请求在后台默默当了“炮灰”。
第三步:为什么必须修复?
虽然数据库的“自动挡”机制保证了数据最终一致性,但这种状态绝对不能持续下去。它的隐性代价非常高:
CPU/线程开销:大量的线程被阻塞、唤醒、上下文切换,CPU资源被白白消耗。
响应时间飙升:牺牲品事务需要等待死锁检测(最长约5秒)才能失败,这直接导致了前端操作的卡顿。
重试放大效应:应用程序捕获到1205错误后通常会重试,一次更新变成两三次,进一步加剧了并发竞争。
潜在的数据风险:目前牺牲品都是“什么都没干”的新建线程。但如果未来某个已经修改了部分数据的线程成为牺牲品,就可能出现部分回滚、状态不一致的严重问题(如防呆失效)。
一句话总结:现在是数据库在硬扛,不是健康状态。
解决方案:对症下药
问题的根源在于:单行UPDATE被错误地选择了并行计划,导致锁粒度从行放大到页。
因此,解决方案就是告诉SQL Server:“别耍小聪明,老老实实一行一行地改。”
推荐的修改方式是在SQL语句中添加查询提示:
UPDATE [业务主表] WITH (ROWLOCK)
SET qc_result = @result,
qc_time = @time
WHERE out_code = @code
OPTION (MAXDOP 1);
解释一下这两个提示的作用:
WITH (ROWLOCK):强制使用行级锁,从根本上杜绝页锁导致的死锁。OPTION (MAXDOP 1):将这条语句的最大并行度设置为1,即强制使用单线程执行。这从根源上消除了多线程交叉锁页的问题。
有人可能会担心,加了 MAXDOP 1 会不会导致整个系统的并发能力下降?答案是不会。这个提示是语句级别的,只影响这一条SQL。不同产品的更新请求依然可以并行执行,因为它们锁的是不同的行,互不干扰。单行更新的持锁时间是微秒级的,完全不用担心阻塞问题。
写在最后
这次排查让我深刻体会到,数据库的“自动化”有时也会好心办坏事。优化器的并行策略在很多场景下能提升性能,但在高频、细粒度的点更新场景下,反而成了性能杀手。
很多时候,系统卡顿不一定是硬件瓶颈或代码逻辑复杂,可能就是一条SQL的执行计划出了问题。作为运维人员,学会透过现象看本质,用好死锁日志、执行计划这些工具,才能真正解决问题。
希望这次的分享能给同样在一线的朋友们一些启发。如果你也有类似的经历,欢迎留言交流。