TiDB 锁冲突的排查与处理:乐观事务、悲观事务与 Lock View 实践
锁冲突是分布式事务型数据库在高并发写入场景下的常见问题,通常表现为等锁超时、写写冲突或死锁。TiDB 自 v3.0 起同时提供乐观事务与悲观事务两种模式,二者的加锁时机与冲突处理方式存在显著差异。本文基于 TiDB 官方文档,梳理两种事务模式的核心机制、Lock View 的排查方法,以及常见锁冲突报错的成因与处理建议,供运维与开发人员参考。
一、事务模式:乐观与悲观
排查锁冲突前,需首先确认集群所采用的事务模式,否则分析方向可能出现偏差。
TiDB 从 v3.0 开始在乐观事务的基础上引入悲观事务,并自 v3.0.8 起对新建集群默认启用悲观事务模式,其行为与 MySQL 基本一致。需要注意的是:由 v3.0.7 及更早版本升级而来的集群不会自动改变默认事务模式,仅新建集群默认使用悲观事务。因此应通过系统变量进行确认:
-- 查看当前事务模式
SHOW VARIABLES LIKE 'tidb_txn_mode';
-- 将集群中所有新建 session 的显式事务设置为悲观模式
SET GLOBAL tidb_txn_mode = 'pessimistic';
此外有一点需特别说明:autocommit 单语句事务会优先尝试使用开销更小的乐观事务方式提交,仅在发生写冲突、进行自动重试时才切换为悲观模式。因此即使集群已设置为悲观模式,单条 UPDATE 在发生冲突时,首次报告的仍可能是写冲突(Write Conflict)错误。
若需在单个事务上显式指定模式,可使用语句级写法。其优先级高于系统变量,且允许乐观、悲观事务混合使用:
BEGIN PESSIMISTIC; -- 强制使用悲观事务
BEGIN OPTIMISTIC; -- 强制使用乐观事务
二、悲观事务的加锁行为
悲观事务下需要区分两种读取语义:
- 快照读:普通
SELECT语句的读取,不加锁,读取的是事务开始前已提交的版本。该机制基于 MVCC 实现,因此读取不会被写入阻塞。 - 当前读:
UPDATE、DELETE、INSERT、SELECT ... FOR UPDATE语句中的读取,会加锁,读取的是最新的已提交版本。
悲观锁在事务提交或回滚时释放。其他尝试修改同一行的写事务会被阻塞,直至锁被释放;而其他事务对该行的读取不会被阻塞。
与 MySQL InnoDB 相比,悲观事务存在若干行为差异,使用时应予以注意:
- 不支持间隙锁(gap lock)。对于带 range 条件的
WHERE子句(如id BETWEEN 1 AND 10 FOR UPDATE),TiDB 不会阻塞该区间内并发的INSERT,此行为与 MySQL 不同。 - 默认不支持
SELECT ... LOCK IN SHARE MODE。可通过启用tidb_enable_noop_functions兼容该语法,但此时不会实际加锁;自 v8.3.0 起,可通过tidb_enable_shared_lock_promotion使其产生加锁行为,但实际加的是排他锁而非共享锁。 - 等锁超时时间由
innodb_lock_wait_timeout控制,默认值为 50 秒,超时后返回兼容 MySQL 的错误码 1205。多个事务等待同一把锁时,大致按事务的start_ts顺序获取锁。 - 死锁由检测器自动检测,其中一个事务会被随机终止并返回错误码 1213。
- 悲观事务的执行时间存在上限,默认为 1 小时,由 TiDB 配置项
performance.max-txn-ttl控制。
三、使用 Lock View 排查锁冲突
仅凭报错信息只能判断存在冲突,难以定位到具体事务与数据行。TiDB 自 v5.1 起提供 Lock View 功能,在 information_schema 中内置了若干系统表用于查看锁等待与死锁信息。需要注意,Lock View 目前仅提供悲观锁的冲突与等待信息。相关系统表包括:
TIDB_TRX/CLUSTER_TIDB_TRX:当前节点或整个集群上正在运行的事务信息,包括事务是否处于等锁状态、等锁时间及已执行语句的 Digest。DATA_LOCK_WAITS:TiKV 上的悲观锁等锁信息,包括阻塞方与被阻塞方的start_ts、被阻塞 SQL 的 Digest,以及发生等待的 key。DEADLOCKS/CLUSTER_DEADLOCKS:最近发生的死锁信息,包括死锁环中各事务之间的等待关系。
查询最近发生的死锁:
SELECT * FROM information_schema.deadlocks;
查询结果会显示死锁环中多个事务之间的等待关系及各事务正在执行的归一化 SQL(去除参数与格式后的形式),并标识发生冲突的 key。
当少数热点 key 被频繁上锁并阻塞较多事务时,可按 key 聚合 DATA_LOCK_WAITS,定位经常发生冲突的数据行:
SELECT `key`, count(*) AS cnt
FROM information_schema.data_lock_waits
GROUP BY `key` ORDER BY cnt DESC;
获得 key 后,可进一步关联 CLUSTER_TIDB_TRX,并使用 TIDB_DECODE_SQL_DIGESTS 函数将 Digest 还原为可读 SQL,以确定阻塞来源:
SELECT l.key, trx.*,
tidb_decode_sql_digests(trx.all_sql_digests) AS sqls
FROM information_schema.data_lock_waits AS l
JOIN information_schema.cluster_tidb_trx AS trx
ON l.current_holding_trx_id = trx.id
WHERE l.trx_id = <事务 start_ts>\G
需要注意,上述系统表反映的是查询执行时刻的运行状态,对多张表执行 join 时数据并不保证在同一时刻获取,可能存在不同步的情况。为降低偶然性,建议多次查询比对。
四、常见锁冲突报错与处理
下表按事务模式归纳常见锁冲突报错及其成因与处理方向。
悲观事务模式:
| 报错 | 成因 | 处理方向 |
|---|---|---|
Lock wait timeout exceeded(1205) |
等锁时间超过 innodb_lock_wait_timeout |
偶发可忽略;频繁出现需从业务逻辑层面调整 |
Deadlock found when trying to get lock(1213) |
检测到死锁,自动终止环中某一事务 | 查询 DEADLOCKS 表分析等待链,调整加锁顺序 |
pessimistic lock retry limit reached |
同一行高并发上锁,写冲突重试达到 pessimistic-txn.max-retry-count 上限 |
可启用 tidb_pessimistic_txn_fair_locking(新建集群默认 ON),代价为冲突场景下吞吐略降 |
TTL manager has timed out |
悲观事务执行时间超过 TTL(默认 1 小时,performance.max-txn-ttl) |
将大事务拆分为小事务 |
乐观事务模式:
| 报错 | 成因 | 处理方向 |
|---|---|---|
| 读写冲突 | 读取时目标 key 上存在未清理的锁,日志中体现为 backoff_types:[txnLockFast] |
后台自动 backoff 重试,少量无需关注 |
KeyIsLocked |
prewrite 阶段发现 key 已被其他事务上锁,监控中 txnLock 上升 |
大量出现需评估业务冲突,或改用悲观事务 |
TxnLockNotFound |
事务提交过慢、超过锁的 TTL,被其他事务回滚 | 排查写入性能是否过低导致提交缓慢 |
五、乐观事务的重试与局限
乐观事务采用两阶段提交,冲突检测主要发生在 TiKV 的 prewrite 阶段。在高冲突场景下,乐观事务容易因写写冲突而提交失败。
需要重点说明的是:自 v8.0.0 起,TiDB 不再支持乐观事务的自动重试,系统变量 tidb_disable_txn_auto_retry 同时被废弃。这一调整源于自动重试本身的局限性:重试时仅重新执行写操作语句并重新获取 start_ts,当事务中存在依赖读取结果进行更新的语句时,重试可能导致更新丢失,从而破坏可重复读隔离级别。
因此,官方推荐的做法是:在高冲突场景下使用悲观事务模式;若仍使用乐观事务,应在应用层捕获 COMMIT 返回的错误并自行实现重试逻辑。
六、处理建议
针对锁冲突问题,建议从以下两个层面着手处理:
- 业务设计层面(治本):统一事务对多行数据的加锁顺序,避免交叉等待引发死锁;在满足正确性的前提下,以快照读替代不必要的当前读(
FOR UPDATE);控制单个事务的规模,避免长事务长时间持锁。 - 参数配置层面(兜底):对同一行高并发上锁的场景,可启用
tidb_pessimistic_txn_fair_locking;保持innodb_lock_wait_timeout为合理值,使无法获取锁的事务及时超时返回,由应用层重试,避免请求长期堆积。