Oracle 锁机制——从 TM 锁到死锁的完整链路

锁是并发控制的灵魂,也是生产故障的常客。很多 DBA 一看到锁就慌,尤其是 enq: TX - row lock contention 排进 Top 5 的时候。其实 Oracle 的锁体系非常清晰:DML 锁(TM)保护表结构,事务锁(TX)保护行数据,DDL 锁保护对象定义。问题是这些锁在 V$LOCK 里的表现不够直观,而且死锁的 trace 文件读起来像天书。这篇文章把锁的层级、ITL(Interested Transaction List)、死锁检测机制,以及一个我花了三小时才搞定的死锁案例拆清楚。

1 TM 锁和 TX 锁不是一回事,但经常一起出现

TM 锁是表级 DML 锁,模式分 3(Row Exclusive,INSERT/UPDATE/DELETE)和 2(Row Share,SELECT FOR UPDATE)。当一个会话 UPDATE 某行时,它先在表上加 TM-3 锁,再在行对应的数据块的事务槽(ITL)上加 TX 锁。TM 锁的作用是防止其他会话对该表做 DDL(比如 DROP TABLE 或 ALTER TABLE),但不阻塞其他 DML。TX 锁才是真正的行级锁,通过数据块头的 ITL 和行头的锁定位字节(lb)实现。

 

enq: TM - contention 通常发生在两种情况:一是外键没建索引,子表插入时需要在父表上加 TM 锁,如果父表上有其他事务持有 TM 锁,就会等待;二是 DDL 被 DML 阻塞,或者反过来。我们给一个客户排查时,发现批量插入子表时频繁出现 enq: TM - contention,根因是父表的外键列没索引,每次子表插入都要在父表上加共享锁,验证外键存在性。给父表的外键列加了索引后,TM 争用消失。

2 ITL 不足:藏在数据块里的隐形杀手

每个数据块头有若干 ITL 槽(默认 INITRANS=2,最大 MAXTRANS=255),用来记录哪些事务修改了该块。如果并发事务同时修改同一个块,ITL 槽不够用,新事务就要等待,等待事件是 enq: TX - allocate ITL entry。这个等待事件很隐蔽,因为它不是行锁冲突,而是块级资源不足。解决办法:对高并发更新的表,建表时把 INITRANS 设高(比如 10),或者在线重建表增加 ITL 槽。

3 死锁:Oracle 能检测,但只能杀一个

死锁(ORA-00060)发生在两个会话互相持有对方需要的锁。比如会话 A 持有行 1 的锁,请求行 2;会话 B 持有行 2 的锁,请求行 1。Oracle 的 DLM(Distributed Lock Manager,RAC 环境)或本地锁管理器每隔几秒检测一次等待图(Wait-for Graph),如果发现环,就选一个牺牲者(通常是提交工作量少的会话),回滚其语句,释放锁,并生成 trace 文件。

 

死锁 trace 文件在 USER_DUMP_DEST 下,包含死锁图(Deadlock Graph),显示会话、SQL、持有的锁和请求的锁。但很多 DBA 不会读这个图,只看到 ORA-00060 就重启应用。其实死锁的根因通常是应用逻辑:更新顺序不一致,或者事务里锁的范围太大。

4 实验:模拟 TX 锁等待、ITL 不足与死锁

实验环境:Oracle 19c,单实例。测试表 test_locks,INITRANS=2,高并发更新。

-- 建表,故意设低 INITRANS
CREATE TABLE test_locks (
  id NUMBER PRIMARY KEY,
  val VARCHAR2(100)
) INITRANS 2 MAXTRANS 10;

-- 插入数据
INSERT INTO test_locks SELECT rownum, 'VAL_'||rownum FROM dual CONNECT BY ROWNUM <= 1000;
COMMIT;

-- 会话 A:更新行 1,不提交
UPDATE test_locks SET val = 'A' WHERE id = 1;

-- 会话 B:更新行 1,等待 TX 锁
UPDATE test_locks SET val = 'B' WHERE id = 1;
-- 预期:会话 B 挂起,等待 enq: TX - row lock contention

-- 查看锁情况
SELECT s.sid, s.serial#, l.type, l.lmode, l.request, o.object_name
FROM v$lock l
JOIN v$session s ON l.sid = s.sid
LEFT JOIN dba_objects o ON l.id1 = o.object_id
WHERE o.object_name = 'TEST_LOCKS';
-- 会话 A:TM-3, TX-6(排他)
-- 会话 B:TM-3, TX-0(请求排他,等待)

ITL 不足模拟:

-- 造一个块里塞满数据,INITRANS=2
CREATE TABLE test_itl (id NUMBER, val VARCHAR2(100)) INITRANS 2;
-- 用 PCTFREE 0 让块尽量满
INSERT /*+ APPEND */ INTO test_itl SELECT rownum, LPAD('X',100,'X') FROM dual CONNECT BY ROWNUM <= 100;
COMMIT;

-- 10 个会话同时更新同一个块里的不同行
-- 因为 INITRANS=2,块头只有 2 个 ITL 槽,第 3 个及以后的事务需要等待
-- 等待事件:enq: TX - allocate ITL entry

死锁模拟:

-- 会话 A
UPDATE test_locks SET val = 'A1' WHERE id = 1;
-- 会话 B
UPDATE test_locks SET val = 'B1' WHERE id = 2;
-- 会话 A
UPDATE test_locks SET val = 'A2' WHERE id = 2; -- 等待 B
-- 会话 B
UPDATE test_locks SET val = 'B2' WHERE id = 1; -- 等待 A,触发死锁
-- 预期:会话 B 报 ORA-00060,被回滚

实验结果:TX 锁等待时,V$LOCK 显示 request=6,lmode=0;ITL 不足时,等待事件明确显示 allocate ITL entry,且 V$SESSION_WAIT 的 P2 参数指向数据块地址;死锁触发后,alert log 出现 "Deadlock detected. See ORA-00060 trace file...",trace 文件里 Deadlock Graph 清晰展示了会话 1 持有 TX-0001-0002,请求 TX-0003-0004,会话 2 反之。

5 那个让我加班三小时的死锁案例

一个电商系统,每天凌晨 1 点有两个批量作业:作业 A 更新订单状态(按 order_id 升序),作业 B 更新库存(按 sku_id 升序)。某天新加了一个作业 C,更新订单的物流信息(按 order_id 降序)。结果作业 A 和 C 在更新同一批订单时死锁了。A 持有 order_id=100 的锁,请求 99;C 持有 99 的锁,请求 100。死锁 trace 文件里 SQL 很长,我一开始没注意到 order_id 的排序方向,以为是普通锁等待。看了半小时 trace,才发现两个 SQL 的 ORDER BY 方向相反。解决办法:统一所有批量更新的排序方向,按主键升序更新。死锁从此绝迹。

【踩坑笔记】生产环境排查锁问题,不要只看 V$LOCK,要结合 V$SESSION_WAIT 看等待事件。如果是 enq: TX - row lock contention,看 P1(usn<<16 + slot)和 P2(sequence)定位具体事务;如果是 enq: TX - allocate ITL entry,看 P3(数据块地址)定位具体块,然后考虑重建表加 INITRANS。死锁的根因 90% 是应用更新顺序不一致,别指望数据库层解决。

6 总结:锁治理的三层漏斗

第一层,设计层:外键必须建索引,批量更新必须统一排序方向,事务尽量短。第二层,表参数层:高并发表 INITRANS 设 5-10,PCTFREE 留足空间给 ITL 扩展。第三层,监控层:用 V$LOCK、V$SESSION_WAIT、DBA_WAITERS/DBA_BLOCKERS 实时监控,锁等待超过 30 秒的会话要告警。最后送一句话:锁是并发的交通规则,没有规则会撞车,规则太严会堵车。DBA 要做的就是画好线、设好灯、罚好款。


请使用浏览器的分享功能分享到微信等