标签: 悲观锁

  • 深入 TiDB 陷阱排查:高并发热点更新引发的 Percolator 悲观锁风暴与 TiKV 线程池雪崩实战

    核心结论:在 TiDB v6.5.1 中,高并发更新极少数量的热点行会导致 Percolator 悲观事务的 AcquirePessimisticLock RPC 风暴,瞬间打满 TiKV 的 Scheduler worker 和 Raftstore 线程池,引发全局 API 99线飙升。破局关键在于:应用层消除热点聚合;数据库侧开启 in-memory 内存悲观锁以绕过 Raft 同步;并严格将 innodb_lock_wait_timeout 降至秒级实现快速失败(Fail-fast)。

    故障现场:P99 飙升与 TiKV 线程饥饿

    排查过程中接到告警,某核心交易集群的 API 响应 99线从平时的 15ms 突增至 4000ms 以上,且出现大量 HTTP 504 甚至 502。查看 Grafana 大盘,整体 QPS 并没有发生明显翻倍,但 TiKV 节点的 Load Average 直接飙到 80 以上。

    查看 TiDB Server 的核心错误日志,满屏的锁等待超时和死锁报错:

    [2023/10/xx 10:15:22.123 +08:00] [WARN] [session.go:1982] ["compile SQL failed"] [error="[kv:1205]Lock wait timeout exceeded; try restarting transaction"] [sql="UPDATE accounts SET balance = balance + ? WHERE account_id = ?"]
    [2023/10/xx 10:15:23.456 +08:00] [WARN] [session.go:1990] ["run statement failed"] [error="[kv:1213]Deadlock found when trying to get lock; try restarting transaction"] 
    

    直接钻取 TiKV-Details 监控面板,发现以下致命现象:

    1. Thread CPU -> Scheduler worker CPU:所有 TiKV 节点的 Scheduler 线程池利用率达到 100%。

    2. Thread CPU -> Raft store CPU:Raftstore 线程池同样被打满。

    3. gRPC 监控:kv_pessimistic_lock 接口的 RPC 延迟 P99 超过 3 秒,积压严重。

    显然,这不是普通的慢 SQL,而是典型的并发热点更新导致的分布式锁争用雪崩。

    为什么 Percolator 悲观锁在高并发热点下会引发 RPC 风暴?

    要理解这个风暴,必须剥开 TiDB 的分布式事务底层实现。Google Percolator 原生的分布式事务是纯乐观的(Optimistic),依赖两阶段提交(Prewrite + Commit)。它的假设是冲突率极低,如果发生冲突,后提交的事务直接 Rollback。但在真实的金融或高并发电商场景中,乐观事务会导致惨烈的 Write Conflict 报错和无限重试。

    为此,TiDB 引入了悲观事务模型,并在 Percolator 的两阶段提交前,强行插入了一个 AcquirePessimisticLock 阶段。

    当 1000 个并发请求试图更新同一行数据时,底层发生了什么?

    1. TiDB Server 解析 SQL,向这行数据所在的 TiKV Region Leader 发起 AcquirePessimisticLock 请求。

    2. TiKV 的 Scheduler 接收到请求,准备在 Lock CF(Column Family)中写入一个悲观锁标识。

    3. 致命点来了:在默认配置下,这个悲观锁的写入是一个真实的强一致性操作。它需要被封装成 Raft Log,通过 Raftstore 线程池同步给多数派副本,才能返回加锁成功。

    4. 当第一个事务加锁成功后,剩下的 999 个事务全部在 TiKV 端进入等待队列,并触发 TiKV 的死锁检测(Deadlock Detector)。

    5. 持续涌入的请求让 TiKV 的 RPC 队列、死锁检测计算图和 Raftstore I/O 瞬间饱和,最终波及该 TiKV 节点上的所有其他无关请求,导致集群级雪崩。

    源码级诊断与现场勘探

    为了验证猜想,必须抓取现场的锁争用拓扑。通过 TiDB 提供的系统表,直接 Dump 当前的悲观锁等待队列。

    在 TiDB 终端执行:

    SELECT 
        WAIT_START_TIME,
        WAIT_AGE_SECS,
        SQL_DIGEST,
        KEY,
        KEY_INFO
    FROM INFORMATION_SCHEMA.DATA_LOCK_WAITS 
    ORDER BY WAIT_AGE_SECS DESC 
    LIMIT 5;
    

    输出结果显示,数百个事务正在等待同一个 KEY(裸字节流),通过 KEY_INFO 解析可以看出,全部集中在 accounts 表的特定 account_id 上。

    接着通过 pd-ctl 定位这个 Key 所在的 Region:

    # 假设热点表 ID 为 123,热点主键为 8888
    $ tiup ctl:v6.5.1 pd -u http://127.0.0.1:2379 region key 74800000000000007b5f7280000000000022b8
    

    拿到 Region ID 后,查看该 Region 的指标,发现其写入 QPS 极高,完全超出了单 Region Raft 组的处理上限。

    止血方案与底层调优实战

    面对这种架构级别的并发热点,单纯增加 TiKV 节点数毫无意义(因为单个热点行只能落在某一个 Region Leader 上)。真正的破局手段必须遵循“防御性编程”与“剥离无效共识”的原则。

    1. 强制快速失败 (Fail-fast)

    业务系统通常有重试逻辑,如果数据库端挂起 50 秒才超时,应用服务器的连接池早就爆了。必须切断 TiDB Server 的长等待,把异常抛回给业务层进行削峰。

    -- 将全局悲观锁等待超时时间从默认的 50s 降低到 3s
    SET GLOBAL innodb_lock_wait_timeout = 3;
    

    注:TiDB 的参数名兼容 MySQL,控制的就是 Percolator 悲观事务的锁等待超时。

    2. 开启内存悲观锁 (In-memory Pessimistic Lock)

    这是 TiDB v6.0 引入的杀手锏特性,也是解决此类问题的根本配置。 悲观锁的目的仅仅是“排他”,用来防止并发冲突,它其实不需要满足持久化(Durability)。即使 TiKV Leader 宕机导致悲观锁丢失,事务在最终的 Prewrite 阶段依然会通过标准 Percolator 机制检测出冲突并回滚。因此,完全可以把悲观锁从 Raft 状态机中剥离出来。

    修改 TiKV 的 tikv.toml 配置文件(通过 tiup edit-config):

    [pessimistic-txn]
    # 开启内存悲观锁,跳过 Raft 同步,极大幅度降低 Raftstore CPU 消耗
    in-memory = true
    # 开启 pipelined 悲观锁机制,优化加锁流程
    pipelined = true
    

    原理解析:开启 in-memory = true 后,悲观锁只存在于 Leader 节点的内存中。AcquirePessimisticLock RPC 到达 Leader 后,只需在内存中打标记即可立即返回,彻底解放了 Raftstore 的磁盘与网络 I/O。根据实测,在热点更新场景下,该配置能将 TiKV 的吞吐量提升 3-5 倍,且延迟降低一个数量级。

    3. 开启锁唤醒优化 (Wake-up Queue)

    在高并发下,如果锁被释放,等待队列中的事务如果同时被唤醒去抢锁,又会引发一次惊群效应(Thundering Herd)。 通过确保 pipelined = true 并配合 TiDB server 的 tidb_pessimistic_txn_fair_locking(v7.0+ 默认开启,v6.5 需手动评估开启),可以实现公平锁排队,避免无效的重试风暴。

    常见问题 (FAQ)

    Q1:开启内存悲观锁(in-memory pessimistic lock)后,如果 TiKV Leader 发生宕机切主,会导致脏写等数据不一致吗? 不会。内存悲观锁丢失仅仅意味着“防线被突破”,相当于退化成了乐观事务。当存活的事务进入 Prewrite 和 Commit 阶段时,依然必须通过标准的 Raft 提交机制写入 Write CF 和 Data CF。底层基于 MVCC 和 ts (Timestamp) 的冲突检测会拦截掉非法事务,最多导致事务回滚报错,绝不会破坏数据一致性。

    Q2:排查过程中发现大量 Resolve Lock 的相关日志且耗时很高,这通常是什么原因? Resolve Lock 是 Percolator 协议处理“僵尸事务”的机制。通常发生在应用侧发起事务 Prewrite 后(可能部分 TiKV 已经写入了 Lock CF),应用实例突然 Crash 或网络断开,没有发送 Commit。后续其他事务访问该 Key 时被锁阻塞,等待 TTL 超时后,会主动触发 Resolve Lock 回滚清理垃圾锁。如果频繁出现,需排查应用层是否发生大规模 OOM 崩溃或网络割裂。

    Q3:面对业务周期性的单线程大批量跑批(如凌晨结算),是使用乐观事务好还是悲观事务好? 推荐使用乐观事务。因为单线程跑批不存在并发写冲突,悲观事务的 AcquirePessimisticLock 阶段完全是无效开销。可以在 Session 级别执行 SET tidb_txn_mode = 'optimistic'; 后再进行批处理。但需要注意,大事务必须分批(Chunk),否则可能会触碰 TiDB 单个事务不可超过 10GB / 30万行的物理硬限制。

  • 深入 TiDB 热点更新雪崩排查:悲观锁引发的 RPC 拥塞与 Wait-For-Graph 内存爆炸实战

    某次生产环境 TiDB (v6.5.0) 核心集群突发 P99 延迟暴增至 8s,QPS 断崖下跌。核心结论:业务对极少热点行高并发 UPDATE,引发 TiKV 悲观锁 RPC 风暴。大量等锁请求致 TiKV 死锁检测器 (Wait-For-Graph) 内存激增与 Scheduler Worker 线程池打满,演变为全局 RPC 拥塞。破局解法:开启 TiKV 内存悲观锁(In-Memory Pessimistic Lock)、调低锁超时触发快速失败,并强推业务层批量更新。

    现场还原:P99 飙升与锁等待超时

    排查过程中接警,某核心支付业务 TiDB 集群 QPS 从 8000 瞬间跌至 300,SQL 99线飙升到 8000ms。登录中控机,使用 tiup cluster display 确认各组件存活,但 Load Average 出现极度倾斜:部分 TiKV 节点 Load 飙升至 80+,而 TiDB Server 节点的 CPU 反而处于闲置状态。

    查看 TiDB 日志,满屏的死锁与超时报错:

    [WARN] [2006] ["Lock wait timeout exceeded; try restarting transaction"] [conn=482910] 
    [WARN] [endpoint.go:616] [error-response] [err="Deadlock found when trying to get lock; try restarting transaction"]
    [INFO] [client.go:683] ["rpc error: code = DeadlineExceeded desc = context deadline exceeded"]
    

    切到 Grafana 监控大盘,几个关键指标印证了猜想:

    1. TiKV-Details -> Scheduler – commit:AcquirePessimisticLock 命令的 QPS 极高,且单个耗时超过 2s。

    2. TiKV-Details -> Thread CPU:Scheduler-worker 线程池 CPU 使用率达到 100%,而 raftstore 线程负载平稳。

    3. TiDB -> KV Errors:Lock Resolve 和 Deadlock 计数器呈指数级上升。

    这典型的由于极度热点数据并发更新,导致的底层分布式锁拥塞惨案。

    为什么高并发热点更新会打爆 TiKV 节点?

    要理解这个故障,必须深入 TiDB 基于 Percolator 分布式事务模型的悲观锁实现。

    原生的 Google Percolator 是一个标准的乐观事务模型(2PC:Prewrite + Commit),只在提交阶段进行冲突检测。但在高并发冲突场景下,乐观事务会导致大面积的 Write Conflict 报错和无意义的重试。为此,TiDB 从 v3.0 开始引入并默认开启了悲观锁。

    在悲观锁模式下,TiDB 拦截了 MySQL 的 FOR UPDATE 或 DML 语句,在执行 Prewrite 之前,会提前向 TiKV 发起一次 AcquirePessimisticLock 的 RPC 请求。

    当成千上万个并发请求去 UPDATE 同一行记录(例如扣减某个爆款商品的库存)时,灾难开始了:

    1. 单点 RPC 风暴:热点数据只存在于一个 Region,所有 TiDB 节点的 AcquirePessimisticLock 请求全部涌向该 Region Leader 所在的单一 TiKV 节点。

    2. 死锁检测器 (Wait-For-Graph) 爆炸:TiKV 为了防止多事务相互等待引发死锁,在内存中维护了一个有向图(Wait-For-Graph)。当成千上万个事务在同一个 Key 上排队等锁时,这个图的节点数和边数急剧膨胀。死锁检测算法在遍历这张庞大的图时,消耗了海量的 CPU 周期,直接打满了 Scheduler-worker 线程。

    3. 队列积压与雪崩:等锁的事务占用着资源不释放,后续的 gRPC 请求在 TiKV 端排队。最终超过客户端设定的 Context Timeout,引发 DeadlineExceeded 报错。更致命的是,RPC 队列拥塞拖垮了同一个 TiKV 上的其他非热点请求,爆炸半径扩散,整个集群雪崩。

    深度防御与参数调优实战

    在分布式系统中,遇到这种极端热点,单纯增加硬件节点毫无意义(因为单行数据只会落在单一 Leader 上)。作为运维架构师,必须从“防御性编程”的角度在 DB 层做硬限制,同时开启底层优化特性。

    1. 斩断长连接:调低锁超时机制(Fail-fast)

    TiDB 默认的悲观锁等待超时时间(innodb_lock_wait_timeout)是 50 秒。在 QPS 几千的场景下,让请求挂起 50 秒等同于自杀。必须立刻修改为 Fail-fast 模式。

    在 TiDB 侧全局调整(需要业务端捕获报错并处理):

    -- 将默认的 50s 修改为 3s,快速释放等待队列的资源
    SET GLOBAL innodb_lock_wait_timeout = 3;
    

    2. 核心大招:开启 TiKV 内存悲观锁 (In-Memory Pessimistic Lock)

    在默认机制下,TiKV 获取悲观锁不仅要在内存排队,还要将锁信息通过 Raft 协议写入本地 RocksDB 并同步给 Follower,这个 I/O 路径极度沉重。 TiDB 在 v6.0 引入了内存悲观锁,在 v6.5 中成熟。它允许将悲观锁仅保留在 Region Leader 的内存中,不走 Raft 同步。即使 Leader 宕机,新 Leader 也能在读写前通过唤醒机制安全恢复。

    编辑集群配置 (tiup cluster edit-config ),在 TiKV 模块中注入:

    server_configs:
      tikv:
        pessimistic-txn.in-memory: true
        # 强烈建议配合 pipelined 提交,减少网络往返延迟
        pessimistic-txn.pipelined: true
    

    执行 tiup cluster reload -R tikv 滚动生效。开启后,AcquirePessimisticLock 的 P99 耗时从百毫秒级直接降至亚毫秒级,彻底缓解了 Scheduler Worker 的压力。

    3. 业务层改造:禁止 DB 当 Redis 用

    防御性运维只能保命,不能治本。排查发现业务在用 UPDATE counter SET val = val + 1 WHERE id = 1 做高频计数。 强推研发改写逻辑:

    • 引入 Redis 做前端原子计数和防刷。

    • 业务聚合请求,将单条记录的并发 Update 改为批量合并更新(Batching),或者改用分片插入(Insert on duplicate key update into multiple hash slots),最后再汇总。

    常见问题

    Q1:如何快速在雪崩现场定位是哪个 Key 引发了悲观锁争抢? A:通过 TiDB 自带的系统表,直接查询当前正在等锁的事务和具体对应的 SQL:

    SELECT * FROM information_schema.DATA_LOCK_WAITS;
    SELECT * FROM information_schema.TIDB_TRX WHERE STATE = 'LockWaiting';
    

    配合 TIDB_HOT_REGIONS 可以精准定位到是哪张表的哪个索引正在遭遇写热点。

    Q2:既然高并发下悲观锁这么容易拥塞,我切回乐观锁(Optimistic)可以吗? A:绝对不建议。乐观锁在遇到高并发热点时,会在最后的 Commit 阶段大面积爆出 Write Conflict 报错。虽然它不会引起 TiKV 侧的锁排队阻塞,但会导致客户端无休止地重试(如果开启了事务自动重试机制),白白浪费网络带宽和 TiDB CPU 计算力,最终一样会导致 QPS 下跌。正确的姿势是:保持悲观锁,开启 In-Memory 优化,并严格控制 innodb_lock_wait_timeout。

    Q3:开启 In-Memory 悲观锁后,如果 Region Leader 发生网络隔离或宕机,会导致锁丢失引发脑裂吗? A:不会。TiDB 的架构设计非常严谨。如果 Leader 宕机,锁虽然在内存中丢失,但发生 Leader 切换时,新的 Leader 会强制要求新的读写请求推进 ReadIndex 或产生新 epoch。此时旧事务在发起 Commit 阶段的 Prewrite 操作时,由于找不到原来的悲观锁,且 Region epoch 已经改变,事务会被直接中止(Abort),从而保证了分布式事务的严格一致性(Linearizability)。