
问题背景### 项目概况去年秋天起我维护一套多平台内容自动发布工具每天早上定时把前一晚备好的内容分发到若干社交平台。整套东西规模不大单机部署一个任务表、一条发布链、几张平台凭证。跑前两个月一切正常晨报安静地躺在各平台时间线上没人需要为它操心。九月中旬的一个周四运营同事在群里甩来两张截图同一段文案加同一张配图在某平台的时间线上并排出现了两条发布人都是机器人账号两条之间隔着十几秒。她配了一句话“机器人精神分裂了。“我当时正在吃早饭盯着截图看了半天倾向于先把事情想简单多半是人手抖点重了。结果查了操作审计那台管理机的登录记录里当天早晨没有任何人工触发。机器自己在半夜醒过来把同一句话讲了两遍。### 发布链路发布链不长画在白板上四个箭头就能装下调度器按平台排班表生成任务写进本地 sqlite 任务表工作进程从任务表领任务做文本校验、图片转码、敏感词检查通过后组装 HTTP 请求提交到平台开放接口平台落库后回一个内容 ID进程把状态机推到已发布”附上回执时间。每个环节都有日志日志按平台分文件滚动。问题环节在第三步和第四步之间提交请求设了 10 秒超时进程内建了一套通用健壮性惯例——失败重试至多 3 次间隔 4 秒。这套惯例是我抄自内部 SDK 模板的十处写操作九处套它从来没出过事。出事需要一种特定条件服务端收下并处理成功但响应在回程路上死掉让客户端以为失败。### 重复事故重复内容出现后的半小时内我先把该平台排班冻结避免它按自己的节奏再发一轮然后开始排查。值得记下这一句事后发现那两条内容除了时间戳文本与图片指纹完全一致连换行位置都没有差别。人手复制粘贴很难做到字节级一致“完全一致这个特征本身就在暗示同一个程序、同一份数据、跑了两次。## 排查过程### 先怀疑重复触发重复内容直觉上的解释是触发器跑了两次”。调度器用的是 APScheduler每个平台一条 cron 表达式任务落库前按日期加平台加内容 ID做主键去重。我先怀疑调度器单例失效进程被守护脚本拉起重启过几次如果调度器不是单例重启窗口里新旧两个实例各触发一轮就会出现两条内容。查法很笨但有效。调度触发时往日志里打一行心跳带进程 PID 与调度器实例 ID。把那台机器上近三十天的调度日志拉出来按 PID 聚合当天早晨六点到七点之间只有一个进程活着只有一个调度器实例在响六条排班各自触发一次一次不多。这条线是干净的。重复触发假设排除。$ grep SCHED-TICK publish-20260917.log | awk {print $3, $4} | sort | uniq -c 1 PID21344 SCHEDmaster-01 1 PID21344 SCHEDmaster-01 ... 6 PID21344 SCHEDmaster-01 # 六条排班,同一进程,各一次### 再怀疑队列重复投递第二嫌疑任务表消费逻辑有洞同一行被领了两次。工作进程的行为是领任务、置 running、干活”。如果领任务语句与置状态之间没有锁两个 worker 并发拉取理论上可以抢同一行。当天 worker 数量是 1这个假设需要先回答哪里来的第二个消费者。查任务表状态机。每条任务有状态列与重试计数列每次领取都会自增计数并落一行审计。把当天六条任务的审计流水拉出来涉事那条任务计数为 1状态流转 pending 到 running 到 submitted没有回头路也没有第二条流水。队列重复投递假设同样干净。排除。sqlite SELECT task_id, status, attempt, ts FROM tasks ... WHERE content_id C-0916-11 ORDER BY ts;task_id status attempt tsT-4471 pending 0 06:00:01.204T-4471 running 1 06:00:01.218T-4471 submitted 1 06:00:13.997### 回到发布日志两条排除之后回到第三步的日志。发布日志里六条任务各有一次提交动作唯独涉事任务留下了两行提交记录间隔正好 4 秒——与重试间隔吻合。第二行后面跟着一个 200 响应与内容 ID说明重试成功拿到了回执。第一行后面跟着的是超时报错客户端在 10 秒整放弃等待打印请求超时进入重试。到这一步链条闭合了一半任务只跑一遍提交却发生两次两次都发生在同一次任务执行内部是重试逻辑自己追加的那一次。还剩一半要回答为什么第一次超时了平台那边却已经收下这就要拉时间线。[06:00:01] publish: submit contentC-0916-11, timeout10s[06:00:11] publish: TIMEOUT after 10.00s, will retry (1/3)[06:00:15] publish: submit contentC-0916-11 (retry 1)[06:00:17] publish: OK http200 id8f2a... - 第二次才拿到回执## 根因确认12 秒### 时间戳对质从平台侧接口拉回这两条内容的时间戳做减法。这里踩了一个小坑平台返回的是秒级精度本机日志是毫秒级对齐时要留意取整。两条内容的创建时间相差 12 秒减去重试间隔 4 秒第一次失败的请求发出时间与第二次成功请求的响应落定时间刚好罩在 10 秒超时线两侧。翻译成人话请求发出后第 10 秒客户端先挂电话而平台在第 11 秒处理完、第 12 秒把内容挂上时间线。客户端与平台之间没有任何一方撒谎只是各说各话——客户端的字典里超时等于失败平台的字典里没有失败这一项它只是慢。platform: post#1 created_at 06:00:12platform: post#2 created_at 06:00:24diff 12s timeout(10s) backoff(4s) - jitter(2s)### 超时的语义超时是一种本地判断不是远端事实。HTTP 语义里客户端放弃等待不产生任何撤回效力请求可能死在路上可能服务端还没开始处理也可能服务端已经处理完、响应死在回程路上。三种可能里只有一种重试是安全的而朴素重试对三种可能一律重发赌的就是服务端没处理。读接口赌错了无所谓再拉一次就是写接口赌错了就是两条一模一样的内容挂在时间线上让运营在群里发截图。这次事故没有造成账号异常平台只是平平静静地把两条都收下了——这恰恰是写接口没有幂等保护时的常态。真正可靠的防线只能建在调用方自己身上。## 三层修复### 层一提交前查重成本最低的一招放在每次提交动作之前拉取本账号近期若干条已发布内容的文本指纹与待提交内容比对命中即停。这一层防不住提交动作内部的重复——它发生在提交之前——但它防得住一切账号层面的重复排班表改坏、人工误触、迁移事故。查重用滚动哈希比对正文加图片摘要文本改一个字都会让指纹漂移误杀率可以接受。pythondef dedup_before_publish(acct, item, api): recent api.list_posts(acct.id, limit20, fields(body, media_hash)) fp fingerprint(item.body, item.media_hash) for p in recent: if fp fingerprint(p.body, p.media_hash): raise AlreadyPublished(f命中近期已发 post_id{p.id})窗口取 20 条是个经验值按日更频率覆盖近一周多。平台接口对列表拉取有分页上限取 20 条一次调用够用。它不是万能的——如果重复发生在窗口之外或者平台本身允许同文重发这一层就漏。所以它只是外围栅栏不是承重墙。### 层二本地幂等键承重墙在本地。为每次提交计算幂等键内容 ID 拼接计划发布日再做一次哈希写入本地 sqlite 的幂等表。提交前先查表有记录就直接复用已知的回执不再发请求提交拿到成功后把键与回执 ID 一起落库。表结构如下sqlCREATE TABLE idempotency ( idem_key TEXT PRIMARY KEY, -- hash(content_id planned_date) content_id TEXT NOT NULL, post_id TEXT, -- 平台回执,超时待确认时为空 state TEXT NOT NULL, -- pending / submitted / confirmed created_at TEXT NOT NULL);关键是提交之前先插 pending 行而不是成功之后才记。超时发生时pending 行已经存在重试逻辑看到 pending 就知道上一次提交命运未卜转而走确认流程。这个顺序设计是把重试前先查从口号变成代码的唯一办法。pythondef submit_with_idem(acct, item, api): key idem_key(item.content_id, item.planned_date) with tx() as db: row db.get(key) if row and row.state confirmed: return row.post_id # 复用回执,绝不重发 if row and row.state pending: return confirm_or_retry(db, acct, item, key, api) db.insert(key, item.content_id, statepending) return do_publish(db, acct, item, key, api)### 层三超时与拒绝分开处理以前的重试逻辑不区分失败类型一张清单包打天下超时、连接断开、5xx、429统统退避重发。改法是把失败分成两族明确的拒绝4xx 业务错误、平台返回了错误码可以直接重发或放弃命运未卜的超时与断连先走查询确认——用本地暂存的草稿指纹去拉账号近期内容列表比对确认平台没有收下才允许重发确认收到了就把回执补进幂等表任务直接标成功。pythondef do_publish(db, acct, item, key, api): try: resp api.publish(acct, item, timeout15) except (Timeout, ConnAbort) as e: known confirm_after_timeout(db, acct, item, api) if known: db.confirm(key, known) return known raise Retryable(e) # 确认没收到,才允许再发 except Rejected as e: raise Permanent(e) post_id resp[id] db.confirm(key, post_id) return post_idpythondef confirm_after_timeout(db, acct, item, api): for _ in range(3): time.sleep(5) # 给平台一点落库时间 recent api.list_posts(acct.id, limit20, fields(body,)) for p in recent: if fingerprint(p.body) fingerprint(item.body): return p.id return None三层叠起来的效果层二保证同一键最多提交一次层三保证超时不等于失败层一兜住一切上游逻辑漏洞造成的账号级重复。任何一层单独拿出来都不够看叠在一起才把 12 秒窗口焊死。## 泛化写接口的通病### 其他接口上的同坑这次的教训不只属于发布接口。工具链里所有写操作都共用同一份重试模板创建草稿、上传素材、更新排班、删除内容。逐条过了一遍凡是服务端处理有副作用、响应可能丢的接口全部纳入幂等表管理纯读接口维持原样读重试不产生副作用错了再来。一张表记录处置| 接口 | 副作用 | 是否纳入幂等 | 理由 ||------|--------|--------------|------|| 发布内容 | 产生公开内容 | 是 | 重发即重复事故 || 上传素材 | 占用存储配额 | 是 | 重传产生冗余素材 || 更新排班 | 覆盖既有配置 | 是 | 乱序回写会覆盖新值 || 拉取列表 | 无 | 否 | 读操作可安全重试 || 删除内容 | 不可逆 | 是 | 见下文 403 教训 |### 客户端生成 ID平台开放接口大多不提供携带客户端幂等令牌的能力能提供的也不叫这个名字用法各异。对能改的自建服务端标准答案是客户端为每个业务动作生成一个稳定 ID随请求一起提交服务端按键去重重复提交返回首次结果。这样超时之后无论重试几次网络上的请求重复了逻辑上的动作永远只有一次。这套工具的发布链里“内容 ID 加日期就是那个稳定 ID幂等表就是它的本地投影。### 对不能改的服务端先查后发是可靠的闸第三方平台改不动重试策略就只能围着先查后发设计任何写操作之前先用读接口确认操作是否已生效。这招的局限也要诚实写下来查询接口与写接口之间没有事务读到没发生与真正去写之间仍有窗口窗口比裸重试小得多但不为零。所以对高危动作公开可见、不可逆、涉账号安全先查后发之上还要叠人工确认开关。闸门分三级自动重试、自动确认、人工放行越靠近不可逆自动成分越少。## 踩坑与教训### 重试是把双刃剑回看事故起点那套至多重试 3 次的模板本身没有错错在它被无差别地套在所有接口上。重试的价值建立在失败等于什么都没发生这个假设上而真实世界里超时恰恰是这个假设破产的场景。健壮性代码往往在最需要它的边界条件上翻车网络正常时它沉默网络抖动的瞬间它替你做了决定。从那以后写模板文档里加了一行硬性要求为写接口套用重试模板前先回答如果这次请求其实成功了重发会发生什么”。答不出来就不许套。### “删除失败被忽略的坑修复完成后的第一次人工复核要删掉那两条重复内容里的后发者。删除接口的调用脚本写得很随手pythondef cleanup(dup_post_id, token_fileNone): r requests.delete( fhttps://api.example-platform.com/v2/posts/{dup_post_id}, headers{Authorization: Bearer read_token()}, timeout8, ) print(r.status_code) return r.json()第一次运行返回 403脚本所在的执行环境没带平台授权头删除属于写操作平台直接拒绝。更糟糕的是脚本没检查状态码上层调度把执行完毕当成删除成功”排班恢复后误以为现场已清理第二天又叠出一条新的重复。补了状态码断言、错误体解析与删除后回读列表确认消失的闭环才真正清干净。教训两条所有写操作的返回码必须当作数据来检查不能只检查脚本没抛异常清理动作也要幂等——删除前先按列表查询确认目标存在删完回读确认消失两头各焊一道确认。### 日志时区复盘时间线时踩到的杂鱼坑平台回执用 UTC 零时区本机日志跟系统时区取数脚本没做换算一度把 12 秒的差值算成几分钟量级白白排查了一条不存在的时间漂移线。统一时间戳格式与参考系这种话说了无数遍轮到自家脚本还是漏。从此所有日志行强制 ISO8601 带时区偏移取数工具链入口处统一转换中间环节禁止裸用本地时间。## 验证与回归### 压测与断网演练上线前做了一轮回归取测试账号用 tc netem 注入 12 秒固定延迟、20% 丢包让发布请求处于必超时、时真时假的混沌区跑 10 轮。结果0 条重复2 轮走了超时后确认分支——平台侧实际已落库客户端拉列表比对命中任务直接标成功而没有重发其余 8 轮正常提交。幂等表里 pending 到 confirmed 的流转完整没有残留 pending 行。$ tc qdisc add dev eth0 root netem delay 12000ms loss 20%$ python tools/stress_publish.py --rounds 10 --acct test-01rounds10 duplicates0 confirm-branch2 leaked-pending0### 观察指标修复不是一锤子买卖之后靠两个指标长期盯一是幂等表里 pending 状态滞留时长超过 5 分钟说明确认流程有洞二是账号级查重命中次数命中即报警因为每一命中都意味着某层防线本该拦住却没拦住需要复盘。上线一个月pending 滞留均为零查重误报两次——都是人工在平台后台重发了同文内容反而说明层一工作正常。## 相关实现本文方案来自一套真实运行的多平台内容自动发布工具三层去重、本地幂等表、超时确认分支与断网回归演练都是这套工具在真实事故之后长出来的部件。形态与实现细节见 dingdang.asia/本文所有代码片段与日志均为脱敏改写。