能活过第一周的推特监控

19 min readSocialAPI 工程团队

能活过第一周的推特监控

监控脚本属于那种东西:一下午都跑得好好的,然后悄无声息地变得不可信。

演示版就二十行:轮询一个搜索,打印新结果。真正搞垮它的不是取数,是取数周围的一切。 一周之内你就会遇到重复告警、周二那次部署留下的缺口,以及一个凌晨四点死掉、谁都没通知的进程。

这篇只讲这四种失效。不讲怎么查 X——那个另有一篇——只讲要存哪些状态,才能让它在没人看着的时候还是对的。

如果你还在纠结自建还是买现成的,免费档对比那篇更适合当起点。这篇假设你已经决定自建——有个免费 demo 端点,动笔之前可以先对一下响应结构。


会坏的四件事

失效 症状 靠什么防
没有去重状态 同一条推反复告警 持久化的 seen-ID,不是内存集合
重启缺口 部署期间一片安静,而且永远不会被发现 按查询持久化的高水位标记
无声死亡 没有告警——和"什么都没发生"长得一模一样 对"缺席"告警的心跳
告警疲劳 什么都往一个频道推,大家把它静音了 在动手之前定好分级路由

这四件事的爆发有固定顺序。 大家先修去重(因为它看得见、烦人),然后撞上重启缺口,而无声死亡往往几个月后才发现——通常是靠「怎么好久没收到告警了」这个念头。


1. 去重状态必须活得比进程长

几乎所有教程都用内存集合:

seen = set()   # 跟进程一起死

这在第一次重启之前都是对的,重启之后你的整个窗口会重新告警一遍。如果你的通知器往频道里发,所有人会再看一遍最近五十条推,然后学会无视它。

修起来很小——持久化,并且加上界:

import json, os, time

STATE = "monitor_state.json"

def load_state():
    if not os.path.exists(STATE):
        return {"seen": {}, "high_water": {}}
    try:
        with open(STATE, encoding="utf-8") as f:
            return json.load(f)
    except (json.JSONDecodeError, OSError):
        # 状态文件损坏,宁可丢弃也不要崩 ——
        # 代价是重复告警一次,而不是再也起不来
        return {"seen": {}, "high_water": {}}

def save_state(st):
    tmp = STATE + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(st, f)
    os.replace(tmp, STATE)          # 原子操作;写一半崩溃也不会截断它

def prune(st, days=7):
    cutoff = time.time() - days * 86400
    st["seen"] = {k: v for k, v in st["seen"].items() if v > cutoff}

三处细节比看起来重要。

用 os.replace 而不是原地写。原地写到一半崩溃会留下一个半截文件,然后下次启动连自己的状态都解析不了。 写临时文件再改名——改名是原子的,所以状态文件永远是「旧的」或「新的」,不会是「坏的」。

捕获解析错误然后继续。 状态文件损坏应该只花掉你一轮重复告警,而不是一个拒绝启动的监控。「出错就大声失败」的本能在这里是错的:这个进程存在的全部意义就是一直跑着。

剪枝。 否则 seen-ID 会无限涨。七天已经很宽裕了——比这更老的东西不可能再作为「新」出现。


2. 重启缺口:光去重不够

去重防的是「重复告警旧推」。它对「你宕着的时候发出来的推」完全无能为力。

这是大家发现不了的那种失效,因为它的症状是"安静"——而安静和"这段时间没人发"长得一模一样。 你 14:00 部署,进程停了 90 秒,这个窗口里落了三条推,它们就这么永远不会被告警。你的日志里什么都不会写。

你需要的是按查询持久化的高水位标记,加上一个会向后多取一截的冷启动:

import requests

API = "https://api.socialapi.tech/v1"
HEADERS = {"X-API-Key": "your_key"}

def check(query, st, backfill=50, normal=20):
    hw = st["high_water"].get(query)
    # 没有高水位 = 冷启动。多向后取一次,
    # 这样一次长时间宕机不会留下永久的洞。
    limit = normal if hw else backfill

    r = requests.get(f"{API}/search/advanced",
                     params={"query": query, "limit": limit},
                     headers=HEADERS, timeout=30)
    if r.status_code == 429:
        return []                      # 被限流:晚点再来,不要推进状态
    r.raise_for_status()

    rows = r.json().get("data") or []
    fresh, now = [], time.time()

    for t in reversed(rows):           # 旧的先处理
        tid = t["id"]
        if tid in st["seen"]:
            continue
        if hw and tid <= hw:           # snowflake ID 本身按时间排序
            continue
        st["seen"][tid] = now
        fresh.append(t)

    if rows:
        st["high_water"][query] = max(t["id"] for t in rows)
    return fresh

tid <= hw 之所以成立,是因为 snowflake ID 是按时间有序的。 你不用解析任何时间戳就得到了时间顺序比较,而且不依赖接口按任何特定顺序返回结果。这里 ID 是字符串,只要长度相同就能正确比较——19 位在可预见的未来都是如此;想严格一点就比 int(tid)。

遇到 429 直接返回、不推进高水位,这是隐蔽的那一处。如果你把限流当成「没有新推」并把标记往前挪,你就永久跳过了一个窗口。 临时失败必须让状态保持原样,好让下一轮接上。自己撞的是三种限流里的哪一种,值得搞清楚——区别在这里。


3. 对「缺席」告警,而不只是对「发生」告警

前面所有东西都是「有事就告警」。到这里还没有任何东西会在监控本身停掉时告警——而这恰恰是潜伏最久的失效,因为一个死掉的监控和一个安静的星期,产出完全相同:什么都没有。

便宜的做法是一个心跳文件加一个独立检查:

def heartbeat():
    with open("monitor_heartbeat", "w") as f:
        f.write(str(int(time.time())))
# cron, 每 15 分钟 —— 故意不放在监控进程里面
AGE=$(( $(date +%s) - $(cat /opt/monitor/monitor_heartbeat 2>/dev/null || echo 0) ))
[ "$AGE" -gt 900 ] && curl -s -X POST "$ALERT_WEBHOOK" \
  -d "{\"text\":\"monitor stale: ${AGE}s\"}"

检查必须活在被检查的进程之外。 跑在监控内部的看门狗会和监控一起死——而那正是你需要它的唯一时刻。 cron、systemd timer、外部可用性监测服务——任何有独立生命周期的东西都行。

如果你压根不想维护这套管道,服务端推送可以把轮询循环和心跳一起省掉——投递保证转移到对方那一侧。三种机制对比讲了什么时候该这么选,以及什么时候轮询确实更好。


4. 路由,要在动手之前定

这里的失效是组织性的,不是技术性的:你把所有东西都推到一个频道,大家把频道静音,监控从此变成装饰。

先把分级定下来,因为它会改变代码:

级别 例子 路由 真正在意的延迟
紧急 指定账号发了故障相关内容 电话叫人 秒
相关 竞品发布、品牌提及 团队频道 分钟
归档 所有命中宽泛词的内容 只入库,不通知 小时

归档这一级是大家会跳过的,而它恰恰是让另外两级可用的原因。 大多数命中不值得一个通知,但值得在你日后问「这事什么时候开始的?」时还查得到。写进存储、不通知任何人、需要时再查。 这也是让你敢把「紧急」级收紧的底气——把流存下来和对它告警是两件不同的活。


拼起来

def main(queries, interval=60):
    st = load_state()
    while True:
        for q in queries:
            try:
                for t in check(q, st):
                    route(q, t)                 # 上面那套分级
            except requests.RequestException as e:
                print("fetch failed:", q, e)    # 一个坏查询不拖垮循环
        prune(st)
        save_state(st)                          # 整轮之后存,不是每条推都存
        heartbeat()
        time.sleep(interval)

整轮之后存,不要每条推都存。 每条推都存只会让磁盘写入翻倍而没有收益——如果你在一轮中间崩掉,最坏情况是这一轮重复告警,而这个下一轮的去重会处理掉。

按查询捕获异常,不是按整个循环。 一个格式错误的查询不该让另外五个查不了。这是「会降级的监控」和「会停掉的监控」之间的区别。

全部就是这些:持久化去重、只在成功时推进的高水位、外部心跳、以及提前定好的路由分级。大概八十行,而这就是「能演示的脚本」和「你敢让它一直跑的东西」之间的差距。

我们的 API 返回的就是上面用到的结构,而且限流是摊在账号池上而不是绑死单一凭据——这一点对监控场景尤其要紧,因为你是在按固定节奏永远发同一个请求。 用 Google 一键注册送 $0.20 额度、不用信用卡,够你拿真实数据先跑一遍再决定。


常见问题

监控推特最好的方式是什么? 大多数场景:定时轮询 + 持久化去重状态。只在真的在意个位数秒级时才加推送——否则你是为可能不需要的延迟增加了一堆活动部件。

为什么我的监控同一条推告警两次? 你的 seen 集合在内存里、重启后没了,或者你是按文本而不是按推文 ID 去重。持久化 ID 并按 ID 做键。

怎么避免重启期间漏推? 存一个高水位标记——每个查询你处理过的最新推文 ID——和去重状态放在一起。冷启动时多取一个大窗口,这样一次宕机不会留下永久缺口。

什么是高水位标记? 你成功处理过的最新一条。小于等于它的都已经做完了。对 X 来说就是推文 ID,它本身就能直接当时间顺序标记,因为 snowflake ID 是按时间有序的。

能靠比较推文 ID 来排时间顺序吗? 可以——snowflake ID 内嵌时间戳,ID 越大越新。按整数比,或者按等长字符串比。这样完全不用解析时间戳。

该多久轮询一次? 60 秒覆盖大多数监控场景。真正的问题不是你能多快轮,而是一条告警可以陈旧到什么程度才算没用——按这个定间隔,然后确认页大小能覆盖两次轮询之间的爆发。

怎么知道我的监控死了? 让它写一个心跳时间戳,然后用独立进程(比如 cron)检查那个文件。死掉的监控和安静的时段从外面看一模一样,所以你必须显式地对「缺席」告警。

为什么看门狗要是独立进程? 因为跑在监控内部的看门狗会和监控一起死——正好是你需要它的那一刻。用 cron、systemd timer 或者外部可用性服务。

状态该存文件还是数据库? 单进程用 JSON 文件就够。多个 worker 共享状态时,或者 seen 集合大到每轮重写文件都浪费时,再换 Redis / Postgres。

怎么防止 seen-ID 集合无限增长? 按时间剪枝——丢掉七天以上的条目。比这更老的不可能再作为新的出现。

状态文件坏了怎么办? 捕获解析错误、从空状态开始,不要崩。代价是重复告警一个窗口,这比一个起不来的监控好得多。用原子写(临时文件 + 改名)让损坏本身变得罕见。

限流之后该推进高水位吗? 不该。临时失败必须让状态保持原样,否则你永久跳过那个窗口。直接返回、什么都不改、下一轮重试。

监控循环里怎么处理限流? 把 429 当成「晚点再来」:跳过这一轮且不推进状态,然后退避。不要把它当成空结果。

为什么第一次跑会收到一堆旧推的告警? 冷启动带大回溯窗口会把已有的命中全捞出来。要么第一轮只播种高水位而不告警,要么接受一次吵闹的启动。

一个监控能带多少个关键词? 这是请求预算问题,不是代码问题——每个查询每轮都是一个独立请求。20 个查询按 60 秒间隔 = 每天 28,800 次请求,加查询之前先算这笔账。

每个关键词都要自己的高水位吗? 要,按查询分。不同查询返回不同结果集,共用一个标记会导致某个查询跳过另一个查询已经推进过的推文。

能对同一批关键词跑多个监控吗? 除非共享状态,否则它们会各自独立地重复告警。要么一个进程负责一组查询,要么用支持原子更新的共享存储。

怎么避免告警疲劳? 动手之前先定级:叫人、通知、还是静默归档。归档这一级才是让另外两级可用的原因——大多数命中值得存下来,但不值得打断任何人。

监控循环该记什么日志? 每轮的计数、取数失败(带上是哪个查询导致的)、以及状态保存确认。够你事后回答「它当时在跑吗?它找到东西了吗?」就行。

接口报错该让循环崩掉吗? 不该。按查询捕获,这样一个坏查询不会停掉其他的,记下日志然后继续。会降级的监控胜过会停掉的监控。

监控用轮询还是推流更好? 轮询更简单、重启自动恢复;推流延迟更低但你拥有一条迟早会断的连接。很多生产环境两条都跑——推流拿速度,低频轮询补完整性。

怎么监控某个具体账号而不是关键词? 同一个循环,换端点——用用户时间线而不是搜索。去重、高水位、心跳都不用改。

这个需要 X 开发者账号吗? 通过第三方 API 读公开数据不需要,你用的是服务商的 key。官方开发者账号是给写操作和官方端点用的。

冷启动能回溯多远? 取决于你的页大小和套餐。选一个能覆盖你现实中最坏宕机时长的窗口——这样缺口是有界的,而不是开放式的。

不跑服务器能做监控吗? 可以,用服务端推送到 serverless 函数或者 Zapier 这类工具。你用「失去对重试的控制」换「不用维护进程」。


监控是最吃「按请求计费 + 限流摊在池上」这两点的场景,因为你是在无限期地按固定节奏发同一个请求。与其听我们说,不如自己测:用 Google 注册送 $0.20 额度,不绑卡。