这篇文章希望从实用性的角度,为大家提供在 OceanBase 观测锁的方法,顺便分析 OceanBase 的锁特性,以便用户更好理解锁与锁重试的机制。
同时,感谢 OceanBase 解决方案同学书水,以及产研同学------涧月、逸畅、亨元、龙吟、洛梵对文章的帮助和建议。
立即试用 OceanBase 企业版,体验国产数据库能力
一个问题:如何查询上锁的 SQL?
这个问题在运维上是个比较难处理的问题,具体场景如下:
DBA 发现一个 SQL 在等锁,错误码是 -6005,可以通过 GV$OB_LOCKS 视图来查到持有锁的那个 SESSION ID,但是通过 PROCESSLIST 视图却看到这个 SESSION 是 SLEEP 状态,不知道这个 SESSION 能不能杀,也无法确定这个是什么业务的请求,无法定位冲突的关联业务。
构造案例:
CREATE TABLE TEST_LOCK(
ID INT PRIMARY KEY,
VAL VARCHAR(100)
);
INSERT INTO TEST_LOCK VALUES(1,'K1'),(2,'K2'),
(3,'K3'),(4,'K4'),(5,'K5');
COMMIT;
-- AUTO COMMIT 是 OFF 的状态
-- 事务1 (先执行)
SELECT * FROM TEST_LOCK WHERE ID = 2 FOR UPDATE;
-- 事务2
SELECT ID FROM TEST_LOCK WHERE ID = 2 FOR UPDATE; -- 进入锁等待
上面事务 2 中的 SQL 被事务 1 的 SQL 上的锁堵塞了。
先通过这个 SQL 查出现在所有的锁对,"锁的对"指的就是持有锁的会话和申请锁的会话,会成对出现,往下看就清晰了。
SELECT
K.R_ID "锁对ID",
K3.ID "会话ID",
CASE WHEN K.TRANS_ID = K1.TRANS_ID THEN '申请锁'
WHEN K.ID1 = K1.TRANS_ID THEN '持有锁'
END AS "操作",
K1.TRANS_ID "事务ID",
ROUND(K1.CTIME /1000000,2) "持续时间(s)",
K3.INFO,
K3.TOP_INFO,
K3.RETRY_INFO,
K3.RETRY_CNT,
K2.DATABASE_NAME ,
K2.TABLE_NAME ,
K.ID3_2 "上锁行",
NOW() TIME_NOW,
DATE_SUB(NOW(), INTERVAL round(K1.CTIME / 1000000,3) SECOND) AS LOCK_TIME,
CASE WHEN K1.ID1 = K3.TRANS_ID THEN
concat(
'WITH KK AS (SELECT REQUEST_ID FROM OCEANBASE.GV$OB_SQL_AUDIT
WHERE TX_ID = ', K1.ID1 ,' )
SELECT /*+ USE_NL(KK K) */K.* FROM OCEANBASE.GV$OB_SQL_AUDIT K WHERE
SVR_IP = \'' , K1.SVR_IP , '''
AND SVR_PORT = ' , K1.SVR_PORT , '
AND TENANT_ID = ' , K1.TENANT_ID , '
AND REQUEST_ID IN (SELECT REQUEST_ID FROM KK)
AND REQUEST_TIME IS NOT NULL
AND UPPER(QUERY_SQL) LIKE \'%' , UPPER(K2.TABLE_NAME) , '%\';')
END AS DO_SQL
FROM
(SELECT
ROW_NUMBER() OVER () AS R_ID,K.TRANS_ID,K.ID1,K.ID3,
SUBSTR(K.ID3,1,INSTR(K.ID3,'-')-1) AS ID3_1,
SUBSTR(K.ID3,INSTR(K.ID3,'-')+1)AS ID3_2
FROM OCEANBASE.GV$OB_LOCKS K
WHERE K.TYPE = 'TR' AND K.BLOCK = 1) K
LEFT JOIN OCEANBASE.GV$OB_LOCKS K1 ON K.ID3 = K1.ID3
LEFT JOIN OCEANBASE.DBA_OB_TABLE_LOCATIONS K2 ON K.ID3_1 = K2.TABLET_ID AND ROLE = 'LEADER'
LEFT JOIN OCEANBASE.GV$OB_PROCESSLIST K3 ON K1.TRANS_ID = K3.TRANS_ID
ORDER BY K.R_ID,K1.TRANS_ID;
结果如图所示:

图1

图2
解释一下从上面这条 SQL 中查询出的内容:
- 锁对:就是指成对出现的持有者和申请者的编号(是我自己创建的编号,就是清晰表示他们是一对)
- 会话 ID:SESSION ID
- 操作:持有锁 / 申请锁
- 事务 ID:TRANS ID
- 持续时间:持有锁 / 申请锁 的持续时间
- INFO:会话正在执行的 SQL,可能是包含在个 PL 中的 SQL。
- TOP INFO:会话执行的外层 SQL,简单理解就是调用的 PL。
- RETRY INFO:重试的原因,等锁重试就是 -6005
- RETRY CNT:重试次数
- DATABASE NAME:锁的那张表的 SCHEMA
- TABLE NAME:锁住的表名
- 上锁行:指的是表的主键对应的值(锁上在主键上)
- TIME NOW:当下的查询时间
- LOCK_TIME:锁操作的时间,对于持有锁来说是开始持有的时间,对于申请锁来说是第一次申请的时间。
- DO_SQL:拷贝出来去 SQL_AUDIT 中查持有者是用什么 SQL 上的锁
主要注意的是,上面的查锁 SQL 只能查被行锁堵塞的情况。希望直接解开锁,可以 KILL 持有者的 会话 ID。如果想要知道持有者用什么 SQL 上的锁,拷贝出 DO_SQL 去执行即可。
COPY 出来的 SQL 如下:
WITH KK AS(SELECT REQUEST_ID FROM OCEANBASE.GV$OB_SQL_AUDIT
WHERE TX_ID = 14983239 )
SELECT /*+ USE_NL(KK K) */K.* FROM OCEANBASE.GV$OB_SQL_AUDIT K WHERE
SVR_IP = '11.161.204.62'
AND SVR_PORT = 2882
AND TENANT_ID = 1002
AND REQUEST_ID IN (SELECT REQUEST_ID FROM KK)
AND REQUEST_TIME IS NOT NULL
AND UPPER(QUERY_SQL) LIKE '%TEST_LOCK%';
不难理解,实际上就是查 GV$OB_SQL_AUDIT 中匹配 TX_ID 和 TEST_LOCK 表相关的 SQL,但是为什么要写成上面这样复杂的 SQL ?
当然是为了性能。
首先 OCEANBASE.GV$OB_SQL_AUDIT 这个表是内存里的虚表组装的视图,有联合主键( svr_ip , svr_port , tenant_id , request_id )
参考下面源码:
def_table_schema(
owner = 'xiaoyi.xy',
tablegroup_id = 'OB_INVALID_ID',
table_name = '__all_virtual_sql_audit',
rowkey_columns = [
('svr_ip', 'varchar:MAX_IP_ADDR_LENGTH'),
('svr_port', 'int'),
('tenant_id', 'int'),
('request_id', 'int'),
],
normal_columns = [
...
],
partition_columns = ['svr_ip', 'svr_port'],
vtable_route_policy = 'distributed',
index = {'all_virtual_sql_audit_i1' : { 'index_columns' : ['tenant_id', 'request_id'],
'index_using_type' : 'USING_BTREE','index_table_id' : '14992','index_table_id_ora' : '19999'}},
)
也就是按照给定主键各列的值作为 WHERE 条件是可以走 TABLE GET 算子的。
另外再解释一下为什么要在 WITH 的第一个表达式里单独获取 REQUEST_ID,原因是这样获取 REQUEST_ID 的效率是最高的,目前虚表不支持 filter pushdown,因此都需要把一行完整的数据先投影出来才能进行 filter 计算。
然而业务模型中通常虚表的 filter 列较少,而 output 列通常是表上所有的列。
简单的理解就是 SELECT REQUEST_ID FROM OCEANBASE.GV$OB_SQL_AUDIT WHERE TX_ID = 14983239; 虽然走了全表扫描,但是 output 除了主键以外就吐了一个列 TX_ID,快很多。
我自己的测试是,通常一个较大的 GV$OB_SQL_AUDIT,这样查可以从 4 分钟左右变成 20 秒内。
额外再说 2 个点:
- 研发同学已经针对虚表不支持 filter pushdown 这个点做出了优化,新版本发布之后马上就不需要绕弯子查了。目前没有的情况下可以考虑试试这个方式查询。
- 这样优化是否有效可以查看执行计划,如果 SQL_AUDIT 本身很小的话,比如小于 10w 行内容,那优化器可能还是不会选择 TABLE GET 算子的。
只要理解了原理,你就可以灵活地运用这个查询了。需要注意的是,因为 SQL_AUDIT 会因为容量限制淘汰最早的信息,所以如果事务是锁了很久的,则无法通过这个 SQL 获取到持有锁的相关信息。
一个有效的方法是按照某个周期(比如 1 分钟),把长时间持锁会话的上锁 SQL 相关 SQL_AUDIT 信息落盘。如果落盘就需要保证落盘 SQL 的性能,以免一个周期内完成不了落盘,正好可以用上面定制的 SQL。
两个锁特性
2.1锁是上在主键上的,是一行一行按照某个顺序上锁的
要理解这个特性,执行下面的 SQL 观测即可。
-- 事务1
SELECT * FROM TEST_LOCK FOR UPDATE;
-- 查看 SQL
SELECT * FROM OCEANBASE.GV$OB_LOCKS;
看图:

图3
- TYPE:TR 的意思是行锁。
- ID 3 这列显示了锁对应的主键值,前面的"203189"对应的是表的 TABLET_ID,后面的 INT:1 对应的是锁对应的主键上的主键值。(锁是上在主键上)
- CTIME:获取到锁以后的持续时间。
这里特别需要注意!每个 CTIME 的值都是不一样的,千万不要认为可以看出是每个行的锁获取的先后时间,这里的时间差来自于虚拟表的实现,由于每行数据都是临时构造的(其中 CTIME = current_time() - tx_ctx_create_time() ),每行的构造都获取了新的 current_time() ,tx_ctx_create_time() 相同的情况下,就造成了后构造的行,CTIME 大的现象。
说明: 在与研发沟通以后,后续版本将得到优化,上面红圈内的 CTIME 将保持一致,也就是抵消了行先后构造造成的 current_time 误差。
那么如何观察行是一行一行按照某个顺序上锁的?可以参考第三节的第二个实验,我会在实验后给出解释。
请注意,无法给出一个明确的上行锁顺序规则,因为情况复杂,比如开启的并行或者是分布式的情况有很多种可能。如果最简单的 SQL 则应该和主键扫描的顺序一致。
2.2 锁的重试,一般会由锁管理器来管理,也可能占着线程直接重试
简单来说,一个 SESSION 在执行 SQL 的过程中如果遇到锁,需要的是等待并按照某个时间周期重试,如果直接占着线程去重试(等于霸占一个CPU)性能上显然是不合适的,于是就有了一个叫做锁管理器的东西(Lock Wait Mgr),事务进入等锁状态就会把自己交给 Lock Wait Mgr 。
这样在等锁的时候,CPU 就可以用来给其他线程提供服务,等有需要重试的时候再唤醒相关的事务线程。(详见文末:OceanBase 官方文档)

图4
来看一个占用线程重试的案例:
-- 创建一个存过
DELIMITER $$
CREATE PROCEDURE JINCHUAN_TEST1()
BEGIN
SELECT 1 ;
SELECT ID FROM TEST_LOCK WHERE ID = 2 FOR UPDATE;
END$$
DELIMITER ;
-- AUTO COMMIT 是 ON 的状态【这个很重要!!!!!】
SET autocommit = ON;
-- 事务1 (先执行)
BEGIN;
SELECT * FROM TEST_LOCK WHERE ID = 2 FOR UPDATE;
-- 事务2
BEGIN;
CALL JINCHUAN_TEST1; -- 进入锁等待
这个时候你用查锁 SQL 去看,发现没有锁对!
直接去查 OCEANBASE.GV O B _ L O C K S 发现没有 B L O C K = 1 的行。这说明,该事务重试没有进入锁管理器,本身它这次的锁等待信息在 G V OB\_LOCKS 发现没有 BLOCK = 1 的行。这说明,该事务重试没有进入锁管理器,本身它这次的锁等待信息在 GV OB_LOCKS发现没有BLOCK=1的行。这说明,该事务重试没有进入锁管理器,本身它这次的锁等待信息在GVOB_LOCKS 中是查不到的。
补充 2 点:
- MYSQL租户 + 存储过程 + autocommit 开启,锁重试就会是占着线程的重试。(以后这个规则会不会改我不知道,如果你的实验失败也不要紧,理解这个实验要表达的含义更重要)
- 如果事务 A 锁 B,B 锁 C。B 占线程重试,C 用的锁管理器,那么 GV$OB_LOCKS 也能看到 B 的信息,但是这个锁对是 C 为申请者,B 为持有者。B 是申请者的锁对信息是看不到的。
进入锁管理器重试和占线程重试的几点重要区别如下:(以下简称管理器和占线程)
- 管理器对于 CPU 更友好,资源消耗少,占线程相反,占用 CPU 资源多。
- 管理器管理的锁等待会话(申请者)信息会出现在 GV$OB_LOCKS 视图中,占线程不会。
- 管理器的重试策略更灵活,可以更快发起重试(策略参加官方文档),而占线程只能按照普通的给定周期来重试。
- 管理器重试的 SQL 在 GV O B _ S Q L _ A U D I T 中只会出现一次,而占线程每次重试都会新写入 G V OB\_SQL\_AUDIT 中只会出现一次,而占线程每次重试都会新写入 GV OB_SQL_AUDIT中只会出现一次,而占线程每次重试都会新写入GVOB_SQL_AUDIT,使得 GV$OB_SQL_AUDIT 信息激增。
简单给个结论,进入锁管理的重试是更好的情况,不过你也需要知道还有占用线程的重试,千万不要一查 GV$OB_LOCKS 里没有 BLOCK = 1 的行就慌了。
接下来我们来看几种稍复杂的情况,更多探索一下锁的机制。
三种相关的复杂情况
3.1 多事务多重等锁,要找到持有锁的源头
构造方式:
-- 按顺序操作事务
-- 事务1
SELECT * FROM TEST_LOCK WHERE ID = 2 FOR UPDATE;
-- 事务2
SELECT * FROM TEST_LOCK WHERE ID = 3 FOR UPDATE;
SELECT * FROM TEST_LOCK WHERE ID = 2 FOR UPDATE; -- 被事务1卡
-- 事务3
SELECT * FROM TEST_LOCK WHERE ID = 3 FOR UPDATE; -- 被事务2卡
查询方式:
WITH RECURSIVE ANCESTRY_PATH
(START_CHILD,CURRENT_PARENT,GENERATIONS)AS(
SELECT TRANS_ID, ID1, 1
FROM OCEANBASE.GV$OB_LOCKS
WHERE TYPE = 'TR' AND BLOCK = 1
UNION ALL
SELECT AP.START_CHILD, PC.ID1, AP.GENERATIONS + 1
FROM ANCESTRY_PATH AP
JOIN OCEANBASE.GV$OB_LOCKS PC ON AP.CURRENT_PARENT = PC.TRANS_ID
AND TYPE = 'TR' AND BLOCK = 1
)
SELECT
AP.START_CHILD AS '等待事务ID',
AP.CURRENT_PARENT AS '最终持锁事务ID',
AP.GENERATIONS AS '迭代查找次数'
FROM ANCESTRY_PATH AP
WHERE AP.CURRENT_PARENT NOT IN(
SELECT TRANS_ID
FROM OCEANBASE.GV$OB_LOCKS WHERE TYPE = 'TR' AND BLOCK = 1);
看图:

图5
不同的等待事务都指向了同一个持锁事务 ID,那么只要使用上面的查锁 SQL 查询,然后找到对应的事务 ID,再使用 DO_SQL 就可以知道对应的上锁 SQL 了。
3.2 持有锁的持续时间比申请锁的持续时间短的情况
按常理先到先得,先申请的没拿到,凭什么后来的人先拿到?
构造方式:
-- 按顺序操作事务
-- 事务1
SELECT * FROM TEST_LOCK WHERE ID = 3 FOR UPDATE;
-- 事务2
SELECT * FROM TEST_LOCK WHERE ID >= 2 FOR UPDATE; -- 被事务1卡
查锁 SQL:

图6
这个时候,事务2被卡在了 id=3 上。
-- 继续执行
-- 事务3
SELECT * FROM TEST_LOCK WHERE ID = 2 FOR UPDATE;
查锁 SQL:

图7
现在事务 2 被卡在 id=2 上,正如标题中写的,事务3是后申请锁的事务,但是先得到了锁还把先申请锁的事务给卡住了。
这个案例,说明了2个点:
(1)事务 2 需要在行 ID = 2、3、4、5 上获取行锁,只要其中一个拿不到,已经拿到的也会放弃,不然事务3是无法获取 ID =2 的锁的。
(2)每次重试获取锁的时候都是按照 2、3、4、5 的顺序一个个去获取的,这样才会显示锁冲突在 ID 更小的2这行上。
相信你可以体会到锁是按某个顺序一行一行申请,一行一行获取的了。
3.3 PL 的锁重试机制,整块重试
写这个例子时候我比较纠结,因为 PL 的锁重试比较复杂,要写清楚需要给到很多篇幅去铺垫前置知识点。我思考以后,决定就留下一个最典型的例子,你需要记住的就是 PL 的锁重试很复杂,但大概率是 PL 块重头重试,之前 PL 中已经执行的 SQL 会被回滚(当然需要满足回滚条件)。
看下面的例子:
查看下表现在的情况:

图8
构造方式:
-- 创建一个存过
DELIMITER $$
CREATE PROCEDURE JINCHUAN_TEST2()
BEGIN
DECLARE KA VARCHAR(200);
SELECT VAL INTO KA FROM TEST_LOCK WHERE ID = 3 FOR UPDATE;
UPDATE TEST_LOCK SET VAL = KA WHERE ID = 4;
SELECT * FROM TEST_LOCK WHERE ID = 2 FOR UPDATE;
COMMIT;
END$$
DELIMITER ;
-- AUTO COMMIT 是 OFF 的状态【这个很重要!!!!!】
SET autocommit = OFF;
-- 按顺序操作事务
-- 事务1
SELECT * FROM TEST_LOCK WHERE ID = 2 FOR UPDATE;
-- 事务2
SELECT * FROM TEST_LOCK WHERE ID = 4 FOR UPDATE;
CALL JINCHUAN_TEST2;
这个时候事务 2 中存储过程 JINCHUAN_TEST2 里的 SELECT ID INTO KB FROM TEST_LOCK WHERE ID = 2 FOR UPDATE; 这句 SQL 会被卡住。
-- 继续执行
-- 事务3
UPDATE TEST_LOCK SET VAL = 'kkk' WHERE ID = 3;
COMMIT;
-- 事务1
ROLLBACK;
等事务全部执行完成后,看下表最后的情况:

图9
后面事务 3 可以执行 DML 成功就说明了 PL 里的 SELECT VAL INTO KA FROM TEST_LOCK WHERE ID = 3 FOR UPDATE; 这句 SQL 在重试的时候是被回滚的,否则就会锁住事务 3 的修改。
简单的结论就是 PL 的执行还是需要注意尽量不要里面包含过长的事务,长事务不但容易被锁,而且还会被回滚很多的 SQL
ONE MORE THING
写文章的过程中,为了清晰地说明一些机制,请教了好几个研发同学。
我也深刻体会到数据库的复杂性,因为一个主题可能涉及到好几个功能模块,都是不同的研发各自负责的,一个问题也需要几人一起才说的清楚。
这时候,我觉得距离业务一线最近的售后,其实是很重要的,能更好的把业务主题串起来,发现数据库产品可以优化的地方,从而帮助产品越做越好。
最后,把文章里 2 个查锁的工具 SQL 的 ORACLE 租户版本贴出来,目的是为了方便更多使用 OceanBase 的人~
-- oracle 租户版查锁 SQL
SELECT
K.R_ID "锁对ID",
K3.ID "会话ID",
CASE WHEN K.TRANS_ID = K1.TRANS_ID THEN '申请锁'
WHEN K.ID1 = K1.TRANS_ID THEN '持有锁'
END AS "操作",
K1.TRANS_ID "事务ID",
ROUND(K1.CTIME /1000000,2) "持续时间",
K3.INFO,
K3.TOP_INFO,
K3.RETRY_INFO,
K3.RETRY_CNT,
K2.DATABASE_NAME ,
K2.TABLE_NAME ,
K.ID3_2 "上锁行",
SYSDATE TIME_NOW,
(SYSTIMESTAMP - K1.CTIME / 86400000000 ) AS LOCK_TIME,
CASE WHEN K1.ID1 = K3.TRANS_ID THEN
'WITH KK AS (SELECT REQUEST_ID FROM GV$OB_SQL_AUDIT
WHERE TX_ID = ' || K1.ID1 || ' )
SELECT /*+ USE_NL(KK K) */ K.* FROM GV$OB_SQL_AUDIT K WHERE
SVR_IP = ''' || K1.SVR_IP || '''
AND SVR_PORT = ' || K1.SVR_PORT || '
AND TENANT_ID = ' || K1.TENANT_ID || '
AND REQUEST_ID IN (SELECT REQUEST_ID FROM KK)
AND REQUEST_TIME IS NOT NULL
AND UPPER(QUERY_SQL) LIKE ''%' || K2.TABLE_NAME || '%'';'
END AS DO_SQL
FROM
(SELECT
ROWNUM AS R_ID,K.TRANS_ID,K.ID1,K.ID3,
SUBSTR(K.ID3,1,INSTR(K.ID3,'-')-1) AS ID3_1,
SUBSTR(K.ID3,INSTR(K.ID3,'-')+1)AS ID3_2
FROM GV$OB_LOCKS K
WHERE K.TYPE = 'TR' AND K.BLOCK = 1) K
LEFT JOIN GV$OB_LOCKS K1 ON K.ID3 = K1.ID3
LEFT JOIN DBA_OB_TABLE_LOCATIONS K2 ON K.ID3_1 = K2.TABLET_ID AND ROLE = 'LEADER'
LEFT JOIN GV$OB_PROCESSLIST K3 ON K1.TRANS_ID = K3.TRANS_ID
ORDER BY K.R_ID,K1.TRANS_ID;
-- 递归找锁源头的 SQL
WITH ANCESTRY_PATH
(START_CHILD,CURRENT_PARENT,GENERATIONS)AS(
SELECT TRANS_ID, ID1, 1
FROM GV$OB_LOCKS
WHERE TYPE = 'TR' AND BLOCK = 1
UNION ALL
SELECT AP.START_CHILD, PC.ID1, AP.GENERATIONS + 1
FROM ANCESTRY_PATH AP
JOIN GV$OB_LOCKS PC ON AP.CURRENT_PARENT = PC.TRANS_ID
AND TYPE = 'TR' AND BLOCK = 1
)
SELECT
AP.START_CHILD AS "等待事务ID",
AP.CURRENT_PARENT AS "最终持锁事务ID",
AP.GENERATIONS AS "迭代查找次数"
FROM ANCESTRY_PATH AP
WHERE AP.CURRENT_PARENT NOT IN(
SELECT TRANS_ID
FROM GV$OB_LOCKS WHERE TYPE = 'TR' AND BLOCK = 1);
立即试用 OceanBase 企业版,体验国产数据库能力