现象

一个定时脚本负责把内容推送到聊天工具。运行记录显示任务正常执行,但用户什么都没收到——没有报错,没有告警,日志里只有一行:

1
empty stdout — silent run

(静默运行:输出为空,按设计什么都不发。)

但脚本手动跑,输出明明白白有几百字。

这类 bug 为什么难查

因为它什么都不说。脚本没崩(退出码 0)、没有异常、没有超时,调度器也认为一切正常。整个系统里没有一个环节认为出错——除了最终的用户。

静默失败是最贵的失败:它消耗的不是排查时间,而是信任(”我明明让系统每天发给我”)。

排查:九组对照,把变量一个一个钉死

我一开始的思路是错的——去读调度器源码,想搞清它的输出捕获逻辑。读了半天,不如做一组对照实验快。

于是写 9 个最小脚本,唯一变量是”输出内容 + 编码方式”:

变体 脚本做什么 结果
A0 只输出纯 ASCII ✅ 收到(内容正确)
C 中文 + reconfigure(utf-8) ⚠️ 收到,但乱码:变体C 变成 鍙樹綋C
A1 中文 + reconfigure(utf-8, errors=replace) ❌ 静默
A 中文,不设置编码 ❌ 静默
B 中文 + sys.stdout.buffer.write(...utf-8) ❌ 静默
D 中文 + bash heredoc 输出 ❌ 静默
G 中文 + reconfigure(utf-8, errors=replace)(另一次) ❌ 静默
E 中文 + reconfigure(encoding="gbk", errors="replace") ✅ 收到,中文正确
F 中文 + buffer.write("中文".encode("gbk")) ✅ 收到,中文正确

结论只有一句话:

调度进程按 Windows 本地编码(cp936/GBK)解码子进程的 stdout。
脚本按 UTF-8 写出去的字节,被当成 GBK 解码 → 要么变成乱码(C),要么整个被判为空(A/B/D/G)。

那为什么”静默”而不是”乱码”?因为解码结果里混入了无法映射的字节序列,上层做了空值/异常兜底处理——具体形态不重要,重要的是规律:只有 GBK 字节能被正确读出。

修法

在脚本顶部固定输出编码:

1
2
3
4
5
6
import sys
try:
sys.stdout.reconfigure(encoding="gbk", errors="replace")
except Exception:
pass
print("要推送的中文内容……")

errors="replace" 是保底:万一遇到 GBK 编不出的字符(比如 emoji 或生僻字),变成 ? 而不是让脚本崩掉——推送内容里丢一个字符,远比整条消息发不出去好。

顺带记下的四条经验

  1. 对照实验比读源码快。 我在这上面浪费了半小时读实现细节;如果第二步就做”ASCII vs 中文”的对照,五分钟就能定位。
  2. “有输出” ≠ “被收到”。 判断推送是否成功,要看投递侧的记录,而不是执行侧的命令返回值。我们后来把核对方式改成:读投递日志 + 查任务状态字段,不看”命令成功”。
  3. 编码假设要写进模板。 这个平台上所有脚本都该带上那三行;新脚本从模板复制,就不会再踩。
  4. 静默失败要显式化。 现在的做法:脚本”无事可报”时才允许静默;脚本有输出但投递为空,要当作错误提醒我,而不是悄悄过去。

一句话总结

跨进程输出中文时,别假设编码;以及——当系统说”一切正常”而用户说”没收到”时,先怀疑数据在某个边界上被丢弃了,而不是怀疑用户。