0
0
0
0
专栏/.../

记一次悲观锁重试到达上限报错分析

 weiyinghua  发表于  2025-06-05

一、背景

以下是 MySQL 或 TiDB 常见的锁等待超时,熟悉 MySQL 的伙伴应该比较容易理解:

CREATE TABLE testA (
    id bigint not null AUTO_INCREMENT PRIMARY KEY,
    val VARCHAR(255) NOT NULL
);
-- 写 5000 行数据

session A

session B

T1

begin;

begin;

T2

UPDATE testA  SET val = '666'  WHERE id = 1;

T3

UPDATE testA SET val = '888' WHERE id BETWEEN 1 AND 5000;

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

但目前有这么个场景,TiDB 只有两个会话 A 和 B:

会话 A 是短事务,单线程循环执行只读的 SELECT ... FOR UPDATE 语句; 会话 B 是长事务,仅执行一次更新操作,最终并未报 Lock wait timeout 错,而是报了 pessimistic lock retry limit reached

只有两个会话时,第一感觉是会话 B 不会达到最大重试次数报错,而是锁等待超时。接下来进行复现并分析原因。

二、问题复现

由于 pessimistic lock retry limit reached 报错和 max-retry-count 配置有关。为更容易复现把配置 max-retry-count 改小,含义是:悲观事务中单个语句最大重试次数,重试次数超过该限制,语句执行将会报错:

tidb 6.5.8> show config where name like '%max-retry-count%';           
+------+--------------------+---------------------------------+-------+
| Type | Instance           | Name                            | Value |
+------+--------------------+---------------------------------+-------+
| tidb | xxx.xx.xx.xxx:4000 | pessimistic-txn.max-retry-count | 1     |
+------+--------------------+---------------------------------+-------+

通过大模型辅助生成的代码可轻松复现,以下是复现的 SQL。同样只有两个会话 A 和 B,会话 A 是短事务,会话 A 先单线程循环执行只读的 SELECT ... FOR UPDATE 语句:

-- 会话 A
begin;
    SELECT val 
    FROM testA 
    WHERE id =(随机选取 1 到 5000 之间的整数)FOR UPDATE;
commit;

会话 B 是长事务,跑一次就有报错如下:

-- 会话 B
begin;
    UPDATE testA
    SET val = ?
    WHERE id BETWEEN 1 AND 5000;
    
    Error 1105 (HY000): pessimistic lock retry limit reached

三、报错如何产生?

TiDB 的悲观锁是在乐观事务基础上实现的,相比乐观事务的两阶段提交多了一个加锁阶段,pessimistic lock retry limit reached 报错是在加锁阶段出现的。写入时 TiDB 先从 PD 获取当前 tso(分布式全局递增时间戳)作为当前锁的 for_update_ts。悲观事务会用 for_update_ts 检查是否存在写冲突,效果等同于可更新的 start_ts,尝试写入并获取锁时检查 key 的 commit_ts,如果大于 for_update_ts 则出现写冲突,就会用当前冲突的 commit_ts(conflict_commit_ts)更新 for_update_ts 再重试 DML。当重试次数超 max-retry-count,就会向客户端返回报错 pessimistic lock retry limit reached。示意图:

对于当前的例子:

会话 A 不断随机更新 5000 行数据中的某一行。

会话 B 要一次性 5000 行的锁,会话 B 加锁的 key 比会话 A 多,每次总有某行数据的 commit_ts 大于当前的 for_update_ts,所以只能不断重试,直到重试次数超 max-retry-count报错 pessimistic lock retry limit reached。在文章有介绍:https://tidb.net/blog/7730ed79

四、只读语句也会使版本变大吗?

我们再结合例子看一下,会话 A 不断循环以下:

begin;
    SELECT val FROM testA WHERE id = 1 FOR UPDATE;
commit; 

会话 B 是长事务,只跑一次:

begin;
    UPDATE testA
    SET val = ?
    WHERE id BETWEEN 1 AND 5000; 

假设最初会话 A 运行事务时,对于 id = 1 这行记录 commit_ts=100。

然后会话 B 获取到 for_update_ts = 101,且会话 B 尝试加锁时,发现这行数据已被会话 A 修改并提交,导致 commit_ts = 102,此时 commit_ts = 102 > for_update_ts = 101,会话 B 会进行悲观锁重试,更新 for_update_ts 为 conflict_commit_ts=102,重新读取数据,再次尝试加锁直到重试上限报错。那问题就来了,只读的 SELECT ... FOR UPDATE 会导致 commit_ts 变大吗?

答:是的,只读语句 SELECT ... FOR UPDATE 的处理和正常的写入处理并无差别,也会留下 MVCC 版本记录。因此,即使是只读语句,只要加了 FOR UPDATE,也会产生写入行为,从而推进版本号。

五、有时报锁超时有时报超重试次数?

为什么在实际使用过程中,有时报错 Lock wait timeout,有时报错 pessimistic lock retry limit reached?

对于文章开头的锁等待例子,会话 A 一直锁住某行数据不提交,会话 B 尝试更新这行数据。在 TiDB 中其实会话 B 会不断根据 wait-for-lock-timeout 重试,重试最大时间为 innodb_lock_wait_timeout。这里和重试最大次数 max-retry-count 无关。如果事务能在 innodb_lock_wait_timeout 内成功获取锁,则可以继续执行,否则报错 Lock wait timeout exceeded。innodb_lock_wait_timeoutmax-retry-limit 两个参数无直接关联,TiDB 中哪个条件先达到,就报哪种错。

六、小结

本文通过对比 MySQL 和 TiDB,在锁等待报错机制上的差异,分析了 Lock wait timeoutpessimistic lock retry limit reached 两种常见错误的成因。尽管触发条件不同,但二者本质上都反映了当前事务存在严重的资源竞争或访问冲突。

对于此类问题,仅靠数据库参数调整(如增加 innodb_lock_wait_timeoutmax-retry-count)无法根本解决。在实际业务中,更应从业务层面如避免大事务、调整粒度、热点、调整访问顺序、简化事务逻辑等方面入手。只有数据库参数优化与业务访问优化相结合,才能有效降低锁冲突概率,提升事务成功率与系统整体吞吐量。

0
0
0
0

版权声明:本文为 TiDB 社区用户原创文章,遵循 CC BY-NC-SA 4.0 版权协议,转载请附上原文出处链接和本声明。

评论
暂无评论