分类: 数据库架构与运维

  • 深入 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 – commitAcquirePessimisticLock 命令的 QPS 极高,且单个耗时超过 2s。

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

    3. TiDB -> KV ErrorsLock ResolveDeadlock 计数器呈指数级上升。

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

    为什么高并发热点更新会打爆 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)。

  • Redis 生产环境 P99 飙升:RDB COW 触发内存淘汰与 Cluster Gossip 故障转移雪崩排查实战

    生产环境 Redis Cluster (6.2.7) 突发 P99 延迟飙升至 2000ms,并伴随频繁的主从切换。核心原因是 BGSAVE 触发 Copy-On-Write 导致内存触碰 maxmemory,引发主线程大规模 LRU 淘汰阻塞。主线程卡顿导致 Gossip 协议心跳超时,误判节点下线并触发级联故障转移。解决方式:预留 30% 内存给 COW,开启 lazyfree-lazy-eviction,并调大 cluster-node-timeout

    故障现场与指标断崖式下跌

    近期某集群告警,监控面板上呈现出典型的“雪崩”特征:

    1. QPS 骤降:单节点 QPS 从 80k 瞬间跌至 5k 以下。

    2. P99 剧烈抖动:平时稳定在 2ms 以内的 P99 突增至 2000ms+。

    3. 连接风暴:客户端因超时大量重连,引发短连接风暴。

    立刻登机拉取 Redis 日志,发现大量内存淘汰警告,紧接着是集群节点的 FAIL 状态广播:

    29302:M 10:23:14.123 * 10000 keys evicted, 153MB freed.
    29302:M 10:23:14.891 * 10000 keys evicted, 142MB freed.
    ...
    29302:M 10:23:19.456 * Marking node 3a8b... as failing (quorum reached).
    29302:M 10:23:19.458 # Cluster state changed: fail
    

    查看当时的内存指标和核心状态:

    $ redis-cli -p 6379 info memory | grep -E "used_memory_human|maxmemory_human|latest_fork_usec"
    used_memory_human:24.1G
    maxmemory_human:24.0G
    latest_fork_usec:89450
    

    现象很明确:由于触发了内存淘汰,主线程被长时间占用,导致集群内的 Gossip 节点心跳无响应,最终引发整个集群拓扑结构的重新计算和主从切换。

    为什么一次 BGSAVE 会引发集群雪崩?

    很多人在配置 Redis 时,习惯把 maxmemory 设为物理内存的 90% 甚至更大,认为这样“不浪费”。这在没有高频写入和持久化的场景下勉强能跑,但一旦触发 BGSAVE (RDB 持久化),就是灾难的开始。

    Redis 执行 BGSAVE 时会 fork() 一个子进程。现代操作系统利用 Copy-On-Write (COW) 机制,父子进程初始共享物理内存页。然而,如果此时集群正处于高频写入状态(特别是大 Key 的更新),父进程在修改数据时,操作系统必须为这些被修改的内存页分配新的物理空间。

    排查过程中的现场数据显示,该节点在 BGSAVE 期间产生了高达 4GB 的 COW 内存:

    # cat /proc/$(pidof redis-server)/smaps | grep -i private_dirty | awk '{sum+=$2} END {print sum/1024 " MB"}'
    3952 MB
    

    雪崩的传导链条如下:

    1. 内存触顶:COW 导致 Redis 实际占用内存 + 自身分配的内存超过了 maxmemory(24GB)。

    2. 同步淘汰阻塞:Redis 触发 maxmemory-policy(当时配的是 allkeys-lru)。在 Redis 6.2 中,如果未开启异步淘汰,主线程必须同步寻找并释放内存。大规模的 Key 淘汰(且包含 Hash/Set 大 Key)死死卡住了主线程。

    3. Gossip 协议“假死”:Redis 集群的节点保活依赖 Gossip 协议,而处理 Gossip 消息的 clusterCron() 是在主线程的事件循环中执行的。主线程被 Eviction 阻塞了 5 秒,导致无法回复其他节点的 PING

    4. 脑裂与故障转移:其他节点超过 cluster-node-timeout(当时配的是激进的 5000ms)未收到 PONG,将其标记为 PFAIL,进而升级为 FAIL,触发 Replica 强制上位。

    5. 全量同步加剧雪崩:旧 Master 恢复后变为 Slave,向新 Master 发起 SYNC,再次触发新 Master 的 BGSAVE。死循环形成。

    防御性配置与底层调优实战

    为了彻底根除这种由于持久化抖动引发的集群雪崩,必须从内存预留、异步淘汰和集群容忍度三个维度进行改造。

    1. 严格的内存水位控制 (COW 预留)

    永远不要把 maxmemory 贴着物理内存上限配置。标准做法是预留 30% – 40% 的内存给 COW、主从复制的 repl-backlog 以及客户端缓冲区。

    # 假设实例物理内存 32GB
    maxmemory 20gb
    # 淘汰策略根据业务改为 volatile-lru 或 volatile-ttl,避免全盘扫描
    maxmemory-policy volatile-lru
    

    2. 开启 Lazyfree 机制

    Redis 4.0 引入了 lazyfree,6.0+ 版本进一步完善。针对内存淘汰引发的阻塞,必须开启惰性删除,将释放内存的动作交给后台线程 (bio 线程池) 执行,保命主线程。

    # 开启惰性内存淘汰
    lazyfree-lazy-eviction yes
    # 开启惰性键过期
    lazyfree-lazy-expire yes
    # 隐式 DEL 转化为 UNLINK
    lazyfree-lazy-user-del yes
    

    3. 调校 Cluster Gossip 参数

    cluster-node-timeout 决定了集群对网络抖动和主线程阻塞的容忍度。千万别为了追求极端的“故障恢复速度”将其设为 3-5 秒。主线程偶然卡顿是常态,误判导致的 Failover 成本极高。

    # 推荐值为 15000 (15秒),足够覆盖绝大多数 RDB fork 和淘汰耗时
    cluster-node-timeout 15000
    

    配合调大复制积压缓冲区,防止主从切换或短连后触发全量重传:

    repl-backlog-size 512mb
    

    4. 彻底接管内核 THP (Transparent Huge Pages)

    在排查中发现,操作系统的 THP 是开启的。THP 会将默认的 4KB 内存页放大为 2MB。在 COW 发生时,即使 Redis 只修改了 10 字节的数据,内核也必须拷贝完整的 2MB 内存页。这直接导致了 BGSAVE 期间内存飙升速度放大了数百倍。

    必须在所有 Redis 宿主机上硬性关闭 THP:

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    # 固化到 rc.local 或 grub 中
    

    常见问题

    Q1:除了 BGSAVE,AOF 重写 (BGREWRITEAOF) 会引发完全一样的问题吗? 会。BGREWRITEAOF 同样依赖 fork() 子进程进行机制。只要有 Fork 操作,在海量写入时就会产生大量 COW 内存。防御策略完全一致。

    Q2:如何快速确认集群是否正因为主线程卡顿而处于 Gossip 瘫痪边缘? 观察 info stats 中的 latest_fork_usec 耗时,以及慢查询日志。如果 latest_fork_usec 超过 100ms,说明 fork 本身就极其耗时(通常由于系统页表太大引起)。同时可以监控 redis_cluster_messages_ping_sentpong_received 的差值斜率。

    Q3:开启了 lazyfree-lazy-eviction,内存就一定不会爆吗? 并非绝对。Lazyfree 只是把内存释放动作交给了后台线程。如果业务的写入速度远大于后台线程释放内存的速度,Redis 的总内存依然会持续上涨,最终触发操作系统的 OOM Killer 直接干掉 Redis 进程。因此,合理的 maxmemory 预留和限流依然是底线。

    Q4:Redis 7.0 的 Multi-part AOF 能解决这个问题吗? Redis 7.0 的 Multi-part AOF 优化了 AOF 重写期间的增量数据追加机制,大幅降低了重写带来的内存开销和 CPU 负担。但对于纯 RDB 的 BGSAVE COW 物理内存翻倍问题,底层机制并没有变,依然受限于内核的页表和 COW 行为。