标签: Percolator

  • 深入 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)。

  • 深入 TiDB 大事务雪崩排查:无脑 DELETE 引发的 Percolator 锁风暴与 TiDB 节点 OOM 惨案

    近期处理了一起极为惨烈的分布式数据库生产事故。核心业务集群(TiDB v6.1)的 P99 延迟在两分钟内从 20ms 直接飙升到 30s,随后多个 TiDB Server 节点接连触发 OOM 被内核直接 Kill,集群 QPS 跌至个位数,几乎处于瘫痪状态。

    排查到底,罪魁祸首是一条没有任何 LIMIT 限制、涉及 8000 万行数据的历史日志清理 SQL(DELETE FROM action_log WHERE create_time < '2023-01-01')。 结论先行:在基于 Percolator 模型的分布式数据库中,将单机关系型数据库的“大事务”思维直接照搬是自杀行为。TiDB 在两阶段提交(2PC)的 Prewrite 阶段需要将所有 Mutate 数据缓存在 TiDB Server 内存中,同时向 TiKV 写入海量 Lock 记录。这不仅会瞬间击穿计算节点的内存配额,还会引发大面积的锁冲突与 ResolveLock 风暴,导致整个集群的 Raft Store 与 Coprocessor 线程池耗尽。

    解决大批量数据修改,必须使用非事务 DML(BATCH ON)或按主键范围切分的批处理脚本。把分布式 DB 当无底洞垃圾桶,它就会把你的业务一起埋了。

    现场还原:从延迟突刺到死亡宣告

    监控大盘上的异动非常典型,呈现出教科书般的“雪崩”曲线:

    1. TiDB 节点内存垂直起飞:某一个 TiDB 节点的内存使用率在 60 秒内从 15% 飙升至 95%。

    2. 锁指标爆炸:TiDB Dashboard 中的 KV Backoff OPS 和 Lock Resolve OPS 激增 1000 倍。

    3. gRPC 阻塞:TiKV 的 gRPC message duration P99 飙升至 15s 以上。

    4. 死亡宣告:系统监控捕获到内核级斩首行动: text kernel: [123456.789] Out of memory: Kill process 2333 (tidb-server) score 850 or sacrifice child kernel: [123456.790] Killed process 2333 (tidb-server) total-vm:41943040kB, anon-rss:33554432kB, file-rss:0kB

    查看存活 TiDB 节点的 tidb.log,满屏的 2PC 提交失败与锁冲突报错:

    [WARN] [2pc.go:1234] ["commit failed"] [conn=889922] [error="[kv:9007]Write conflict, txnStartTS=441234567890123456 is stale"]
    [WARN] [backoff.go:234] ["txnLockNotFound"] [conn=889922] [caller="resolveLock"] 
    

    核心原理解析:为什么一条 DELETE 能干趴整个集群?

    很多开发习惯了 MySQL (InnoDB) 的行为,认为一条几千万行的 DELETE 最多就是跑得慢、产生大量 Undo/Redo log、导致主从延迟。但在 TiDB 这种计算与存储分离、基于 Percolator 事务模型的 HTAP 架构中,机制完全不同。

    一条巨型 DELETE 在 TiDB 的执行生命周期,就是一场灾难的酝酿过程:

    1. 计算节点内存撑爆 (TiDB OOM)

    TiDB 为了支持乐观/悲观事务,在事务提交前,会将所有修改(对于 DELETE,就是将被删记录的 Key 和空 Value)缓存在 TiDB Server 的内存中(memDB)。 8000 万行记录,如果每行转化出的 KV 占 200 Bytes,单条事务在内存中就需要硬吃至少 15GB 的堆内存。再加上 Go 语言在应对这种瞬间海量小对象分配时,GC 往往会严重滞后,导致实际 RSS 占用翻倍,轻松击穿 tidb_server_memory_limit 的软限制,直接被 OS OOM-Killer 带走。

    2. Prewrite 阶段的锁风暴 (Lock Storm)

    哪怕服务器内存够大扛住了第一波,在 2PC 的 Prewrite 阶段,TiDB 会向 TiKV 写入分布式的锁:

    • 从这 8000 万个 Key 中选出一个作为 Primary Key (Primary Lock)。

    • 将剩余的 7999 万多条记录作为 Secondary Locks 写入 TiKV,并全部指向那个 Primary Lock。

    此时,TiKV 集群被灌入数千万个 Lock CF(Column Family)记录。如果其他正常的业务请求(哪怕是读操作)碰巧访问到了这 8000 万行数据中的任意一行,按照 Percolator 协议,读请求会被锁阻塞。

    3. ResolveLock 级联雪崩

    当正常请求遇到这些锁,且发现锁所属的事务持锁时间过长时,会尝试进行清锁操作(ResolveLock):

    • 读请求会去反查 Primary Lock 的状态,确认那个巨型事务到底提交了没有。

    • 由于巨型事务的 Primary Lock 所在 Region 可能正处于极高的负载中,反查 RPC 出现堆积和超时。

    • 海量的正常请求全部卡在 ResolveLock 阶段,TiKV 的 Coprocessor 线程池和 gRPC 线程池被彻底打满,导致全表甚至全库的请求响应卡死,这就是经典的读写相互阻塞。

    防御性加固与解决方案

    修复这个烂摊子,第一步是立刻 Kill 掉那个执行 DELETE 的会话,但这只是止血。为了彻底杜绝此类问题,必须从架构配置和研发规范上进行双重封堵。

    1. 严格限制事务大小与内存配额

    不要指望开发自觉,必须在配置层面进行防御性斩断。检查并调整 TiDB 配置文件:

    [performance]
    # 限制单事务的最大容量,默认 100MB,最大不超过 1GB。绝不给跑百 GB 级别事务的机会。
    txn-total-size-limit = 104857600
    
    [mem-quota]
    # 限制单条 SQL 的内存使用,超过后触发 oom-action
    query = 1073741824 # 1GB
    oom-action = "cancel" # 默认通常是 cancel,确保内存超限时直接终止 SQL 而不是拖死节点
    

    注:在 TiDB v6.1+ 中,全局内存控制 server-memory-quota 和 tidb_server_memory_limit 系统变量已经完善,但精细到 query 级别的 cancel 依然是防范 OOM 的最后一道防线。

    2. 使用非事务 DML 或分批处理

    对于大批量历史数据清理,正确的做法是将其切分为无数个小事务。TiDB 官方提供了一项专用于此类场景的功能:Non-transactional DML。

    -- 将大 DELETE 拆分为基于主键或者时间范围的小批量操作
    BATCH ON id LIMIT 5000 
    DELETE FROM action_log WHERE create_time < '2023-01-01';
    

    这条语句会在 TiDB 内部自动按 id 划分范围,每次只在一个小范围内执行 DELETE 并独立提交,从而绕过事务大小限制,彻底避免长事务持有海量锁导致的 OOM 和锁风暴。

    3. TiKV 侧 RocksDB 与 Raft 调优

    排查中发现 TiKV OOM 或高负载,往往是因为写入量太大导致 RocksDB Write Stall。保证 block-cache 配置合理,不超过系统内存的 45%。对于高频批量删除业务,考虑调大 max-background-jobs 加速 Compaction,避免 Tombstone 过多导致后续查询扫描性能断崖式下跌。

    排查清单 (大事务与 OOM 问题速查)

    1. dmesg 与 OOM 确认:快速执行 dmesg -T | grep -i oom,确认 tidb-server 或 tikv-server 是否被内核 Kill,排除网络分区导致的假死。

    2. 排查慢查询与内存大户:查询 INFORMATION_SCHEMA.SLOW_QUERY 或 TiDB Dashboard,按 Mem_max 或 Process_time 倒序,揪出未加 LIMIT 或扫描行数极大的问题 SQL。

    3. 核对事务配额参数:检查集群的 txn-total-size-limit 参数是否被违规调大(正常业务不应超过 100MB)。

    4. 监控 Lock 冲突指标:在 Grafana -> TiDB -> KV Errors 面板中,重点观察 KV Backoff OPS (特别是 txnLock 和 txnLockFast),若该指标激增,说明集群存在大事务或热点记录的严重写冲突。

    5. 垃圾回收 (GC) 状态确认:大批量 DELETE 后,务必通过 mysql.tidb 表检查 GC Safe Point 是否正常推进。大量的无用版本积压会拖慢整个集群的物理读取效率。

  • TiDB 集群 P99 暴涨至 5000ms:一个 2 亿行大事务 DELETE 引发的 Percolator 惨案

    某次排查过程中,核心交易集群的 TiDB 节点发生大面积 OOM,集群 P99 延迟从日常的 10ms 直接飙升到 5000ms 以上,TiKV 节点接连抛出 Server is busy 拒绝服务。先说最终结论: 某位研发在后台归档任务中,执行了一条没有任何 LIMIT 和分批的 DELETE 语句,企图在一个事务内删掉 2 亿行历史数据。由于对底层 Percolator 分布式事务模型一无所知,这个超级大事务不仅瞬间抽干了 TiDB Server 的内存,残留在 TiKV 的海量锁和 MVCC 墓碑(Tombstone)更是直接引发了读写雪崩。

    案发现场:从 OOM 到全局雪崩

    监控面板上,故障的爆发几乎是垂直的:

    1. tidb_server_memory_usage 指标在 3 分钟内从 4GB 飙升到 64GB(容器 Limit),随后节点被内核 OOMKilled。

    2. TiKV 的 Raft apply duration P99 飙到秒级,Coprocessor CPU 打满。

    3. 应用端出现大量 java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction 和 Region is unavailable。

    切到机器上抓一下 dmesg,典型得不能再典型的 OOM:

    [123456.789] Memory cgroup out of memory: Kill process 5678 (tidb-server) score 1980 or sacrifice child
    [123456.790] Killed process 5678 (tidb-server) total-vm:85934028kB, anon-vm:67108864kB, file-vm:0kB, shmem-vm:0kB
    

    翻看 INFORMATION_SCHEMA.SLOW_QUERY 和存活节点的 TiDB 日志,抓到了罪魁祸首:

    DELETE FROM trade_orders WHERE create_time < '2023-01-01 00:00:00';
    

    就是这么一句平平无奇的 SQL,命中了近 2 亿条数据。

    当我拿着这条 SQL 去找对应业务线的开发时,得到的答复是:“我们用的是分布式数据库啊,底层不是无限水平扩展的吗?删个历史数据怎么就挂了?”

    这种把分布式数据库当成魔法、完全无视底层物理定律的想法,是导致大多数生产灾难的根源。分布式 != 无底洞。

    刨根问底:为什么分布式数据库最怕“大事务”?

    在单机 MySQL (InnoDB) 中,大事务会撑爆 Undo Log,导致长事务阻塞和主从延迟。而在 TiDB 这类基于 Percolator 模型的分布式 HTAP 数据库中,大事务的杀伤力是指数级的。

    1. OOM 的元凶:两阶段提交(2PC)的内存缓冲

    TiDB 处理事务使用的是 Percolator 模型的变种。在事务提交(Commit)之前,客户端(即 TiDB Server)会把所有修改的数据缓存在自己的内存中。 当执行这句 2 亿行的 DELETE 时,TiDB Server 需要将这 2 亿个 Key 的修改操作(在底层,DELETE 也是一种写入,即写入包含 Tombstone 标记的 KV)装进内存。 算一笔最简单的账:单行数据的 Key + Value 加上事务元数据假设为 200 Byte。 200,000,000 * 200 Byte ≈ 40 GB。 更要命的是,Go 语言在处理如此庞大的对象分配时,GC 会产生巨大的开销,内存碎片加上堆栈扩展,轻轻松松就能把 64GB 的容器内存干爆。

    2. “掩耳盗铃”的配置修改

    其实 TiDB 为了防止这种惨案,出厂设置是有保护机制的:txn-total-size-limit 默认通常为 100MB。 理论上,这个事务早就该报 Transaction too large 失败了。但我查阅配置变更历史时发现,前段时间该业务线抱怨过几次批量更新报错,某位缺乏敬畏之心的运维,直接将全网的 txn-total-size-limit 改成了 10GB! 放开这种硬性防御阈值,等于拆掉了保险丝。TiDB 成功绕过了配置限制,然后死在了物理内存耗尽上。

    3. 锁残留与 Resolve Lock 风暴

    TiDB Server OOM 崩溃后,灾难并没有结束。 在 Percolator 2PC 的 Prewrite 阶段,TiDB 会在 TiKV 端写入大量的 Primary Lock 和 Secondary Lock。TiDB Server 进程猝死,导致这些锁变成了“孤儿锁”。 此时,正常的业务请求如果读取到了这些被锁住的 Key,就会发现事务处于 Pending 状态。为了保证 ACID,读请求必须触发锁清理机制(Resolve Lock)。 几十万个并发查询撞上几千万个残留锁,瞬间引发了海量的 RPC 交互:

    [WARN] [endpoint.go:612] ["error response"] [err="Key is locked (primary)"] 
    [WARN] [resolve.go:128] ["resolve lock timeout"] [txn=43981293847123984]
    

    TiKV 的 RPC 线程池直接打满,Raftstore 处理缓慢,最终导致大面积的 Region unavailable,连正常的小事务也无法提交。

    终极解法与避坑指南

    对于分布式数据库的批量数据清理,绝对不能用传统的“大事务一波流”。如果你需要删几亿条数据,请把“防御性编程”刻在脑子里。

    正规的落地姿势有三种:

    方案 A:非事务 DML(Non-transactional DML) 新版 TiDB 提供了原生的批处理语法,直接在内部完成分批提交,不保证事务的原子性(反正删历史数据也不需要原子性),彻底绕过大事务限制:

    BATCH ON id LIMIT 10000 
    DELETE FROM trade_orders WHERE create_time < '2023-01-01 00:00:00';
    

    方案 B:按时间分区的 Drop Partition 对于日志流、流水表,建表时就应该规划好时间分区(Partition By Range)。清理历史数据只需一条 ALTER TABLE trade_orders DROP PARTITION p2022;。这在底层仅仅是元数据的解绑,瞬间完成,没有 MVCC,没有锁冲突。

    方案 C:TiDB TTL (Time to Live) 机制 如果业务特性允许,直接在表结构上加上 TTL 属性:

    ALTER TABLE trade_orders TTL = `create_time` + INTERVAL 1 YEAR;
    

    交由 TiDB 后台按 Region 慢慢清理,对前台业务透明。

    排查清单:同类大事务问题速查 (Troubleshooting Checklist)

    1. 核对 OOM 与系统日志 立刻在 TiDB 节点执行 dmesg -T | grep -i oom,如果命中 tidb-server,说明发生过严重的内存挤兑,大概率是大事务或者无索引的巨型 JOIN。

    2. 定位元凶 SQL 检索 INFORMATION_SCHEMA.SLOW_QUERY,重点关注 Mem_max、Txn_start_ts 和 Query_time 极大的语句: SELECT query, mem_max, process_time FROM information_schema.slow_query ORDER BY mem_max DESC LIMIT 5;

    3. 检查全局限制配置 不要盲目调大保护参数。检查 tidb_mem_quota_query(单条 SQL 内存限制)和 txn-total-size-limit(总事务大小限制),恢复到合理阈值(推荐单事务不要超过 1GB)。

    4. 清理遗留的悲观锁/乐观锁 如果 OOM 后集群持续卡顿,观察 Grafana 中的 TiKV-Details -> Locks 面板。必要时可通过临时调低 resolve-lock 的 backoff 时间来加速孤儿锁清理,或联系官方辅助清理陈旧的 MVCC tombstone 触发手动 Compaction。

    分布式架构给了你海量存储的错觉,但底层的内存、网络 IO 和锁机制依然遵循着严密的物理约束。在生产环境敲下回车之前,想想底层要付出多大的代价。