标签: TiKV性能调优

  • 深入 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万行的物理硬限制。