同一个脚本,为什么不能跑第二遍:发布流水线的幂等性设计
同一个脚本,为什么不能跑第二遍:发布流水线的幂等性设计
凌晨一点,我把博客园的发布脚本跑了两遍。
第一遍成功了。第二遍是因为不确定第一次到底有没有生效——页面上没有任何"发布成功"的反馈,我不想赌。于是脚本又灌了一遍正文,又点了一次发布。
结果你猜到了:文章正文字数变成了 10468,而我的源文件是 5234 字。
正好两倍。
这不是 bug,这是幂等性缺失。同一个操作执行两次和执行一次,结果应该一样;而我这个脚本执行两次,结果是执行一次的两倍。
一、三个平台,三种性格
同一个晚上,我在三个平台上各撞了一次"重跑"问题,但表现完全不同。
博客园:重复即污染。 灌正文的脚本第二次执行时,没有先清空编辑器,直接把同样的 5234 字又贴了一遍。服务端老老实实存了 10468 字。发布也成功了——一篇正文重复两遍的文章堂而皇之上了线。
简书:重复即孤儿。 它的建稿接口是 POST,每次调用都会新建一篇草稿。我第一次跑,建了稿 A;第二次跑,建了稿 B。现在 A 和 B 内容一样,都在草稿箱里躺着,只有一个能发出去,另一个成了没人管的孤儿。
CSDN:重复即自愈。 它的打标签脚本我跑过至少五次。第一次因为标签输入框没弹出来失败了,第二次带上文章 ID 重跑,好了;后来想改标签,又跑,又好了。这个脚本每次执行都先导航到指定文章、先读当前状态、再做动作——它天生就是幂等的。
同样一套自动化思路,在三个平台上呈现出三种截然不同的性格。区别不在代码量,在有没有为"重跑"做过设计。
二、幂等是什么,为什么这里特别重要
幂等(idempotent)的定义很朴素:
f(x) 执行一次的结果 == f(x) 执行 n 次的结果
HTTP 规范早就把这个概念用在了方法语义上:GET、PUT、DELETE 被定义为幂等的——重复调用不会有额外副作用;POST 不是——每次调用都可能创建一个新资源。
自动化脚本恰好是 HTTP 方法的放大版。把发布流水线拆开,每个动作其实都能对到某个方法上:
| 动作 | 本质 | 幂等? |
|---|---|---|
| 读文章当前正文 | GET | ✅ 幂等 |
| 灌正文(先清空再写) | PUT | ✅ 幂等 |
| 打标签(设成固定集合) | PUT | ✅ 幂等 |
| 建新稿 | POST | ❌ 不幂等 |
| 点发布 | POST | ❌ 不幂等 |
| 上传图片 | POST | ❌ 不幂等 |
关键洞察:一条发布链里,只有"建稿"和"发布"是天生不幂等的,其余全都可以设计成幂等。而我踩的坑,是把一个本该幂等的动作(灌正文)写成了不幂等。
为什么自动化场景对幂等的要求比人操作高得多?因为人天然不会重复。你编辑一篇文章,第二次打开编辑器,看到里面已有内容,你会先删掉再写——这个"先看再删"的动作是本能,不需要思考。而脚本只会无脑执行你给它的指令,它不会"看一眼"。
所以幂等性不是锦上添花,是把人类的本能翻译成代码。
三、幂等的三个零件
把博客园那次事故拆开,一个幂等的"灌正文"动作需要三个零件。
零件一:操作前的清理
错误写法:
// 直接把内容贴进去,不管里面原来有什么
await editor.focus();
await insert(markdown);
正确写法:
// 先清空,再灌入
await editor.focus();
await selectAll(); // Ctrl+A
await press('Delete');
await verifyEmpty(); // 确认真的是空的,不是"看起来是空的"
await insert(markdown);
注意最后那个 verifyEmpty。我这次的经历是:Ctrl+A + Delete 之后读长度,读到的是 0,我才敢灌。如果读到的是别的数字,说明清理没生效,得重来,不能往下走。
零件二:操作后的校验
灌完之后立刻读回来,和源文件比:
const expected = sourceFile.length;
const actual = await readEditorLength();
if (actual !== expected) {
throw new Error('LENGTH_MISMATCH: expected ' + expected + ', got ' + actual);
}
这个校验是我这次唯一没做的一步。如果我做了,10468 会在发出前就被拦下,而不是等文章上线之后才发现。
校验必须是自动的、强制的、抛异常的。不能是"看一眼差不多就行"——脚本没有"差不多"这个判断能力,只有相等或不相等。
零件三:前置状态检查(可选,但能救大命)
在执行任何动作之前,先问一句"现在是什么状态":
const state = await readCurrentState();
if (state.isEmpty) {
await fillAndPublish();
} else if (state.hasDraft) {
await reuseDraft(state.draftId); // 复用,别新建
} else {
await createNew(); // 万不得已才 POST
}
这个模式在简书上价值最大。它的建稿是不幂等的 POST,但如果前面加一层"先查有没有同标题的未发布草稿",重跑就不再产生孤儿——第二次跑会复用第一次建的那篇。
四、正面案例:CSDN 那个"打不死"的脚本
反过来看 CSDN。它的打标签脚本我重跑过五次,每次都收敛到同一个正确状态。为什么?
// 伪代码:cs-tag3.js 的核心结构
await navigateTo('/editor?articleId=' + articleId); // 每次都回到同一篇文章
await waitForEditor();
const tags = await readCurrentTags(); // 先读当前标签
const target = ['自动化', '运维'];
for (const t of target) {
if (!tags.includes(t)) { // 已有就不加
await addTag(t);
}
}
await verify(target); // 最后校验
三个性质让它天然幂等:
- 锚定同一个目标:靠
articleId定位,不是"当前打开的是哪篇就操作哪篇"。 - 先读后写:加标签前先看已有标签,避免重复添加。
- 终态校验:最后检查标签集合是否等于目标集合。
这其实是声明式的做法:不说"做一次添加动作",而说"确保最终状态是这个集合"。前者是过程,重复执行会累积;后者是目标,重复执行会收敛。
把脚本从"执行动作"改写成"达成状态",是获得幂等性最省事的一条路。
五、还有一类幂等:故障恢复
幂等性还有个更实际的用途——失败后重跑。
发布链是会中途失败的。网络抖一下、验证码弹出来、平台限流、标签候选列表没加载完。这些失败发生在一个长链条的中间,前面成功的步骤不能白费。
这时候你只有两个选择:
- 从头再跑一遍:要求整条链幂等,否则就会重复发布、重复建稿、重复上传。
- 从断点续跑:要求每一步都有状态记录,能回答"做到哪了"。
我最终给发布链加的是第二种:每一步都把产物 ID 写进日志文件(文章 ID、note ID、slug),下一轮启动时先读日志,已经完成的步骤跳过,只做没做的。配合第一步的幂等清理,两头都有了保障。
# 断点续跑的日志长这样
51CTO=15000997
TENCENT=2760572
JIANSHU=144869424
CNBLOGS=23292556
下次跑同样一条链,脚本先 source 这个文件,发现五个平台都有 ID,就跳过发布,只做校验——确认线上文章确实存在、字数确实对。
"可重放"比"一次成功"更重要。 一次成功靠运气,可重放靠设计。
六、一份可以直接抄的清单
给所有要重复执行的脚本,按这个清单过一遍:
| # | 检查项 | 不做的后果 |
|---|---|---|
| 1 | 输入类操作前是否先清空 | 内容翻倍(我这次) |
| 2 | 输入类操作后是否校验长度 | 脏数据直接上线 |
| 3 | 是否锚定唯一 ID,而非"当前页面" | 操作错对象 |
| 4 | 创建类操作前是否先查重 | 孤儿资源堆积 |
| 5 | 是否"先读后写"而非无脑写 | 重复累积 |
| 6 | 终态是否校验 | 失败静默通过 |
| 7 | 是否有断点记录可续跑 | 失败后从头再来 |
| 8 | 是否显式 process.exit(0) |
僵尸进程挂住整条链 |
最后一条和幂等无关,但它和"重跑"是同一类问题——我的四平台链曾经被一个不退出的僵尸进程堵死十小时。一次执行留下的痕迹,必须在执行结束时全部收干净:打开的连接要关,起的进程要退,建的临时标签要关。
七、小结
回到那个 10468。它教会我的不是"记得先清空编辑器"这种具体技巧,而是一个判断标准:
每写一个要重复执行的脚本,先问自己:如果我现在立刻再跑一次,会发生什么?
如果答案是"什么都不会发生,只是浪费几秒"——合格。
如果答案是"会再发一篇文章"——不合格,去补前置检查。
如果答案是"内容会翻倍"——不合格,去补清理和校验。
如果答案是"不知道"——那你迟早会知道,用事故的方式。
工程上把这种事叫幂等性。我更喜欢另一个说法:对重复的容忍度。真实世界里的操作一定会重复——手抖、网卡、不确定、想确认。一个不能容忍重复的系统,本质上是在要求操作者永远不犯错。
而那是不可能的要求。

浙公网安备 33010602011771号