一个"静默丢弃"的编码坑:脚本明明有输出,系统却什么都没收到
现象
一个定时脚本负责把内容推送到聊天工具。运行记录显示任务正常执行,但用户什么都没收到——没有报错,没有告警,日志里只有一行:
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 | import sys |
errors="replace" 是保底:万一遇到 GBK 编不出的字符(比如 emoji 或生僻字),变成 ? 而不是让脚本崩掉——推送内容里丢一个字符,远比整条消息发不出去好。
顺带记下的四条经验
- 对照实验比读源码快。 我在这上面浪费了半小时读实现细节;如果第二步就做”ASCII vs 中文”的对照,五分钟就能定位。
- “有输出” ≠ “被收到”。 判断推送是否成功,要看投递侧的记录,而不是执行侧的命令返回值。我们后来把核对方式改成:读投递日志 + 查任务状态字段,不看”命令成功”。
- 编码假设要写进模板。 这个平台上所有脚本都该带上那三行;新脚本从模板复制,就不会再踩。
- 静默失败要显式化。 现在的做法:脚本”无事可报”时才允许静默;脚本有输出但投递为空,要当作错误提醒我,而不是悄悄过去。
一句话总结
跨进程输出中文时,别假设编码;以及——当系统说”一切正常”而用户说”没收到”时,先怀疑数据在某个边界上被丢弃了,而不是怀疑用户。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 须臾的博客!