
1. 从告警风暴到数据洞察为什么需要统计Zabbix告警表运维圈里有句老话不怕告警多就怕告警乱。Zabbix作为老牌监控系统几乎每个中大型环境里都能见到它的身影。它每天产生的告警数据量非常可观——一个中等规模的集群一天几千条告警是常态遇到网络抖动或者批量重启上万条也不稀奇。这些告警记录默认躺在Zabbix的数据库里通过Web界面一条条翻看效率极低。真正有价值的信息比如哪类告警最频繁哪个时间段是告警高峰哪些主机长期反复触发同一问题全都被淹没在流水账里。这就是用Python统计Zabbix监控告警表这件事的核心价值所在。它要解决的问题很明确把Zabbix数据库里散落的告警记录通过Python脚本做聚合、分类、排序最终输出一份能直接看懂的统计报表。适合谁来参考一是日常需要写运维周报、月报的运维工程师二是想从监控数据里挖出系统稳定性趋势的SRE三是对Python数据处理感兴趣、想找个真实场景练手的技术爱好者。哪怕你之前没怎么碰过Zabbix的数据库结构只要会基本的SQL和Python这篇内容都能让你跑通一套属于自己的告警统计流程。我最初做这件事的动机很朴素每周例会上领导问这周系统稳定吗我总不能回答感觉还行。我需要数据说话。于是开始研究Zabbix的告警表结构写脚本做统计中间踩了不少坑也总结出一些比官方文档更实用的经验。下面就把整套思路和实操细节完整拆开讲。2. 摸清Zabbix告警数据的存储结构2.1 alerts表才是告警记录的主战场很多人第一反应是去查events表因为Zabbix的Web界面里问题和事件看起来都跟告警有关。但真正记录告警动作是否发出、发给谁、发送结果如何的是alerts表。这两张表的关系需要先理清楚否则统计口径会出错。events表记录的是发生了什么——比如某个触发器从OK变成PROBLEM这是一条事件。而alerts表记录的是针对这个事件告警动作执行了几次、通过什么媒介、成功还是失败。举个例子一个触发器触发了问题配置了邮件和短信两种告警方式那么events表里只有一条PROBLEM事件但alerts表里会有两条记录一条邮件、一条短信。如果你要统计系统一共发生了多少次故障应该看events如果你要统计一共发出了多少条告警通知那必须看alerts。我建议做告警统计时以alerts表为主因为运维周报里通常关心的是告警通知的发送情况包括发送总量、成功率、各媒介分布等。当然如果领导更关心故障次数那就得结合events表。下面这张表把两张表的关键字段做个对照方便你按需选择。字段名alerts表含义events表含义alertid/eventid告警记录唯一ID事件唯一IDclock告警发送时间戳事件发生时间戳sendto告警接收目标不适用subject告警标题不适用status发送状态0未发送/1已发送/2失败/3重试不适用retries重试次数不适用error发送失败原因不适用value不适用事件类型0 OK/1 PROBLEMseverity不适用严重级别0-52.2 时间戳字段的坑Unix时间戳不是给人看的Zabbix数据库里所有时间字段存的都是Unix时间戳单位是秒。比如clock字段的值可能是1717036800这种数字。直接查出来根本没法看必须转换。Python里用datetime.fromtimestamp()就能搞定但要注意时区问题。Zabbix默认存的是UTC时间戳而你的服务器和数据库可能配置了不同的时区。我踩过的坑是脚本在本地跑出来时间是对的放到服务器上跑就差了8小时。后来统一在脚本里显式指定时区转换问题才解决。from datetime import datetime, timezone, timedelta # 假设从数据库取出的clock值为1717036800 timestamp 1717036800 # 转换为东八区时间 dt datetime.fromtimestamp(timestamp, tztimezone(timedelta(hours8))) print(dt.strftime(%Y-%m-%d %H:%M:%S)) # 输出2024-05-30 12:00:00提示如果你的Zabbix数据库和脚本运行环境时区不一致建议在脚本开头就统一设置时区避免后续所有时间计算都出错。2.3 告警状态码和严重级别的对应关系alerts表的status字段有四个值0表示未发送可能是告警被禁用或还在队列里1表示已发送成功2表示发送失败3表示重试中。统计发送成功率时分母应该是status in (1,2,3)的记录因为状态0的告警根本没进入发送流程算进去会拉低成功率导致数据失真。events表的severity字段从0到5分别对应未分类、信息、警告、一般严重、严重、灾难。做告警分级统计时这个字段非常关键。我通常会把严重级别3以上的告警单独拎出来做重点分析因为这类告警往往对应真实的业务影响。3. 用Python连接Zabbix数据库的几种姿势3.1 直连数据库最直接但要注意权限最朴素的方式就是用Python的数据库驱动直接连Zabbix的后端数据库。Zabbix支持MySQL/MariaDB和PostgreSQL两种主流后端所以你需要根据实际情况选择pymysql或psycopg2。直连的好处是灵活想查什么字段就查什么字段不受API限制。坏处是需要数据库账号密码而且如果Zabbix数据库负载本来就高你的统计查询可能会加重负担。import pymysql connection pymysql.connect( hostyour_zabbix_db_host, port3306, userreadonly_user, passwordyour_password, databasezabbix, charsetutf8mb4, cursorclasspymysql.cursors.DictCursor ) with connection.cursor() as cursor: sql SELECT alertid, clock, sendto, subject, status, retries, error FROM alerts WHERE clock UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY)) cursor.execute(sql) results cursor.fetchall() print(f最近7天告警记录数{len(results)})这里有几个实操要点。第一强烈建议创建一个只读账号专门给统计脚本用不要用Zabbix的默认管理员账号避免误操作。第二查询时一定要加时间范围条件alerts表在繁忙环境里可能有几百万行全表扫描会把数据库拖垮。第三charset要设成utf8mb4否则告警标题里的中文或特殊字符可能显示成乱码。3.2 走Zabbix API更安全但字段受限如果数据库权限不好拿或者你不想给脚本直连数据库的权限那就走Zabbix API。Python里可以用requests库直接调也可以用现成的pyzabbix封装库。API方式的好处是权限可控、不直接碰数据库坏处是API返回的字段没有数据库那么全而且大量数据拉取时性能不如直连。import requests import json ZABBIX_URL http://your_zabbix_server/api_jsonrpc.php AUTH_TOKEN your_api_token headers {Content-Type: application/json-rpc} payload { jsonrpc: 2.0, method: alert.get, params: { output: extend, time_from: 1716432000, time_till: 1717036800, search: {status: [1, 2, 3]} }, id: 1, auth: AUTH_TOKEN } response requests.post(ZABBIX_URL, headersheaders, datajson.dumps(payload)) alerts response.json().get(result, []) print(f通过API获取告警数{len(alerts)})API方式有个细节要注意alert.get方法默认返回的条数有限制如果告警量很大需要分页拉取。另外API的time_from和time_till参数也是Unix时间戳和数据库保持一致。3.3 两种方式的选型对比对比维度直连数据库Zabbix API权限要求需要数据库账号需要API Token字段完整度全部字段可见部分字段受限查询性能高可写复杂SQL中受API限制对Zabbix影响可能增加数据库负载增加Web服务负载适合场景大批量历史数据分析小规模实时查询我的建议是如果只是做周报月报这种批量统计直连数据库更高效如果是嵌入到其他系统里做实时查询走API更合适。两者并不互斥可以按场景混用。4. 告警统计的核心维度与SQL实现4.1 按告警类型聚合找出最吵的那个触发器告警统计最有价值的一个维度就是哪类告警最多。Zabbix的alerts表里subject字段通常包含了触发器名称但直接按subject分组会有问题——同一个触发器在不同主机上触发的告警subject可能因为主机名不同而略有差异。更稳妥的做法是关联events表和triggers表按触发器ID分组。SELECT t.description AS trigger_name, COUNT(*) AS alert_count, SUM(CASE WHEN a.status 1 THEN 1 ELSE 0 END) AS success_count, SUM(CASE WHEN a.status 2 THEN 1 ELSE 0 END) AS fail_count FROM alerts a JOIN events e ON a.eventid e.eventid JOIN triggers t ON e.objectid t.triggerid WHERE a.clock UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY)) GROUP BY t.triggerid, t.description ORDER BY alert_count DESC LIMIT 20;这条SQL跑出来就是一张告警排行榜直接放进周报里非常有说服力。我通常会把前10名做成柱状图一眼就能看出哪些触发器在制造噪音。如果某个触发器一周触发了几百次那基本可以判定是阈值设置不合理需要优化。4.2 按时间维度统计定位告警高峰时段按小时或按天统计告警数量能帮我们发现系统的脆弱时间段。比如每天凌晨2点到4点告警特别多那可能是备份任务导致的每周一上午告警集中可能是业务高峰期压力大。SQL里用FROM_UNIXTIME配合DATE_FORMAT就能实现。SELECT DATE_FORMAT(FROM_UNIXTIME(clock), %Y-%m-%d %H:00:00) AS hour_slot, COUNT(*) AS alert_count FROM alerts WHERE clock UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY)) GROUP BY hour_slot ORDER BY hour_slot;Python拿到这个结果后可以用matplotlib画一张折线图横轴是时间纵轴是告警数。如果不想画图直接在终端里用字符输出一个简易的柱状图也行适合快速查看。4.3 按主机维度统计识别问题最多的机器哪些主机告警最多往往意味着这些机器要么配置有问题要么承载了不稳定的业务。按主机统计需要关联events表和hosts表因为alerts表本身不直接存主机ID。SELECT h.host AS host_name, COUNT(*) AS alert_count FROM alerts a JOIN events e ON a.eventid e.eventid JOIN hosts h ON e.hostid h.hostid WHERE a.clock UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY)) GROUP BY h.hostid, h.host ORDER BY alert_count DESC LIMIT 20;这里有个坑Zabbix的events表里hostid字段在某些版本里可能为0或者不准确尤其是涉及多主机触发器的时候。如果发现统计结果里出现大量主机名为空或为0的记录需要检查Zabbix版本和触发器配置。替代方案是通过functions和items表反查主机但SQL会复杂很多。4.4 告警发送成功率统计别让通知渠道背锅告警发出去了但没送达比不告警还危险。统计发送成功率能帮我们发现邮件服务器、短信网关等通知渠道的问题。SELECT CASE WHEN status 1 THEN 发送成功 WHEN status 2 THEN 发送失败 WHEN status 3 THEN 重试中 ELSE 未发送 END AS send_status, COUNT(*) AS count, ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) AS percentage FROM alerts WHERE clock UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY)) GROUP BY status;如果失败率超过5%那就得排查通知渠道了。我遇到过因为邮件服务器被限流导致大量告警发送失败的情况当时就是靠这个统计发现的。5. 从原始数据到可视化报表的完整脚本5.1 脚本整体架构设计一个完整的告警统计脚本我习惯分成四个模块配置加载、数据获取、数据处理、报表输出。配置单独放一个文件或字典里方便修改数据库连接信息和统计时间范围。数据获取层封装数据库连接和查询逻辑数据处理层做聚合计算报表输出层负责生成文本报告或图表。import pymysql from datetime import datetime, timedelta, timezone from collections import defaultdict class ZabbixAlertStats: def __init__(self, db_config, days7): self.db_config db_config self.days days self.conn None self.alerts [] self.stats {} def connect(self): self.conn pymysql.connect(**self.db_config, charsetutf8mb4, cursorclasspymysql.cursors.DictCursor) def fetch_alerts(self): sql SELECT a.alertid, a.clock, a.sendto, a.subject, a.status, a.retries, a.error, e.severity, e.value, h.host FROM alerts a LEFT JOIN events e ON a.eventid e.eventid LEFT JOIN hosts h ON e.hostid h.hostid WHERE a.clock UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL %s DAY)) with self.conn.cursor() as cursor: cursor.execute(sql, (self.days,)) self.alerts cursor.fetchall() def compute_stats(self): total len(self.alerts) success sum(1 for a in self.alerts if a[status] 1) fail sum(1 for a in self.alerts if a[status] 2) retry sum(1 for a in self.alerts if a[status] 3) # 按主机统计 host_counter defaultdict(int) for a in self.alerts: host_counter[a.get(host) or 未知主机] 1 # 按严重级别统计 severity_counter defaultdict(int) severity_map {0: 未分类, 1: 信息, 2: 警告, 3: 一般严重, 4: 严重, 5: 灾难} for a in self.alerts: sev a.get(severity) if sev is not None: severity_counter[severity_map.get(sev, 未知)] 1 self.stats { total: total, success: success, fail: fail, retry: retry, success_rate: round(success / total * 100, 2) if total else 0, top_hosts: sorted(host_counter.items(), keylambda x: x[1], reverseTrue)[:10], severity_dist: dict(severity_counter) } def generate_report(self): lines [] lines.append( * 50) lines.append(fZabbix告警统计报告最近{self.days}天) lines.append( * 50) lines.append(f告警总数{self.stats[total]}) lines.append(f发送成功{self.stats[success]}{self.stats[success_rate]}%) lines.append(f发送失败{self.stats[fail]}) lines.append(f重试中{self.stats[retry]}) lines.append() lines.append(--- 告警最多的主机TOP10 ---) for host, count in self.stats[top_hosts]: lines.append(f {host}: {count}条) lines.append() lines.append(--- 严重级别分布 ---) for sev, count in self.stats[severity_dist].items(): lines.append(f {sev}: {count}条) return \n.join(lines) def run(self): self.connect() self.fetch_alerts() self.compute_stats() report self.generate_report() print(report) self.conn.close() return report5.2 关键参数的计算逻辑说明脚本里几个参数的计算方式需要特别说明。发送成功率的分母是total也就是所有查出来的告警记录数。但前面提过状态为0的告警不应该算进去。所以更严谨的做法是在SQL里就过滤掉status 0的记录或者在Python里计算时排除。# 更严谨的成功率计算 valid_alerts [a for a in self.alerts if a[status] in (1, 2, 3)] success_rate sum(1 for a in valid_alerts if a[status] 1) / len(valid_alerts) * 100严重级别分布里severity字段可能为None因为LEFT JOIN的时候如果events表里没有对应记录这个字段就是空的。脚本里用if sev is not None做了过滤避免报错。5.3 输出格式的灵活切换报表输出不一定非得是纯文本。如果想让周报看起来更专业可以输出Markdown表格直接贴到文档里。也可以输出JSON格式方便其他系统消费。def generate_markdown_report(self): lines [] lines.append(f## Zabbix告警周报最近{self.days}天\n) lines.append(f- 告警总数**{self.stats[total]}**) lines.append(f- 发送成功率**{self.stats[success_rate]}%**\n) lines.append(| 主机 | 告警数 |) lines.append(|------|--------|) for host, count in self.stats[top_hosts]: lines.append(f| {host} | {count} |) return \n.join(lines)我通常会把文本报告和Markdown报告都生成一份文本的用于终端快速查看Markdown的用于写周报。6. 实操中踩过的坑与性能优化经验6.1 大表查询慢索引和分批处理alerts表在运行一年以上的Zabbix环境里数据量轻松过千万。直接SELECT *全表扫描查询时间可能长达几十秒甚至超时。我最初的脚本就因为这个原因经常跑挂。后来做了两件事解决一是确保clock字段有索引Zabbix默认会建但有些环境可能被删了二是把大查询拆成按天分批查每次只查一天的数据然后在Python里合并。def fetch_alerts_by_day(self): all_alerts [] for i in range(self.days): day_start datetime.now() - timedelta(daysi1) day_end datetime.now() - timedelta(daysi) sql SELECT ... FROM alerts a LEFT JOIN events e ON a.eventid e.eventid WHERE a.clock UNIX_TIMESTAMP(%s) AND a.clock UNIX_TIMESTAMP(%s) with self.conn.cursor() as cursor: cursor.execute(sql, (day_start, day_end)) all_alerts.extend(cursor.fetchall()) self.alerts all_alerts分批查询虽然多跑了几次数据库但每次查询都走索引总体耗时反而更短而且不会因为单次查询太大导致数据库连接超时。6.2 中文乱码字符集要统一Zabbix数据库、连接字符集、Python脚本编码这三者只要有一个不一致中文告警标题就会变成乱码。我的经验是数据库连接参数里显式指定charsetutf8mb4Python文件开头加# -*- coding: utf-8 -*-输出到文件时用encodingutf-8。三处都统一了基本不会出问题。6.3 时区不一致显式转换最保险前面提过时区问题这里再强调一下。如果Zabbix服务器、数据库、脚本运行环境三者的时区不一致统计出来的每天告警数会完全错位。最保险的做法是在脚本里把所有时间戳都显式转换成同一个时区比如统一用东八区。TZ timezone(timedelta(hours8)) def ts_to_str(ts): return datetime.fromtimestamp(ts, tzTZ).strftime(%Y-%m-%d %H:%M:%S)6.4 统计口径要提前和需求方对齐这个坑不是技术层面的但比技术坑更致命。我曾经统计了一份告警总数报告结果领导说他要的是故障次数两者差了将近十倍。后来才明白alerts表里一条故障可能对应多条告警通知邮件、短信、微信各一条而领导关心的是故障本身。所以做统计之前一定要先确认清楚统计的是告警通知数还是故障事件数时间范围是按告警发送时间还是事件发生时间这些口径不对齐后面全白做。统计口径数据来源适用场景告警通知数alerts表评估通知渠道负载故障事件数events表value1评估系统稳定性触发器触发次数events表按objectid分组优化触发器阈值主机告警数alertseventshosts识别问题主机7. 让统计脚本真正融入日常运维流程脚本写出来只是第一步能不能坚持用下去才是关键。我的做法是把它挂到定时任务里每周一早上自动跑一次生成的Markdown报告直接发到工作群里。这样不需要任何人手动触发统计结果自然就沉淀下来了。# crontab示例每周一早上8点执行 0 8 * * 1 /usr/bin/python3 /opt/scripts/zabbix_alert_stats.py /var/log/zabbix_stats.log 21如果团队有内部Wiki或者文档系统可以让脚本把报告直接写入指定页面。更进一步可以把统计结果推送到内部的数据看板用图表展示趋势变化。我目前的做法是脚本生成JSON数据再由另一个轻量服务读取JSON渲染成网页图表整体链路很轻维护成本低。还有一个容易被忽略的点统计脚本本身也需要监控。如果脚本某天跑失败了没人发现那周报就断了。我的做法是在脚本最后加一个简单的健康检查把执行结果写到一个状态文件里再用Zabbix自己监控这个文件的时间戳。用监控系统监控监控统计脚本听起来有点套娃但确实有效。最后分享一个我在实际使用中觉得最实用的技巧把每周的统计结果存一份历史归档这样不仅能看当周数据还能做同比和环比。比如本周告警数比上周下降了15%这种趋势性的结论比单纯罗列数字有价值得多。归档用最简单的JSON文件按日期命名就行不需要上数据库。import json from datetime import datetime def archive_stats(stats, output_dir/opt/zabbix_stats/archive): filename f{output_dir}/stats_{datetime.now().strftime(%Y%m%d)}.json with open(filename, w, encodingutf-8) as f: json.dump(stats, f, ensure_asciiFalse, indent2) print(f统计结果已归档至{filename})这套流程跑顺之后每周的告警统计从原来手动翻页面加Excel拼凑的大半天压缩到了脚本自动跑完的几分钟。省下来的时间可以用来做更有价值的事比如真正去优化那些排名靠前的触发器。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。