标签: 分布式数据库

  • 深入 TiDB 读延迟雪崩排查:长事务阻塞 GC 引发 MVCC 堆积与 TiKV Coprocessor OOM 惨案

    某次排查过程中,业务反馈核心交易链路上游频繁报超时(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,发现底层的 ScanNext 操作次数飙升了近万倍;同时 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),最终把数据拼装返回。

    这就导致了:

    1. CPU 爆炸:无休止的 Next() 调用榨干了 Coprocessor CPU。

    2. 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)

    1. 读延迟剧增且 CPU 打满:如果整体 QPS 平稳但 P99 飙升,首查 Grafana TiKV-Details -> Coprocessor -> Total Ops DetailsNext 调用次数是否异常放大。

    2. 确认 GC 状态:查询 mysql.tidb 表中的 tikv_gc_safe_point,对比当前系统时间,若滞后超过 1 小时,必有长事务或死锁阻塞。

    3. 定位僵尸事务:使用 SELECT * FROM INFORMATION_SCHEMA.CLUSTER_PROCESSLIST WHERE TIME > N 定位超长事务,必要时立刻 KILL TIDB

    4. 验证 MVCC 版本堆积度:通过 pd-ctl 或者慢查询日志中 Total_keysProcess_keys 的比值来判断读放大比例,若 Total_keys 远大于 Process_keys,说明扫描了大量废弃历史版本。

  • 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 transactionRegion 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_maxTxn_start_tsQuery_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 和锁机制依然遵循着严密的物理约束。在生产环境敲下回车之前,想想底层要付出多大的代价。