分类: 数据库运维

  • 深入 Zabbix 陷阱排查:原生 Housekeeper 扫表引发的 History Syncer 阻塞与 Proxy 队列雪崩实战

    监控系统本身的雪崩,是运维架构中最不能忍受的“灯下黑”。近期接手处理了一起 Zabbix 分布式集群的严重延迟故障。现象极为惨烈:全局 30+ Zabbix Proxy 出现严重的数据积压,Server 端监控队列(Queue)飙升至 50万以上,引发大面积的“Zabbix agent on xxx is unreachable”误告警,On-Call 手机直接被打爆。

    直接抛结论:当 Zabbix 集群的 NVPS(每秒处理的新值数量)超过 5000 时,绝对不能依赖 Zabbix 原生的 Housekeeper 来清理历史数据。本次故障的根因正是 Housekeeper 唤醒后,向后端 MySQL 提交了海量的 DELETE 语句,导致 InnoDB Buffer Pool 频繁换页,底层存储 I/O 被随机写彻底打满。数据库的极度缓慢导致 Zabbix Server 的 history syncer(历史数据同步器)进程 100% 阻塞,进而耗尽 History Cache,最终拒绝 Proxy 的数据写入请求,引发全链路雪崩。

    解法极其粗暴但有效:全局关闭 Housekeeper 的历史数据清理功能,将 Zabbix 核心时序表(history、history_uint、trends 等)全部改造为基于 clock 字段的按天表分区(Table Partitioning),通过 DROP PARTITION 替代 DELETE 实现 O(1) 复杂度的过期数据淘汰。

    故障现场与弱智的灾难链

    排查过程中,我第一时间查看了 Zabbix Server 的内部性能指标图表(Zabbix internal process busy %)。图表显示:

    • history syncer 进程繁忙度直接拉平在 100%。

    • trapper 进程(负责接收 Proxy 数据的进程)繁忙度紧随其后,也达到 100%。

    • Zabbix 内部缓存 History cache % free 跌至 0%。

    登录数据库节点,敲下 iostat -x 1,磁盘 %util 钉死在 100%,w/s(每秒写请求)极高。 再切进 MySQL 执行 SHOW FULL PROCESSLIST;,满屏都是类似下面的慢查询:

    DELETE FROM history_uint WHERE itemid=45923 AND clock<1696118400;
    

    这就是典型的架构认知缺失。指望关系型数据库用标准 SQL 处理时序数据的生命周期,是一种极其天真的做法。

    底层原理解析:为什么 DELETE 是时序数据的毒药?

    在 MySQL InnoDB 引擎中,执行一条 DELETE 语句的成本是极高的。它不是简单地把数据抹掉,而是:

    1. 寻址与锁:在二级索引和聚簇索引(B+树)中找到目标记录,加上行锁(甚至间隙锁)。

    2. 写 Undo Log:为了支持事务回滚和 MVCC,必须把删除前的数据写入 Undo 表空间。

    3. 写 Redo Log:修改内存中的数据页,并记录 Redo Log 以保证持久化。

    4. B+ 树维护:大量删除会导致数据页变为空(Page Empty),触发树的分裂与合并(Page Merge),产生极严重的写放大(Write Amplification)。

    当 Housekeeper 试图一次性删除几千万条过期监控数据时,InnoDB 的 Buffer Pool 被大量的脏页填满,后台刷脏(Page Cleaner)线程疯狂工作,直接导致数据库整体吞吐量暴跌。 此时,Zabbix Server 的 history syncer 进程正试图将内存中的新监控数据批量 INSERT 到 MySQL。由于 MySQL 处于高负载的锁竞争和 I/O 拥塞状态,INSERT 被迫挂起。 history syncer 阻塞后,Zabbix Server 分配的 HistoryCacheSize(通常配置为几十MB到几百MB)迅速被新来的数据填满。缓存一旦打满,接收 Proxy 数据的 trapper 进程就无法将数据写入缓存,只能挂起。最终,Proxy 无法推送数据,只能将数据堆积在 Proxy 本地的 SQLite 库中,全局队列雪崩就此形成。

    改造方案与落地实战

    不要试图通过调优 InnoDB 参数(如 innodb_io_capacity 或 innodb_buffer_pool_size)来解决这个问题,那是治标不治本。解法只有一种:表分区。

    1. 禁用 Housekeeper

    在 Zabbix Web 界面:Administration -> General -> Housekeeping。 将 History 和 Trends 的 Enable internal housekeeping 勾选全部去掉。(注意:Events、Services 等小数据量表可以保留由 Housekeeper 清理)。 或者在 zabbix_server.conf 中直接修改配置并重启:

    # 禁用所有后台自动清理操作(非极端情况不建议全禁,建议走 GUI 精细化控制)
    HousekeepingFrequency=0
    

    2. 核心表实施 Range 分区

    对于 MySQL,必须按时间戳(clock)字段对大表进行拆分。Zabbix 核心的大表主要有:

    • history (浮点数)

    • history_uint (无符号整数 – 通常是最大的表)

    • history_str, history_text, history_log

    • trends, trends_uint

    以 history_uint 为例,改造分区的核心 SQL 逻辑如下:

    -- 注意:操作前必须停止 Zabbix Server,确保无新数据写入
    ALTER TABLE history_uint 
    PARTITION BY RANGE (clock) (
        PARTITION p20231001 VALUES LESS THAN (UNIX_TIMESTAMP('2023-10-02 00:00:00')),
        PARTITION p20231002 VALUES LESS THAN (UNIX_TIMESTAMP('2023-10-03 00:00:00')),
        -- 预先建好未来几天的分区...
        PARTITION p_max VALUES LESS THAN MAXVALUE
    );
    

    (注:如果原表已经包含海量数据,直接 ALTER 会锁表很久,建议采用新建分区表 -> INSERT INTO ... SELECT -> RENAME TABLE 的平滑迁移方案。)

    3. 部署分区维护脚本

    分区建好后,需要通过 Cron 每天定时执行脚本,创建未来的新分区,并 DROP 掉过期的老分区。

    ALTER TABLE history_uint DROP PARTITION p20230901;
    

    降维打击就在这里:DROP PARTITION 是一个 DDL 操作,它在文件系统层面上直接删除了对应的 .ibd 数据文件。不走 Buffer Pool,不写 Undo Log,不触发 B+ 树合并,时间复杂度永远是 O(1)。原本需要数小时且耗尽 I/O 的清理任务,现在只需要几毫秒。

    总结

    Zabbix 是一个优秀的监控框架,但它的默认配置是为了让新手能在单机上无脑跑起来而设计的,绝对不是为了生产环境的高可用。在面对大规模监控场景时,任何依赖 RDBMS 进行高频 Delete 操作的设计都是反模式的。如果条件允许,直接使用 TimescaleDB(PostgreSQL 的时序插件)作为 Zabbix 后端,利用其原生的 Chunk 机制进行数据淘汰,是更优雅的现代化选择。

    排查清单:Zabbix 队列堆积与延迟速查

    1. 查 Zabbix 内部性能瓶颈:先看 Zabbix server performance 面板,如果 history syncer > 90% 且 History cache % free < 10%,问题必在后端数据库写入慢。如果 poller 进程 > 90%,则是自定义监控脚本超时或网络不通导致进程耗尽。

    2. 查数据库 I/O 负载:使用 iostat 观察磁盘写延迟,通过 SHOW PROCESSLIST 抓取长事务或慢查询,确认是否存在 DELETE 大量扫表。

    3. 确认 Housekeeper 配置:进入 Administration -> General -> Housekeeping,确认 NVPS > 1000 时,是否已强制接管并禁用了 History 和 Trends 的内置清理。

    4. 查 Proxy 积压状态:在 Server 端执行 zabbix_get -s -k "zabbix[proxy,history,queue]",精准定位是哪个 Proxy 无法上报数据,区分是全局故障还是单节点网络隔离。

  • 深入 PostgreSQL 陷阱排查:长事务冻结 xmin 引发的 MVCC 膨胀风暴与 Autovacuum I/O 瘫痪实战

    某次核心交易链路突发严重告警,PostgreSQL 主库 Load Average 瞬间飙升至 80+,NVMe SSD 的 IOPS 被彻底打满(100% util),核心订单更新接口的 p99 延迟从 5ms 直接恶化到 3000ms 以上,业务几乎处于熔断边缘。

    结论先行:这是一起典型的由开发侧“防御性代码”缺失引发的内核级雪崩。一个低频的 BI 抽数 Python 脚本在开启事务后,因遇到未捕获的异常而挂起,导致连接池中残留了一个长达 72 小时的 idle in transaction(空闲事务)。这个幽灵连接死死卡住了 PostgreSQL 的全局 xmin(最老活跃事务ID)水位线。在这 3 天里,核心订单表经历数千万次 UPDATE,由于 xmin 冻结,Autovacuum 无法清理任何死元组(Dead Tuples),导致一张原本 5GB 的表膨胀到了 80GB。当监控系统触发阈值,运维手动 kill 掉该幽灵连接的瞬间,积压的 Autovacuum 进程如饿狼般扑向这 75GB 的垃圾数据,引发了灾难性的 I/O 读写风暴。

    最终救火操作:立即调整 autovacuum_vacuum_cost_limit 降低清理 I/O 速率,并在线使用 pg_repack 重组表空间碎片。

    案发现场与底层逻辑

    排查过程异常简单但令人窒息。登录数据库,敲下第一条排查 SQL:

    SELECT pid, state, duration, query 
    FROM (
        SELECT pid, state, now() - xact_start AS duration, query 
        FROM pg_stat_activity 
        WHERE state = 'idle in transaction'
    ) a 
    ORDER BY duration DESC LIMIT 1;
    

    结果赫然显示一个持续了 3 days 02:14:15 的空闲事务。

    为什么一个没有任何读写动作的空闲连接,能把数据库逼到 OOM 甚至 I/O 瘫痪的绝境?这必须从 PostgreSQL 的 MVCC(多版本并发控制)实现机制说起。

    不同于 MySQL (InnoDB) 将 Undo Log 存放在独立的表空间,PostgreSQL 采用了“Append-Only”式的 MVCC 设计。每次对一行数据进行 UPDATE,PG 内核并不会覆盖原数据,而是将其标记为不可见(设置 xmax),并在堆表(Heap Table)中插入一行新版本的数据。

    为了实现快照隔离(Snapshot Isolation),PG 必须保留那些“可能被当前活跃事务读取”的旧版本数据。Autovacuum 进程在决定是否物理清理一行死元组时,判断依据是:该行数据的 xmax(删除它的事务ID)是否小于当前系统所有活跃事务中最小的那个 xmin。

    由于那个挂起的 BI 抽数脚本死死咬住了 3 天前的 xmin,这 3 天内产生的所有历史数据版本,对 Autovacuum 来说都是“不可触碰”的。于是,系统只能眼睁睁看着堆表无限制膨胀,不仅消耗大量磁盘,更致命的是污染了 shared_buffers:业务正常的全表扫描或范围查询,不得不把海量的无用死元组读入内存,导致 Buffer Cache 命中率雪崩。

    致命的“按下葫芦浮起瓢”:Autovacuum I/O 风暴

    当确认是长事务作祟后,通常的操作是 SELECT pg_terminate_backend(pid); 杀掉连接。但真正的灾难往往发生在此时。

    长事务被清理,全局 xmin 瞬间推进,原本被冻结的 75GB 死元组瞬间变成了“可回收垃圾”。此时,一直处于休眠或空转状态的 Autovacuum Worker 立即被唤醒,开始对严重膨胀的表执行 Vacuum 扫描。

    由于前期有同学为了“加快常规清理速度”,将 autovacuum_vacuum_cost_limit 从默认的 200 激进调大到了 10000,且 autovacuum_vacuum_cost_delay 设为了 0。这就导致 Autovacuum 进程在扫描那 75GB 数据时,几乎没有任何限流(Throttling),直接跑满了底层存储的吞吐带宽。

    普通 Vacuum 会产生大量的随机读(扫描表页面和索引页面)以及写入(更新 FSM 空间映射表和 VM 可见性映射表,并产生相应的 WAL 日志)。正常的业务读写被这股突如其来的 I/O 洪流无情碾压,最终引发了 Load 飙升和 QPS 熔断。

    防御性配置与最佳实践

    把数据库的生死存亡交由业务代码的严谨性来保证,是运维架构的失职。针对此类 MVCC 膨胀与 Vacuum 陷阱,必须在数据库层面实施强制的防御性配置。

    1. 强制斩断长事务(核心防线) 在 postgresql.conf 中必须配置以下参数,绝不允许无限期的空闲事务存在。

    # 终止超过 60 秒一直处于 idle in transaction 状态的会话
    idle_in_transaction_session_timeout = 60000 
    # 限制单个语句的最大执行时间(防慢查询打爆 CPU)
    statement_timeout = 30000 
    

    2. 精细化控制 Autovacuum 的 I/O 成本 不要试图关闭 Autovacuum,也不要无脑调大参数。Vacuum 应该像温水煮青蛙一样持续在后台运行。

    # 默认 200 太小,10000 太大,通常建议根据 IOPS 能力设为 1000-2000
    autovacuum_vacuum_cost_limit = 2000
    # 给予每次清理后短暂的暂停,避免连续霸占 I/O
    autovacuum_vacuum_cost_delay = 2ms 
    

    3. 监控 oldest_xmin 与死元组率 常规的 CPU 和内存监控无法提前预警 MVCC 膨胀。必须将 pg_stat_user_tables.n_dead_tup(死元组数)和 age(datfrozenxid) 接入 Prometheus。

    此外,需要明白一个残酷的事实:普通的 VACUUM 操作只能把空间标记为可用,更新 FSM 表(Free Space Map),供后续 INSERT/UPDATE 复用,它绝对不会将物理磁盘空间还给操作系统。如果表已经膨胀到了 80GB,常规 VACUUM 跑完,表文件依然是 80GB。要缩减物理文件大小,只能使用锁定全表的 VACUUM FULL,或者在生产环境使用非阻塞工具 pg_repack 或 pg_squeeze。

    排查清单:PostgreSQL 膨胀与 I/O 突增速查

    1. 查杀空闲长事务: 定位并清理:SELECT pid, pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle in transaction' AND state_change < now() - INTERVAL '5 minutes';

    2. 确认表级膨胀率(死元组占比): 使用内置统计视图:SELECT relname, n_live_tup, n_dead_tup, (n_dead_tup::float / (n_live_tup + n_dead_tup + 1)) * 100 AS dead_tup_ratio FROM pg_stat_user_tables ORDER BY dead_tup_ratio DESC LIMIT 5;

    3. 排查复制槽(Replication Slot)残留: 除了长事务,废弃的复制槽也会冻结 xmin 和 WAL。检查并清理:SELECT slot_name, plugin, slot_type, active, age(xmin) FROM pg_replication_slots;

    4. 抓取当前 Vacuum 进度与 I/O 开销: 查看 pg_stat_progress_vacuum 视图,确认当前正在执行清理的表,以及处于哪个扫描阶段(如 scanning heap 或 vacuuming indexes)。

  • 深入 PostgreSQL 陷阱排查:频繁更新索引列打破 HOT 机制引发的 WAL 写放大与 IOPS 耗尽实战

    排查过程中接到告警,某核心订单库主节点 IOPS 持续处于 100% 饱和状态,iowait 飙升导致 CPU Load Average 冲破 80,P99 延迟从平时的 10ms 劣化到 4000ms+。排查发现 QPS 并没有出现突增,但磁盘的写入吞吐量(Write Bandwidth)却达到了平时的十倍以上,且主要消耗在 WAL 日志写入和 Autovacuum 进程上。直接抛出结论:开发人员在 updated_at 字段上创建了毫无意义的单列 B-Tree 索引,且业务代码每次 UPDATE 必定刷新该时间戳。这一行为彻底破坏了 PostgreSQL 的 HOT(Heap-Only Tuples) 机制,导致每一次普通的数据行更新都退化为全局索引更新,叠加 Checkpoint 后的 Full Page Writes (FPW),引发了灾难级的 WAL 写放大与 MVCC 索引膨胀。删掉废柴索引并微调 fillfactor 后,IOPS 直接断崖式下降 85%。

    案发现场与指标佐证

    登陆机器第一步,先看系统 I/O 状态,iostat -x 1 的数据简直刺眼:

    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme1n1           0.00     0.00  120.50 8540.20  1024.00 98560.00    23.00    14.50    1.68    3.50    1.65   0.11 100.00
    

    高 w/s,高 wkB/s,磁盘完全被写请求打满。

    切进数据库,看看到底是哪些表在作妖。通过查询 pg_stat_user_tables,我抓取了系统里 Update 最频繁的前 5 张表,重点观察它们的 HOT 更新率(HOT Ratio):

    SELECT relname,
           n_tup_upd,
           n_tup_hot_upd,
           ROUND((n_tup_hot_upd::numeric / NULLIF(n_tup_upd, 0)) * 100, 2) AS hot_ratio
    FROM pg_stat_user_tables
    WHERE n_tup_upd > 100000
    ORDER BY n_tup_upd DESC LIMIT 5;
    

    执行结果:

      relname   | n_tup_upd | n_tup_hot_upd | hot_ratio 
    ------------+-----------+---------------+-----------
     orders     |  85940321 |        120340 |      0.14
     users      |   4039102 |       3980120 |     98.53
     inventory  |  15039201 |      14800300 |     98.41
    

    orders 表的 HOT 更新率竟然只有可怜的 0.14%。这就意味着,这表中 99.86% 的 Update 操作,PG 内核都在老老实实地写入全套索引记录。

    我查了一下 orders 表的索引结构:除了主键和常规的状态、用户 ID 索引外,赫然躺着一个 idx_orders_updated_at。 问业务研发为什么建这个索引?答曰:“ORM 自动生成的,想着以后可能会按修改时间排序拉取数据,就先建上了。” 毫无数据访问逻辑支撑的“防御性建索引”,在 MySQL 里可能只是浪费点空间和微小的写入性能,但在 PostgreSQL 里,这简直是自杀。

    为什么打破 HOT 机制是致命的?

    PostgreSQL 的 MVCC 实现与 MySQL (InnoDB) 截然不同。InnoDB 是原地更新(In-Place Update),旧版本数据存放在 Undo Log 中;而 PG 没有独立的 Undo 表空间,它的 Update 本质上是 Delete + Insert。即标记旧元组(Tuple)为不可见,并在页(Page)中插入一个全新的元组。

    如果每次 Update 都要在所有索引树上插入新指针,这种写放大是不可接受的。因此 PG 引入了 HOT (Heap-Only Tuples) 机制。 满足 HOT 的条件极其严苛,但收益极高:

    1. 更新的字段上不能有任何索引。

    2. 当前数据页(Block)内部必须有足够的剩余空间存放新元组。

    只要满足上述条件,PG 就只会在 Heap 层面做行链更新(旧行指向新行),所有的索引结构都无需修改。

    在本次事故中:

    1. 业务线每修改一次订单状态,都会执行类似 UPDATE orders SET status = 2, updated_at = NOW() WHERE id = 100; 的语句。

    2. 因为 updated_at 存在索引,直接破坏了 HOT 条件 #1。

    3. 表的 fillfactor 是默认的 100,页面平时塞得满满当当,破坏了 HOT 条件 #2。

    蝴蝶效应开始了:

    • 写放大 X 倍: orders 表共有 6 个索引。一次单行更新,不仅要写 1 个新的 Heap 数据,还要在 6 个 B-Tree 索引中各插入 1 条新记录。

    • WAL 风暴: PG 为了保证崩溃恢复(Crash Recovery),在每次 Checkpoint 后的第一次页面修改时,会将整个 8KB 页面完整写入 WAL(即 Full Page Writes, FPW)。因为索引频繁变动,大量的数据页和 6 个索引树的页面都在被高频触碰,导致大量触发 FPW,WAL 日志生成速度高达数百 MB/s,直接榨干 SSD 的 IOPS。

    • Autovacuum 瘫痪: 索引膨胀导致 Autovacuum 清理死元组时需要扫描和清理庞大的索引树,进一步加剧了磁盘 I/O 竞争,引发恶性循环。

    破局与调优落地

    弄懂了底层逻辑,解决起来极其简单,只需要做减法。

    1. 物理超度无用索引 既然业务根本没有使用 updated_at 过滤数据的慢查询,果断 DROP INDEX CONCURRENTLY idx_orders_updated_at;。 (注意:必须加上 CONCURRENTLY 以避免锁表阻塞线上交易)。

    2. 预留页内空间,召唤 HOT 机制 对于这种极度高频 Update 的核心表,必须将页填充率(fillfactor)下调,给页内更新留出空间:

    ALTER TABLE orders SET (fillfactor = 85);
    

    注:这条命令仅对新分配的页生效。对于存量数据,我不建议在线上直接跑 VACUUM FULL,而是用 pg_repack 选个低峰期重整表结构,或者就让它随着增量数据自然过渡。

    3. WAL 压缩兜底 为了防御未来可能的类似写放大,在 postgresql.conf 中开启 WAL 压缩(PG 15+ 推荐 zstd,低版本用 on 启用 pglz):

    wal_compression = zstd
    

    开启后会消耗微量的 CPU 算力(1%-2%),但能将 FPW 造成的 WAL 日志体积压缩 60% 以上,对 I/O 瓶颈极具性价比。

    调整执行后的 10 分钟内,orders 表的 HOT Ratio 迅速攀升至 80% 以上,磁盘写入量暴跌至几兆每秒,iowait 归零,P99 延迟回归 10ms,世界清静了。

    排查清单:PostgreSQL HOT 机制与 WAL 异常速查

    如果你遇到类似的 PG 磁盘 I/O 瓶颈,请按以下 4 步排查:

    1. 查 HOT 命中率 执行本文上方的 pg_stat_user_tables 检查 SQL,如果高频更新表(n_tup_upd 大)的 hot_ratio 低于 50%,必有隐患。

    2. 查索引命中与浪费 通过 pg_stat_user_indexes 查询哪些索引从未被 Scan 过: sql SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0 AND schemaname = 'public'; 没有任何查询命中的索引,如果还在被高频 Update 字段牵连,立刻 Drop。

    3. 查 WAL 生成速率 在 pg_stat_bgwriter 或系统级通过监控面板查看 WAL Write 速率。如果 WAL 写入量远超实际业务数据变动量,重点排查大面积 Update 打破 HOT 引发的 FPW 风暴。

    4. 查 fillfactor 配置 执行 SELECT relname, reloptions FROM pg_class WHERE relname = '你的表名';。如果高频 Update 且无索引覆盖的表 fillfactor 还是默认的空值(即 100),建议改至 85-90 之间。

  • 深入 MySQL 陷阱排查:微型 Redo Log 引发的 Checkpoint 同步刷新与间隙锁雪崩实战

    近期处理了一起极为典型的数据库生产事故。某核心交易系统的 MySQL 集群在业务高峰期,呈现出诡异的“心跳骤停”现象:TPS 周期性从 4000 瞬间跌零,持续 3-5 秒后恢复,期间伴随海量的 Lock wait timeout exceeded; try restarting transaction 报错,上游微服务大面积阻塞。

    直接抛出结论:这是一起由极不合理的 Redo Log 容量配置引发的连锁雪崩。底层原因是 innodb_log_file_size 依然保持着陈旧的 128MB 默认值,高并发批量更新瞬间撑爆了 Redo Log 的 Checkpoint Age,触发了 InnoDB 最严厉的 Sync Flush(同步刷新)机制。用户线程被强制挂起等待脏页刷盘,而这些被挂起的线程手中正死死捏着二级索引的 Next-Key Lock(间隙锁),最终导致全表范围的插入和更新操作被全部阻塞,演变成全局锁雪崩。

    案发现场与“不可原谅”的默认值

    排查过程中,通过监控面板看到服务器的 NVMe SSD 处于半闲置状态(IOPS 峰值不到 15k),但 MySQL 的响应时间 P99 却飙升到了秒级。

    登入主机,抓取 SHOW ENGINE INNODB STATUS\G,核心日志段暴露了致命问题:

    ---
    LOG
    ---
    Log sequence number          28394058291
    Log flushed up to            28394058291
    Pages flushed up to          28393801000
    Last checkpoint at           28393798000
    Max checkpoint age           204800000
    Checkpoint age target        198000000
    Modified age                 260291    
    Checkpoint age               260291    -- 注意这里的差值
    

    稍微懂点 InnoDB 内核机制的人,看到这里血压就上来了。给一台 32 核 128G、配着企业级 NVMe 盘的物理机部署 MySQL,居然连 innodb_log_file_size 都不改,留着 2 个 128MB 的文件。这就好比给法拉利装了个拖拉机的排气管,油门踩到底只能憋死发动机。

    底层原理解析:当 Redo Log 变成系统瓶颈

    InnoDB 的 Redo Log 是一个环形缓冲区(Ring Buffer)。系统必须保证在这个环里,写入新日志覆盖老日志之前,老日志对应的 Buffer Pool 脏页已经刷入磁盘。这个安全距离的标尺,就是 Checkpoint Age。

    Checkpoint Age = Log sequence number (LSN) - Last checkpoint at

    当高并发的 DML 语句涌入,LSN 飞速推进,而后台 page_cleaner 线程刷脏页的速度跟不上时,Checkpoint Age 就会不断膨胀。InnoDB 为此设定了三道防线:

    1. Async Watermark (75%):超过此阈值,后台线程开始拼命(异步)刷脏页。

    2. Sync Watermark (90%):一旦触及这条红线,灾难降临。InnoDB 会触发同步刷新(Sync Flush),直接挂起当前执行 DML 的用户线程,强行参与脏页刷盘,直到 Checkpoint Age 回落。

    在这个阶段,你的 TPS 会呈现断崖式下跌,直逼 0。更要命的是,用户线程是在持有锁的状态下被挂起的。

    间隙锁(Gap Lock)的推波助澜

    如果仅仅是 TPS 跌零几秒,上游服务顶多重试。但这套系统里,开发写了一个非常经典的“防御性”状态更新逻辑:

    UPDATE orders SET status = 2, update_time = NOW() 
    WHERE status = 1 AND type = 'promotion' LIMIT 1000;
    

    status 字段是一个区分度极低的非唯一索引。在可重复读(RR)隔离级别下,InnoDB 为了防止幻读,会对命中的记录加 Record Lock,并对记录前后的间隙加 Gap Lock,统称 Next-Key Lock。

    当这个 UPDATE 语句跑到一半,刚好触发了 Redo Log 的 Sync Flush,该线程被无情挂起。此时,它持有了一个覆盖极大范围的间隙锁。接下来的 3-5 秒内:

    • 任何试图插入 status = 1 附近间隙的新订单请求,全部被阻塞在 WAITING FOR LOCK。

    • 其他业务流程因为拿不到锁,锁等待队列瞬间堆积,迅速打满 innodb_thread_concurrency,引发系统雪崩。

    破局与调优:拒绝花里胡哨,回归物理本质

    解决这种问题,不需要什么高深的分布式改造,只需要把 InnoDB 的 I/O 管道打通。

    1. 扩容 Redo Log 容量 确保 Redo Log 能够支撑业务高峰期至少 1 小时的写入量。在旧版本中需要优雅停机重建日志文件,如果是 MySQL 8.0.30+,可以直接动态调整:

    -- MySQL 8.0.30 及以上版本动态扩容至 4GB
    SET GLOBAL innodb_redo_log_capacity = 4294967296; 
    

    如果是旧版本,配置 my.cnf:

    innodb_log_file_size = 2G
    innodb_log_files_in_group = 2
    

    2. 匹配硬件的 I/O 吞吐能力 SSD 时代,默认的 innodb_io_capacity = 200 就是在自废武功。让 page_cleaner 能够放开手脚刷脏页,避免 Checkpoint LSN 滞后:

    # 根据 NVMe 性能压测结果调整
    innodb_io_capacity = 10000
    innodb_io_capacity_max = 20000
    # 调整刷脏线程数,通常与 Buffer Pool Instances 保持一致
    innodb_page_cleaners = 8
    innodb_buffer_pool_instances = 8
    

    3. 改造高危的 DML 语句 严禁在 RR 隔离级别下,通过非唯一索引进行大范围的批量更新。将批量操作改为通过主键驱动: 先 SELECT id FROM orders WHERE status = 1 LIMIT 1000,再 UPDATE orders SET status = 2 WHERE id IN (...)。这样只锁具体的行,彻底避开 Gap Lock 的踩踏。

    排查清单:InnoDB I/O 与锁雪崩速查

    1. 查 LSN 推进速率与 Checkpoint 延迟:使用 SHOW ENGINE INNODB STATUS 观察 Log sequence number 与 Last checkpoint at 的差值,若接近总 Log 容量的 80% 以上,存在极高 Sync Flush 风险。

    2. 查 Page Cleaner 状态:若 Error Log 中频繁出现 page_cleaner: 1000ms intended loop took XXXX ms,说明刷脏线程已成为瓶颈,检查 I/O 延迟或调大 innodb_io_capacity。

    3. 查 锁等待链与行锁升级:通过 SELECT * FROM performance_schema.data_locks;(MySQL 8.0)确认阻塞源头,重点排查持有 X, GAP 锁且事务持续时间较长的僵尸线程。

    4. 查 脏页水位线:监控 Buffer pool dirty pages 占比,若长期逼近 innodb_max_dirty_pages_pct(默认 90%),意味着 Buffer Pool 成为 I/O 抖动的蓄水池,需结合 I/O 调优或扩大 Buffer Pool 内存。

  • 深入 PostgreSQL 陷阱排查:僵尸复制槽引发的 xmin 冻结与 Autovacuum I/O 风暴实战

    排查过程中,我们接手了一个险些造成核心交易系统雪崩的 PostgreSQL 性能故障。表现非常直接:主库 Load Average 突然从 2 飙升到 80+,核心订单表查询的 99 线延迟从 5ms 暴涨至 3s 以上,底层 NVMe SSD 的 I/O 写入利用率打满 100%。

    直接抛出结论:下游大数据团队停用了一个 Flink CDC 抽取任务,但未在 PG 主库清理对应的逻辑复制槽(Logical Replication Slot)。这个处于离线(active = false)状态的僵尸复制槽死死拖住了全局的 catalog_xmin 推进,导致主库 MVCC 机制失效,几个核心大表的死元组(Dead Tuples)疯狂膨胀。当某位值班兄弟发现 WAL 目录快满,为了释放空间“急中生智”手动 Drop 掉该复制槽时,被压抑已久的 xmin 水位线瞬间释放,触发了史诗级的 Autovacuum 工作流。几个 Worker 进程以近乎全速的 I/O 扫描并清理上百 GB 的死元组,直接击穿了存储 I/O,引发全站查询阻塞。

    盲目 Drop 复制槽而不评估 MVCC 历史债务,是典型的只管表象不顾底层的操作。下面拆解底层的运作机理和防御性配置。

    案发现场与指标特征

    故障爆发时,首先看 iostat:

    iostat -dx 1
    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme0n1           0.00     0.00 45200.00 1205.50 361600.00 15600.00    16.25    32.50    0.70    0.65    2.50   0.02 100.00
    

    磁盘 %util 顶在 100%,大量的随机读(r/s 极高),这通常不是正常的业务流量。

    切到数据库内部看 pg_stat_activity 的等待事件:

    SELECT pid, wait_event_type, wait_event, state, query 
    FROM pg_stat_activity 
    WHERE state = 'active' AND wait_event_type = 'IO';
    

    发现几十个业务连接卡在 DataFileRead,更致命的是,出现了几个底层的进程,query 字段显示: autovacuum: VACUUM public.orders

    回头翻看慢查询日志和监控,发现在 Autovacuum 启动前,有人执行了: SELECT pg_drop_replication_slot('flink_cdc_orders');

    这就是血案的导火索。

    底层原理解析:为什么 Drop 复制槽会击穿 I/O?

    PostgreSQL 的 MVCC 实现与 MySQL/Oracle 不同,它没有独立的 Undo Log 表空间,而是将旧版本的数据(Tuple)直接留在原表的 Heap 文件中。

    为了判断哪些旧版本 Tuple 可以被安全清理,PG 维护了一个全局的可见性底线——xmin horizon。如果系统中没有任何活跃事务、预备事务或复制槽需要看到某个旧版本,Autovacuum 就可以将其作为死元组(Dead Tuple)回收。

    逻辑复制槽(用于 CDC 日志解析)为了保证在解码 WAL 时能够读取到完整的系统表元数据,会持有一个 catalog_xmin。 只要下游 CDC 断开连接且不推进 LSN,这个复制槽就会变成“僵尸”,其 catalog_xmin 永远停留在断开的那一刻。

    -- 查看复制槽状态的致命视角
    SELECT slot_name, plugin, active, xmin, catalog_xmin, restart_lsn 
    FROM pg_replication_slots;
    

    连锁反应如下:

    1. 垃圾回收停滞:catalog_xmin 停滞,导致全局 xmin horizon 无法推进。

    2. 表极度膨胀:业务高频执行 UPDATE/DELETE,产生海量 Dead Tuples,但 Autovacuum 扫描后发现这些 Tuple 还处于 xmin horizon 之上,无法物理回收。表体积从 50GB 膨胀到 200GB。

    3. WAL 堆积:物理/逻辑复制槽不仅阻塞 xmin,还会通过 restart_lsn 阻塞 WAL 日志的清理。PG 的 pg_wal 目录开始告警。

    4. 引爆 I/O 风暴:运维强行 pg_drop_replication_slot(),释放了 catalog_xmin 和 WAL 限制。xmin horizon 瞬间跳跃到当前最新。Autovacuum 再次醒来时,发现有 150GB 的死元组“终于”可以清理了,开始执行极其沉重的索引和堆表扫描。如果没有做好 I/O 成本节流配置,Autovacuum 会消耗掉所有磁盘带宽。

    止血与防御性配置落地

    1. 现场紧急止血

    不能任由 Autovacuum 把主库 I/O 打死,必须先将其阻断,再限流恢复。

    第一步,干掉正在作恶的 autovacuum 进程(PG 内部机制会自动重启它,但我们要争取时间修改配置):

    -- 找出 autovacuum worker 并 cancel
    SELECT pg_cancel_backend(pid) 
    FROM pg_stat_activity 
    WHERE query LIKE 'autovacuum: %';
    

    第二步,动态调低 Autovacuum 的 I/O 侵略性:

    -- 增加每次清理达到成本限制后的睡眠时间(默认可能是2ms,调大到20ms)
    ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '20ms';
    -- 减小单次清理的成本限制(让它更快休眠)
    ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 200;
    -- 重新加载配置
    SELECT pg_reload_conf();
    

    2. 长期防御性调优(避坑指南)

    别指望所有研发都会记得下线 CDC 时通知 DBA,系统必须具备自我保护能力。

    防御手段 A:限制复制槽的 WAL 保留量 (PostgreSQL 13+ 特性) 在 PG 13 之前,一个失联的复制槽可以直接把磁盘撑爆。PG 13 引入了救命参数 max_slot_wal_keep_size。当复制槽保留的 WAL 超过此值时,PG 会强制使该槽失效(Invalidate),宁可让下游 CDC 报错重做,也不能让主库挂掉。

    # postgresql.conf
    # 允许单个复制槽最多保留 50GB 的 WAL
    max_slot_wal_keep_size = 50GB 
    

    防御手段 B:杀死长连接与长事务 除了复制槽,长时间 Idle in transaction 的连接同样会死锁 xmin horizon。

    # postgresql.conf
    # 强制断开闲置超过 10 分钟的事务连接,防止 xmin 冻结
    idle_in_transaction_session_timeout = 600000 
    

    防御手段 C:精细化 Autovacuum I/O 控制 默认的 Autovacuum 参数在 SSD 时代显得过于保守,但如果是极度膨胀后的突发清理,又会过于激进。建议基于磁盘 I/O 能力计算 cost_limit,不要使用一刀切的默认值。基于 NVMe 盘的基线建议:

    autovacuum_max_workers = 5
    autovacuum_vacuum_cost_limit = 2000
    autovacuum_vacuum_cost_delay = 2ms
    

    保证日常能快读清理完毕,不至于把债务堆积到爆发。

    排查清单:PostgreSQL 存储膨胀与 I/O 突增速查

    为了便于后续快速定位同类问题,请重点检查以下几项:

    1. 检查全局 xmin 阻塞源: 执行 SELECT * FROM pg_replication_slots WHERE active = false; 查找僵尸复制槽。

    2. 排查长事务/预备事务: 查询 pg_stat_activity 中 state = 'idle in transaction' 且 backend_xid / backend_xmin 极老的会话;排查 pg_prepared_xacts 中未提交的 2PC 事务。

    3. 确认表膨胀率 (Dead Tuples): 查询 pg_stat_user_tables,对比 n_live_tup 与 n_dead_tup 的比例,若 n_dead_tup 极高且持续不降,说明 MVCC 清理链已断裂。

    4. 监控 Autovacuum 行为: 观察系统视图 pg_stat_progress_vacuum,检查当前是否有表正在经历阶段耗时极长的 vacuum_index 或 vacuum_heap。

    5. WAL 堆积安全阈值验证: 检查当前 PG 版本是否配置了 max_slot_wal_keep_size 作为底线防御,否则将随时面临全盘占满的宕机风险。

  • 深入 Redis 陷阱排查:RDB bgsave 触发内存淘汰风暴与 Gossip 协议假死引发的集群雪崩实战

    结论先行:Redis 集群在执行 RDB bgsave 时若伴随高并发写入,极易引发严重 Copy-on-Write(CoW)导致内存飙升。一旦触及 maxmemory 阈值并触发同步淘汰(Eviction)风暴,将长时间阻塞主线程。这不仅会导致业务请求响应耗时(99线)飙升至秒级,更会引发 Cluster Gossip 协议的 Ping/Pong 响应超时,最终触发集群误判节点下线与无意义的主从切换(Failover),造成全局雪崩。

    案发现场:诡异的 P99 尖刺与主从频繁切换

    某次排查过程中,监控大盘发出严重告警。核心 Redis 集群(版本 6.2.6,3主3从架构)的 API 99线从平时的 2ms 瞬间飙升至 4500ms。与此同时,DBA 团队收到多个节点的主从切换告警通知。

    登录其中一台发生切换的原主节点,查看 Redis 日志,发现大量如下报错:

    7892:M 15:32:11.102 * Asynchronous AOF fsync is taking too long (disk is busy?). Writing the AOF buffer without waiting for fsync to complete, this may slow down Redis.
    7892:M 15:32:16.455 # Connection with replica 10.x.x.5:6379 lost.
    7892:M 15:32:20.123 # Cluster state changed: fail
    7892:M 15:32:25.881 # Marking node 9b3d... as failing (quorum reached).
    

    直觉判断是磁盘 IO 瓶颈或是网络抖动,但排查底层系统指标发现:

    1. iostat -x 1 显示磁盘 util 虽然达到了 70%,但并未完全打死,await 也在合理范围内。

    2. 节点间的网络 ping 延迟极低,无丢包。

    进一步通过 redis-cli 提取案发时间段的内核和内存指标:

    redis-cli -p 6379 info stats | grep evicted
    # 结果显示 evicted_keys 在几秒钟内增加了近 40万。
    
    redis-cli -p 6379 info persistence | grep -E "rdb_last_bgsave|latest_fork_usec"
    # rdb_last_bgsave_status:ok
    # rdb_last_bgsave_time_sec: 18
    # latest_fork_usec: 24500
    

    至此,线索闭环:这是一起典型的由 RDB 快照引发的内存暴涨,继而触发淘汰机制阻塞主线程,最终击穿 Gossip 协议导致集群脑裂的惨案。

    为什么 RDB Fork 会触发内存淘汰风暴并导致 Gossip 假死?

    很多研发认为 Redis 是单线程的,且 RDB 是通过 bgsave 在后台子进程完成的,不会影响主进程。这是一个极其危险的误区。

    1. Copy-on-Write (CoW) 带来的内存刺客 当 Redis 执行 bgsave 时,主进程会调用 Linux 的 fork() 系统调用创建子进程。利用操作系统的 CoW 机制,父子进程初始共享同一块物理内存。但如果此时业务端有大量的写请求(SET/HSET 等),主进程在修改数据前,必须先将原有内存页(通常是 4KB,如果开启了 THP 则是 2MB)复制一份。 此时,Redis 实例的实际物理内存占用量 = 现有数据大小 + CoW 复制的页大小。如果写入极为频繁,内存占用会在短时间内急速飙升,直接撞上 maxmemory 限制。

    2. 同步淘汰(Eviction)风暴阻塞主线程 当内存触及 maxmemory,Redis 会根据配置的 maxmemory-policy(如 allkeys-lru)开始清理内存。 在 Redis 6.2 默认配置下,如果没有开启懒释放(lazyfree-lazy-eviction yes),内存淘汰操作是在主线程中同步执行的。如果 CoW 导致的内存超发极大,Redis 需要在一个事件循环周期内强制淘汰数十万个 Key。寻找 LRU 目标、解除哈希表映射、释放内存,这一整套动作将主线程完全卡死。

    3. Gossip 协议假死与雪崩 Redis Cluster 维持高可用依赖于 Gossip 协议。每个节点通过主线程的 clusterCron() 函数(默认每 100ms 运行一次)向其他节点发送 Ping,并处理 Pong。 当主线程被淘汰风暴卡死长达数秒时,clusterCron() 根本无法获得执行机会:

    • 节点无法响应其他节点的 Ping 报文。

    • 其他节点在超过 cluster-node-timeout(默认 15000ms,部分激进配置可能设为 5000ms)未收到响应后,会将该节点标记为 PFAIL(疑似下线)。

    • 随后通过 Gossip 传播,集群半数以上主节点确认该节点失联,状态升级为 FAIL,强制触发 Replica 提主流程(Failover)。

    由于主从切换,客户端连接断开重连,引发缓存短暂不可用,流量直接打穿到 DB,最终演变为全局雪崩。

    防御性加固与最佳实践

    不要指望业务侧降低并发来适应底层,运维架构的底线是通过系统性配置兜底。针对此陷阱,需实施以下加固:

    1. 预留足够的内存 Buffer (绝对铁律) 严禁将 maxmemory 设置为机器物理内存的极限。标准做法是:maxmemory 绝不能超过系统可用内存的 70%。如果实例承载重度写入,甚至需要降至 50%-60%,专门为 RDB 的 CoW 留出 Buffer,避免触发淘汰。

    2. 强制开启 Lazyfree 异步淘汰 从 Redis 4.0 开始引入了异步释放,但在 6.x 版本中淘汰策略默认仍是阻塞的。必须在 redis.conf 中明确开启:

    # 开启异步内存淘汰,避免阻塞主线程
    lazyfree-lazy-eviction yes
    # 对于大 Key 的 DEL 操作也建议走异步 (UNLINK 代替 DEL)
    lazyfree-lazy-user-del yes
    

    3. 审视系统内核参数 THP Transparent Huge Pages (THP) 是内存杀手。开启 THP 后,内存页大小从 4KB 变为 2MB。这意味着即使只修改了 10 个字节的数据,CoW 也要复制整个 2MB 的内存页,导致内存碎片和消耗速度剧增 500 倍。 强制关闭:

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    

    4. 调整 Cluster 容忍度 不要把 cluster-node-timeout 设置得过小。如果网络环境非极度苛刻,保持默认的 15000ms 即可。过小(如 3000ms)会导致极易因为一次大 Key 的删除或短暂的 IO 抖动引发误切换。

    cluster-node-timeout 15000
    

    常见问题 (FAQ)

    Q1:为什么我们在监控上看到系统的总剩余内存(Free)还有很多,但 Redis 依然触发了 Eviction? 因为 Redis 触发淘汰只看内部配置的 maxmemory 阈值,与宿主机的剩余物理内存无关。即使机器有 128G 内存,如果 maxmemory 设置为 10G,一旦 Redis 自己计算的内存使用量(包含数据、客户端缓冲区等,但不包括 CoW 子进程消耗)超过 10G,就会开始无情淘汰。

    Q2:如何准确监控 RDB 执行期间 Copy-on-Write 消耗的内存大小? 可以通过解析 Redis 日志获取,每次 bgsave 结束后,Redis 会打印一行日志: Background saving terminated with success 同时在 INFO STATS 中的 latest_fork_usec 可以看到 fork 耗时。但最精准的监控方式是在 bgsave 期间查看 /proc//smaps 中的 Private_Dirty 字段增长量,或者在 bgsave 结束后直接查看 Redis 日志中输出的 RDB: XX MB of memory used by copy-on-write 核心提示。

    Q3:为了避免这种问题,是否可以在集群模式下彻底关闭 RDB,只用 AOF? 不建议彻底关闭 RDB。虽然全量同步可以通过无盘复制(diskless replication)缓解,但新节点加入或严重断网后的重同步依然依赖 RDB 快照机制生成。更好的做法是控制快照生成的频率(取消过于频繁的 save m n 自动触发条件),将备份操作通过定时任务强制调度到业务低峰期执行,并确保 lazyfree 和足够的内存水位。

  • 深入 Zabbix 监控雪崩排查:Proxy 积压引发的 History Syncer 阻塞与数据库底层调优实战

    Zabbix 队列风暴的元凶往往不是 Server 计算能力不足,而是底层数据库 IO 瓶颈与 Proxy 离线数据猛灌。本文通过排查某次 NVPS 突发至 35k 导致的 History Syncer 打满与监控瘫痪事件,深入解析 MySQL 8.0 针对 Zabbix 写入优化的底层逻辑,并给出 Proxy 防御性配置与模板预处理过滤的实战方案。

    故障现场:History Syncer 进程 100% 死亡螺旋

    某次排查过程中,一套承载 20,000+ 主机、2,000,000+ 监控项的 Zabbix 6.0.22 LTS 集群突发严重告警。现象非常典型:

    1. Zabbix Queue 爆炸:延迟超过 10 分钟的 item 数量瞬间飙升到 150,000 以上。

    2. 内部进程打满:Zabbix Server 告警 Zabbix server history syncer processes more than 100% busy。

    3. 前端瘫痪:Web 界面卡死,报 Zabbix server is not running: the information displayed may not be current。

    查看 zabbix_server.log,满屏的慢查询和超时:

    23851:20231012:142211.512 [Z3005] query failed: [1205] Lock wait timeout exceeded; try restarting transaction [insert into history_uint (itemid,clock,ns,value) values (152342,1697101321,213412,0),(...)]
    23851:20231012:142215.123 server #12 active [history syncer #4]
    23851:20231012:142215.123 Zabbix server history syncer processes 100% busy
    

    很明显,数据落盘卡住了。History Syncer 负责将内存 cache 中的监控数据批量写入底层 MySQL。一旦它被阻塞,Server 内存中的 History Cache 会迅速耗尽,触发自我保护机制,拒绝接收任何新数据,最终导致 Poller 和 Trapper 进程全部雪崩。

    登陆底层 MySQL 8.0.34 节点,敲下 iostat -x 1,看到数据盘的 %util 稳稳地锁死在 100%,await 高达 200ms+。

    为什么 Proxy 断网恢复会导致 Zabbix Server 瞬间雪崩?

    排查发现,在雪崩发生前 15 分钟,某跨机房专线发生了短暂抖动。该机房部署了 3 台 Zabbix Proxy(Active 模式),承载了约 8,000 台主机的监控抓取。

    这里牵扯到 Zabbix Proxy 的底层工作机制。Proxy 默认会将采集到的数据暂存在本地的 SQLite3(或 MySQL)中。当与 Server 断开连接时,Proxy 会根据 ProxyOfflineBuffer 的配置(默认 1 小时)在本地堆积数据。

    雪崩的逻辑链条:

    1. 专线抖动,3 台 Proxy 与 Server 失联,期间不断采集并缓存数据到本地。

    2. 专线恢复,Proxy 瞬间将积压的数十万条历史数据打包。

    3. Proxy 根据 DataSenderFrequency=1(每秒发送)无脑向 Server 的 Trapper 进程猛灌。

    4. Server 的 Trapper 进程将海量数据塞入 History Cache。

    5. History Syncer 进程全速运转,向 MySQL 发起天量 INSERT INTO history... 请求。

    6. MySQL InnoDB Buffer Pool 的脏页刷新速率跟不上写入速率,Redo Log 爆满,触发同步刷盘,导致 IO 彻底僵死。

    防御性配置:限制 Proxy 突发流量

    为了防止类似情况再次发生,必须对 Proxy 的回传机制进行限流。

    1. 调优 Proxy 端缓存发送频率与体积 不要让 Proxy 一次性将积压数据全吐出来。在 zabbix_proxy.conf 中调整:

    # ProxyOfflineBuffer=1 # 离线缓存保留时间,不要设置太大,无意义的历史数据宁可丢弃
    DataSenderFrequency=1
    # 增加批量发送的限制(隐式受制于 Server 端 Trapper 进程处理能力)
    

    注:Zabbix 6.0 引入了 Proxy 内存缓存机制,但在面对海量离线回传时,核心仍是保护 Server 的 DB IO。

    2. 调优 Server 端接收与刷盘并发 在 zabbix_server.conf 中调整:

    # 控制 Trapper 进程数,不要无限调大,防止压垮 History Cache
    StartTrappers=50
    # 增加 History Cache 大小,做大内存缓冲池,争取时间
    HistoryCacheSize=2G
    HistoryIndexCacheSize=512M
    # 增加 Syncer 进程数,但不要超过 DB 磁盘阵列的物理 IO 并发能力上限
    StartHistoryPollers=20
    

    数据库底层调优:拯救被压垮的 MySQL 8.0

    Zabbix 是一个典型的“读少写极其密集”的系统,标准的 MySQL 默认配置在这里就是灾难。针对本次 IO 瓶颈,我们在 my.cnf 中进行了以下针对性调优。

    1. 禁用 Doublewrite Buffer 与放宽事务持久性

    由于 Zabbix 数据并非金融级账本,丢失一两秒的监控数据完全可以接受。

    [mysqld]
    # 核心:将事务刷盘策略改为 2。每次提交仅写入 OS Cache,每秒刷盘一次。
    # 直接将 IOPS 需求降低一个数量级。
    innodb_flush_log_at_trx_commit = 2
    
    # 针对支持原子写的存储设备(如现代 NVMe SSD 或部分企业级 SAN),关闭双写缓冲
    innodb_doublewrite = 0
    
    # 优化 Redo log 大小,防止频繁触发 Checkpoint 导致 IO 抖动
    innodb_redo_log_capacity = 4G # MySQL 8.0.30+ 的新参数,替代旧的 innodb_log_file_size
    

    2. 匹配硬件的 InnoDB IO 刷盘能力

    MySQL 默认假设你的磁盘很慢(innodb_io_capacity=200),这会导致在 SSD 环境下脏页刷得太慢,最终堆积引发急剧抖动。

    # 根据实际 FIO 测试结果配置
    innodb_io_capacity = 3000
    innodb_io_capacity_max = 6000
    innodb_flush_sync = OFF # 避免 Checkpoint 时卡死用户查询
    

    3. 表分区与废弃 Housekeeper

    导致底层 IO 缓慢的另一个隐患是 Zabbix 自带的 Housekeeper 清理进程。它通过 DELETE FROM history WHERE clock < ... 清理过期数据,这在海量数据下会产生巨大的锁竞争和 Undo Log 开销。

    必须彻底关闭 Housekeeper 对历史表和趋势表的清理: Web 界面:Administration -> General -> Housekeeping,关闭 History and Trend 的 Enable internal housekeeping。

    替代方案:使用 MySQL 表分区(Table Partitioning)。 每天为 history、history_uint、history_str 等表建一个新分区,清理数据时直接 ALTER TABLE ... DROP PARTITION,这是元数据操作,耗时 0.1 秒,没有任何 IO 负担。

    自定义模板防作死指南:在 Proxy 侧掐断垃圾数据

    数据库调优只是续命,真正的治本之策是降低 NVPS(New Values Per Second)。排查中发现,某开发团队的自定义模板中,有一个抓取应用日志错误状态的 item,类型居然是 Text,且每 5 秒抓取一次。无论状态是否改变,全量文本都在往 DB 里塞,直接打爆了 history_str 表。

    过滤绝招:Discard unchanged with heartbeat

    利用 Zabbix 的 Preprocessing(预处理)功能,直接在 Proxy 内存中过滤掉无用数据,根本不让它通过网络发给 Server。

    配置步骤:

    1. 打开 Item 的 Preprocessing 选项卡。

    2. 添加 Step:Discard unchanged with heartbeat。

    3. 参数设置为 1h(或 3600s)。

    原理解析: 如果该 Item 的值相比上次抓取没有发生变化,Proxy 会直接将这个数据丢弃,不往 Server 发送。只有当值发生变化,或者超过设定的 heartbeat 时间(比如 1 小时没有变化),才会发送一次数据保持激活。 仅仅配置了这一项,我们的整体 NVPS 从 35,000 直接断崖式下降到 12,000,MySQL IO 负载瞬间降至 15% 以下。

    常见问题

    Q1:Web 界面经常报 Zabbix Server is not running,但查看进程都在,怎么回事? 通常是因为 PHP 前端通过 TCP 10051 端口请求 Server 的 StartTrappers 进程超时。大概率是因为 History Syncer 阻塞,导致 Trapper 进程全都在等待获取 Cache 锁。检查数据库负载,或适当增加 StartTrappers 数量。

    Q2:StartPollers 到底设置多少合适?为什么我设了 1000 还是不够? 千万不要无脑调大 Poller 数量。Poller 过多会导致严重的上下文切换和内存消耗。查看 Zabbix server data collector processes 图表,如果 Poller 使用率长期 > 75%,首先应该考虑将监控项改为 Active 模式(让 Agent 主动推),或者把采集任务剥离给下层 Proxy。

    Q3:存在大量 SNMP 监控导致 Poller 经常超时卡死,如何缓解? SNMP 采用 UDP,极易丢包阻塞。最佳实践:1) 将 SNMP 采集全部下放给专属的 Zabbix Proxy,将故障隔离;2) 在 Host 级别勾选 Use bulk requests;3) 在 zabbix_server.conf 和 zabbix_proxy.conf 中增加 Timeout=15(默认只有 3 秒,对于老旧交换机绝对不够)。

  • 深入 TiDB 大事务雪崩排查:无脑 DELETE 引发的 Percolator 锁风暴与 TiDB 节点 OOM 惨案

    近期处理了一起极为惨烈的分布式数据库生产事故。核心业务集群(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 当无底洞垃圾桶,它就会把你的业务一起埋了。

    现场还原:从延迟突刺到死亡宣告

    监控大盘上的异动非常典型,呈现出教科书般的“雪崩”曲线:

    1. TiDB 节点内存垂直起飞:某一个 TiDB 节点的内存使用率在 60 秒内从 15% 飙升至 95%。

    2. 锁指标爆炸:TiDB Dashboard 中的 KV Backoff OPS 和 Lock Resolve OPS 激增 1000 倍。

    3. gRPC 阻塞:TiKV 的 gRPC message duration P99 飙升至 15s 以上。

    4. 死亡宣告:系统监控捕获到内核级斩首行动: 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 问题速查)

    1. dmesg 与 OOM 确认:快速执行 dmesg -T | grep -i oom,确认 tidb-server 或 tikv-server 是否被内核 Kill,排除网络分区导致的假死。

    2. 排查慢查询与内存大户:查询 INFORMATION_SCHEMA.SLOW_QUERY 或 TiDB Dashboard,按 Mem_max 或 Process_time 倒序,揪出未加 LIMIT 或扫描行数极大的问题 SQL。

    3. 核对事务配额参数:检查集群的 txn-total-size-limit 参数是否被违规调大(正常业务不应超过 100MB)。

    4. 监控 Lock 冲突指标:在 Grafana -> TiDB -> KV Errors 面板中,重点观察 KV Backoff OPS (特别是 txnLock 和 txnLockFast),若该指标激增,说明集群存在大事务或热点记录的严重写冲突。

    5. 垃圾回收 (GC) 状态确认:大批量 DELETE 后,务必通过 mysql.tidb 表检查 GC Safe Point 是否正常推进。大量的无用版本积压会拖慢整个集群的物理读取效率。

  • 深入 PostgreSQL 生产表膨胀雪崩:长事务挂起引发的 autovacuum 失效与 XID Wraparound 宕机危机

    近期处理了一起极其经典的 PostgreSQL 数据库性能雪崩事故。核心表现为核心集群 CPU Load 飙升至 100+,读写 P99 延迟从 5ms 暴增到 3000ms,同时监控面板上的磁盘利用率以肉眼可见的速度疯狂攀升(每小时吃掉数十 GB)。

    结论先行:业务服务因某个非预期的异常退出,留下了一个长达数天的 idle in transaction(事务空闲)会话。这个幽灵会话死死按住了全局的 xmin 水位线,导致底层的 autovacuum 进程虽然疯狂拉起扫表,却无法清理任何死元组(Dead Tuples),最终引发海量表膨胀,并险些触发 PG 核心的 XID Wraparound(事务 ID 环绕)强制只读宕机保护。

    解决方法极其简单粗暴:pg_terminate_backend(pid) 杀掉僵尸进程,并在全局强制开启 idle_in_transaction_session_timeout 防御性配置。随后通过 pg_repack 无锁重建膨胀表。

    现场还原:当磁盘 I/O 被无效扫描打满

    排查过程中,第一视角的监控极其惨烈:

    1. iostat 显示底层 NVMe 盘的 %util 长时间顶在 100%,大量的随机读写。

    2. 慢查询日志被打爆,平平无奇的单行 UPDATE 和 SELECT 居然要跑几秒钟。

    直觉告诉我,数据扫描路径出问题了。连上数据库,直接看活跃会话:

    SELECT pid, usename, state, backend_xid, backend_xmin, duration 
    FROM (
        SELECT pid, usename, state, backend_xid, backend_xmin, 
               now() - xact_start AS duration 
        FROM pg_stat_activity 
        WHERE state != 'idle'
    ) sq 
    ORDER BY duration DESC LIMIT 5;
    

    结果极其刺眼:排名第一的会话状态是 idle in transaction,duration 已经高达 96:12:45(整整四天!)。

    再看系统视图里的表膨胀情况:

    SELECT relname, n_live_tup, n_dead_tup, 
           round(n_dead_tup::numeric / (n_live_tup + n_dead_tup + 0.01) * 100, 2) AS dead_ratio
    FROM pg_stat_user_tables 
    ORDER BY n_dead_tup DESC LIMIT 5;
    

    核心订单表的 n_dead_tup 高达数亿,dead_ratio 超过 70%。这意味着业务每次查询,PG 都要在磁盘上额外扫描 70% 的废弃数据,I/O 不炸才是见鬼了。

    底层原理:为什么一个 idle 会话能拖垮整个集群?

    很多人从 MySQL 迁移到 PostgreSQL 时,最不适应的就是它的 MVCC(多版本并发控制)实现。

    MySQL 把旧版本数据存放在独立的 Undo Log 里,而 PG 的设计更为激进——直接把新老版本(Tuples)写在同一个数据文件中。 当执行 UPDATE 或 DELETE 时,PG 只是在老元组的头部打上过期标记(xmax),然后插入一个新元组。这些被打上标记的老旧死元组,全靠后台的 autovacuum 进程来回收空间。

    但 autovacuum 清理死元组有一个铁律:必须保证当前系统中没有任何活跃事务可能再访问到这些元组。

    这里就涉及全局最小活跃事务 ID(xmin)。 如果系统中存在一个事务 A(比如我们抓到的那个僵尸会话),它在 4 天前开启(执行了 BEGIN 并且做过查询),那么 PG 必须为事务 A 保留它开启那个时间点的所有数据快照。 在事务 A 提交或回滚之前,全局的 xmin 水位永远无法向前推进。

    这就是最致命的地方:即便这 4 天里产生了上亿个死元组,autovacuum 正常按计划被唤醒,它扫描了整个表,发现这些死元组的 xid 都比那个僵尸事务 A 的 xid 要大,于是它一个字节都不能删,只能无奈地退出。循环往复,白白消耗大量 I/O 去扫表,却做着无用功。

    致命一击:XID Wraparound 保护

    更可怕的还在日志里。查看 postgresql.log,发现大量类似这样的告警:

    WARNING:  database "prod_db" must be vacuumed within 10000000 transactions
    HINT:  To avoid a database shutdown, execute a database-wide VACUUM in that database.
    

    PG 的事务 ID(XID)是一个 32 位的无符号整数,最大约 42 亿。为了处理环绕(即 XID 耗尽后从头开始),PG 把 XID 空间一分为二,过去 21 亿是“过去”,未来 21 亿是“未来”。 为了防止极其古老的事务 ID 变成“未来”导致数据不可见,PG 强制要求在 XID 跨度达到 20 亿之前,必须通过 VACUUM 冻结(Freeze)旧事务。

    因为那个 4 天前的僵尸事务拦住了 autovacuum 的清理与冻结逻辑,XID 正在逼近环绕红线。一旦触发 autovacuum (to prevent wraparound),这是最高优先级的强制清理操作,它会无视常规调度并疯狂吃光 I/O。如果最后还没清理完,PG 会为了保护数据不损坏,强行将整个数据库锁死进入只读模式(shutdown)。

    防御性落地:如何给系统系上安全带

    一个开发连直连线上 DB 手敲 BEGIN 忘了 COMMIT 去喝咖啡,或者微服务里一个没有设置 Timeout 的 HTTP 请求持有了 DB 链接挂死,就能让整个集群陪葬。这种架构容错率极低,必须从配置层面进行防御性斩断。

    1. 止血操作: 立刻执行斩首,将该 PID 强杀:

    SELECT pg_terminate_backend(pid);
    

    杀掉之后,autovacuum 终于能工作了,观察磁盘 I/O 依然很高,但那是正在真正清理死元组。

    2. 核心防御配置(必须写进 postgresql.conf):

    # 强制终止空闲在事务中的会话(救命配置,单位毫秒)
    idle_in_transaction_session_timeout = 600000  # 10分钟
    
    # 强制终止超长查询(防止烂SQL打满CPU)
    statement_timeout = 30000  # 30秒
    
    # 开启 autovacuum 慢执行日志,增强可观测性
    log_autovacuum_min_duration = 1000 # 超过1秒的清理记录到日志
    

    3. 空间回收: autovacuum 只能把死元组标记为可复用,它不会把磁盘空间还给操作系统(除非死元组刚好在文件的最后)。 对于已经严重膨胀的表,直接执行 VACUUM FULL 会获取最高级别的排他锁(AccessExclusiveLock),直接导致业务阻塞报错。 生产环境的唯一正解是使用 pg_repack 或 pg_squeeze 插件:

    # 在线无锁重建膨胀表,将真实数据拷贝到临时表并交换文件指针
    pg_repack -h localhost -d prod_db -t public.orders -j 4
    

    排查清单与同类问题速查

    1. 检查挂起长事务:周期性监控 pg_stat_activity 中 state = 'idle in transaction' 且 duration > 5m 的会话,直接触发告警。

    2. 监控表膨胀率:通过 pg_stat_user_tables 结合 pg_class 估算 dead_tuple 比例,超过 20% 的大表需人工介入检查。

    3. 关注 XID Age:监控 datfrozenxid 的年龄(age(datfrozenxid)),如果超过 autovacuum_freeze_max_age(默认 2 亿)且持续攀升,说明系统的冻结机制已失效,距离全盘宕机倒计时开始。

    4. 警惕复制槽(Replication Slot)滞留:除了长事务,未被消费的废弃逻辑复制槽也会拖住 xmin,导致主库无法清理死元组,需通过 pg_replication_slots 视图排查清理。