上午十点,刚把架构改造方案的拓扑图画完,监控大屏突然红了一片。应用端开始疯狂报 Lock wait timeout exceeded,伴随着偶尔的 Transaction is too large 异常。
打开 Grafana 扫了一眼 TiDB 集群的状态:几个 TiDB Server 节点的内存呈直角飙升,接着发生了 OOM 重启;底层的 TiKV 节点 CPU 几乎跑满,Scheduler command duration 面板里,prewrite 和 commit 的 P99 耗时已经飙到了数十秒。
很显然,整个集群被什么东西卡死,发生了严重的锁阻塞。
连上存活的 TiDB 节点,敲下 SHOW PROCESSLIST,我很快抓到了罪魁祸首。那是一条极度简单、却在这个场景下极度致命的 SQL:
DELETE FROM order_history WHERE create_time < '2023-01-01';
没有 LIMIT,没有按主键分批,什么保护机制都没有。问了下业务侧,这张表里一年前的历史数据大概有四千多万行。开发人员的逻辑很简单:“既然换了分布式数据库,容量和算力应该随便造,一条 DELETE 删几千万条数据怎么了?MySQL 可能会锁表,你们分布式不应该更牛吗?”
这种把分布式数据库完全当成单机 MySQL 甚至当成黑盒来用的思维,是导致这场灾难的根本原因。
分布式数据库确实能横向扩展,但它绝不是无脑承接烂 SQL 的垃圾桶。在 TiDB 这类基于 Percolator 模型的分布式事务引擎中,大事务是天然的毒药。今天必须把底层逻辑拆透,看看一条千万级的 DELETE 是怎么把一个健康的集群拖进深渊的。
在单机 MySQL (InnoDB) 中,大批量删除主要消耗的是 Undo Log、Redo Log 以及 Buffer Pool,顶多把这条数据所在的数据页锁住,影响局部的并发。
但在 TiDB 中,事务采用的是基于 Google Percolator 模型的两阶段提交(2PC)。当我们执行这行 DELETE 时,底层到底在发生什么?
第一步是读取与内存构建。TiDB Server 并非一边查一边删,它需要先将满足 create_time < '2023-01-01' 的所有记录的主键全部扫出来。在这个过程中,这四千万行数据的 Mutation(变更操作)会被缓存到 TiDB Server 的内存里。即使 TiDB 后来对大事务做过一定优化,但单事务的限制依旧存在(默认事务大小限制为 100MB,最多几百万条 KV)。即使通过改参数强行突破了限制,TiDB Server 节点的内存也会被瞬间抽干,引发 OOM 崩溃。
第二步是致命的 Prewrite 阶段。假设内存勉强扛住了,TiDB 开始向 PD(Placement Driver)获取 TSO 作为事务的 start_ts,然后进入预写阶段。Percolator 模型会在修改的这数千万个 Key 中,选出一个作为 Primary Lock(主锁),剩下的全部作为 Secondary Lock(从锁),这些从锁都会包含一个指向主锁的指针。
接着,TiDB 会并发地向 TiKV 节点发送这些 Prewrite 请求。此时,TiKV 层开始疯狂地写 Lock CF(列族)和 Default CF。几千万个锁如同雨点般砸向整个集群的各个 Region。
第三步,雪崩的形成(锁冲突与 Resolve Lock)。Percolator 模型保证了强一致性的快照隔离(Snapshot Isolation)。在这条巨型 DELETE 语句尚未进入 Commit 阶段(即 Primary Lock 未提交)时,其他正常的业务查询如果读到了这几千万个 Key 中的任何一个,会发生什么?
读请求会看到这里有一个未提交的 Lock。为了保证一致性,它不能直接跳过,必须等。它会等待这个锁被释放,或者等待锁的 TTL 过期。
一旦 TTL 过期,读请求还要主动触发 Resolve Lock(清理锁)操作——顺着 Secondary Lock 找到 Primary Lock,检查那个大事务到底有没有提交,如果没有,就尝试回滚它。
当整个 TiKV 充满了这种未决的 Lock 时,系统的 Resolve Lock 开销会呈指数级上升。正常的读写请求全被卷入这场找锁、等锁、清锁的漩涡中,最终导致整个 TP 业务瘫痪。
在故障现场,我没有别的选择,立即 KILL 掉了这个执行中的大事务连接。
但噩梦并没有马上结束。在分布式环境中,大事务的回滚代价同样极其高昂。TiKV 需要清理掉刚才写入的上千万个 Lock 记录。在接下来的十几分钟里,监控上的垃圾回收(GC)指标依然居高不下,直到异步清理动作彻底完成,集群的延迟才慢慢降回毫秒级。
分布式不仅仅是几台机器连在一起那么简单。存储底座从 B+ 树变成了 LSM-Tree,事务模型从单机的本地日志变为了基于时间戳的分布式 2PC,这些底层原理的剧变,要求开发者必须改变对数据库的交互方式。
如果在单机时代,我们提倡“大事务拆小事务”是为了减少锁表时间和 Undo 空间;那么在 Percolator 分布式事务模型下,“大事务拆小事务”就是关乎集群生死存亡的铁律。
对于这种历史数据的清理,最干净的做法是在建表时就规划好时间分区(Partition),清理时直接一条 ALTER TABLE order_history DROP PARTITION p2022,这在底层只是元数据的变更,瞬间完成。
如果非要用 DELETE,就老老实实写一个脚本,按主键范围每次处理 2000 到 5000 行,循环往复。
把单机时代的粗暴操作原封不动地搬到架构复杂的分布式系统上,是对技术的无知。任何时候,不敬畏系统底层的运行机制,系统必定会以最直接的方式给你一个惨痛的教训。