分类: 系统架构

  • 深入 Zabbix 陷阱排查:未过滤 JSON 大对象轮询引发的 Proxy 积压与 MySQL 脏页刷新风暴实战

    近期处理了一起极具破坏性的监控系统瘫痪事故。现象非常经典:Zabbix Server 内部队列(Queue)在十分钟内飙升至 10 万以上,海量 nodata 触发器误报风暴瞬间淹没了告警通道,Zabbix Web 前端直接 502。最终结论很简单:业务团队在 Zabbix 中挂载了一个低效的自定义模板,每 30 秒通过被动模式(Passive Check)拉取一次包含海量无用信息的 500KB JSON 报文,并直接存入 history_text 表。这种把关系型数据库当日志库用的粗暴操作,直接打满了 Zabbix Proxy 的同步通道,并引发了后端 MySQL InnoDB 的脏页刷新风暴(Page Flush Storm)。

    解决这类问题,单纯调大 Zabbix 的 HistoryCacheSize 或升级数据库磁盘只是扬汤止沸。真正的解法在于架构级的防御设计:在边缘端(Proxy/Agent)前置预处理(Preprocessing),剥离无效载荷,配合 MySQL 历史表分区(Table Partitioning)与 InnoDB 落盘策略调优,才能彻底稳住底盘。

    案发现场与深层诊断

    排查过程中,第一眼看到的是 Zabbix Server 自身的监控指标全盘崩溃。 提取 Zabbix Server 日志,满屏的告警触目惊心:

    Zabbix server history syncer processes more than 75% busy
    Zabbix server poller processes more than 75% busy
    [Z3005] query failed: [1205] Lock wait timeout exceeded; try restarting transaction
    

    直接通过运行时控制命令导出诊断信息:

    zabbix_server -R diaginfo="historycache"
    

    查看输出,History Cache 和 History Index Cache 的剩余空间几乎为零。这说明 Zabbix Server 的数据接收层已经被塞满,而负责将数据落盘的 History Syncer 进程被卡死。

    此时切到后端 MySQL 节点,跑一下 iostat -x 1,发现数据盘的 %util 长期死锁在 100%,写入延时(w_await)飙升到 200ms 以上。 登入 MySQL 查看引擎状态:

    SHOW ENGINE INNODB STATUS\G
    

    发现 Modified db pages 指标极高,Buffer Pool 的脏页比例突破了 innodb_max_dirty_pages_pct 的阈值。Page Cleaner 线程疯狂刷盘,引发了严重的 I/O 拥塞,导致事务提交时写 Redo Log 发生等待。

    究竟是哪个业务指标在疯狂灌水?直接查 Zabbix 数据库的 items 表:

    SELECT h.host, i.key_, i.delay, i.value_type 
    FROM items i 
    JOIN hosts h ON i.hostid = h.hostid 
    WHERE i.status = 0 AND i.value_type IN (1, 4) -- 1: character, 4: text
    ORDER BY i.delay ASC LIMIT 10;
    

    真相大白:某个新上线的微服务,为了图省事,写了一个自定义脚本获取应用状态,返回的是一个完整的、未经任何裁剪的巨型 JSON 结构。更致命的是,配置为 Text 类型,每 30 秒采集一次,且通过被动模式(Zabbix Server/Proxy 主动建立 TCP 连接去拉取)。

    为什么这个操作不可原谅?

    在运维架构中,监控系统是生命线,对监控系统本身进行 DoS 攻击是极其低级的错误。

    1. 被动模式阻塞 Poller 进程:被动模式下,Zabbix Poller 需要等待 Agent 执行脚本并返回结果。当脚本执行耗时或报文过大时,TCP 会话长期占用 Poller 进程。默认几百个 Poller 瞬间耗尽,其他正常主机的 CPU、内存采集自然被挂起,引发大面积 nodata 误报。

    2. 关系型数据库的文本噩梦:Zabbix 依赖的 MySQL InnoDB 对高频、大字段(Blob/Text)的插入极度不友好。海量的无序大文本写入不仅无法有效压缩,还会导致 Redo Log 极速轮转,触发 Checkpoint 强行刷脏,打爆磁盘 IOPS。把 Zabbix 当作 ELK 或 Prometheus(TSDB)来用,是典型的架构选型错位。

    止血与根治方案

    应对这种监控血崩,必须分级处理,先止血,再重构。

    第一步:紧急熔断,恢复全局监控 直接在数据库层面禁用该危险模板的所有监控项,并清理历史缓存,重启 Server:

    UPDATE items SET status = 1 WHERE key_ LIKE 'custom.app.json_dump%';
    
    systemctl restart zabbix-server
    

    第二步:利用 Zabbix Preprocessing 进行边缘降维 如果业务确实需要从该 JSON 中提取状态码,绝对不应将原始 JSON 透传给 Server。 利用 Zabbix 4.0 引入的 Preprocessing(预处理)功能,在 Agent 或 Proxy 端完成数据解析:

    • 监控项类型改为 Numeric (unsigned)。

    • 在 Item 的 Preprocessing 标签页添加:JSONPath -> $.data.status_code。 这样,Zabbix Proxy 拉取到 500KB 的 JSON 后,仅提取出数字状态码(如 200 或 500),再由 Proxy 将这几个字节的整型数据上报给 Server。网络带宽占用降低了 99.9%,数据库写入压力直接消失。

    第三步:主动模式改造 将 Item 类型从 Zabbix agent 更改为 Zabbix agent (active)。把连接发起方从 Server/Proxy 转移到 Agent 端,彻底释放 Server 端宝贵的 Poller 进程池。

    第四步:MySQL 数据库底层防御性调优 针对 Zabbix 这种典型的“重写轻读”场景,必须对 InnoDB 进行专项调优:

    # my.cnf
    # 牺牲极端的 ACID 保证,换取成倍的写入吞吐量。Zabbix 丢失最近 1 秒的数据完全可接受。
    innodb_flush_log_at_trx_commit = 2 
    
    # 提升 Buffer Pool 命中率,通常设为物理内存的 60%-70%
    innodb_buffer_pool_size = 32G 
    
    # 匹配 SSD 的真实 IOPS 能力,避免 Page Cleaner 罢工
    innodb_io_capacity = 3000
    innodb_io_capacity_max = 6000
    
    # 优化大事务日志
    innodb_log_file_size = 2G
    

    同时,强烈建议对 Zabbix 的 history 系列表(尤其是 history_uint 和 history)实施按天分区(Table Partitioning)。Zabbix 内部的 Housekeeper 线程执行 DELETE 语句清理旧数据时会产生海量碎片和全表锁;改成分区表后,清理历史数据仅需一条 ALTER TABLE DROP PARTITION,瞬间完成且零磁盘 I/O。

    同类问题排查清单(Troubleshooting Checklist)

    1. Server 性能自证:检查 Zabbix 内部监控指标 zabbix[process,history syncer,avg,busy] 和 zabbix[process,poller,avg,busy],若持续 >75%,说明落盘或采集被阻塞。

    2. 大对象排查:在 Zabbix DB 执行 SELECT key_, delay FROM items WHERE value_type=4 AND status=0; 揪出文本类高频轮询项。

    3. Proxy 积压检查:通过 zabbix[proxy,{$PROXY_NAME},lastaccess] 监控 Proxy 心跳延迟,若延迟激增,多为 Proxy 本地 SQLite/MySQL I/O 瓶颈或 Server 拒收。

    4. 数据库 I/O 饱和度确认:使用 iostat -dmx 1 观察 w/s 与 %util,结合 MySQL SHOW ENGINE INNODB STATUS 中的 Log sequence number 推进速度,判断是否遭遇 InnoDB 脏页刷新瓶颈。

  • 深入 Raft 陷阱排查:非对称网络分区引发的 Term 暴涨与 Leader 选举雪崩实战

    某次排查底层分布式 KV 存储集群的大面积超时告警,现象极其极端:读写 QPS 瞬间跌穿到零,P99 延时直接打满,整个集群处于完全瘫痪状态。最终结论并不复杂:机房一台边缘交换机故障,导致节点网卡出现非对称半连接(能发不能收)。由于该版本的 Raft 引擎实现未强制开启 Pre-Vote(预投票)机制,导致这个被隔离的孤岛节点疯狂发起选举,疯狂拉升全局的 Term(任期),合法 Leader 被这颗携带高 Term 的“毒丸”反复强制拉下马。解决思路:直接在引擎配置中强行开启 Pre-Vote 与 CheckQuorum,并在网络层剔除丢包源。

    案发现场:QPS 归零与疯狂跳动的 Term

    排查过程中,第一眼看 Grafana 面板,rate(raft_server_proposals_applied_total[1m]) 已经掉进冰窟窿,没有任何请求能被成功 Commit。与之形成鲜明对比的是,raft_term 指标像坐了火箭,几分钟内从 1024 飙升到了 85401 并在持续暴涨。

    切到原 Leader 节点 A 的日志,满屏的告警:

    [WARN] raft: Node A received RequestVote from Node C with higher term 85401. Stepping down.
    [INFO] raft: Node A entered state Follower
    [INFO] raft: Node A election timeout, starting election...
    [INFO] raft: Node A became candidate at term 85402
    [INFO] raft: Node A became leader at term 85402
    [WARN] raft: Node A received RequestVote from Node C with higher term 85403. Stepping down.
    

    集群并没有发生严重的硬件宕机,但这 5 个节点不仅没有稳定选出新 Leader,反而陷入了无休止的“选举-下台-再选举”的死循环。

    愚蠢的底层逻辑:教科书版 Raft 的致命缺陷

    Raft 协议的强 Leader 模型是把双刃剑。如果在生产环境中直接生搬硬套基础版本的 Raft 协议,而不引入 Diego Ongaro 在博士论文第 6 章提到的工程扩展,就是给自己埋雷。

    在这个案发现场,节点 C 遭遇了非对称网络分区:它收不到 Leader A 发来的 Heartbeat,导致自身的 election timer 必定超时。按照标准协议,节点 C 会做两件事:

    1. Term = Term + 1

    2. 转换为 Candidate 并向集群广播 RequestVote

    由于是非对称分区,C 的拉票请求成功发给了 A、B、D、E。尽管 C 的日志可能不是最新的,它绝对不可能赢得多数派选票当上 Leader。但是,Raft 协议里有一条铁律:任何节点(包括现任 Leader)只要收到比自己大的 Term,必须立刻无条件退化为 Follower。

    节点 C 携带的高 Term 就是一颗无解的“毒丸”。这导致了极其恶心的循环: 孤岛节点 C 增加 Term -> 广播给集群 -> 现任 Leader A 看到更高的 Term 被迫下台 -> 集群暂时无主,发起新一轮选举 -> A 或 B 凭借更新的日志重新当选 Leader -> 刚当选,又收到孤岛 C 发来的更高 Term 的拉票请求 -> 再次被迫下台。

    结果就是整个集群变成了一台只消耗 CPU 跑选举、不干任何业务逻辑的“Term 自增器”。

    破局与防御性配置:Pre-Vote 与 CheckQuorum

    要彻底阻断这种因为单点网络抖动引发的全局雪崩,必须在架构层面实现“防御性编程”。主流成熟的 Raft 实现(如 etcd/raft, TiKV)都已经内置了相关机制,但很多时候默认并未开启或配置不当。

    核心修复手段是开启 Pre-Vote(预投票)。

    Pre-Vote 的逻辑非常克制:节点在真正增加自己的 Term 并发起正式选举之前,必须先发起一轮预投票(PreVote)。只有当集群中大多数节点回复“我愿意投给你”(意味着这些正常节点也没有收到有效 Leader 的心跳)时,该节点才被允许自增 Term 并进入正式的 Candidate 状态。

    在我们的配置文件中,直接将以下参数打死:

    raft:
      # 强制开启预投票,阻断孤岛节点无脑推高 Term
      enable_pre_vote: true
    
      # 开启 CheckQuorum:Leader 如果连不上多数派会自动 StepDown,防止脑裂和幽灵复现
      check_quorum: true
    
      # 选举超时基准值与抖动值,防止多个候选人同时发起选举导致选票瓜分 (Split Vote)
      election_timeout: 1000ms
      election_timeout_jitter: 500ms
    

    开启 Pre-Vote 后,当孤岛节点 C 再次因为收不到心跳而发起预投票时,正常节点 A 和 B 仍在正常通信,确信当前 Leader A 存活,它们会直接拒绝 C 的预投票请求。C 拿不到多数派的预投票许可,它的 Term 就永远被按死在原地,无法再去毒害现任 Leader,系统瞬间恢复高可用。

    排查清单:同类问题速查

    1. 监控 raft_term 增速:正常集群的 Term 几个月都不会变。如果出现短时间内成百上千次的暴涨,100% 存在网络隔离或节点假死导致的频繁选举。

    2. 核对非对称网络连通性:不要只在 Leader 上 ping Follower。必须全节点交叉使用 ping 和 nc -vz 验证双向连通性与端口状态,特别是大包传输是否有 Drop。

    3. 确认 Pre-Vote 开启状态:检查你所使用的中间件/自研引擎的 Raft 配置,未开启 Pre-Vote 的引擎不具备抵抗网络分区的生产级能力。

    4. 排查 election_timeout 设置:如果底层磁盘 I/O 毛刺(如 Fsync 慢)导致的心跳处理延迟大于 election_timeout,同样会引发假选举。确保 election_timeout 至少是心跳间隔的 5~10 倍以上。

  • 深入 OpenLDAP 陷阱排查:syncrepl 脑裂引发的 contextCSN 紊乱与 SSSD 认证雪崩实战

    某次处理线上核心业务主机鉴权大面积超时,根因是 OpenLDAP(2.4.59 版本)Consumer 节点网络抖动重连后,触发 syncrepl 机制追平数据。因核心属性缺失索引,导致 Provider 节点发生全目录扫描(Full DIT Scan),slapd CPU 瞬间飙升至 400%。叠加 SSSD 客户端超时重试机制,演变成对 LDAP 集群的 DDoS。解法:在线补充 entryCSN 等底层索引,收紧 SSSD 探测与退避策略。

    现场:Load 飙升与 SSH 登录卡死

    排查过程中,监控系统报出大量机器 SSH 登录卡顿,甚至直接超时(连接耗时 > 30s)。查看 LDAP 监控面板,发现 Provider 节点(Master)的 slapd 进程 CPU 使用率打满,Load Average 飙升至 45,QPS 从日常的 200 突增至 8000+。

    抓取 Provider 节点的 slapd 日志(olcLogLevel: 256):

    slapd[11302]: conn=102435 op=1 SRCH base="dc=example,dc=com" scope=2 deref=0 filter="(objectClass=*)"
    slapd[11302]: conn=102435 op=1 SRCH attr=* +
    slapd[11302]: conn=102435 op=1 SEARCH RESULT tag=101 err=0 nentries=125430 text=
    

    同时,客户端侧的 SSSD (/var/log/sssd/sssd_example.com.log) 疯狂刷屏:

    [sdap_get_users_done] (0x0040): Recoverable error from LDAP server: 115 (Operations error)
    [sssd[be[example.com]]] [be_ptask_execute] (0x0040): Task [SUDO Rules Refresh]: execution failed [143]: Connection timed out
    [sssd[be[example.com]]] [fo_resolve_service_send] (0x0020): No available servers for service 'LDAP'
    

    直观现象是:Consumer 节点由于网络短暂断开,尝试通过 syncrepl 协议向 Provider 节点同步数据,但由于查询极为缓慢,不仅同步失败,还把 Provider 节点拖垮,进而导致全网依托 SSSD 的 Linux 机器鉴权请求堆积,最终雪崩。

    根因定位:缺失的暗坑索引

    通过 ldapsearch 提取两端节点的 contextCSN(上下文提交序列号),发现已经发生断层(Split-brain 雏形):

    # Provider 端
    ldapsearch -x -LLL -H ldap://provider.example.com -b "dc=example,dc=com" -s base contextCSN
    # contextCSN: 20231025143021.123456Z#000000#000#000000
    
    # Consumer 端
    ldapsearch -x -LLL -H ldap://consumer.example.com -b "dc=example,dc=com" -s base contextCSN
    # contextCSN: 20231025142810.654321Z#000000#000#000000
    

    Consumer 落后约 2 分钟。当它发起 Sync Request 时,会带上自己的 contextCSN,Provider 需要找出所有 entryCSN 大于该值的条目进行增量推送。

    此时检查 Provider 端的 olcDbIndex 配置:

    ldapsearch -Y EXTERNAL -H ldapi:/// -b "olcDatabase={2}mdb,cn=config" olcDbIndex
    

    输出结果中,仅有 uid, cn, memberUid, objectClass 等常规业务索引,唯独没有 entryCSN 和 entryUUID。

    为什么 syncrepl 追逃会导致 Provider 节点 CPU 被打满?

    在 RFC 4533(LDAP Content Synchronization Operation)中,syncrepl 的增量同步(Refresh phase)高度依赖 entryCSN 和 entryUUID 这两个内部操作属性。

    当 Consumer 携带过期的 contextCSN 请求增量数据时,Provider 内部执行的等效查询过滤条件实际上包含: (&(objectClass=*)(entryCSN>=20231025142810.654321Z#000000#000#000000))

    在 LMDB 存储引擎中,如果一个属性没有配置等值(eq)索引,LDAP 就会退化为全目录树遍历(Full DIT Scan)。对于包含 10 万+ 条目(User/Group/Sudoers)的目录树,每次重连都会导致 Provider 将 10 万个条目从内存/磁盘中全量载入,逐一比较 entryCSN 字符串。

    单次扫描可能耗时 3-5 秒。而此时 SSSD 客户端设置的 ldap_search_timeout 通常是 3 秒。 这产生了一个致命的死循环:

    1. syncrepl 触发全表扫描,阻塞 Provider 线程池(olcThreads 默认 16)。

    2. SSSD 在 Consumer/Provider 上的常规鉴权查询排队。

    3. SSSD 查询超时,断开连接,立即向下一个备用节点重试。

    4. 重试流量像海啸一样涌来,彻底击穿所有 LDAP 节点的可用连接数。

    防御性调优实战

    要彻底解决该问题,必须从服务端底层索引和客户端退避策略两端同时下手。

    1. 在线补充核心索引 (LDAP 端)

    切记不要直接修改底层文件,必须通过 OLC (cn=config) 动态生效。编写 LDIF 文件 add_index.ldif:

    dn: olcDatabase={2}mdb,cn=config
    changetype: modify
    add: olcDbIndex
    olcDbIndex: entryCSN eq
    -
    add: olcDbIndex
    olcDbIndex: entryUUID eq
    -
    add: olcDbIndex
    olcDbIndex: memberOf eq
    

    注:如果你开启了 memberof overlay,也必须为其建立 eq 索引,否则组查询同样会导致慢查询。

    应用配置并等待后台构建索引完成(LMDB 支持在线建索引,期间会有短暂的 IO 升高,但不阻塞读写):

    ldapmodify -Y EXTERNAL -H ldapi:/// -f add_index.ldif
    

    2. 加固 syncrepl 同步参数 (LDAP 端)

    原生 syncrepl 配置往往忽略了重试退避和保活机制,网络微小抖动极易引发连接挂起。修改 Consumer 上的 olcSyncrepl:

    olcSyncrepl: {0}rid=101 provider=ldap://provider.example.com 
      bindmethod=simple binddn="cn=replicator,dc=example,dc=com" credentials="xxx" 
      searchbase="dc=example,dc=com" 
      type=refreshAndPersist 
      retry="60 10 300 3"    # 核心退避策略:每60秒重试10次,若失败则每300秒重试3次(或者无限次 +)
      keepalive="240:10:30"  # 开启 TCP Keepalive:空闲240秒后,每10秒探测一次,30次失败断开
      timeout=3              # 控制连接超时时间,防止同步线程被死锁
    

    3. SSSD 容灾与熔断配置调优 (Client 端)

    SSSD 的默认配置过于激进,在面对服务端降级时缺乏足够的容忍度。修改 /etc/sssd/sssd.conf 中的关键参数:

    [domain/example.com]
    id_provider = ldap
    auth_provider = ldap
    chpass_provider = ldap
    ldap_uri = _srv_, ldap://provider.example.com, ldap://consumer.example.com
    
    # 【熔断与退避】
    # 当服务器标记为离线后,多久后重新尝试连接(避免无脑重试打死服务端)
    ldap_connection_expire_timeout = 60
    ldap_network_timeout = 3
    ldap_opt_timeout = 3
    ldap_search_timeout = 6
    
    # 【本地缓存加固】
    # 允许在 LDAP 不可用时,使用本地缓存的凭据进行登录
    cache_credentials = True
    # 延长离线缓存的有效期(默认可能太短)
    offline_credentials_expiration = 7
    
    # 【查询优化】禁用冗余枚举,减少服务端压力
    enumerate = False
    # 优化 Sudo 规则拉取频率(如果使用了 sudo provider)
    ldap_sudo_smart_refresh_interval = 900
    ldap_sudo_full_refresh_interval = 10800
    

    重启 SSSD 后生效:systemctl restart sssd; sss_cache -E。

    常见问题 (FAQ)

    Q1:如何判断我的 OpenLDAP 集群是否发生了脑裂(Split-Brain)? 首先在集群所有节点上执行 ldapsearch 获取根部的 contextCSN。如果发现某些节点的 contextCSN 已经停止更新,且时间差超过了你的同步延迟容忍度,再查看 slapd 日志中是否有 CSN too old 或重复的 op=1 SRCH base="dc=example,dc=com" 且 err=0 nentries=全量条目数,这表明增量同步已失效,正在退化为全量拉取或已经卡死。

    Q2:如果 syncrepl 彻底断层,无法自动追平,如何手动介入恢复?

    1. 停止落后节点(Consumer)的 slapd 服务。

    2. 备份当前数据(可选):slapcat -n 2 -l backup.ldif。

    3. 清空落后节点的 LMDB 数据目录(如 /var/lib/ldap/*,保留 DB_CONFIG 如果有的话)。

    4. 在 Provider 节点使用 slapcat 导出全量数据,并拷贝至 Consumer。

    5. 在 Consumer 节点使用 slapadd -q -l export.ldif 导入数据(这一步会自动包含 Provider 的 contextCSN)。

    6. 启动 Consumer 的 slapd,此时同步会从最新的 contextCSN 瞬间无缝衔接。

    Q3:高可用架构下,推荐使用 N-Way Multi-Master 还是 MirrorMode? 作为踩过无数坑的老鸟,强烈建议使用 MirrorMode(Active-Standby 模式)。OpenLDAP 的 N-Way Multi-Master 在面对并发写入更新同一条目(特别是组成员更新)时,极易产生难以自动解决的冲突(Conflict entries)。MirrorMode 结合 Keepalived/HAProxy 提供单一写入入口,同时保持双向的 syncrepl 复制,既保证了写操作的数据一致性,又实现了无缝的故障转移,是目前生产环境最为稳妥的落地架构。

    Q4:为什么通过 SSSD 查组下的用户很慢,但直接 ldapsearch 很快? 这通常是因为 SSSD 默认开启了复杂的嵌套组解析(RFC 2307bis)。如果你的 DIT 设计中没有使用嵌套组,应该在 SSSD 中配置 ldap_group_member = memberUid(针对 posixGroup)或确保 OpenLDAP 端加载了 memberof 模块。若是后者,务必检查是否为 member 和 memberOf 属性建立了 eq 索引,否则 SSSD 在展开每个用户组时,依然会触发服务端的高级范围搜索惩罚。