某次排查过程中,业务反馈核心交易链路上游频繁报超时(Timeout),监控显示整个 TiDB 集群的查询 P99 延迟从平时的 8ms 暴涨至 6000ms 以上,紧接着监控告警触发:多个 TiKV 节点相继 OOM 重启,集群陷入雪崩状态。
不绕弯子,直接抛出排查结论:某业务研发绕过数据平台,使用客户端直连线上核心 OLTP 集群,开启了一个长事务执行极其复杂的分析型查询,且中途因客户端崩溃导致连接处于“Sleep”挂起状态长达 14 个小时未提交。
根据 TiDB 基于 Percolator 模型的 MVCC 原理,为了保证该长事务的可重复读(Repeatable Read),全局 GC(Garbage Collection)的 Safepoint 被强行锁死在此事务的 start_ts,无法向前推进。导致的结果是:核心交易表产生的数千万次 Update/Delete 产生海量的历史版本(Tombstone)无法被清理。正常的单行主键查询被下推到 TiKV Coprocessor 后,底层的 RocksDB Iterator 被迫扫描成千上万个废弃版本数据才能找到最新记录,读放大呈指数级飙升,直接打满 Coprocessor 线程池并耗尽了 TiKV 的物理内存。
案发现场与暴力干预
当时接手排查时,现象非常诡异。慢查询日志里并没有突发的大流量,所有的正常交易 SQL(哪怕是主键 SELECT)都慢得令人发指。登录故障所在的 TiKV 宿主机查看现场:
# dmesg -T | grep -i oom
[xxx] Out of memory: Killed process 12345 (tikv-server) total-vm:42949672960kB, anon-rss:32145678kB, file-rss:0kB, shmem-rss:0kB
TiKV 已经被内核 OOM-Killer 献祭。查看 Grafana 监控 TiKV-Details -> Coprocessor Detail -> Total Ops Details,发现底层的 Scan 和 Next 操作次数飙升了近万倍;同时 TiKV-Details -> Thread CPU -> Coprocessor CPU 直接画了一条顶格的直线。
经验直觉告诉我,这不是 SQL 索引没建好,而是底层存储引擎在“负重前行”。立即查看 GC 状态:
SELECT * FROM mysql.tidb WHERE variable_name IN ('tikv_gc_safe_point', 'tikv_gc_last_run_time');
果然,tikv_gc_safe_point 的时间戳停留在十几个小时前。
找出罪魁祸首的命令很简单,拉取全集群执行时间超过 1 小时的长事务:
SELECT INSTANCE, ID, USER, HOST, DB, COMMAND, TIME, STATE, INFO
FROM INFORMATION_SCHEMA.CLUSTER_PROCESSLIST
WHERE TIME > 3600 ORDER BY TIME DESC;
抓到一个 TIME 高达 50000+ 秒的 Sleep 连接。没有任何犹豫,直接 KILL TIDB 斩断该连接。
大约等待了 5 分钟(GC 重新计算 Safepoint 并开始后台清理),TiKV 的 CPU 使用率断崖式下跌,P99 延迟回归 10ms 以内,报警全部解除。
底层原理解析:为什么一个挂起的连接能搞挂整个集群?
很多人把 TiDB 当作单机 MySQL 来用,缺乏对分布式 MVCC 机制的敬畏。在 Percolator 事务模型中,任何数据的更新(Update)和删除(Delete)本质上都是写入一条带有新时间戳(commit_ts)的记录,而非就地修改。
为了防止磁盘被无尽的历史版本撑爆,TiDB 后台有一个 GC Leader 节点,定期(默认 10 分钟)推进 Safepoint,并通知 TiKV 清理掉 Safepoint 之前的旧版本。
但这里有一个极其致命的硬性约束:Safepoint 的推进绝对不能超过集群中当前正在运行的最老事务的 start_ts。如果不加这个限制,长事务在执行中途,其依赖的老版本数据被 GC 提前清理掉了,就会报出著名的 GC life time is shorter than transaction duration 错误。
当出现一个几小时不提交的僵尸事务时,GC Safepoint 被迫停滞。我们看看底层的读放大是怎么产生的:
在 TiKV 侧,数据存储在 RocksDB 中。当你执行 SELECT * FROM table WHERE id = 1 时,Coprocessor 会构造一个 RocksDB Iterator 并在该键值区间进行 Seek,然后不断调用 Next() 往下扫。
正常情况下,扫到最新的有效记录就返回了。但由于 GC 停滞,该行数据如果经历了 10 万次高频更新,RocksDB 里就会存在 10 万个带有不同版本号的旧数据。Iterator 必须强行越过(遍历)这 10 万个逻辑删除标识(Tombstone),最终把数据拼装返回。
这就导致了:
-
CPU 爆炸:无休止的
Next()调用榨干了 Coprocessor CPU。 -
OOM 惨案:读取海量垃圾版本导致 Block Cache 被频繁换入换出(Thrashing),内存中驻留了大量无用的多版本数据结构,直至突破
memory-usage-limit防线引发 OOM。
防御性配置与避坑指南
把这种“一粒老鼠屎坏了一锅汤”的风险暴露在默认配置下,是极度危险的运维架构。要想在生产环境中活得久,必须在服务端建立防御机制。
1. 全局只读事务超时熔断 严格限制单个查询的最长执行时间,超过阈值由服务端主动掐断。
-- 设置全局 SQL 超时时间为 30 分钟(毫秒计算)
SET GLOBAL max_execution_time = 1800000;
2. OOM 防御:单次查询内存硬限 防止垃圾 SQL 或者深层无索引 JOIN 直接撑爆 TiDB 节点的内存。
-- 限制单条 SQL 占用最大内存为 4GB
SET GLOBAL tidb_mem_quota_query = 4294967296;
-- 配置超过配额时的行为为 CANCEL(直接熔断报错)
SET GLOBAL tidb_oom_action = 'CANCEL';
3. 长时间空闲连接杀手(Idle Timeout)
对于文中这种事务开启后客户端挂死导致的 Sleep 状态,必须通过空闲超时来兜底:
-- 断开空闲时间超过 3600 秒的交互式连接
SET GLOBAL interactive_timeout = 3600;
SET GLOBAL wait_timeout = 3600;
4. 架构隔离:HTAP 的正确打开方式
永远不要在 OLTP 的存储节点(TiKV)上跑重度分析型查询。如果业务确实需要拉取全表进行长周期聚合分析,必须通过 TiFlash 列存引擎进行物理隔离。利用 set @@session.tidb_isolation_read_engines = "tiflash"; 强行将耗时分析路由到 TiFlash,保护核心交易链路。
排查清单 (Troubleshooting Checklist)
-
读延迟剧增且 CPU 打满:如果整体 QPS 平稳但 P99 飙升,首查 Grafana
TiKV-Details -> Coprocessor -> Total Ops Details中Next调用次数是否异常放大。 -
确认 GC 状态:查询
mysql.tidb表中的tikv_gc_safe_point,对比当前系统时间,若滞后超过 1 小时,必有长事务或死锁阻塞。 -
定位僵尸事务:使用
SELECT * FROM INFORMATION_SCHEMA.CLUSTER_PROCESSLIST WHERE TIME > N定位超长事务,必要时立刻KILL TIDB。 -
验证 MVCC 版本堆积度:通过
pd-ctl或者慢查询日志中Total_keys与Process_keys的比值来判断读放大比例,若Total_keys远大于Process_keys,说明扫描了大量废弃历史版本。