前几天和一个做电商后台的朋友吃饭,他正被一个线上 bug 搞得焦头烂额:用户下单时库存扣减失败,订单卡在“待支付”状态两小时。排查下来,罪魁祸首是数据库死锁——两个事务同时抢同一批库存记录,互相等着对方释放锁,谁也没办法。这事儿听着挺技术,但说白了就像两个人同时想进一扇门,谁也不肯让,结果都堵在门口。数据库死锁就是这么个道理:多个事务在抢占资源时形成循环等待,系统只能随机杀掉一个事务来打破僵局。今天咱就聊聊数据库解决死锁的三种主流方法,看看程序员是怎么在数据的世界里“疏通拥堵”的。

第一种方法是超时机制,这招简单粗暴。数据库给每个事务设定一个等待锁的时间上限,比如默认 50 秒。要是某个事务等了 30 秒还没拿到锁,系统直接判定它超时,自动回滚该事务,释放它占用的资源。好处是实现容易,不用额外维护复杂的数据结构。但缺点也很明显:超时时间设短了,容易误杀正常执行稍慢的事务;设长了,死锁就在那儿耗着,用户等得心慌。实际场景里,比如银行转账系统,交易高峰期事务并发量大,超时机制就特别管用——系统设置 20 秒超时,那些因为网络延迟或负载过高导致的事务卡顿,直接回滚释放资源,保证整体吞吐量。不过在高性能场景下,这招就显得粗糙,容易引发不必要的回滚,增加系统负担。
第二种方法是等待图检测,这招更精细。数据库会维护一个“谁在等谁”的有向图,节点代表事务,边代表等待关系。比如事务 A 等事务 B 释放锁,就画一条从 A 到 B 的边。系统定期扫描这个图,一旦发现环状结构——比如 A 等 B,B 等 C,C 又等 A——就知道死锁发生了,然后挑一个“代价最小”的事务杀掉。这个“代价”通常看事务执行时间、已占资源数量、回滚的难易程度。比如事务 A 已经跑了 5 秒,改了 10 条记录,事务 B 只跑了 2 秒改了 3 条记录,系统会优先杀 B。这方法准确率高,能杜绝误杀,但维护等待图需要额外的内存和 CPU 开销,在事务量超大的系统里,光画图就会吃掉不少性能。像金融交易系统这类对一致性要求极高的场景,等待图检测是标配——宁可牺牲部分性能,也要保证“宁可错杀一千,不可放过一个死锁”。
第三种方法是死锁预防,这招从源头下手。最常见的是“一次性锁申请”:要求事务在执行前一次性申请所有可能用到的锁。比如一个事务要更新订单表和库存表,它得在开始时就把两张表的锁都拿到手,拿不到就等,拿到了才继续。这样可以彻底避免循环等待,因为每个事务要么全拿,要么全等,不会出现“你拿一张,我拿另一张”的局面。但缺点也明显:事务必须提前知道自己会动哪些资源,这在复杂场景下几乎不可能。比如电商平台的双十一大促,一个用户下单可能涉及优惠券、积分、库存、物流等多个模块,事务启动时根本算不清后面会触发多少资源。实际中,这招常用于银行核心账务系统——每笔交易涉及的账户范围固定,比如转账就是两个账户,提前申请锁既简单又可靠。
这三种方法各有千秋,但实际系统往往不是只用一种。大部分数据库默认组合使用:先让超时机制兜底,处理简单场景;同时开启等待图检测,在后台监控死锁;对于关键业务,再结合业务逻辑做预防。比如 MySQL 的 InnoDB 引擎,默认启用了等待图检测和超时机制的双保险:超时时间设为 50 秒,同时每秒钟扫描一次等待图。一旦发现死锁,优先回滚修改行数最少的事务,减少回滚成本。这种组合拳的好处是,既能在低并发时快速响应,又能在高并发时精准处理。
选哪种方法,还得看业务场景。如果你做的是高并发的秒杀系统,用户能接受偶尔的超时失败,那超时机制加上足够的重试逻辑就够了,省资源又高效。要是你维护银行核心系统,死锁意味着账务不一致,那就必须使用等待图检测,哪怕慢一点也要保证准确。至于死锁预防,更适合事务资源固定、可预测的场景,比如库存扣减、账户扣款。很多新手程序员一上来就想用最“高级”的等待图检测,结果系统性能下降很多,后来发现超时机制加业务重试完全够用。
说到底,数据库死锁就像城市交通拥堵,没有一种方法能包治百病。超时机制像限时通行——等太久直接赶走;等待图检测像交警现场指挥——找到堵点精准疏导;死锁预防像提前规划路线——出发前就定好所有站点。作为开发者,先搞清楚自己的业务场景:用户能接受多长的等待时间?系统对一致性的要求有多严?资源抢占的模式是否固定?想明白这些问题,选哪种方法心里就有数了。下次再遇到死锁问题,不妨先想想这三种办法,看看哪招最对症。毕竟,数据库不会自己开口说话,但它的锁机制一直在用沉默的方式告诉你系统运行的秘密。


