WhatsApp 账号异常检测的自动化告警系统设计

WhatsApp 账号异常检测的自动化告警系统设计

目录

  1. 为什么需要自动化告警
  2. 告警系统要覆盖哪些维度
  3. 指标定义与采集层
  4. 规则引擎与告警分级
  5. 通知渠道与收敛策略
  6. 告警运维:抑制、升级与闭环
  7. 生产环境的落地经验
  8. 小结

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/钉钉/企微等企业级通知平台,都可以在这个基础上扩展。

相关推荐
码流怪侠2 小时前
【GitHub】Bend:让 GPU 并行编程像写 Python 一样简单
python·github
2401_894915533 小时前
GEO 搜索优化完整源码从零部署:环境配置、集群搭建全流程
开发语言·python·tcp/ip·算法·unity
FORCECON14 小时前
力控SCADA驱动 :告别传统寻址,EIP协议全新适配升级
自动化·驱动·scada·监控组态软件·采集监控平台
zhiSiBuYu05174 小时前
Python3 模块开发与应用实战指南
python
龙仔7255 小时前
人大金仓OS_Core数据库自动备份实施笔记(银河麒麟Linux)
linux·数据库·笔记·备份·人大金仓
sunxr.2275 小时前
Mysql-----最后一次作业
数据库·mysql
databook5 小时前
用方差阈值过滤掉“惰性特征”
python·机器学习·scikit-learn
Freak嵌入式5 小时前
MCU 低功耗模式解析:时钟门控、电源门控、深度休眠
人工智能·python·单片机·嵌入式硬件·开源·依赖倒置原则·micropython