在学习过程中,遇到一个存储过程,串行执行可以正常运行,但并行执行就会卡住。本文从代码逻辑出发,分析其卡住的根本原因,研究达梦自治事务的读视图机制,并通过最小化实验复现问题、验证修复方案。
在学习过程中,遇到一个存储过程,串行执行可以正常运行无异常,但并行执行,则会在第二个会话发生循环卡死,无法执行的情况,原存储过程部分SQL语句如下:
以下是导致循环卡死的核心代码块:
LOOP
SELECT V_LOCKED_FLAG
INTO VI_LOCKED_FLAG
FROM SF_REF_TABLE_STATUS
WHERE V_TABLE_NAME = 'SF_PROJECTNO_VERIFY';
IF VI_LOCKED_FLAG = 'Y' THEN
DBMS_LOCK.SLEEP(5);
ELSE
UPDATE SF_REF_TABLE_STATUS
SET V_LOCKED_FLAG = 'Y'
WHERE V_TABLE_NAME = 'SF_PROJECTNO_VERIFY';
COMMIT;
这段逻辑的目的是:通过 SF_REF_TABLE_STATUS 表中的 V_LOCKED_FLAG 字段,实现一个简单的互斥锁,保证同一时刻只有一个会话能执行 SF_PROJECTNO_VERIFY 的插入逻辑。
LOOP:无限循环,直到抢到锁并执行完才 EXIT。
SELECT V_LOCKED_FLAG INTO VI_LOCKED_FLAG …:查状态表,看 SF_PROJECTNO_VERIFY 这把锁当前是 ‘Y’ 还是 ‘N’。
IF VI_LOCKED_FLAG = ‘Y’ THEN DBMS_LOCK.SLEEP(5);:如果锁被占用,睡 5 秒,然后循环回去再查。
ELSE 分支:
UPDATE … SET V_LOCKED_FLAG = ‘Y’:把锁置为 ‘Y’,表示自己抢到了。
COMMIT;:提交这个更新,让其他会话能看到锁已被占用。
PRAGMA AUTONOMOUS_TRANSACTION;:声明为自治事务。
自治事务是一个在主事务中独立运行的子事务,就像一个“事务中的事务”。
它的核心特点是:
1)独立提交与回滚:自治事务中的 COMMIT 或 ROLLBACK 操作只作用于自身,不会影响主事务
2)主事务挂起:当自治事务在执行时,主事务会处于挂起状态,等待自治事务完成后才继续。
3)必须显式结束:一个关键点是,自治事务必须显式地提交或回滚,否则会报错
这意味着这个存储过程里的 COMMIT / ROLLBACK 只影响它自己,不会提交或回滚调用者的主事务。
串行时,两个会话是一前一后执行的:
会话 A 调用存储过程,抢到锁,把 V_LOCKED_FLAG 改成 ‘Y’ 并 COMMIT。
A 执行插入逻辑,完成后把锁改回 ‘N’ 并 COMMIT,退出。
A 完全结束之后,会话 B 才开始调用。
B 的自治事务是全新开始的,第一次 SELECT 就读到 ‘N’。
B 正常抢锁、执行、释放锁。
所以串行时,B 根本不会经历“锁被占用”的等待阶段,自然正常。
并行时,两个会话同时或重叠执行:
会话 A 先抢到锁,把 V_LOCKED_FLAG 改成 ‘Y’ 并 COMMIT。
在 A 还没释放锁的这段时间里,会话 B 也开始执行。
B 的自治事务开始,第一次 SELECT V_LOCKED_FLAG,读到 ‘Y’。
B 进入 IF VI_LOCKED_FLAG = ‘Y’ 分支,只执行 DBMS_LOCK.SLEEP(5),没有 COMMIT 或 ROLLBACK。
B 的自治事务读视图从第一次 SELECT 开始就冻结了。之后即使 A 把锁改回 ‘N’ 并提交,B 再次 SELECT 仍然读到 ‘Y’。
B 于是永远在“读到 ‘Y’ → 睡 5 秒 → 再读还是 ‘Y’”里循环,卡死。
这是本段代码故障的基本原因
问题的根因在于达梦 PL/SQL 中的事务可见性策略,此策略由参数 TRX_VIEW_POLICY 控制。
在达梦 PL/SQL 中,同一个事务内多次执行 SELECT,读到的数据可能不会刷新,会一直使用事务开始时的读视图。这就是为什么等待方 B 永远读到 ‘Y’。
要打破这个冻结的读视图,必须显式结束当前事务,即执行 COMMIT 或 ROLLBACK。下一个查询才会开启新的事务,看到最新数据。
因此,此故障是自治事务与冻结视图、未显式提交或回滚多重作用下的结果。
存储过程声明了 PRAGMA AUTONOMOUS_TRANSACTION,这意味着它拥有独立的事务生命周期。在主事务挂起的同时,自治事务内部形成了一个相对封闭的数据环境。
在 TRX_VIEW_POLICY=0(默认值)的前提下,自治事务一旦开始,其读视图(Read View)就被固定。在这个事务的生命周期内,哪怕外部会话(会话 A)已经提交了锁释放,会话 B 依然只能读取到事务开始那一刻的快照数据。
等待分支中只有 DBMS_LOCK.SLEEP(5),没有显式的事务结束语句(COMMIT 或 ROLLBACK)。这直接导致事务一直不结束,读视图持续冻结,形成了“读旧值 → 等 5 秒 → 再读还是旧值”的死循环。
代码结构层面:任何在自治事务内部使用 SELECT 进行状态检查(Check),并结合 LOOP 循环进行自旋等待(Spin-wait)的逻辑,只要等待分支缺少 COMMIT/ROLLBACK,都存在同样的冻结风险。
并发执行场景:该问题只在并行/并发场景下暴露。串行执行时,第二个会话在第一个会话完全结束后才启动,不会进入等待分支,因此天然规避了该缺陷;一旦两个会话的执行周期发生重叠,触发概率极高。
数据库环境层面:若数据库参数 TRX_VIEW_POLICY 保持默认值 0,且 COMPATIBLE_MODE 等参数配置未修改,达梦在处理此类 PL/SQL 并发时默认遵循快照读,该问题属于达梦默认配置下的普遍行为,并非特定业务表(如 SF_REF_TABLE_STATUS)的孤立问题。
业务影响层面:一旦触发,等待方会话将陷入无限循环,长期占用数据库连接与会话资源,可能导致连接池耗尽;同时,因锁标志迟迟未释放或重复抢锁,可能引发业务数据重复插入或阻塞后续所有依赖该锁的业务流程。
为验证分析猜想和实现优化改写,在本地环境做复现测试如下:
为实现最小化复现,需要一张表,一列存表名,一列存锁标志。
V_TABLE_NAME VARCHAR2(100),
V_LOCKED_FLAG VARCHAR2(2) DEFAULT 'N'
);
INSERT INTO LOCK_TEST VALUES ('SF_PROJECTNO_VERIFY', 'N');
COMMIT;
把原代码的抢锁循环单独抽出来,其他全部省略。关键是保留三个要素:自治事务、先 SELECT 判断、等待分支里没有 COMMIT。
CREATE OR REPLACE PROCEDURE SP_LOCK_BUG AS
PRAGMA AUTONOMOUS_TRANSACTION;
VI_LOCKED_FLAG VARCHAR2(2);
BEGIN
LOOP
-- 原版写法:先 SELECT 判断锁状态
SELECT V_LOCKED_FLAG
INTO VI_LOCKED_FLAG
FROM LOCK_TEST
WHERE V_TABLE_NAME = 'SF_PROJECTNO_VERIFY';
IF VI_LOCKED_FLAG = 'Y' THEN
-- 等待分支:只睡,不 COMMIT
DBMS_OUTPUT.PUT_LINE('锁被占用,等待中... 当前读到: ' || VI_LOCKED_FLAG);
DBMS_LOCK.SLEEP(5);
ELSE
-- 抢到锁
UPDATE LOCK_TEST
SET V_LOCKED_FLAG = 'Y'
WHERE V_TABLE_NAME = 'SF_PROJECTNO_VERIFY';
COMMIT;
DBMS_OUTPUT.PUT_LINE('抢到锁,开始执行业务...');
DBMS_LOCK.SLEEP(15); -- 模拟业务耗时,给 B 足够时间进入等待
-- 释放锁
UPDATE LOCK_TEST
SET V_LOCKED_FLAG = 'N'
WHERE V_TABLE_NAME = 'SF_PROJECTNO_VERIFY';
COMMIT;
DBMS_OUTPUT.PUT_LINE('释放锁,退出。');
EXIT;
END IF;
END LOOP;
END;
/
该存储过程包含了原问题存在的所有特点,如下:
1)PRAGMA AUTONOMOUS_TRANSACTION:和原代码一致。
2)SELECT … INTO VI_LOCKED_FLAG:先读锁状态。
3)IF VI_LOCKED_FLAG = ‘Y’ THEN DBMS_LOCK.SLEEP(5):等待分支只睡,没有 COMMIT/ROLLBACK。
4)DBMS_LOCK.SLEEP(15):让 A 持有锁足够久,保证 B 能在 A 释放前进入等待分支。
在窗口A执行:
CALL SP_LOCK_BUG();
可以看到调用该存储过程无异样,可正常调用
在A调用该执行过程的同时,在B窗口调用该存储过程:
CALL SP_LOCK_BUG();
则会提示“锁被占用,等待中…当前读到Y,这就证明B的视图被冻结,无法感知到A已经提交的锁释放。
原代码采用 SELECT 先判断锁状态,再决定是否 UPDATE。这种方式存在两个缺陷:
1)并发窗口期:SELECT 和 UPDATE 是两步操作,两个会话可能同时读到 ‘N’,随后都去执行 UPDATE,导致同时抢到锁,破坏了互斥性。
2)快照读局限:SELECT 是快照读,无法感知其他会话在此期间提交的最新数据,极易读到过期状态。
因此,改写时直接使用 UPDATE … WHERE V_LOCKED_FLAG = ‘N’。UPDATE 是当前读,能获取最新已提交数据;同时,数据库的行级锁保证了只有一个会话能成功将 ‘N’ 改为 ‘Y’,其余会话会因不满足 WHERE 条件而更新 0 行。这种“原子操作”天然保证了并发安全性。
原代码在等待分支中只执行了 DBMS_LOCK.SLEEP(5),没有结束当前事务。由于存储过程声明了 PRAGMA AUTONOMOUS_TRANSACTION,在达梦的 TRX_VIEW_POLICY 默认策略(0)下,事务一旦开始,其读视图(快照)就固定了。即便外部持锁会话已经释放锁并提交,等待方的事务也看不到这个变化。
因此,改写必须在没抢到锁时先执行 COMMIT。这里的 COMMIT 不是提交业务数据,而是强制结束当前事务,清空旧的读视图。只有事务结束,下一轮循环重新执行 UPDATE 时,才能开启一个全新的内部事务,获取到新的读视图,从而看到锁已被释放的最新状态。COMMIT 之后再执行 SLEEP(5),等待并进入下一轮重试。
原代码通过 IF VI_LOCKED_FLAG = ‘Y’ 来判断是否进入等待。改写后,SELECT 语句被移除,判断依据应改为 IF SQL%ROWCOUNT = 0。
SQL%ROWCOUNT = 0:表示 UPDATE 没有命中任何行,即锁当前被占用(V_LOCKED_FLAG 不是 ‘N’),当前会话没有抢到锁,应执行等待分支。
SQL%ROWCOUNT = 1:表示 UPDATE 成功命中了 1 行,即当前会话成功抢到锁,进入业务处理分支。
这种判断方式直接依赖数据库返回的真实更新结果,比在内存中判断变量值更加严谨和准确。
参考以上要求,改写存储过程:
CREATE OR REPLACE PROCEDURE SP_LOCK_FIX AS
PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
LOOP
UPDATE LOCK_TEST
SET V_LOCKED_FLAG = 'Y'
WHERE V_TABLE_NAME = 'SF_PROJECTNO_VERIFY'
AND V_LOCKED_FLAG = 'N';
IF SQL%ROWCOUNT = 0 THEN
COMMIT;
DBMS_OUTPUT.PUT_LINE('锁被占用,等待中...');
DBMS_LOCK.SLEEP(5);
ELSE
COMMIT;
DBMS_OUTPUT.PUT_LINE('抢到锁,开始执行业务...');
DBMS_LOCK.SLEEP(15);
UPDATE LOCK_TEST
SET V_LOCKED_FLAG = 'N'
WHERE V_TABLE_NAME = 'SF_PROJECTNO_VERIFY';
COMMIT;
DBMS_OUTPUT.PUT_LINE('释放锁,退出。');
EXIT;
END IF;
END LOOP;
END;
重置锁状态:
UPDATE LOCK_TEST SET V_LOCKED_FLAG = 'N' WHERE V_TABLE_NAME = 'SF_PROJECTNO_VERIFY';
COMMIT;
5.3并行执行:
在A窗口执行修复后的存储过程:
CALL SP_LOCK_FIX();
在A执行的同时,在B窗口执行同一存储过程:
# CALL SP_LOCK_FIX();
此处可见,B不再卡死,在等待了 3 个周期后,成功刷新读视图,抢到锁并正常退出。
达梦的 MVCC 机制下,普通 SELECT 通常是快照读,读取的是事务开始时刻的一致性快照;而 UPDATE、DELETE 等写操作是当前读,会读取最新已提交的数据,并对行加锁。
原代码用 SELECT 判断锁状态,本质上是快照读,无法感知其他会话的新提交;改用 UPDATE … WHERE V_LOCKED_FLAG = ‘N’ 后,变成当前读,才能正确抢锁。理解快照读与当前读的区别,是排查这类并发问题的关键。
Oracle 的自治事务同样有读一致性快照,等待方如果只 SELECT 不结束事务,也可能读不到最新数据,需要 COMMIT 或 ROLLBACK 刷新。
MySQL InnoDB 在 REPEATABLE READ 隔离级别下,普通 SELECT 也是快照读,同一事务内多次读可能结果一致;但 UPDATE 是当前读,能读到最新已提交数据。
可见,“用 SELECT 做自旋锁判断”在多个数据库中都是危险写法,原子 UPDATE 加事务边界控制是更通用的做法。
这个案例表面上是存储过程“并行卡死”,实质是达梦自治事务读视图冻结与自旋锁实现方式共同作用的结果。
存储过程用 SELECT 先判断锁状态,再决定是否 UPDATE 抢锁;等待方在 IF VI_LOCKED_FLAG = ‘Y’ 分支里只执行 DBMS_LOCK.SLEEP(5),没有 COMMIT 或 ROLLBACK。由于该存储过程声明为自治事务,在达梦默认的 TRX_VIEW_POLICY=0 策略下,等待方的读视图在第一次 SELECT 时就已经固定,之后即使持锁方提交了锁释放,等待方仍然读到旧的 ‘Y’,于是永远在“读到 ‘Y’ → 睡 5 秒 → 再读还是 ‘Y’”中循环,无法退出。
串行执行时,第二个会话在第一个会话完全结束之后才开始,第一次 SELECT 就读到 ‘N’,根本不会进入等待分支,因此表现正常。
修复的关键有两点:一是把抢锁方式从“先 SELECT 判断”改为“直接 UPDATE … WHERE V_LOCKED_FLAG = ‘N’”,利用 UPDATE 的当前读和行锁保证原子性,并用 SQL%ROWCOUNT 判断是否抢到;二是在没抢到锁时先 COMMIT 结束当前事务,再 SLEEP,让下一轮 UPDATE 能够开启新事务、刷新读视图。这样等待方在持锁方释放锁后就能读到最新的 ‘N’,成功抢锁并退出。
达梦数据库通过 TRX_VIEW_POLICY 控制事务可见性策略。
默认值为 0,含义是“脚本中的 SQL 语句不更新事务可见性视图”,即同一个事务内多次 SELECT 会复用事务开始时的读视图,不会因为其他会话提交而刷新。如果将其设为 1,则语句级更新视图,每次 SELECT 都能看到最新已提交数据。
但修改这个参数会影响整个数据库的事务行为,生产环境通常不建议随意调整,正确的做法仍然是在代码层面显式 COMMIT 或 ROLLBACK 来结束事务、刷新读视图。
自治事务适合用于日志记录、审计、独立提交等场景,它可以让子操作独立于主事务提交或回滚。
但自治事务也有明显的陷阱:它必须显式结束,否则会报错;它的读视图独立且冻结,不会随外部事务提交而刷新;在自治事务中做长时间等待或循环时,尤其要注意事务边界。
原存储过程在异常处理里先 ROLLBACK 再 UPDATE 锁标志并 COMMIT,这是正确的释放锁方式;但在等待分支里缺少 COMMIT,就成了死循环的根源。
基于本案例的教训,在达梦(以及其他依赖 MVCC 的关系型数据库)中实现并发控制时,应遵循以下规范:
规范一:禁止使用“先查后改”的方式实现自旋锁
不要用 SELECT 去判断锁状态,再执行 UPDATE。正确的做法是直接使用原子 UPDATE,在 WHERE 条件中带上旧状态(如 AND V_LOCKED_FLAG = ‘N’),然后通过 SQL%ROWCOUNT 的结果(0 表示未抢到,1 表示抢到)来决定后续逻辑。这不仅能避免并发窗口期,还能确保读取到最新数据。
规范二:自旋等待必须正确控制事务边界
在循环等待的逻辑中,如果本轮没有抢到锁,必须先执行 COMMIT 或 ROLLBACK 结束当前事务,然后再进行 SLEEP 等待。只有结束事务,下一轮循环才能获取新的读视图,看到其他会话已提交的最新状态。
规范三:为循环等待设置超时与最大重试次数
原代码的 LOOP 是无条件死循环,一旦因为机制问题卡死,将长期占用数据库连接。在实际开发中,即使逻辑正确,也应增加最大重试次数或超时退出机制(例如等待超过 60 秒则抛出异常释放资源),避免耗尽连接池。
规范四:自治事务的使用原则
在自治事务中,务必确保每条执行路径(包括正常流程和异常分支)都能显式地 COMMIT 或 ROLLBACK。在异常处理中,应在 ROLLBACK 之后,独立提交释放锁的操作,防止死锁。
文章
阅读量
获赞
