日更流水线的防重复发布设计:台账主键、幂等判定与失败分类

做多平台自动发布,最贵的 bug 不是「发不出去」,而是「同一条内容发出去两遍」。

发不出去只是慢一点,重跑一次就行;重复发布是已经造成的事实------平台侧可能限流,粉丝侧直接看到两条一模一样的东西,而且你没法撤回。我们在跑「不可能片场」AI 短片的全网分发流水线(9 个平台 + 掘金日更),这篇文章记录我们在防重复上踩过的坑和最终定下来的三道防线。

一、重复发布是怎么发生的

先说清楚敌人长什么样。自动发布链路里,重复几乎不来自「手抖点了两次」,而来自这几种情况:

  1. HTTP 超时但服务端其实已经成功。我们的发布 HTTP 客户端默认 540 秒超时,头条那次就是 540 秒超时返回失败,服务端其实已经传完了。调用方看到失败 → 重跑 → 第二条。
  2. 进程被沙箱杀掉,输出为空。Electron 应用跑 CLI 会真的开浏览器窗口,我所在的宿主环境会把它当 GUI 进程 SIGTERM 掉,表现是「无输出 + 退出码 1」。状态完全未知,你只能重跑。
  3. 文件被改名归档后,防重复主键漂移。视频发完要归档重命名,如果主键用了当前文件名,改名之后同一条内容就成了「新内容」。

这三类有个共同点:发起方无法判断上一次到底成没成。所以防重复不能靠调用方自觉,必须在写入侧做幂等。

二、第一道防线:写入侧的幂等判定

矩阵应用内部有个 changeData({ fileName, type, item }),所有发布记录都经它落盘。它在 type === "add"fileName === "pushData" 时先查重,命中就不新增、改更新。判定函数是这个:

js 复制代码
function recordValue(value) {
  return String(value || "");
}

function isSamePushDataRecord(existingItem, newItem) {
  if (
    existingItem.textOtherName !== newItem.textOtherName ||
    existingItem.pt !== newItem.pt ||
    existingItem.textType !== newItem.textType
  ) {
    return false;
  }

  if (newItem.textType === "article") {
    return (
      recordValue(existingItem.partition) === recordValue(newItem.partition) &&
      recordValue(existingItem.articleFilePath) === recordValue(newItem.articleFilePath) &&
      recordValue(existingItem.content) === recordValue(newItem.content) &&
      recordValue(existingItem.coverPath) === recordValue(newItem.coverPath) &&
      recordValue(existingItem.category) === recordValue(newItem.category) &&
      articleTagsValue(existingItem) === articleTagsValue(newItem) &&
      recordValue(existingItem.summary) === recordValue(newItem.summary)
    );
  }

  return existingItem.selectedFile === newItem.selectedFile;
}

几个值得抄的细节:

recordValue 统一归一化。 String(value || "")undefinednull"" 三种写法全部等价。不做这步,今天传了 summary: ""、明天传了 summary: undefined,判定就不相等,去重直接失效。

主键里带 partition,不带账号名。 partition 是 Electron 的会话分区(我们这边形如 persist:不可能片场掘金),一个分区对应一套独立的 cookie。同平台换账号就是换分区,天然区分开;而账号名可能有别名、空格、大小写问题,不适合做主键。

视频和文章用不同宽度的主键。 视频只要四元组(textOtherName + pt + textType + selectedFile);文章因为文件体积小、内容可能被改,主键扩到了九项,把 content 也算进去。

查重命中之后不是简单丢弃,而是原地更新计数:

js 复制代码
const duplicateIndex = list.findIndex(
  existingItem =>
    !existingItem.scheduledTask &&
    !newItem.scheduledTask &&
    isSamePushDataRecord(existingItem, newItem)
);

if (duplicateIndex === -1) {
  list.push(newItem);
} else {
  const oldItem = list[duplicateIndex] || {};
  const attemptCount = (Number(oldItem.publishAttemptCount) || 1) + 1;
  list[duplicateIndex] = {
    ...oldItem,
    publishAttemptCount: attemptCount,
    republishCount: Math.max(0, attemptCount - 1),
    publishStatus: "publishing",
    lastPublishMessage: "重新发布中",
    lastPublishAt: Date.now(),
  };
}

...oldItem 在前,所以历史成功/失败计数被保留,只把 attempt +1、状态重置为 publishing。这样一条内容在记录里永远只有一行,重试次数一目了然,不会攒出一堆重复行。

注意 !existingItem.scheduledTask && !newItem.scheduledTask 这个条件:定时任务是允许并存的(同一个视频定时发两次是合法需求),所以定时任务不参与去重。这个取舍是对的,但要知道它存在。

三、第一道防线的两个漏洞

漏洞一:去重是按天文件做的,跨天无效。 落盘路径是:

js 复制代码
const targetDate = item.date || today;              // YYYY-MM-DD
const filePath = path.join(folderPath, `${targetDate}.json`);
const list = readDayData(filePath);                 // 只读这一天

也就是说,今天发过的东西,明天再发一遍,应用内部不会拦。这对「每天换内容的日更」没问题,但对「隔天重发同一支视频」完全失效。

漏洞二:文章主键包含 content,改一个字就失效。 如果昨天发布失败,今天改了 Markdown 里一个错别字再发,content 变了,判定为「新内容」,直接新增一条。而服务端那边,昨天的那次可能已经成功了------于是重复。

这两个漏洞都必须靠应用之外的台账来补。

四、第二道防线:外部台账

我们在流水线目录维护了一个独立的 ledger.json,结构是 publishes 对象,key 格式:

css 复制代码
juejin-article|2026-08-30|掘金
下载 (2).mp4|视频号|persist:不可能片场视频号

内容标识 | 平台中文名 | 分区 ,其中「内容标识」对视频用发布时的原始文件名、对文章用当天日期(同一天第二篇用 日期-2)。

三条铁律:

  1. 发布前必查,status 为 success 的绝不重发。 台账是防重复的唯一依据,不查台账就发布等于裸奔。
  2. 键里存原始路径,不存当前路径。 视频归档改名后,台账另记 archivedTo / currentPath 两个字段,但防重复键保持发布那一刻的原始路径不动。否则改名一次,去重就断一次。
  3. 改台账要留证据链。 下面单独说。

五、失败分类:不是所有失败都该重试

重跑之前先要判断「上一次到底属于哪种失败」。CLI 用退出码区分:

退出码 含义 处置
0 成功 记 success,结束
3 登录态异常或未登录 先补登录,别重跑(重跑必然再失败)
4 needsAttention,例如 outcome: "draft_saved" 内容已落到草稿箱,不是失败,不该重发
其他 具体错误,如 点击确认后发布弹窗仍未关闭 可重试,但要限制次数

outcome: "draft_saved" 这一类最容易被误判。它意味着平台侧已经把内容收进草稿了,此时重发一定会多出一条。看到 needsAttention: true 就应该停下来人工确认,而不是自动重试。

我们对「发布弹窗仍未关闭」这类偶发抖动的策略是:等 60 秒,最多重试 1 次。它本质是确认按钮点下去之后弹窗没及时消失,重试大概率能过,但不能无限重试------每次重试都在赌「上一次真的没成功」。

六、超时不等于失败:以服务端的记录为准

这是整套流程里最反直觉、也最重要的一条。

发布任务有个总超时,超时后应用会往记录里写 failed

js 复制代码
const timer = setTimeout(() => {
  const message = `发布超时(${min} 分钟),请检查网络或登录态`;
  updateRecord("failed", message);
  finish({ exitCode: 1, status: "failed", message, id: recordId });
}, CLI_PUBLISH_TIMEOUT_MS);

但这个 failed 是「发起方没等到结果」,不是「服务端失败了」。判定真实结果必须查应用自己写的记录:

python 复制代码
import json, datetime

rec = json.load(open(
    r"C:\Users\molan\Documents\MatrixMedia\data\pushData\2026-08-30.json",
    encoding="utf-8"))

for x in rec:
    if x["pt"] == "视频号":
        print(x["publishStatus"],
              "ok=", x["publishSuccessCount"],
              "fail=", x["publishFailCount"],
              "attempt=", x["publishAttemptCount"])
        print(datetime.datetime.fromtimestamp(x["lastPublishAt"] / 1000))

注意 pt 字段存的是中文平台名视频号),不是平台代码(sph);lastPublishAt 是毫秒时间戳,要除以 1000。再配合主进程日志里成功时打的 ✅ <平台>视频上传成功,两边时间戳对得上才算确认。

七、台账会过期:改判要留证据链

最后一点,也是最容易被忽略的:台账本身会失真

我们接手这套流水线时就遇到一条:下载 (2).mp4 → 视频号 台账记的是 failed(当时理由写的是登录失效),实际后来重试成功了,只是没人回头更新台账。如果照着台账重发,就是一条重复视频。

改判不能靠感觉,凑齐三份证据才动手:

  1. pushData/2026-08-30.json 里该条 publishStatus=successok=1 / fail=1 / attempt=2lastPublishAt=1788063519662 → 换算 12:18:39;
  2. 日志 2026-08-30.log 对应行 [04:18:34.650Z] ✅ 视频号视频上传成功(UTC,即 12:18:34);
  3. 同日志更早处 [03:31:39.885Z] ERROR 视频号登录状态已失效(11:31:39,即被记为 failed 的那一次)。

三份证据互相印证,才能把状态从 failed 改成 success。而且改的时候要把完整证据链写进 reconcileNote 字段------只改 status 的话,下次接手的人还得重新查一遍。

顺带一提,应用内部对计数的处理并不完全一致:查重分支用 ...oldItem 保留了历史成功/失败计数,而 updateRecord 是按当次 status 重新算 1 / 0。读记录时看到 attempt=12 / ok=2 这种数字,要知道它背后是多条路径共同写出来的,别拿单一字段直接下结论。

小结

防重复不是一个开关,是分层的结果:

  • 写入侧幂等isSamePushDataRecord)拦住绝大多数「同一天同内容重跑」,成本最低;
  • 外部台账补上跨天和「内容被改动过」的漏洞,代价是要手工维护;
  • 失败分类 + 以服务端记录为准保证「该重试的重试、不该重试的别乱试」。

以及一条贯穿始终的原则:任何一层的失败结论,都要能被更靠近服务端的一层推翻。调用方的超时、沙箱的 SIGTERM、退出码,都只是「没观测到成功」,不等于「失败了」。

本文来自「不可能片场」AI 短片生产线的实践记录。

相关推荐
考虑考虑16 小时前
kubectl命令
运维·后端·自动化运维
SelectDB4 天前
开源!Apache Doris 上线 Profile 可视化诊断:基于 Doris Skills 破解 AI 误诊难题
数据库·agent·自动化运维
这个DBA有点耶5 天前
数据库运维的“自动驾驶”:KES-Operator如何把DBA经验编码为软件
数据库·dba·自动化运维
1点东西25 天前
做了个AI日志平台,零代码接入,嘎嘎好用
后端·aigc·自动化运维
混沌福王1 个月前
AI 数字员工是什么 —— 《从零构建 7×24 小时 AI Agent》第一章
自动化运维
寒水馨1 个月前
Linux下载、安装PowerShell-v7.6.4(附安装包powershell-7.6.4-linux-x64.tar.gz)
linux·跨平台·自动化运维·devops·命令行·powershell·脚本语言
易样1 个月前
企业级Devops— 打造完整 CICD 实践指南(十):Ingress配置与应用
自动化运维·devops
易样1 个月前
企业级Devops— 打造完整 CICD 实践指南(九):Jenkins自动化构建
自动化运维·devops
易样1 个月前
企业级Devops— 打造完整 CICD 实践指南(八):容器化实战
自动化运维·devops