在 OceanBase 中定位上锁 SQL 并分析锁重试的机制

这篇文章希望从实用性的角度,为大家提供在 OceanBase 观测锁的方法,顺便分析 OceanBase 的锁特性,以便用户更好理解锁与锁重试的机制。

同时,感谢 OceanBase 解决方案同学书水,以及产研同学------涧月、逸畅、亨元、龙吟、洛梵对文章的帮助和建议。

立即试用 OceanBase 企业版,体验国产数据库能力

180 天免费试用,零门槛开通

一个问题:如何查询上锁的 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 企业版,体验国产数据库能力

180 天免费试用,零门槛开通