分类: 数据库运维

  • 深入 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 TrendEnable internal housekeeping

    替代方案:使用 MySQL 表分区(Table Partitioning)。 每天为 historyhistory_uinthistory_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.confzabbix_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 OPSLock 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-quotatidb_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-servertikv-server 是否被内核 Kill,排除网络分区导致的假死。

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

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

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

    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. 慢查询日志被打爆,平平无奇的单行 UPDATESELECT 居然要跑几秒钟。

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

    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 transactionduration 已经高达 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)写在同一个数据文件中。 当执行 UPDATEDELETE 时,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_repackpg_squeeze 插件:

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

    排查清单与同类问题速查

    1. 检查挂起长事务:周期性监控 pg_stat_activitystate = '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 视图排查清理。