能活过第一周的推特监控
能活过第一周的推特监控
监控脚本属于那种东西:一下午都跑得好好的,然后悄无声息地变得不可信。
演示版就二十行:轮询一个搜索,打印新结果。真正搞垮它的不是取数,是取数周围的一切。 一周之内你就会遇到重复告警、周二那次部署留下的缺口,以及一个凌晨四点死掉、谁都没通知的进程。
这篇只讲这四种失效。不讲怎么查 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 额度,不绑卡。