WhatsApp 账号异常检测的自动化告警系统设计
目录
- 为什么需要自动化告警
- 告警系统要覆盖哪些维度
- 指标定义与采集层
- 规则引擎与告警分级
- 通知渠道与收敛策略
- 告警运维:抑制、升级与闭环
- 生产环境的落地经验
- 小结
1. 为什么需要自动化告警
当你管理的账号从几个变成十几个之后,靠"时不时看一眼"的方式监控状态已经不现实了。
问题不是你不想看,而是:
- 你不可能 24 小时盯着屏幕;
- 异常往往发生在你不在的时候(凌晨、周末);
- 有些异常是渐进的(成功率每天降 1%),肉眼看不出趋势;
- 出了事之后你再去看日志,关键的上下文可能已经丢了。
你需要一个不需要人盯着的监控系统:它在后台持续观察所有账号的关键指标,一旦发现异常就主动通知你。
2. 告警系统要覆盖的核心维度
| 维度 | 监控什么 | 正常值 | 告警阈值示例 |
|---|---|---|---|
| 发送量 | 各账号每分钟/每小时发送数 | 波动 ±20% | 突然归零 或 突增 300% |
| 成功率 | 发送成功 / 总发送 | ≥ 95% | 连续 10 分钟 < 90% |
| 错误率 | 失败 / 总发送 | ≤ 5% | 单分钟 > 15% |
| 延迟 | 单条消息平均耗时 | < 5 秒 | P99 > 30 秒 |
| 新联系人比 | 新号码 / 总发送数 | < 50% | > 70% |
| 429 频率 | 收到限速响应的次数 | 0-2 次/小时 | > 10 次/小时 |
| 账号状态 | ACTIVE / DEGRADED / SUSPENDED | 全部 ACTIVE | 任意非 ACTIVE |
3. 指标定义与采集层
3.1 统一指标数据结构
python
import time
import json
import sqlite3
from dataclasses import dataclass, field, asdict
from enum import Enum
from typing import Optional
class MetricType(str, Enum):
COUNTER = "counter" # 计数器(只增不减,如总发送数)
GAUGE = "gauge" # 仪表盘(可升可降,如当前TPM)
HISTOGRAM = "histogram" # 直方图(分布型数据,如延迟)
@dataclass
class MetricPoint:
"""单条指标数据"""
metric_name: str # 如 "account.sent.count"
account_id: str
metric_type: MetricType
value: float
timestamp: float = field(default_factory=time.time)
tags: dict = field(default_factory=dict) # 额外维度标签
class MetricCollector:
"""指标采集器"""
def __init__(self, db_path: str):
self.db_path = db_path
self._init_table()
def _init_table(self):
conn = sqlite3.connect(self.db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS metrics_raw (
id INTEGER PRIMARY KEY AUTOINCREMENT,
metric_name TEXT NOT NULL,
account_id TEXT NOT NULL,
metric_type TEXT NOT NULL,
value REAL NOT NULL,
tags TEXT DEFAULT '{}',
timestamp REAL NOT NULL
)
""")
# 联合索引:按时间范围+账号查询是最高频操作
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_metrics_time_account
ON metrics_raw(timestamp, account_id)
""")
conn.commit()
conn.close()
def record(self, point: MetricPoint):
"""记录一条指标"""
conn = sqlite3.connect(self.db_path)
conn.execute("""
INSERT INTO metrics_raw
(metric_name, account_id, metric_type, value, tags, timestamp)
VALUES (?, ?, ?, ?, ?, ?)
""", (
point.metric_name, point.account_id, point.metric_type.value,
point.value, json.dumps(point.tags), point.timestamp,
))
conn.commit()
conn.close()
def batch_record(self, points: list[MetricPoint]):
"""批量记录(减少连接开销)"""
if not points:
return
conn = sqlite3.connect(self.db_path)
conn.executemany("""
INSERT INTO metrics_raw
(metric_name, account_id, metric_type, value, tags, timestamp)
VALUES (?, ?, ?, ?, ?, ?)
""", [
(p.metric_name, p.account_id, p.metric_type.value,
p.value, json.dumps(p.tags), p.timestamp)
for p in points
])
conn.commit()
conn.close()
def query_range(
self, metric_name: str, account_id: str,
since_sec: int = 3600
) -> list[dict]:
"""查询某个指标在最近 N 秒的数据"""
since = time.time() - since_sec
conn = sqlite3.connect(self.db_path)
rows = conn.execute("""
SELECT timestamp, value, tags FROM metrics_raw
WHERE metric_name = ? AND account_id = ?
AND timestamp >= ?
ORDER BY timestamp ASC
""", (metric_name, account_id, since)).fetchall()
conn.close()
return [
{"timestamp": r["timestamp"], "value": r["value"],
"tags": json.loads(r["tags"])}
for r in rows
]
3.2 采集埋点位置
指标采集应该嵌入到业务代码的关键路径上:
python
class InstrumentedSender:
"""
带指标采集的发送器包装类。
在实际 send_fn 的前后自动记录指标。
"""
def __init__(self, actual_send_fn, collector: MetricCollector):
self.send_fn = actual_send_fn
self.collector = collector
def send(self, recipient: str, content: str, account_id: str) -> bool:
start = time.time()
success = False
error_type = ""
try:
success = self.send_fn(recipient, content)
except Exception as e:
error_type = type(e).__name__
raise
finally:
latency_ms = (time.time() - start) * 1000
now = time.time()
# 记录 3 条指标
self.collector.batch_record([
MetricPoint(
metric_name="account.sent.count",
account_id=account_id,
metric_type=MetricType.COUNTER,
value=1.0 if success else 0.0,
tags={"success": str(success)},
timestamp=now,
),
MetricPoint(
metric_name="account.sent.latency_ms",
account_id=account_id,
metric_type=MetricType.HISTOGRAM,
value=latency_ms,
tags={},
timestamp=now,
),
MetricPoint(
metric_name="account.sent.error",
account_id=account_id,
metric_type=MetricType.COUNTER,
value=0.0 if success else 1.0,
tags={"error_type": error_type or ("none" if success else "unknown")},
timestamp=now,
),
])
return success
核心思路: 业务代码本身不需要知道"监控"这回事。它只是正常调 send(),指标采集在包装层自动完成。这样即使以后换掉底层发送实现或更换监控系统,业务代码一行都不用改。
4. 规则引擎与告警分级
有了指标之后,下一步是定义"什么情况算异常"以及"异常了该怎么通知"。
python
from enum import Enum
from dataclasses import dataclass, field
class AlertLevel(str, Enum):
INFO = "info" # 信息级:记录但不通知
WARNING = "warning" # 警告:写日志 + 看板高亮
CRITICAL = "critical" # 严重:即时消息推送
EMERGENCY = "emergency" # 紧急:即时消息 + 电话/短信
@dataclass
class AlertRule:
"""单条告警规则"""
rule_id: str
name: str
metric_name: str
condition: str # 条件表达式(如 "value == 0")
level: AlertLevel
window_sec: int = 300 # 时间窗口(秒):在这个窗口内评估条件
threshold_count: int = 1 # 窗口内需要满足条件的次数
cooldown_sec: int = 900 # 冷却期(秒):同一条规则触发后多久不再重复触发
message_template: str = "" # 告警消息模板
enabled: bool = True
# 预置规则库
DEFAULT_RULES: list[AlertRule] = [
AlertRule(
rule_id="send_dropped_to_zero",
name="发送量骤降至零",
metric_name="account.sent.count",
condition="value == 0",
level=AlertLevel.CRITICAL,
window_sec=600, # 10分钟窗口
threshold_count=1, # 只要出现一次就触发
cooldown_sec=1800,
message_template="{account} 在过去 {window_min} 分钟内发送量为零,请检查调度器是否正常运行。",
),
AlertRule(
rule_id="success_rate_drop",
name="成功率下降",
metric_name="account.sent.count", # 配合 error 计算成功率
condition="success_rate < 0.90",
level=AlertLevel.WARNING,
window_sec=600,
threshold_count=1,
cooldown_sec=1800,
message_template="{account} 成功率降至 {rate:.1%}(阈值 90%),最近 {window_min} 分钟内有 {fail_count} 次失败。",
),
AlertRule(
rule_id="error_spike",
name="错误激增",
metric_name="account.sent.error",
condition="value >= 10",
level=AlertLevel.CRITICAL,
window_sec=300, # 5分钟窗口
threshold_count=1,
cooldown_sec=900,
message_template="{account} 在过去 5 分钟内出现 {count} 次错误,疑似触发平台限制。",
),
AlertRule(
rule_id="latency_spike",
name="延迟突增",
metric_name="account.sent.latency_ms",
condition="p99 > 30000", # P99 > 30秒
level=AlertLevel.WARNING,
window_sec=300,
threshold_count=1,
cooldown_sec=1800,
message_template="{account} 消息延迟 P99 达到 {p99:.0f}ms(正常应 <5000ms)。",
),
AlertRule(
rule_id="new_contact_ratio_high",
name="新联系人占比过高",
metric_name="account.sent.count", # 需要配合 unique_recipient 计算
condition="new_ratio > 0.70",
level=AlertLevel.WARNING,
window_sec=3600,
threshold_count=1,
cooldown_sec=3600,
message_template="{account} 新联系人占比达 {ratio:.0%(阈值 70%),建议增加老客户互动比例。",
),
]
class RuleEvaluator:
"""规则评估引擎"""
def __init__(self, rules: list[AlertRule], collector: MetricCollector):
self.rules = [r for r in rules if r.enabled]
self.collector = collector
self._last_trigger: dict[str, float] = {} # rule_id → 上次触发时间
self._alert_history: list[dict] = []
def evaluate_all(self, account_id: str) -> list[dict]:
"""评估该账号的所有规则,返回触发的告警列表"""
triggered = []
now = time.time()
for rule in self.rules:
# 检查冷却期
last_trigger = self._last_trigger.get(rule.rule_id, 0)
if now - last_trigger < rule.cooldown_sec:
continue
# 从采集器拉取窗口内的数据
data = self.collector.query_range(
rule.metric_name, account_id, rule.window_sec
)
if not data:
continue
# 执行条件判断(简化版:实际可用 eval 或表达式解析器)
values = [d["value"] for d in data]
alert = self._evaluate_condition(rule, values, account_id)
if alert:
self._last_trigger[rule.rule_id] = now
triggered.append(alert)
self._alert_history.append(alert)
return triggered
def _evaluate_condition(
self, rule: AlertRule, values: list[float], account_id: str
) -> Optional[dict]:
"""执行一条规则的判断逻辑"""
# 根据不同的 rule_id 使用对应的计算方式
total = sum(values) if values else 0
count = len(values)
triggered = False
extra = {}
if rule.rule_id == "send_dropped_to_zero":
triggered = total == 0
elif rule.rule_id == "success_rate_drop":
sent_total = total
fail_data = self.collector.query_range(
"account.sent.error", account_id, rule.window_sec
)
fail_total = sum(d["value"] for d in fail_data) if fail_data else 0
rate = 1 - (fail_total / max(sent_total + fail_total, 1))
extra = {"rate": rate, "fail_count": fail_total}
triggered = rate < 0.90 and sent_total + fail_total > 10 # 至少有10条才判断
elif rule.rule_id == "error_spike":
triggered = total >= 10
extra = {"count": total}
elif rule.rule_id == "latency_spike":
sorted_vals = sorted(values)
p99_idx = int(len(sorted_vals) * 0.95)
p99 = sorted_vals[min(p99_idx, len(sorted_vals)-1)]
extra = {"p99": p99}
triggered = p99 > 30000
elif rule.rule_id == "new_contact_ratio_high":
# 这个需要额外的 unique recipient 数据
pass # 实际实现中需接入去重统计
if triggered:
return {
"rule_id": rule.rule_id,
"name": rule.name,
"level": rule.level.value,
"account": account_id,
"message": rule.message_template.format(
account=account_id,
window_min=rule.window_sec // 60,
**extra,
),
"triggered_at": time.time(),
"extra": extra,
}
return None
5. 通知渠道与收敛策略
告警产生了,接下来要解决"怎么通知"和"怎么避免刷屏"两个问题。
python
@dataclass
class NotificationChannel:
name: str
enabled: bool = True
min_level: AlertLevel = AlertLevel.WARNING # 最低通知级别
class AlertNotifier:
"""告警通知分发器"""
def __init__(self):
self.channels: dict[str, callable] = {}
self._recent_alerts: deque = deque(maxlen=200) # 最近告警历史
self._dedup_window_sec = 300 # 5分钟内相同内容不重复通知
def register_channel(self, name: str, handler: callable, min_level: AlertLevel):
"""注册一个通知渠道"""
self.channels[name] = {
"handler": handler,
"min_level": min_level,
}
def notify(self, alert: dict):
"""分发一条告警到所有符合条件的渠道"""
level = AlertLevel(alert["level"])
# 收敛检查:5分钟内同样的 rule+account 组合是否已发过
dedup_key = f"{alert['rule_id']}:{alert['account']}"
recent_keys = [
f"{a['rule_id']}:{a['account']}" for a in self._recent_alerts
if time.time() - a.get("triggered_at", 0) < self._dedup_window_sec
]
if dedup_key in recent_keys:
return # 跳过重复通知
for ch_name, ch_config in self.channels.items():
ch_level = ch_config["min_level"]
# 只有告警级别达到渠道的最低门槛才发送
if self._level_ge(level, ch_level):
try:
ch_config["handler"](alert)
except Exception as e:
print(f"[告警通知失败] 渠道 {ch_name}: {e}")
self._recent_alerts.append(alert)
@staticmethod
def _level_ge(a: AlertLevel, b: AlertLevel) -> bool:
"""比较告警级别高低"""
order = [AlertLevel.INFO, AlertLevel.WARNING,
AlertLevel.CRITICAL, AlertLevel.EMERGENCY]
return order.index(a) >= order.index(b)
# ---------- 具体通知渠道实现 ----------
def log_channel_handler(alert: dict):
"""日志渠道:写入文件"""
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
line = f"[{ts}] [{alert['level'].upper()}] [{alert['account']}] {alert['message']}\n"
with open("alerts.log", "a") as f:
f.write(line)
def console_channel_handler(alert: dict):
"""控制台渠道:打印到 stdout"""
emoji = {"info":"ℹ️","warning":"⚠️","critical":"🔥","emergency":"🚨"}
print(f"{emoji.get(alert['level'],'')} [{alert['level'].upper()}] "
f"{alert['account']}: {alert['message']}")
# 示例:注册渠道
notifier = AlertNotifier()
notifier.register_channel("log", log_channel_handler, AlertLevel.INFO)
notifier.register_channel("console", console_channel_handler, AlertLevel.WARNING)
# notifier.register_channel("slack", slack_webhook_handler, AlertLevel.CRITICAL)
# notifier.register_channel("sms", sms_gateway_handler, AlertLevel.EMERGENCY)
收敛三原则:
| 策略 | 做法 | 效果 |
|---|---|---|
| 冷却期 | 同一条规则触发后 N 秒内不重复通知 | 避免同一问题狂刷 |
| 去重窗口 | 相同 rule+account 的告警在 M 分钟内合并为一条 | 减少噪音 |
| 分级路由 | 低级走日志,中级走群消息,高级才推手机/电话 | 让重要信息不被淹没 |
6. 告警运维:抑制、升级与闭环
一个能用的告警系统不只是"发现问题并通知",还需要处理整个生命周期:
告警产生
↓
① 抑制检查(维护窗口?已知问题?)→ 是则丢弃
↓ 否则
② 分发通知
↓
③ 等待确认(人工 ACK / 自动恢复)
├─ 30min 内无人处理 → 升级(通知更高级别的人)
├─ 15min 内自动恢复 → 自动关闭 + 发送恢复通知
└─ 人工确认处理 → 关闭 + 记录处理结果
↓
④ 归档分析(每周汇总:哪些告警最多?平均修复时长?)
python
@dataclass
class AlertLifecycle:
"""告警生命周期管理"""
alert_id: str
status: str = "firing" # firing / acknowledged / resolved / suppressed
fired_at: float = field(default_factory=time.time)
acknowledged_at: float = 0
resolved_at: float = 0
acknowledged_by: str = ""
resolution: str = ""
escalation_level: int = 0 # 升级次数
class LifecycleManager:
def __init__(self, db_path: str):
self.db_path = db_path
self._init_table()
self._suppression_rules: list[dict] = [] # 维护窗口等抑制规则
def should_suppress(self, alert: dict) -> bool:
"""检查是否应该在抑制期内"""
for rule in self._suppression_rules:
# 例如:全局维护窗口
if rule["type"] == "maintenance_window":
# 简化判断:如果当前在维护时段内则抑制非 EMERGENCY 级
if rule.get("active"):
if alert["level"] != "emergency":
return True
return False
def escalate(self, lifecycle: AlertLifecycle):
"""升级告警"""
lifecycle.escalation_level += 1
# 这里可以触发更高级别的通知渠道
print(f"[升级] 告警 {lifecycle.alert_id} 已升级到第 {lifecycle.escalation_level} 级")
7. 生产环境的落地经验
我们以 WAWarmer 的监控模块为例,看它的告警系统是怎么做的。
① 它的告警分三级通知
- INFO(如某账号今日首次发送成功)→ 仅写入数据库,不推送任何人;
- WARNING(如成功率降到 85%)→ 写入日志 + 看板橙色标记 + 推送到工作群;
- CRITICAL(如发送量归零或连续 429)→ 以上全部 + @值班同学 + 如果 10 分钟内未 ACK 则打电话。
这套分级确保了日常运营不会被海量 INFO 刷屏,但真正出事时一定能触达。
② 它有一个"静默期"配置
每次发布新版本、做大规模迁移或者已知会有短暂波动的时候,运营可以在界面上设置一个"全局静默 2 小时"。期间所有非 EMERGENCY 告警被自动抑制。这避免了每次变更后都有一堆误报干扰团队。
③ 它做了告警质量的周报
每周一上午自动输出一份上周告警统计:
- TOP 5 最常触发的规则(说明可能是误报,需要调阈值);
- 平均确认时长(MTTA:Mean Time To Acknowledge)和平均修复时长(MTTR);
- "幽灵告警"清单(触发后 5 分钟内自动恢复且无人处理的,说明阈值太敏感)。
这些数据直接用来优化规则参数。经过两个月迭代,它的误报率从最初的 ~40% 降到了 ~12%。
8. 小结
自动化告警系统的价值在于把"被动救火"变成"主动防御"。核心就三件事:
- 指标先行:先想清楚你要监控什么,再谈怎么告警。没指标的告警就是瞎报警;
- 分级通知:不同严重程度走不同渠道,避免"狼来了"效应;
- 持续治理:告警系统上线不是终点,而是起点。定期复盘误报率和漏报率,持续调优规则。
如果你的团队也在做多账号或多节点的运营,建议先从以下三点入手:
- 先挑 3 个最关键指标(发送量/成功率/错误数),写最简单的"超过阈值就打印"脚本;
- 加一条冷却期逻辑(同规则 15 分钟内不重复通知),这是防止刷屏的最小投入;
- 把告警记录存下来(哪怕只是一个 JSON 文件),一周后回看哪些是有价值的、哪些是噪音。
这套方案的搭建大约 2-3 个工作日。如果要接 Grafana 做可视化面板、加基于历史基线的动态阈值(不用固定阈值,而是跟自己的过去比)、或者对接 PagerDuty/钉钉/企微等企业级通知平台,都可以在这个基础上扩展。