上个月在某客户现场,应用侧突然报出一片锁等待超时。我登录达梦8数据库,手工翻了半天V$LOCK和V$SESSION,才勉强拼出谁堵了谁。当时就在想:达梦8的锁阻塞巡检SQL,怎么就没有一份像样的现成脚本可用?网上的案例大多拿Oracle思维硬套,字段对不上,跑出来一片红。后来我花了两天时间,把达梦8的动态视图翻了个遍,又结合实际故障反复迭代,沉淀出一套“最终锁阻塞巡检SQL”。这篇就把它完整拆开讲,包含三条可落地的SQL、结果解读思路、根因判断方法和止血操作。不管你是刚接手达梦8的DBA,还是正在做国产数据库迁移的运维同学,这篇都能让你少走很多弯路。
1. 一次锁风暴之后,我为什么要另起炉灶写巡检SQL
1.1 事故现场的“三分钟恐慌”
那天下午三点半,业务方在群里说订单查询接口大量超时,紧跟着监控告警刷屏。我第一反应是数据库卡了,结果一看系统CPU和IO都很低,内存也正常,这就有意思了——负载不高但SQL堆积,十有八九是锁阻塞。
于是我开始翻达梦8的动态视图。说实话,达梦8的锁相关视图和Oracle长得有点像,但细节差异很大。Oracle里V$LOCK的SID、TYPE、ID1、ID2一眼就能看出锁结构,而达梦8的锁信息分散在V$TRX、V$LOCK、V$SESSION三张视图里:V$TRX里能看到事务状态和等待的锁标识,V$LOCK里能看到锁的类型和被等待情况,V$SESSION里才有会话、SQL和客户端IP。当时我手动关联三张表翻了一个多小时,才确认是一个应用连接池里的陈年事务没有提交,堵住了一张订单表的行锁。
从那次之后我就决定,必须整出一套能一键理清锁关系的巡检SQL。手工翻会话的时代必须结束。
1.2 系统视图初探:达梦8的锁信息到底藏在哪里
很多从Oracle转过来的DBA,第一反应是去V$LOCK里找SID和TYPE,但在达梦8上会碰壁。我建议先把三张核心视图的字段摸一遍再说。
V$TRX是事务视图,核心字段包括ID(事务ID)、STATUS(事务状态)、WAIT_LTID(当前正在等待的锁事务标识,为空就是没在等锁)、LAST_SEND_TIME和LAST_RECV_TIME。V$LOCK是锁视图,核心字段包括LTID(锁事务标识)、LOCK_TYPE(锁类型)、TABLE_NAME(锁涉及的表)、MODE(锁模式)、BLOCKED(是否阻塞了别人)、SESS_ID(持锁会话ID)。V$SESSION则是会话视图,核心字段包括SESS_ID、TRX_ID(当前事务ID)、SQL_TEXT(当前执行的SQL)、CLNT_IP(客户端IP)、CREATE_TIME(会话创建时间)、STATE(会话状态)。
注意,V$LOCK里的LTID和V$TRX里的WAIT_LTID是同一套锁标识体系,这就是我们打通锁链路的关键钥匙。但这两个字段的格式通常是“数字-数字”,前半段是实例相关信息,后半段才是事务序号,直接和V$TRX.ID做等值关联往往会失败。这一点下面会专门演示。
1.3 诊断三步法:等待者→持锁者→根因
排查锁阻塞,我习惯用三步法。第一步,列出所有正在等待锁的事务和会话,这些是“被堵的人”。第二步,对每个被等待的锁标识,找到持有它的会话,这些是“堵路的人”。第三步,如果堵路的人自己也在等待另一把锁,就继续向上递归,直到找到那个既不等待别人、又持有锁的根会话。
这套方法本质上和Oracle的阻塞树分析一模一样,只是落地到达梦8上,查询语句要根据视图字段重新设计。下面的最终版SQL,就是按这三步组织起来的。
2. 最终版SQL拆解:先找等待者,再锁阻塞源,最后串成链
2.1 先从等待者入手:V$TRX WAIT_LTID全量清单
第一条SQL,站在被阻塞事务的角度,把正在等待锁的事务全部拉出来。这一步相当于问数据库:“现在谁在排队?”
SELECT t.id AS wait_trx_id, t.status AS wait_trx_status, t.wait_ltid AS wait_ltid, s.sess_id AS wait_sess_id, s.sql_text AS wait_sql, s.clnt_ip AS wait_client, s.clnt_type AS wait_client_type, s.create_time AS wait_sess_create_time, (SYSDATE - s.create_time) * 86400 AS wait_sess_seconds FROM v$trx t JOIN v$session s ON s.trx_id = t.id WHERE t.wait_ltid IS NOT NULL ORDER BY wait_sess_seconds DESC;这里有个关键点:不是所有等待锁的事务都代表“故障”。正常业务里偶尔几毫秒的锁等待是常态,但这个视图只反映当前瞬间还在排队的事务,所以看到WAIT_LTID非空的记录,就说明确实有东西堵住了别人。之所以按会话创建时长倒序,是因为等得越久,业务超时的风险越大,越需要优先处理。
2.2 再从持锁者收口:V$LOCK BLOCKED=1锁定源头
第二条SQL,站在锁本身的角度,找出所有正在阻塞其他会话的锁和持锁会话。这一条是诊断的关键。
SELECT l.ltid AS lock_id, l.lock_type AS lock_type, l.table_name AS lock_table, l.mode AS lock_mode, l.blocked AS is_blocking, h.sess_id AS holder_sess_id, h.sql_text AS holder_sql, h.clnt_ip AS holder_client, h.clnt_type AS holder_client_type, h.create_time AS holder_sess_create_time, h.state AS holder_state, ht.status AS holder_trx_status, ht.used_undo_size AS holder_undo_size FROM v$lock l JOIN v$session h ON h.sess_id = l.sess_id LEFT JOIN v$trx ht ON ht.id = h.trx_id WHERE l.blocked = 1 ORDER BY h.create_time ASC;V$LOCK里的BLOCKED字段是这个SQL的灵魂。它表示“这把锁当前是不是被别的会话在等待”。只要BLOCKED=1,就说明这把锁的持有者就是造成阻塞的源头。ORDER BY h.create_time ASC是为了把“持有锁时间最长”的会话排在最前面,因为它引发的阻塞扩散范围通常也最大。
2.3 一条SQL串起整条等待链
前两条SQL分开看已经能定位大部分问题,但效率还不够高。于是我把等待者和持锁者直接串成一条等待关系链,一条SQL输出两边信息。
SELECT w.sess_id AS wait_sess_id, w.sql_text AS wait_sql, w.clnt_ip AS wait_client, t.wait_ltid AS wait_lock_id, l.lock_type AS lock_type, l.table_name AS lock_table, l.mode AS lock_mode, h.sess_id AS hold_sess_id, h.sql_text AS hold_sql, h.clnt_ip AS hold_client, h.create_time AS hold_sess_create_time, ht.status AS hold_trx_status FROM v$trx t JOIN v$session w ON w.trx_id = t.id JOIN v$lock l ON l.ltid = t.wait_ltid JOIN v$session h ON h.sess_id = l.sess_id LEFT JOIN v$trx ht ON ht.id = h.trx_id WHERE t.wait_ltid IS NOT NULL;如果这条SQL能正常跑出结果,说明V$LOCK的LTID和V$TRX的WAIT_LTID格式完全一致,可以直接关联。但我遇到过某些版本里两边的LTID格式有差异,WAIT_LTID多了一段前缀。这时候不要急,有两种办法:一是先分别执行V$TRX和V$LOCK的查询,把两边LTID的样本各拿出来对比,确认格式差异;二是用LIKE或者字符串截断方式做近似关联,比如只截取后半段事务号部分。
2.4 字段核对:不同小版本的动态视图可能不一样
达梦8的动态视图在不同小版本里,字段确实有过微调。比如有的版本V$LOCK里有SESS_ID,有的写法里则需要通过事务ID反查会话。所以拿到SQL后先别急着在生产库上跑,建议先执行下面这条确认字段:
SELECT COLUMN_NAME, DATA_TYPE FROM ALL_TAB_COLUMNS WHERE TABLE_NAME IN ('V$TRX', 'V$LOCK', 'V$SESSION') ORDER BY TABLE_NAME, COLUMN_ID;这一步几分钟就能做完,但能避免生产环境上SQL直接报错。我自己的脚本里也加了一个变量存字段名,每次换环境先跑一次字段核对,再决定用哪种关联方式。这不是多余,踩过坑的人都会懂。
3. 输出结果里的门道:锁类型、模式和状态字符怎么看
3.1 锁类型:TID、TID_EXT、OBJ、TAB都代表什么
拿到SQL结果后,很多人第一反应是看表名,其实锁类型才是判断严重程度的第一步。达梦8常见的锁类型有这么几类。
TID锁是事务锁,也就是行级锁。对某一行做UPDATE、DELETE、SELECT FOR UPDATE时,会在对应行上打TID锁。这是业务运行中最常见的锁,出现阻塞通常和具体某一行有关。TID_EXT是TID扩展锁,一般出现在需要锁定更大范围数据的场景,比如批量更新跨越了大量行,或者某些DDL操作需要避免行锁和表结构变更冲突时。TID_EXT的出现往往意味着锁范围已经不止一两行,值得警惕。
OBJ锁是对象锁,典型场景是ALTER TABLE、CREATE INDEX这类DDL执行期间,需要对整个对象加锁。TAB锁是表锁,通常来自显式LOCK TABLE语句,或者锁升级后的结果。
判断危险程度很简单:如果锁类型是TID且涉及的行很少,多半是普通行锁竞争;如果出现TAB和TID_EXT,说明锁的范围在扩张,影响面可能很大,要赶紧看SQL。
3.2 MODE数字和STATE状态怎么看
V$LOCK的MODE字段是数字,代表锁的模式。达梦8的锁模式语义和Oracle接近:数字越大,锁的排他性越强。一般看到S(共享锁)大多是读场景下的对象锁,看到X(排他锁)或SRX(共享行排他)就要小心,因为写操作经常申请这类锁,冲突概率高。
实际巡检时,我不太纠结MODE的具体数字对应哪一档,更关注的是它和SQL类型的匹配关系。比如一个UPDATE语句持有X锁,这是正常的;但一条普通的SELECT试图申请一个排他级模式的锁,那就要检查是不是程序里写错了锁渲染级别。
V$SESSION的STATE字段同样重要。常见值有ACTIVE和IDLE。ACTIVE表示会话正在执行操作,IDLE表示会话空闲但连接还保持着。一个持锁会话如果STATE是IDLE但事务状态是ACTIVE,这是最典型的应用连接池连接未释放信号,后面处理章节会展开。
3.3 拿一个实际输出示例逐行读一遍
假设巡检SQL输出了下面这样一行:
| wait_sess_id | wait_sql | wait_lock_id | lock_type | lock_table | hold_sess_id | hold_sql | hold_trx_status |
|---|---|---|---|---|---|---|---|
| 5213 | UPDATE orders SET status=1 WHERE order_no='A1001' | 580-2089 | TID | orders | 5177 | (空) | ACTIVE |
先看wais_sess_id 5213,它正在执行一条订单更新,等的是TLID 580-2089。对应V$LOCK里同一条TLID,持锁会话是5177。奇怪的是5177的SQL_TEXT为空,但持锁事务状态是ACTIVE。空SQL一般意味着会话当前没有在跑任何语句,可事务又是ACTIVE的,说明5177之前执行了一条DML,之后迟迟没有提交或回滚,连接就挂在连接池里了。结合CLNT_IP指向某个应用服务器,基本可以断定是应用侧漏了提交。
这时候把顺序理一下:应用服务器上的请求一直在改A1001这行,但最早的改动一直没提交,后来的改动全部在行锁上排队。业务表现为订单接口超时,数据库负载反而不高。
3.4 严重程度判断标准
我在日常巡检总结了一套简单的分级标准。轻微:TID锁阻塞,等待会话数≤3,等待时间几十秒内,一般等持锁事务提交就自动恢复,可以不干预。中等:TID_EXT或TAB锁出现,多个表同时出现等待,等待时间超过几分钟,业务已经出现零星超时,需要介入。严重:阻塞链超过两层,同一把锁堵住大量会话,或者出现死锁报错,业务大面积超时,必须立即定位根会话并处理。
这套标准不绝对,但能帮你在告警刷屏时快速决定“等一等”还是“马上动手”。
4. 从结果到处理:先分清场景,再决定动刀方式
4.1 场景A:持锁会话空闲但事务ACTIVE——连接泄漏
这是最常见的锁故障来源。结果里看到持锁会话SQL_TEXT为空、STATE为IDLE,但V$TRX状态ACTIVE,基本就是应用连接池里的连接把事务带在了身上,却一直没有提交。常见原因包括:程序里手动开启事务后没有写COMMIT;异常分支没有走finally块导致事务没释放;连接池取了连接后业务代码没正常归还。
处理方式:先联系应用负责人确认连接池配置和对应服务节点,然后评估是否可以直接关闭会话。如果这个陈年事务已经存在很久,业务影响已经形成,我会优先使用SP_CLOSE_SESSION把会话关掉,事务自动回滚。这是达梦8里的标准会话终止手段。
SP_CLOSE_SESSION(5177);执行后可以再跑一遍第二条SQL,确认BLOCKED=1的记录消失。大部分情况下,几分钟内锁风暴就解除了。
4.2 场景B:持锁会话在跑大事务——评估还是动手
如果持锁会话的STATE是ACTIVE,SQL_TEXT里是一条正在执行的UPDATE或DELETE,且已经运行了很长时间,情况就复杂一些。一方面,这个会话可能还在分批处理数据,人为终止会丢掉全部进度;另一方面,它每多执行一秒,后面排队的会话就多遭殃一秒。
我的建议是先看事务的开始时间和已产生的UNDO大小。如果事务刚启动不久,SQL也正常,可以给两分钟观察;如果事务已经跑了很久,或者明显是个性能很差的计划在跑,那就需要让业务确认是否可以取消。取消操作也还是SP_CLOSE_SESSION,但要提醒:大事务回滚本身可能需要时间,回滚期间依然会阻塞其他会话。实际操作中,回滚一个跑了半小时的大事务,可能比继续执行还慢。这种情况下,有经验的DBA会先评估回滚预估时间,再决定是否联系业务。决策一定要和业务方一起做,不要DBA单方面下手。
4.3 场景C:持锁者自己也在等待另一把锁——递归锁链与死锁
有时候你按BLOCKED=1找到持锁会话,结果发现这个会话自己也有一条WAIT_LTID,它也在等另一把锁。这就说明阻塞不是单点,而是串了一条链。这时候不能只处理第一层,要顺着链条找到最上游那个“不等待别人但持有锁”的根会话。
如果链条最后绕成一个环——A等B、B等C、C等A,那就是死锁。达梦8有死锁检测机制,会自动回滚其中一个事务,但检测间隔内业务已经受累了。对于这种场景,光靠SP_CLOSE_SESSION太被动,正确做法是把整条链上的会话都列出来,优先处理最上游的根会话,然后观察下层等待是否自动消散。
4.4 止血工具:SP_CLOSE_SESSION与LOCK_WAIT_TIMEOUT
SP_CLOSE_SESSION是达梦8关闭会话的标准存储过程,参数是会话ID。它比直接KILL进程更安全,因为数据库会收到明确的关闭请求,回滚事务后释放资源。执行前我习惯先看会话对应的事务状态,确认回滚影响面。如果只是一个空闲连接挂着一个未提交事务,直接关闭风险很小。如果是一个正在执行批量操作的事务,一定要和业务沟通后再动手。
除了手动止血,还建议设置锁等待超时参数。达梦8里LOCK_WAIT_TIMEOUT决定事务等待锁的最长时间,超过后直接报错而非无限等待。生产环境我通常建议设置为30秒到60秒,避免一个锁故障把所有会话全部拖死。设置方式可以直接用达梦管理工具的参数配置页,也可以执行系统参数修改语句,改完后通过V$PARAMETER确认。
4.5 一个决策表,方便生产环境照着做
| 持锁会话STATE | 持锁事务STATUS | 最可能原因 | 建议处理 |
|---|---|---|---|
| IDLE | ACTIVE | 应用连接未释放、事务未提交 | 联系应用确认,必要时SP_CLOSE_SESSION |
| ACTIVE | ACTIVE | 正在执行大事务或慢SQL | 评估SQL和回滚成本,与业务协商后决定 |
| IDLE | NULL | 可能是DDL或临时对象锁 | 确认对象和来源,谨慎处理 |
| ACTIVE | WAIT | 持锁者本身也在等另一把锁 | 沿阻塞链上溯,先处理根会话 |
5. 常规化巡检:参数调优、定时脚本和基线管理
5.1 两个值得关注的参数:LOCK_WAIT_TIMEOUT和DEADLOCK_CHECK_INTERVAL
锁巡检SQL本身是“事后诊断工具”,但在生产环境里,更重要的是把“事后”变成“事前”。LOCK_WAIT_TIMEOUT和DEADLOCK_CHECK_INTERVAL这两个参数,我每次新环境接手都会确认一遍。
LOCK_WAIT_TIMEOUT,单位秒,表示一个事务在等待锁时最多能等多久。默认值在不同初始化参数组里可能不一样,如果不小心是-1,那就是无限等待。脚本如果刚好踩到一个长期未释放的锁,所有后续操作全部挂住,业务表现就是“整个库像死了一样”。所以我的习惯是:生产环境一定要显式设置一个有限值,30到60秒比较平衡。DEADLOCK_CHECK_INTERVAL则是死锁检测的触发间隔。间隔太短会增加系统开销,太长又会让死锁影响时间变长。我一般保持默认,只有在死锁高发的情况下才调小。
这两个参数最好不要在运行中反复修改,确认一次后写进初始化参数文件,避免实例重启后恢复默认值。
5.2 定时巡检脚本的思路
巡检SQL写好了,如果只在出故障时才想起来跑,价值就砍了一半。我建议把它做成一个定时任务,每分钟或者每五分钟执行一次,把输出结果落到日志文件,再写个简单的阈值判断。这里是一个用disql定时执行的核心思路:
#!/bin/bash DISQL="/home/dmdba/dmdbms/bin/disql" OUTPUT="/tmp/lock_check_$(date +%Y%m%d%H%M%S).log" $DISQL SYSDBA/SYSDBA@localhost:5236 <<EOF SPOOL ${OUTPUT} SELECT t.id AS wait_trx_id, t.wait_ltid, s.sess_id AS wait_sess_id, s.sql_text AS wait_sql FROM v$trx t JOIN v$session s ON s.trx_id = t.id WHERE t.wait_ltid IS NOT NULL; SPOOL OFF; EOF这样一旦告警出现,DBA能直接翻最近几分钟的锁历史,而不是等故障发生了再临时去捞快照。配合操作系统的定时任务,这套脚本完全可以当轻量级监控用。
5.3 基线数据与历史记录
锁阻塞的巡检不能只看“有没有”,还要看“多不多”。我建议建一张锁事件历史表,字段包括记录时间、等待会话数、阻塞源会话、锁类型、涉及表。每次巡检脚本跑完,就把结果插入历史表。
半个月之后你再去查这张表,就能看到某个业务表的锁等待频率是不是在上升。我之前就是靠这种历史数据,提前发现一个核心表在业务高峰期经常出现TID锁排队,后续推动了应用侧优化更新逻辑,把大事务拆成了小事务。没有历史基线,这种趋势根本看不出来。
5.4 图形化视图辅助验证
最后分享一个偷懒技巧。达梦8自带的管理工具里,在“会话”页面勾选“显示锁等待”,能直接看到会话之间的等待关系图,比SQL输出更直观。但图形界面有个问题:会话一多,关系线密密麻麻,反而看不清。所以我通常先用SQL输出拿到阻塞源会话ID,再在图形工具里定位那个会话,双击看它的SQL和连接属性。两条路配合着用,效率最高。
6. 巡检SQL之外,我踩过的几个坑
6.1 以为V$LOCK只有一行,其实每把锁可能多行
第一次拿V$LOCK查阻塞源时,我以为一个持锁事务就只有一条锁记录。后来发现同一个事务对同一张表的多个行加锁,会输出多行记录,而且V$LOCK里相同LTID可能出现多次。如果你只看第一行就下结论,很可能把锁涉及的表搞错。正确的做法是锁关联时以LTID和SESS_ID为维度去重,再汇总TABLE_NAME和MODE,才能看到这个持锁事务到底锁了哪些对象。
6.2 SQL_TEXT为空不代表会话没干活
V$SESSION里的SQL_TEXT字段反映的是当前正在执行的语句。如果一个会话执行完UPDATE后停留在“等待客户端命令”状态,它的SQL_TEXT就是空的。这在连接池场景里太常见了。所以要判断一个会话有没有持锁,不能只看SQL_TEXT,还必须看V$TRX里对应事务的STATUS。事务状态是ACTIVE,锁就在;SQL_TEXT只是锦上添花。
6.3 把锁等待误判成慢SQL
锁等待和慢SQL的症状很像,都是SQL堆积,但处理思路完全相反。慢SQL要优化执行计划,加索引;锁等待要找到持锁事务并协调释放。如果判断错了,对着一条等待锁的SQL反复调索引,问题一点都不会好转。区分方法还是看系统负载:慢SQL通常伴随CPU或IO升高,锁等待则往往负载不高但等待事件大量出现。再加上我们前面说的V$TRX.WAIT_LTID,一查便知。
6.4 统计信息过期导致的锁范围扩大
有一次巡检发现一张只有几千行的配置表频繁出现TAB锁,当时百思不得其解。最后发现是因为统计信息过期,优化器选择了全表扫描更新路径,导致锁范围从原来的行锁级别膨胀到了表锁级别。虽然结果上还是能跑,但阻塞范围完全变了。这个问题用巡检SQL能发现现象,根因排查要看执行计划。所以每次处理完锁阻塞,我都会顺手看一眼相关SQL的执行计划,避免同样的问题换个马甲再回来。
6.5 版本升级后脚本字段变化
达梦8升级过一个小版本后,我的脚本突然报“列不存在”。仔细核对才发现,V$SESSION的结构有调整,个别字段挪了位置,还有一两个字段名变化。从那以后,我养成了每次版本升级后先跑一次字段核对SQL的习惯。这也再次印证了第四节开头说的:换环境、升版本之后,先用ALL_TAB_COLUMNS确认字段,别拿老脚本直接压生产。
最后再分享一个小技巧。我在生产环境落地这套巡检SQL时,额外建了一张锁事件记录表,脚本每次执行只插入“非正常等待”的会话,正常几毫秒的瞬时等待一律过滤。一个月下来,这张表就是一份非常宝贵的锁健康档案。你在排障的时候手忙脚乱翻实时输出,不如提前把历史上每一次锁故障都记下来,下次再遇到同类问题,直接查档案,比临时分析快得多。达梦8的锁体系不难,难的是把诊断过程沉淀成可复用的工具,希望这套SQL和思路能帮你把这块短板补上。