内容运营自动化最容易被低估的一块,不是写稿,也不是发布通道,而是**「此刻到底有几个我在跑」**。
写稿写错可以改,发布入口的 DOM 选择器失效可以修。但并发实例的问题不一样:它平时的失败方式是「静默正常」——两个实例各自都认为自己是对的,各自都拿到了成功回执,代价只体现在平台上多了一条重复内容,以及一次无法解释的账号风控。等你在推送数据里发现它,已经晚了。
这篇记录一次真实的并发事故,以及事后怎么把「谁在跑」从人的纪律变成代码的约束。
一次并发实例事故
我的日更流水线挂在调度器上,每天 10:00 触发一次。某天出现了罕见情况:同一个自动化任务被实例化了两次。10:00 触发的那次因为工作量大,一路续跑到 11:32;用户在 11:30 手动「继续」,于是第二个实例在第一个实例尚未收尾时启动。
两个实例共用同一个输出路径:
C:\Users\<user>\AppData\Local\Temp\mmcli-juejin-YYYY-MM-DD.txt
第二个实例按流程准备发布,第一步是清掉上一轮的输出文件:
Remove-Item $env:MMCLI_OUT -ErrorAction SilentlyContinue
这一行报了「文件被另一进程占用」。当时的第一反应是「换个路径绕开」,但冷静下来看,这个报错本身就是一个明确的信号:有另一个进程正握着这个文件句柄,也就是有另一次发布正在进行。
于是中止了第二个实例。事后核对:第一个实例已于 11:33:24 完成发布,推送数据里只有一条记录。第二个实例那次「失败」,恰好避免了一次重复发布。
结论很反直觉:那个被当成故障的句柄冲突,其实是整套流程里唯一一次生效的并发保护。
事后沉淀下来的防护一共三道闸门,先把全貌放在这里,后面逐个拆:
锁应该锁「资源」,不是锁「机器」
事后复盘,我原本的假设是错的。我以为并发只发生在「两个不同的进程在同一台机器上」这种场景,但实际上,调度器重入、用户手动唤起、超时未杀干净的重试,都会造出同一份逻辑任务的多个执行体。它们可能同时写入同一份台账、抢占同一个浏览器窗口、发布同一条内容。
照着这个前提,我在值守侧落地了一个协作式运行锁,思路是:锁保护的是「这份资源正在被谁使用」,而不是「这台机器上只有一个进程」。
它的接口刻意做得很薄:
python _runlock.py --status 查看锁状态(exit 0=free, 3=held)
python _runlock.py --acquire <video> --job x.json 申请锁
python _runlock.py --hb 心跳,刷新 mtime
python _runlock.py --finish <result.json> 收尾:写运行记录 + 释放锁
python _runlock.py --release 释放锁
几个刻意的设计取舍:
- 不改变任何发布业务逻辑。锁只在入口和收尾生效,把原有
main()整体塞进run_body(),外面包一层「拿锁 → 执行 → finally 释放」。发布语义零改动,这是能安全上线的前提。 - 协作式而非强制。锁只约束值守脚本,手工调用不受影响。工程上强锁容易把自己锁死,协作锁加上可观测性反而更实用。
- 三条硬约束:禁止
--force强抢锁;锁未释放时宁可空跑一个周期,也不冒险发;finally必须保证任何路径(正常返回、提前 return、异常)都能释放。
宁可少发一条,也不要重复发两条。这个取舍,是在事故之后才想明白的。
陈旧锁判定:pid 和 mtime 缺一不可
协作锁立刻带来一个新问题:进程被超时杀掉,锁文件还在,下个周期怎么办?
只靠 pid 存活判断不够:进程死了 pid 会被系统复用,你可能误判成一个活着的锁。只靠时间也不够:正常一次运行可能 10~30 分钟,判太短会误抢,判太长会拖慢一个周期。
所以用了双判据:
# 锁最长持有时间(秒)。超时视为前一次运行已死(进程被杀 / 机器休眠),允许抢占。
DEFAULT_HOLD = 45 * 60
def pid_alive(pid):
"""Windows 安全的进程存活检测(os.kill 在 Windows 上不可用)。"""
if not pid:
return False
import subprocess
r = subprocess.run(["tasklist", "/NH", "/FI", "PID eq %d" % pid],
capture_output=True, timeout=20)
out = r.stdout or b""
if not out:
# 拿不到输出时保守返回 True(不误抢占活锁),由 mtime 超时兜底
return True
return str(pid).encode() in out
这里踩过一个 Windows 专属的坑:tasklist 在中文系统上输出 GBK,如果用 text=True 让 Python 按 UTF-8 解码,会直接抛 UnicodeDecodeError。解决办法是不解码,把 stdout 当字节串,直接做 str(pid).encode() in out 的字节匹配。
另外,运行期间用 --hb 刷新锁文件 mtime,等于一个显式心跳。判定逻辑就是:pid 不存活,或 mtime 超过 45 分钟 → 视为陈旧锁,自动接管。这样卡死的运行最多拖慢一个周期,不需要人工干预。
配套还有一个 runs/current.json 进行中标记,保鲜期 2 小时,给选片侧用:正在发布、或上一轮超时中断状态不明的条目,下一个周期直接跳过。
第二道闸:发布前二次确认量闸
锁解决的是「同一份任务的重入」。但还有一类并发是锁管不到的:两个实例错过了彼此——比如第一个实例已经发布完、释放了锁,第二个实例此时才启动,它看到的是一把干净的、没人持有的锁。
所以锁之外,还需要一道基于事实的量闸,而不是基于锁状态的量闸。发布前必须重新统计「今天到底发出去几篇」:
# 当日掘金 success 计数:pushData 与主日志取 max
import json, os, re
from datetime import datetime, timezone, timedelta
def quota_today(day: str) -> int:
# ① pushData:pt == '掘金' 且 publishStatus == 'success'
p = rf"C:\Users\<user>\Documents\MatrixMedia\data\pushData\{day}.json"
n_push = 0
if os.path.exists(p):
items = json.load(open(p, encoding="utf-8"))
items = items if isinstance(items, list) else items.get("list", [])
n_push = sum(1 for it in items
if it.get("pt") == "掘金" and it.get("publishStatus") == "success")
# ② 主日志:日志是 UTC,需要 +8 换算北京日期
n_log = 0
for d in (day, (datetime.strptime(day, "%Y-%m-%d")
- timedelta(days=1)).strftime("%Y-%m-%d")):
lp = rf"C:\Users\<user>\AppData\Roaming\matrix-video\logs\{d}.log"
if not os.path.exists(lp):
continue
for line in open(lp, encoding="utf-8", errors="ignore"):
if "文章发布成功" not in line:
continue
m = re.match(r"\[(\S+?Z)\]", line)
if not m:
continue
utc = datetime.strptime(m.group(1), "%Y-%m-%dT%H:%M:%S.%fZ")
bj = utc.replace(tzinfo=timezone.utc).astimezone(timezone(timedelta(hours=8)))
if bj.strftime("%Y-%m-%d") == day:
n_log += 1
return max(n_push, n_log)
这段代码里有两个必须记住的细节,都是踩出来的:
publishStatus的 JSON 格式是"publishStatus": "success",冒号后面有一个空格。 任何写死成"publishStatus":"success"的正则或字符串匹配,都会永远匹配不到,而且不报错——它只是安静地返回 0。- 日志是 UTC,必须换算。 北京 10-15 的 00:00~08:00,对应的日志落在 UTC 的
10-14.log尾部。只查当日文件会漏掉半夜那一段,量闸就会偏低。所以我同时扫当日和前一日的日志文件,再用时间戳判断归属。
量闸的语义是:只要今天已经有 1 篇成功记录,就不发。 这是最后一道,也是最可靠的一道——因为它的依据不是任何进程的内部状态,而是已经落到磁盘上的、平台回执过的结果。
第三道闸:把句柄冲突当信号,不是故障
回到最开始那次事故。「文件被另一进程占用」这个报错,之所以能在关键时刻救场,是因为它撞在了 Windows 的文件句柄语义上:同一时刻只有一个进程能持有写句柄。
我后来把它固化成一条显式的发布前检查:
# 发布前:确认 MMCLI_OUT 不存在(或 mtime 不是本轮)
if (Test-Path $env:MMCLI_OUT) {
try {
Remove-Item $env:MMCLI_OUT -ErrorAction Stop
} catch {
Write-Output "ABORT: MMCLI_OUT 被占用,存在并发发布实例"
exit 9
}
}
裸的 Remove-Item 失败时被忽略,等于把信号吞掉了。加上 -ErrorAction Stop 加显式捕获,就把这个侧信道变成了中止指令。它不优雅,但它是个真话——哪个进程在用这个文件,系统最清楚。
台账的读-改-写竞态
还有一处并发是很容易被漏掉的:台账文件本身。
我的日更流水线和一个每 4 小时运行一次的视频值守任务,都会往同一份 ledger.json 追加记录。观察到的现象是:一次完整的「读取 → 字典里加一个键 → 整体写回」,通常不会丢条目;但风险窗口真实存在——如果两个进程在彼此的读写之间交错,后写的那次就会覆盖掉先写的那次新增。
所以记账的规矩现在写死成三条:
# 1) 每次记账都重新 load 最新文件(不要复用循环外的旧快照)
# 2) 只往 dict 里加自己那条键,不做整体重建
# 3) 立即写回,绝不延迟
d = json.load(open(LEDGER, encoding="utf-8"))
d["publishes"][key] = entry
d["updatedAt"] = now_iso()
tmp = LEDGER + ".tmp"
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
os.replace(tmp, LEDGER) # 原子替换,避免写到一半被读到半截文件
os.replace 是这里唯一一个「顺手的正确做法」:它保证读的一方要么看到旧文件,要么看到新文件,绝不会读到写了一半的残文件。
另外,写入前后各做一次计数对账(新增 1 / 消失 0),一旦对不上就立刻停下来查,不要继续往下跑。
把纪律变成代码
回头看,这次事故里所有能救场的东西,都不是「更小心一点」,而是「把判断做成了代码」:
- 句柄冲突 → 中止指令(而不是被忽略的日志噪音)
- 进程重入 → 协作式锁 + 陈旧判定(而不是「记得别开两个」)
- 重复发布 → 基于已落盘事实的量闸(而不是基于锁的内部状态)
- 台账覆盖 → 读最新、只加自己、立即原子写回
内容运营自动化做到后面,瓶颈往往不在内容本身,而在这些运行时的小概率事件上。它们平时不出现,一出现就有可能把账号搭进去。
人的纪律会疲劳,会遗忘,会在赶时间的时候被跳过。代码写的约束不会。
宁可空跑一个周期,也别让它发两条。