近期处理了一起极为惨烈的分布式数据库生产事故。核心业务集群(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 当无底洞垃圾桶,它就会把你的业务一起埋了。
现场还原:从延迟突刺到死亡宣告
监控大盘上的异动非常典型,呈现出教科书般的“雪崩”曲线:
-
TiDB 节点内存垂直起飞:某一个 TiDB 节点的内存使用率在 60 秒内从 15% 飙升至 95%。
-
锁指标爆炸:TiDB Dashboard 中的
KV Backoff OPS和Lock Resolve OPS激增 1000 倍。 -
gRPC 阻塞:TiKV 的
gRPC message durationP99 飙升至 15s 以上。 -
死亡宣告:系统监控捕获到内核级斩首行动:
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 问题速查)
-
dmesg 与 OOM 确认:快速执行
dmesg -T | grep -i oom,确认tidb-server或tikv-server是否被内核 Kill,排除网络分区导致的假死。 -
排查慢查询与内存大户:查询
INFORMATION_SCHEMA.SLOW_QUERY或 TiDB Dashboard,按Mem_max或Process_time倒序,揪出未加 LIMIT 或扫描行数极大的问题 SQL。 -
核对事务配额参数:检查集群的
txn-total-size-limit参数是否被违规调大(正常业务不应超过 100MB)。 -
监控 Lock 冲突指标:在 Grafana -> TiDB -> KV Errors 面板中,重点观察
KV Backoff OPS(特别是txnLock和txnLockFast),若该指标激增,说明集群存在大事务或热点记录的严重写冲突。 -
垃圾回收 (GC) 状态确认:大批量 DELETE 后,务必通过
mysql.tidb表检查 GC Safe Point 是否正常推进。大量的无用版本积压会拖慢整个集群的物理读取效率。