分类: 数据库架构与运维

  • 深入 PostgreSQL 陷阱排查:Idle in Transaction 引发的 MVCC 巨量膨胀与 IOPS 耗尽实战

    排查过程中,某核心业务数据库突然遭遇性能断崖:QPS 从日常的 8000 跌至不足 500,99线延迟从 5ms 飙升至 3.5s,主机 Load Average 直接突破 120,IOPS 被瞬间打满。 最终结论:这是一个典型由“防御性配置缺失”加上“烂SQL”引发的血案。某个后台数据同步 Python 脚本由于异常终止,遗留了一个长达 72 小时的 idle in transaction 僵尸连接。这个僵尸连接死死拖住了全局的 xmin 水位,导致 Autovacuum 进程虽然疯狂运转,却无法清理这三天内产生的几千万条 Dead Tuples。核心表体积膨胀了 40 倍,原本走内存缓存的 Index Scan 退化成了疯狂刷盘的物理 I/O,最终引发整个集群雪崩。

    解法很直接:Kill 掉僵尸 PID,利用 pg_repack 在线重建表空间,并在全局配置文件中强制开启 idle_in_transaction_session_timeout。

    案发现场与指标确认

    当时接警后,第一反应是看系统监控。iostat -x 1 显示磁盘 %util 持续 100%,读写带宽却并不高,典型的随机小 I/O 耗尽了存储性能。 切到数据库层,执行基础探查:

    SELECT datname, usename, state, query, extract(epoch FROM now() - xact_start) AS duration
    FROM pg_stat_activity
    WHERE state != 'idle' ORDER BY duration DESC LIMIT 5;
    

    输出结果令人窒息,排名第一的连接 state 为 idle in transaction,duration 竟然高达 259200 秒(整整三天)。

    紧接着看一眼核心交易表的膨胀情况:

    SELECT relname, n_live_tup, n_dead_tup, 
           round(n_dead_tup * 100.0 / (n_live_tup + n_dead_tup + 1), 2) AS bloat_ratio
    FROM pg_stat_user_tables 
    WHERE relname = 'orders_hot';
    

    结果显示 n_live_tup 为 120 万,而 n_dead_tup 高达 4800 万,膨胀率超 97%。表物理大小从正常的 500MB 飙到了将近 20GB。

    底层原理解析:为什么一个 Idle 连接能搞死整个集群?

    要理解这个致命陷阱,必须搞懂 PostgreSQL 的 MVCC(多版本并发控制)底层实现。 与 MySQL InnoDB 将旧版本数据塞进独立 Undo Log 的做法不同,PostgreSQL 采用了“原地追加”的设计。当你执行 UPDATE 时,PG 其实是做了一个 DELETE + INSERT,旧行(Dead Tuple)和新行(Live Tuple)都存在同一个数据页(Page)里。

    为了清理这些旧行,PG 依赖后台的 Autovacuum 进程。但 Autovacuum 回收旧行的核心前提是:系统中没有任何活跃事务可能还会读取到这行数据。 判断标准就是事务 ID(XID)。如果旧行的 xmax(即删除该行的事务 ID)小于当前系统中所有活跃事务的最小事务 ID(全局 xmin),这行数据才能被安全回收。

    灾难的逻辑链条就此闭环:

    1. 那个挂掉的 Python 脚本开启了一个事务(BEGIN),执行了一条简单的 SELECT,然后由于网络问题断开或进程 OOM 被杀,但它使用的连接池没有发送 COMMIT 或 ROLLBACK。

    2. 数据库层面,这个连接就卡在了 idle in transaction 状态,它的事务 ID 被永久保留。

    3. 核心表 orders_hot 每天有极高频的并发 UPDATE,产生海量 Dead Tuples。

    4. Autovacuum 定期唤醒扫描这块表,但一比对 XID,发现那个长事务的 xmin 比这些 Dead Tuples 的 xmax 还要老。

    5. Autovacuum 只能叹口气,原封不动地放过这些旧数据。

    6. 数据页被迅速塞满,新数据只能申请新的 Page,导致表物理文件无限膨胀。缓存命中率(Hit Ratio)断崖式下跌,每次查询都需要扫过几百个全是废数据的 Page,I/O 直接被打爆。

    在开发眼里,似乎只是“一个空闲连接没关”,而在 PG 内核引擎里,这等同于“冻结了整个数据库的时间线”。

    解决与止血方案

    不要试图在业务高峰期直接执行 VACUUM FULL,这会申请 AccessExclusiveLock 排他锁,直接导致全表读写阻塞,引发更惨烈的级联故障。

    第一步:物理超度僵尸连接

    SELECT pg_terminate_backend(pid) 
    FROM pg_stat_activity 
    WHERE state = 'idle in transaction' AND extract(epoch FROM now() - xact_start) > 300;
    

    注:干掉之后,Autovacuum 终于能够正常回收之后的数据页,但这无法缩减已经膨胀的物理文件大小。

    第二步:使用 pg_repack 无锁重建 借助 pg_repack 插件,在不影响业务读写的情况下重新组织表物理文件。

    pg_repack -h localhost -U postgres -d mydb -t orders_hot -j 4
    

    原理:它会建一张结构相同的新表,触发器同步增量数据,老表数据导完后交换表名,全程只需要极短的锁时间。

    第三步:配置层面的防御性编程 永远不要相信客户端侧的规范,所有的保障必须在数据库内核层面兜底。在 postgresql.conf 中强制注入以下参数:

    # 斩断 idle in transaction 狗皮膏药,超时 60 秒直接 Kill(PG 9.6+ 支持)
    idle_in_transaction_session_timeout = 60000 
    
    # 设置普通语句执行超时,防止烂 SQL 拖死 CPU
    statement_timeout = 30000 
    
    # 如果是 PG 14+,可以考虑开启 idle_session_timeout
    idle_session_timeout = 300000 
    

    避坑总结

    技术团队过度依赖 ORM 框架和连接池默认配置,往往容易忽略数据库原生会话生命周期的管理。PG 的 MVCC 架构在带来极高并发性能的同时,也把垃圾回收的压力交给了运维层面。 在架构设计时,如果你的业务属于“高频 Update/Delete”场景,监控体系里如果没有对 pg_stat_user_tables.n_dead_tup 和长事务时间做强力告警,那就是在抱着定时炸弹裸奔。

    排查清单:PostgreSQL MVCC 膨胀与长事务速查

    1. 一键定位长事务与慢查询 sql SELECT pid, datname, usename, state, backend_xid, backend_xmin, extract(epoch FROM now() - xact_start) AS duration, query FROM pg_stat_activity WHERE state != 'idle' AND xact_start IS NOT NULL ORDER BY duration DESC LIMIT 10;
    2. 侦测表空间与索引膨胀率 重点关注 n_dead_tup 与 n_live_tup 比例。比例超过 20% 且表极大的,需要介入处理。 sql SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, last_autoanalyze FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;
    3. 检查 Autovacuum 运行状态 如果系统 I/O 正常但膨胀严重,检查 Autovacuum 进程是否在干活,或者是否被长事务卡住。 sql SELECT * FROM pg_stat_activity WHERE query LIKE 'autovacuum%';
    4. 验证防御性配置是否生效 执行 SHOW idle_in_transaction_session_timeout;,确保该值不为 0。
  • 深入 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)。

  • Redis 生产环境 P99 飙升:RDB COW 触发内存淘汰与 Cluster Gossip 故障转移雪崩排查实战

    生产环境 Redis Cluster (6.2.7) 突发 P99 延迟飙升至 2000ms,并伴随频繁的主从切换。核心原因是 BGSAVE 触发 Copy-On-Write 导致内存触碰 maxmemory,引发主线程大规模 LRU 淘汰阻塞。主线程卡顿导致 Gossip 协议心跳超时,误判节点下线并触发级联故障转移。解决方式:预留 30% 内存给 COW,开启 lazyfree-lazy-eviction,并调大 cluster-node-timeout。

    故障现场与指标断崖式下跌

    近期某集群告警,监控面板上呈现出典型的“雪崩”特征:

    1. QPS 骤降:单节点 QPS 从 80k 瞬间跌至 5k 以下。

    2. P99 剧烈抖动:平时稳定在 2ms 以内的 P99 突增至 2000ms+。

    3. 连接风暴:客户端因超时大量重连,引发短连接风暴。

    立刻登机拉取 Redis 日志,发现大量内存淘汰警告,紧接着是集群节点的 FAIL 状态广播:

    29302:M 10:23:14.123 * 10000 keys evicted, 153MB freed.
    29302:M 10:23:14.891 * 10000 keys evicted, 142MB freed.
    ...
    29302:M 10:23:19.456 * Marking node 3a8b... as failing (quorum reached).
    29302:M 10:23:19.458 # Cluster state changed: fail
    

    查看当时的内存指标和核心状态:

    $ redis-cli -p 6379 info memory | grep -E "used_memory_human|maxmemory_human|latest_fork_usec"
    used_memory_human:24.1G
    maxmemory_human:24.0G
    latest_fork_usec:89450
    

    现象很明确:由于触发了内存淘汰,主线程被长时间占用,导致集群内的 Gossip 节点心跳无响应,最终引发整个集群拓扑结构的重新计算和主从切换。

    为什么一次 BGSAVE 会引发集群雪崩?

    很多人在配置 Redis 时,习惯把 maxmemory 设为物理内存的 90% 甚至更大,认为这样“不浪费”。这在没有高频写入和持久化的场景下勉强能跑,但一旦触发 BGSAVE (RDB 持久化),就是灾难的开始。

    Redis 执行 BGSAVE 时会 fork() 一个子进程。现代操作系统利用 Copy-On-Write (COW) 机制,父子进程初始共享物理内存页。然而,如果此时集群正处于高频写入状态(特别是大 Key 的更新),父进程在修改数据时,操作系统必须为这些被修改的内存页分配新的物理空间。

    排查过程中的现场数据显示,该节点在 BGSAVE 期间产生了高达 4GB 的 COW 内存:

    # cat /proc/$(pidof redis-server)/smaps | grep -i private_dirty | awk '{sum+=$2} END {print sum/1024 " MB"}'
    3952 MB
    

    雪崩的传导链条如下:

    1. 内存触顶:COW 导致 Redis 实际占用内存 + 自身分配的内存超过了 maxmemory(24GB)。

    2. 同步淘汰阻塞:Redis 触发 maxmemory-policy(当时配的是 allkeys-lru)。在 Redis 6.2 中,如果未开启异步淘汰,主线程必须同步寻找并释放内存。大规模的 Key 淘汰(且包含 Hash/Set 大 Key)死死卡住了主线程。

    3. Gossip 协议“假死”:Redis 集群的节点保活依赖 Gossip 协议,而处理 Gossip 消息的 clusterCron() 是在主线程的事件循环中执行的。主线程被 Eviction 阻塞了 5 秒,导致无法回复其他节点的 PING。

    4. 脑裂与故障转移:其他节点超过 cluster-node-timeout(当时配的是激进的 5000ms)未收到 PONG,将其标记为 PFAIL,进而升级为 FAIL,触发 Replica 强制上位。

    5. 全量同步加剧雪崩:旧 Master 恢复后变为 Slave,向新 Master 发起 SYNC,再次触发新 Master 的 BGSAVE。死循环形成。

    防御性配置与底层调优实战

    为了彻底根除这种由于持久化抖动引发的集群雪崩,必须从内存预留、异步淘汰和集群容忍度三个维度进行改造。

    1. 严格的内存水位控制 (COW 预留)

    永远不要把 maxmemory 贴着物理内存上限配置。标准做法是预留 30% – 40% 的内存给 COW、主从复制的 repl-backlog 以及客户端缓冲区。

    # 假设实例物理内存 32GB
    maxmemory 20gb
    # 淘汰策略根据业务改为 volatile-lru 或 volatile-ttl,避免全盘扫描
    maxmemory-policy volatile-lru
    

    2. 开启 Lazyfree 机制

    Redis 4.0 引入了 lazyfree,6.0+ 版本进一步完善。针对内存淘汰引发的阻塞,必须开启惰性删除,将释放内存的动作交给后台线程 (bio 线程池) 执行,保命主线程。

    # 开启惰性内存淘汰
    lazyfree-lazy-eviction yes
    # 开启惰性键过期
    lazyfree-lazy-expire yes
    # 隐式 DEL 转化为 UNLINK
    lazyfree-lazy-user-del yes
    

    3. 调校 Cluster Gossip 参数

    cluster-node-timeout 决定了集群对网络抖动和主线程阻塞的容忍度。千万别为了追求极端的“故障恢复速度”将其设为 3-5 秒。主线程偶然卡顿是常态,误判导致的 Failover 成本极高。

    # 推荐值为 15000 (15秒),足够覆盖绝大多数 RDB fork 和淘汰耗时
    cluster-node-timeout 15000
    

    配合调大复制积压缓冲区,防止主从切换或短连后触发全量重传:

    repl-backlog-size 512mb
    

    4. 彻底接管内核 THP (Transparent Huge Pages)

    在排查中发现,操作系统的 THP 是开启的。THP 会将默认的 4KB 内存页放大为 2MB。在 COW 发生时,即使 Redis 只修改了 10 字节的数据,内核也必须拷贝完整的 2MB 内存页。这直接导致了 BGSAVE 期间内存飙升速度放大了数百倍。

    必须在所有 Redis 宿主机上硬性关闭 THP:

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    # 固化到 rc.local 或 grub 中
    

    常见问题

    Q1:除了 BGSAVE,AOF 重写 (BGREWRITEAOF) 会引发完全一样的问题吗? 会。BGREWRITEAOF 同样依赖 fork() 子进程进行机制。只要有 Fork 操作,在海量写入时就会产生大量 COW 内存。防御策略完全一致。

    Q2:如何快速确认集群是否正因为主线程卡顿而处于 Gossip 瘫痪边缘? 观察 info stats 中的 latest_fork_usec 耗时,以及慢查询日志。如果 latest_fork_usec 超过 100ms,说明 fork 本身就极其耗时(通常由于系统页表太大引起)。同时可以监控 redis_cluster_messages_ping_sent 和 pong_received 的差值斜率。

    Q3:开启了 lazyfree-lazy-eviction,内存就一定不会爆吗? 并非绝对。Lazyfree 只是把内存释放动作交给了后台线程。如果业务的写入速度远大于后台线程释放内存的速度,Redis 的总内存依然会持续上涨,最终触发操作系统的 OOM Killer 直接干掉 Redis 进程。因此,合理的 maxmemory 预留和限流依然是底线。

    Q4:Redis 7.0 的 Multi-part AOF 能解决这个问题吗? Redis 7.0 的 Multi-part AOF 优化了 AOF 重写期间的增量数据追加机制,大幅降低了重写带来的内存开销和 CPU 负担。但对于纯 RDB 的 BGSAVE COW 物理内存翻倍问题,底层机制并没有变,依然受限于内核的页表和 COW 行为。