← 随机比特 / 所有内容

pool cleaner three defenses

2026-04-26 · 随机比特

凌晨 4 点告警:100 个 OpenAI 账号 74 个挂了。脚本拒绝清理,救了池子

3 月 28 日凌晨 4 点,运维脚本在它的常规轮次里跑出来一行字:

[2026-03-28 04:00 UTC] Pool scan: 100 accounts
  account_deactivated: 74 ← DELETE
  insufficient_quota: 19
⚠️  SAFETY: 74% of pool would be deleted (>50%). Likely an upstream outage. Aborting.

100 个账号里 74 个被服务端返回了 account_deactivated。如果按字面执行,这一轮 cleanup 会一刀切掉池子的 74%,剩 26 个。

按字面执行就是错的——脚本拒绝了。

接下来 18 小时,每 6 小时一次,同样的 100 个账号、同样的 74 个 deactivated、同样的拒绝。直到第 4 次扫描,剩 26 个。然后剩 28、剩 36、剩 67……再后来一个新批次涌进来又是 158 个、又是 343 个,每批新号里又是 50% 以上被服务端打成 deactivated。

整整一周,脚本拒绝执行,池子保住了。

如果当时脚本"按错误码诚实清理",整个池子会在 6 小时内清零。

这套保护池子不被自己的清理脚本误删的设计,是这个项目跑了一年最值钱的部分。三道防线,今天我把它拆开讲。

为什么"按错误码诚实清理"是错的

最朴素的写法是:

for account in pool:
    if check(account) in {'account_deactivated', 'token_invalidated'}:
        soft_delete(account)

这套写法在 99% 的常规日子工作良好——每天清掉 1-15 个真实失效的号,池子稳定在 70-130。

它在 1% 的日子会把池子杀光。

那 1% 是什么?是上游服务自己抽风:负载尖峰、风控批量误伤、token 检验接口偶发性返错码。表象都是 account_deactivated,但实际账号没死。

直接相信这个错误码 → 一次扫描清掉池子大半 → 下一次扫描发现池子空了 → 再补号也来不及,业务这段时间已经全部 503。

更阴的是:这种"上游集体抽风"不是罕见 bug,是月度级别会发生的事。所以你不能祈祷遇不到,必须设计成"遇到了不出事"。

第一道防线:单次清理超过 50% 直接拒绝

SAFETY_THRESHOLD = 0.5

if len(to_delete) / len(pool) > SAFETY_THRESHOLD:
    print(f"⚠️  SAFETY: {pct}% of pool would be deleted (>50%). Aborting.")
    return  # 不删,不报错,仅记录

这条规则的意思是:单次清理影响超过半个池子,就当我没看见

它的代价是真出大批量失效时(确实有这种时候——服务商定期刷一波 ToS 违规),第一次扫描不会清,要等连续多次确认。用一次延迟,换一次整池保护——这个交换永远值得。

为什么阈值是 50%,不是 80%、90%?因为关键不是"百分比多少",是"上限多少"。一次清理上限设在 50%,意味着即便最坏情况,第二天池子还有一半,业务可以继续跑、可以补号。如果设 90%,意味着剩 10%,业务直接断。

写自动化清理时,第一个该问自己的问题不是"什么该删",是"删错的话池子能不能撑到我下次能干预"

第二道防线:连续 3 次同一批被标记,才执行

第一道防线挡掉了"瞬时上游抽风"。但确实存在"真的批量失效"的情况——服务商集中封号、IP 段被风控、月底批量配额清零。这些不是抽风,是真的死了。

怎么区分?用时间

STATE_FILE = '.pool-cleaner-state.json'
CONSECUTIVE_SCANS_TO_FORCE = 3

state = load_state()  # {prev_flagged: [id1, id2, ...], count: int}

if set(to_delete) == set(state['prev_flagged']):
    state['count'] += 1
else:
    state['count'] = 1
    state['prev_flagged'] = to_delete

if state['count'] >= CONSECUTIVE_SCANS_TO_FORCE:
    # 同一批账号被标记 3 次,这是真死了,不是 outage
    soft_delete(to_delete)
    state = reset_state()
else:
    print(f"Consecutive blocked scans: {state['count']}/3")

关键不是"等多久",是等"同一组账号"被 fatal 标记多少次

如果是上游抽风,第二次扫描时:抽风可能已经恢复(标记的账号变了 / 数量变了),set(to_delete) != set(prev_flagged),counter 重置回 1。

如果是真集体失效,每次扫描标记的都是同一批 ID,counter 累加,到 3 触发 force-delete。

实测下来:3 次(每次间隔 6 小时,共 18 小时)足够区分大部分上游抖动。4 月初有一次 134 个账号里 119 个被标记,就是连续 3 次都是同一组 ID,第 3 次触发 force-delete,剩 15——那次是真的批量封号,不是 outage。

第三道防线:永远 soft-delete,不真删

def soft_delete(account_id):
    db.execute("UPDATE accounts SET deleted_at = NOW() WHERE id = %s", account_id)
    # 不要:DELETE FROM accounts WHERE id = %s

物理删除是不可逆的。Soft-delete 只设一个时间戳。

意义在于:前两道防线都被绕过的极端情况,soft-delete 给你最后一根稻草。脚本 bug 把好账号删了,看一眼数据库,UPDATE accounts SET deleted_at = NULL WHERE deleted_at > '2026-03-28' AND ...,5 秒钟全恢复。

这条规则的代价几乎为零(数据库多个时间戳字段而已),收益是把"灾难级 bug"变成"用 SQL 5 秒能修的小事"。

任何写 / 删 / 改用户数据的自动化脚本,都该有这一层。包括 cron 跑的备份清理、用户隔离、订单关闭、消息撤回——所有"批量改写"操作。

频率怎么定:6 小时一次,不是 1 小时一次

crontab 是这样的:

0 */6 * * * /opt/sub2api-deploy/pool-cleaner.py --execute --quiet

不是 1 小时一次。理由有三个:

一、给上游抖动留恢复时间。上游服务的故障窗口经常是 30-90 分钟。1 小时一次扫描,故障期间能扫到 1-2 次,counter 容易累积。6 小时一次,单次故障最多扫到 1 次,counter 不会误触发。

二、给"同组 × 3 次"留窗口。3 次 × 6 小时 = 18 小时。这是把"误判到正解"的最长延迟控制在小一天内。如果是 1 小时 × 3 次 = 3 小时,遇到上游 90 分钟故障就有 3 次都同组——counter 直接到 3,force-delete 触发,池子炸了。频率太高反而绕过第二道防线

三、给运维留人工干预窗。SAFETY 第一次触发时会写日志,6 小时一次的频率意味着收到告警后还有 12 小时(next 2 个扫描周期)可以人工查清楚再决定。1 小时一次的话,等你看到告警,第 3 次扫描已经触发 force-delete 了。

频率不是越高越好。频率本身是一道防线

池子规模的真实曲线

跑了一年,池子规模在这几个值之间跳:

平时:    70-130
事件期:  突降到 15-26(连续大批失效后剩下的)
补号期:  快速涨到 300-400(一次性补)
稳定后:  回落到 70-130(自然损耗 + 新号补充)

最低点 15。最高点 394。今天 91。

这个跨度很说明问题:池子不是恒定的,是动态平衡。脚本设计时如果假设"每天清几个号、池子稳定 100",就会在 03-28 那种 74/100 一下死的日子崩掉。

真实情况是池子会有"事件 + 补号"周期,规模 5-20 倍波动是常态。任何脚本逻辑都不能假设池子大小恒定——因为它不恒定。所以 SAFETY 用百分比(>50%)而不是绝对数字(>100),FORCE 用同组比对而不是绝对计数——参数都是相对值不是绝对值。

三道防线的真实含金量

把三道防线放一起:

防线 挡的是什么 代价
单次 ≤50% 上游瞬时抽风导致的整池清空 真大批失效时延迟一次扫描
同组 × 3 次 持续抖动 vs 真失效的判别 真失效要等 18 小时(3 次 × 6h)
Soft-delete 前两道全失守的兜底 数据库一个时间戳字段

跑了一年下来,这三道防线触发了至少 5 次——每一次如果按字面错误码删,池子要么清零、要么剩个位数。每一次脚本自己说"我不删",再补号、再排查,业务平稳过去。

最值钱的判断:自动化清理脚本最大的风险,不是漏删一些该删的,是误删一批不该删的。前者顶多池子里多一些没用的号,每次扫描多花几秒;后者直接断业务,恢复要小时级别。

写在最后

跑代理池一年,最不愿意让人看到的不是池子有多大、不是 RPS 有多高,是清理脚本里那一段拒绝执行的逻辑

那段逻辑看起来像是"脚本不工作"——实际上它工作得最好的时候,就是它什么都不做的时候。

任何在写"批量验活"、“批量清理”、"批量重置"的自动化系统的人,下一行代码先问自己一句:这个动作如果错了,池子还活得下去吗?