注册
DM9并发更新场景下的锁等待与事务超时:复现、定位与优化【#达梦数据库 #达梦同行者征文】
专栏/技术分享/ 文章详情 /

DM9并发更新场景下的锁等待与事务超时:复现、定位与优化【#达梦数据库 #达梦同行者征文】

南子🌸byLeo 2026/09/10 30 1 0
摘要

01coverdm9lockwait.png

1. 问题来源与测试目标

最近在梳理DM9并发更新问题时,我碰到一个很典型的现象:业务线程执行一条UPDATE后迟迟不返回,应用日志最后只留下一个超时。第一反应很容易往慢SQL上靠,但真正排查时,两者的方向并不一样。慢SQL通常要看执行计划、扫描行数和I/O;锁等待首先要回答的是:这条SQL是不是正在等另一个事务?谁在等?又是谁一直没有提交?

为了把这个现象说清楚,我单独做了一组双会话实验:会话A更新商品库存后保持事务不提交,会话B再去更新同一条记录,同时采集等待关系、锁明细和JDBC超时结果。这样变量少一些,问题也更容易复现,不会被复杂业务逻辑干扰。

这次我只验证三个问题:

  1. 两个事务更新同一条商品记录时,后执行的事务会出现什么现象;
  2. 请求超过应用设定时间后,实际错误码和返回时间是什么;
  3. 多行更新统一访问顺序后,在相同并发条件下能否稳定完成。

这里需要先分清一个概念。锁等待本身不一定是故障。事务A修改一行数据但尚未提交,事务B修改同一行时等待A结束,这是正常的并发控制。真正需要排查的是等待时间过长,并且已经超过应用、连接池或数据库允许的时间范围。

2. 测试环境与边界说明

本次测试使用DM9 x86 CentOS 7安装介质,实际运行环境是Ubuntu 24.04隔离容器。数据库程序能够启动,JDBC可以连接并完成事务测试,不过这只能证明本次功能验证可运行,不能据此替代正式兼容性认证。生产部署时,安装包、操作系统和CPU架构仍应按照官方适配范围选择。

项目 本次实际环境
DM9服务端 DM Database Server 64 V9;03151060506-20260417-322930-20218;DB Version 0x7000D
JDBC驱动 DmJdbcDriver11 9.1.0.26
操作系统 Ubuntu 24.04.3 LTS;Linux 6.18.35;x86_64
CPU 9 vCPU;AMD EPYC 9V74 80-Core Processor;KVM
内存 15 GiB;无Swap
磁盘 32 GiB虚拟磁盘;系统标记ROTA=1
Java OpenJDK 17.0.20
实例参数 端口5236;页大小16K;BUFFER 2048 MB;WORKER_THREADS 16;TASK_THREADS 16
测试程序 自编Java JDBC双会话并发程序

安装包由5个分卷重新合并,ZIP的SHA256为:

348cb2295b0c6f205275473eff07c7b7a0986d3ac95a6e037b0d49fede157a1b

该值与上传时保存的校验文件一致。测试容器对后台进程和网络命名空间有额外限制,因此数据库服务与JDBC程序放在同一个命令会话中运行。这个限制不会改变两条数据库连接之间的事务关系,但文章中的时间数据仍然只代表本次环境。

02dm9locktestenvironment.png

3. 数据模型与并发脚本设计

为了把问题收窄,我新建了LOCK_PRODUCT表,只保留商品ID、库存、版本号和更新时间。这里验证的是两个事务命中同一条记录后的锁冲突,大数据量反而容易增加无关变量,用两条可控记录更方便观察事务行为。

CREATE TABLE LOCK_PRODUCT ( PRODUCT_ID INT PRIMARY KEY, PRODUCT_NAME VARCHAR(64) NOT NULL, STOCK INT NOT NULL, VERSION_NO INT DEFAULT 0 NOT NULL, UPDATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ); INSERT INTO LOCK_PRODUCT VALUES (1001, 'DM9技术手册', 1000, 0, CURRENT_TIMESTAMP); INSERT INTO LOCK_PRODUCT VALUES (1002, '数据库课程', 1000, 0, CURRENT_TIMESTAMP); COMMIT;

两条数据库连接都关闭自动提交。会话A先更新PRODUCT_ID=1001,但暂时不提交;会话B随后更新同一行。测试程序使用System.nanoTime()记录会话B从提交SQL到返回之间的时间,避免把建连和初始化耗时算进去。

正式测试前,我把每一轮的库存重置为1000、版本号重置为0,并单独提交。这样上一轮事务不会影响下一轮结果。

03twosessionupdateflow.png

04lockproducttestdata.png

4. 同一记录并发更新的正常等待

第一组测试不设置短超时,目的不是制造报错,而是确认正常锁等待的行为。

会话A执行下面的更新后保持事务打开:

UPDATE LOCK_PRODUCT SET STOCK = STOCK - 1, VERSION_NO = VERSION_NO + 1, UPDATED_AT = CURRENT_TIMESTAMP WHERE PRODUCT_ID = 1001; -- 此处暂不提交

会话B执行完全相同的SQL。程序等待1500毫秒后提交会话A,再观察会话B何时返回。

05sessionaholdsrowlock.png

最终一轮的三次结果如下:

轮次 会话A保持时间 会话B实际耗时 执行结果 最终库存 最终版本号
1 1500 ms 1501 ms 成功 998 2
2 1500 ms 1501 ms 成功 998 2
3 1500 ms 1502 ms 成功 998 2

会话B的耗时基本贴着会话A的持锁时间走,中位数为1501毫秒。会话A一提交,会话B就继续完成更新。库存从1000变成998,版本号从0变成2,说明两次更新都提交成功,并没有发生丢失更新。

这个结果也说明,看到UPDATE停在那里时,不能马上把它定义成慢SQL。SQL执行计划可能没有问题,它只是还没有拿到需要的锁。

06normallockwaitresults.png

5. 等待关系与锁明细定位

第二组测试让会话A继续持有同一行,会话B进入等待。等待开始约700毫秒后,我用第三条管理连接查询V$TRXWAIT

SELECT ID AS WAIT_TRX_ID, WAIT_FOR_ID AS BLOCK_TRX_ID FROM V$TRXWAIT;

最终一轮采集到的结果是:

WAIT_TRX_ID    BLOCK_TRX_ID
48113          48112

这条结果已经把关系说得很清楚:事务48113正在等待事务48112。事务编号是运行时产生的,每次执行都会变化,所以现场排查必须查询当时的动态视图,不能照搬文章里的编号。

07vtrxwaitblockingchain.png

接着,把两个事务对应的锁记录查出来:

SELECT TRX_ID, TABLE_ID, LTYPE, LMODE, BLOCKED FROM V$LOCK WHERE TRX_ID IN ( SELECT ID FROM V$TRXWAIT UNION SELECT WAIT_FOR_ID FROM V$TRXWAIT );

结果中同时出现了OBJECT / IXTID / X记录,其中事务48113的一条TID锁记录为LMODE=X、BLOCKED=1。结合V$TRXWAIT,可以确认它不是单纯执行时间长,而是在等待事务48112释放冲突资源。

实际排查时,我会先看V$TRXWAIT确定等待方向,再看V$LOCK确认锁类型和阻塞状态。两个视图一起使用,比只盯着应用日志里的“请求超时”更直接。

6. JDBC语句超时的实际表现

这一部分有个细节需要单独说明。

我先查询了本实例V$DM_INI中名称包含WAIT的参数,共返回18项,其中有DDL_WAIT_TIMEBLDR_WAIT_TIME等,但本次DM9版本没有返回LOCK_WAIT_TIMEOUTTRX_WAIT_TIME。因此我没有照搬网上其他版本的参数修改命令,而是在会话B的JDBC语句上设置3秒超时:

try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setQueryTimeout(3); ps.setInt(1, 1001); ps.executeUpdate(); }

完整程序独立运行4次,超时结果如下:

运行批次 设置值 实际返回时间 错误码 SQLState 错误摘要
1 3 s 4028 ms -608 22000 session request timeout
2 3 s 4028 ms -608 22000 session request timeout
3 3 s 4008 ms -608 22000 session request timeout
4 3 s 4018 ms -608 22000 session request timeout

实际返回时间稳定在4秒左右,比setQueryTimeout(3)多约1秒。这里的3秒是JDBC语句层设置值,4018毫秒是从executeUpdate()开始到异常返回的端到端时间。驱动发起取消、服务端处理请求以及程序重新获得执行权都可能带来额外耗时,所以不能把设置值理解成精确到毫秒的截止点。

更重要的是,这次收到的-608 session request timeout属于会话请求超时。根因是否为锁等待,不能只靠这一行错误判断。本文能够确认锁等待,是因为同一时间的V$TRXWAIT记录了48113等待48112,V$LOCK也记录了TID / X / BLOCKED=1。错误日志和数据库现场证据能够互相对应,结论才算站得住。

超时发生后,程序先回滚会话B,再回滚会话A,没有把失败事务继续留在连接上。这个处理很关键。连接池如果把异常后的连接直接归还,而事务状态又没有清干净,后面的请求会更难查。

08vlockandtimeoutevidence.png

7. 固定更新顺序的对照验证

只把超时时间调大,通常解决不了持锁事务过长的问题。应用层更应该控制事务边界和数据访问顺序。

对照测试中,每轮同时启动两个事务,每个事务都先更新1001,再更新1002,中间保留30毫秒处理时间,最后提交:

int[] productIds = {1001, 1002}; Arrays.sort(productIds); for (int productId : productIds) { updateStock(conn, productId); } conn.commit();

两个事务仍然可能短暂等待,因为它们都会先访问1001,但锁定顺序保持一致后,不会人为形成“事务A拿着1001等1002、事务B拿着1002等1001”的交叉路径。

同一套程序独立运行4次,每次20轮、每轮2个事务,共执行160个并发事务:

运行批次 事务数 成功 失败 平均耗时 P95
1 40 40 0 48.750 ms 66 ms
2 40 40 0 47.675 ms 65 ms
3 40 40 0 47.550 ms 64 ms
4 40 40 0 47.800 ms 65 ms

四次运行全部完成,失败数为0。最终一轮结束后,两条记录的库存都从1000变成960,版本号都变成40,与40个成功事务完全对应。

我不会据此写成“固定顺序可以消除所有锁等待”。它能减少交叉锁序,但不能让同一条热点记录变成无竞争状态。数据分布、事务长度、索引变化和并发规模都会影响结果。准确一点的表述是:在本次两行、双事务、固定顺序的对照条件下,160个事务全部成功,没有出现请求超时。

09orderedupdatevalidation.png

8. 从超时日志到阻塞事务的排查步骤

根据这次测试,我把现场排查顺序整理成下面几步。

8.1 先保存应用侧证据

至少记录错误发生时间、数据库地址、业务请求ID、SQL摘要、绑定主键、JDBC错误码、SQLState和实际耗时。只留一句“接口超时”,后面很难与数据库会话对应。

还要确认超时来自哪一层。HTTP网关、线程池、连接池、JDBC语句和数据库内部等待,都可能有自己的时间限制。它们虽然都叫“超时”,含义并不一样。

8.2 在独立连接中查询等待关系

业务连接已经阻塞时,不要继续用同一条连接执行诊断SQL。应使用单独的管理连接查询V$TRXWAITV$LOCK,先得到等待事务与阻塞事务的关系,再结合会话信息定位来源。

如果查询时阻塞已经消失,动态视图可能为空,因此采集动作要尽量靠近故障时间。生产环境最好把关键等待信息纳入定时监控,而不是每次都等人手工登录。

8.3 回到事务边界检查持锁原因

常见问题不是单条UPDATE写得多复杂,而是事务里夹了别的工作,例如远程接口、文件上传、消息发送、循环计算,甚至等待人工确认。数据库更新已经完成,但提交要等到整段业务结束,锁自然也被一起拖长。

我个人更倾向于先改事务边界:把与数据库一致性无关的耗时操作移出事务,更新完成后尽快提交;异常路径必须回滚;批量任务按可控批次提交。这样做通常比单纯把超时时间从3秒改成30秒更有效。

8.4 统一多行更新顺序

一次事务需要修改多条记录时,可以先对主键排序,再按固定顺序更新。订单扣减多个商品库存、账户间转账、批量修改任务状态,都适合先检查访问顺序是否一致。

8.5 对超时重试保持克制

重试可以处理少量瞬时冲突,但必须满足几个条件:当前事务已经完整回滚;操作具备幂等性;重试次数有限;两次重试之间有退避时间;日志能够关联原请求。没有这些前提,直接重试只会把热点行压得更重。

线上需要结束阻塞会话时,还要先确认事务归属和影响范围,不能只因为它排在阻塞链前面就直接处理。未提交事务可能包含已经完成但尚未持久化的业务操作,贸然中断会带来数据和业务风险。

9. 测试结论与适用范围

这次把并发更新单独拆出来后,几个现象比较明确。

第一,同一行更新发生冲突时,后执行的事务会等待持锁事务结束。会话A保持1500毫秒,三次会话B耗时分别为1501、1501、1502毫秒;会话A提交后,两次更新均正常完成。

第二,应用收到请求超时,并不等于已经知道了数据库根因。本次JDBC设置3秒后,实际约4秒返回-608,而V$TRXWAITV$LOCK才是确认锁等待的直接证据。

第三,固定更新顺序不能消除热点竞争,但能够避免程序自己制造交叉访问顺序。本次4批共160个并发事务全部成功,平均耗时保持在47.550~48.750毫秒,P95为64~66毫秒。

最后还有一个版本细节值得记录:本次DM9实例没有在V$DM_INI中查到LOCK_WAIT_TIMEOUTTRX_WAIT_TIME,所以本文没有给出未经验证的数据库参数修改命令。遇到类似问题时,参数名称和作用范围应以实际版本查询结果及对应官方文档为准。

说到底,锁等待排查不是看到“超时”就开始调参数。先把等待链找出来,确认谁持锁、事务为什么没有结束,再决定是调整事务边界、统一访问顺序、增加监控,还是设置有限重试。顺序对了,问题通常会清楚很多。

10lockwaitsummarychecklist.png


#达梦数据库 #达梦同行者征文

评论
后发表回复

作者

文章

阅读量

获赞

扫一扫
联系客服