在DB2数据库中,死锁环通常由两个或多个事务互相持有对方需要的资源锁而形成。比如事务A持有订单表的行锁,准备更新库存表;事务B持有库存表的行锁,准备更新订单表。此时A等待B释放库存锁,B等待A释放订单锁,谁也无法继续。DB2死锁检测器会定期扫描锁等待关系,一旦识别出这样的闭合回路,就会选择一个代价最小的事务作为牺牲者并回滚,该事务会收到SQLCODE -911或SQLSTATE 40001错误。死锁环与单纯的锁等待不同,后者只会阻塞一段时间,前者如果不被数据库介入,相关事务会永远僵持。

牺牲者并不是固定的,也未必是最后发起请求的事务。DB2根据已经消耗的日志空间、事务运行时间、锁数量等因素选择回滚代价较低的一方。因此生产环境中,同一个业务事务有时成功有时失败,可能就是死锁环中选择牺牲者的结果。
死锁环的形成机制与锁类型
要理解死锁环,需要先理解DB2的锁分级。DB2支持行级锁、表级锁、表空间锁以及各种意向锁。行级锁的并发粒度最好,但如果事务扫描大量行,锁数目增多,DB2可能自动升级为表锁,进一步扩大锁冲突范围。意向锁用于在表级标识行级锁的存在,比如一个事务持有行级排他锁,会在表上持有意向排他锁,其他事务请求表级排他锁时就会提前发现冲突。
死锁环的形成依赖四个条件:事务持有资源并继续申请其他资源,已经持有的资源不能被其他事务强制剥夺,事务之间形成等待,等待关系首尾相连。下面两个事务就是最典型的交叉加锁:
-- 事务A BEGIN; UPDATE orders SET status = 'PAID' WHERE order_id = 1001; UPDATE inventory SET stock = stock - 1 WHERE sku = 'SKU-001'; COMMIT; -- 事务B BEGIN; UPDATE inventory SET stock = stock - 1 WHERE sku = 'SKU-001'; UPDATE orders SET status = 'PAID' WHERE order_id = 1001; COMMIT;
事务A先锁定orders表的行,再请求inventory表的行锁;事务B恰好相反。如果两个事务几乎同时执行,A持有了orders行锁等待inventory锁,B持有了inventory行锁等待orders锁,循环就成立了。DB2默认的死锁检测间隔由DLCHKTIME参数控制,检测到环后不会等待锁超时,而是立刻回滚牺牲者。锁超时参数LOCKTIMEOUT主要用于打断迟迟不释放的普通锁等待,不能替代死锁检测。
定位死锁环的实用方法
定位死锁环的关键是还原锁等待图,找出谁持有锁、谁在等待、锁属于哪张表、对应哪条SQL。DB2提供了db2pd命令,它是一个轻量级诊断工具,不需要连接数据库,也不依赖快照。执行以下命令可以查看锁等待信息:
db2pd -db sample -locks wait
输出中会列出等待中的锁,包括锁名、锁模式、持有者事务句柄、等待者事务句柄等信息。结合-transactions和-agents参数可以进一步看到事务对应的应用句柄和最后执行的SQL。以下是一个简化的等待关系示例:
Locks being waited on : AppHandl TranHdl Lockname Mode Status -------- ------- ------------------------- ---- ------ 10345 7 00000001000000010000000001 X Waiting 10346 8 00000001000000020000000001 X Waiting
只看单行输出还不够,需要把多个等待记录连接起来。比如应用10345等待的锁由事务8持有,而应用10346等待的锁由事务7持有,两者互相等待,就能确认死锁环。实际输出中锁名对应表空间ID和对象ID,需要再通过db2pd -db sample -locks show detail或查询SYSCAT.TABLES把锁名映射到具体表。
对于事后分析和持续监控,更适合使用死锁事件监视器。创建死锁事件监视器后,DB2会在每次死锁发生时写入详细的死锁图,包括参与事务的隔离级别、锁模式、SQL文本、回滚事务等信息。创建和启用命令如下:
CREATE EVENT MONITOR deadlock_mon FOR DEADLOCKS WITH DETAILS WRITE TO UNFORMATTED EVENT TABLE; SET EVENT MONITOR deadlock_mon STATE = 1;
查询事件表可以快速获得历史死锁记录。相比实时抓取db2pd快照,事件监视器提供更完整的上下文,适合定位偶发死锁和统计死锁热点。需要注意事件监视器会产生一定开销,建议在问题排查期间开启,或使用过滤条件控制收集范围。
通过SQL设计和索引优化打破死锁环
防止死锁环最直接的做法是消除交叉加锁顺序。如果所有事务在访问多张表时都遵循相同的顺序,那么即使发生竞争,也只会形成单向等待,而不会闭环。例如统一规定先更新orders表,再更新inventory表,前面的两个事务就不会互相卡住。这个规则看起来简单,但在大型应用中需要由代码规范或数据访问层统一实现。
批量更新时,即使涉及的表相同,行级锁的获取顺序也可能因访问路径不同而交叉。比如两个批量任务使用不同的索引扫描同一批账户,一个先锁账户1001再锁1002,另一个先锁1002再锁1001,仍然可能形成死锁环。可以在游标中通过ORDER BY主键固定行的处理顺序,避免交叉锁:
DECLARE c CURSOR FOR SELECT id FROM accounts WHERE status = 'PENDING' ORDER BY id FOR UPDATE;
索引缺失也是死锁和锁等待放大的常见原因。如果UPDATE或DELETE的WHERE条件无法使用索引,DB2可能需要扫描大量数据页,从而持有更多行锁,甚至触发锁升级为表锁。为外键列、频繁参与关联的列以及更新条件列创建合适的索引,可以缩小锁范围并减少锁冲突。示例如下:
CREATE INDEX idx_orders_cust ON orders(customer_id); CREATE INDEX idx_inventory_sku ON inventory(sku);
另一个被忽视的因素是事务持续时间。事务持有锁的时间越长,与其他事务发生交叉等待的概率越高。应避免在事务内执行网络调用、文件读写或等待用户输入;批量任务可以把大事务拆分成多个小事务,每处理一批就提交一次。这样即使发生死锁,回滚和重试的代价也更小。
隔离级别、参数和重试策略
DB2支持多种隔离级别,不同隔离级别对锁的持有方式和持续时间影响明显。默认的游标稳定性隔离级别只锁定当前处理的行,已经在并发性和一致性之间取得较好平衡;可重复读和读稳定性会持有更长时间的读锁,容易增加死锁概率;未提交读隔离级别允许读操作不获取行锁,适合只读报表场景,可以显著减少与写事务的锁冲突。读写事务需要根据业务一致性要求选择合适隔离级别,不能为了减少死锁而盲目使用未提交读。
数据库参数层面,LOCKTIMEOUT决定事务等待锁的最长秒数,超过后请求方会收到SQLCODE -911或SQLSTATE 40001;DLCHKTIME控制死锁检测间隔。缩短DLCHKTIME可以更快发现死锁环,但检测本身需要扫描等待图,过短会增加系统开销。实际调优时应从默认值开始,结合监控逐步调整,不要只靠调参数解决由访问顺序导致的死锁。
应用系统必须将死锁视为可重试的临时错误。死锁牺牲者的选择是数据库行为,业务方无法预先指定哪个事务失败。捕获到SQLSTATE 40001或SQLCODE -911后,如果事务具备幂等性,可以回滚后稍作等待再重新执行。以下是一个简单的重试逻辑:
int retry = 0;
while (retry < 3) {
try {
connection.setAutoCommit(false);
updateOrder(connection, orderId);
updateInventory(connection, sku);
connection.commit();
break;
} catch (SQLException e) {
connection.rollback();
if ("40001".equals(e.getSQLState()) || e.getErrorCode() == -911) {
retry++;
Thread.sleep(200L * retry);
} else {
throw e;
}
}
}
重试前必须回滚当前事务,否则连接仍持有部分锁,可能加剧后续冲突。非幂等操作则需要设计补偿逻辑,例如记录原始状态或使用唯一业务键保证重复执行结果一致。总体上,死锁环的治理不是单一层面的工作,需要结合SQL访问顺序、索引设计、事务粒度和监控告警共同完成。