一个 UTF-8 乱码引发的排查
背景与痛点
我自己运营着一个内容站,API 接口负责接收长中文内容——文章、摘要、音频文稿。某天开始,日志里偶尔冒出几个 U+FFFD 替换符(�):好好的中文,中间夹着几个问号模样的方块字。最气人的是它的脾气:偶发。同样的内容重发一次,可能就好;过两天,另一篇又坏。定位这种问题最消耗人的不是技术,是你连「稳定复现」都做不到。
当时我的第一反应和大多数人一样:肯定是前端编码没设对。查了表单,没问题;查了 Content-Type 头,没问题;怀疑中间有没有代理偷偷转码,也没有。折腾到差点去改前端的时候,我逼自己停下来,重新看了三遍坏数据,注意到两个此前忽略的细节。
方案与实现
细节一:只有长内容出问题,几百字的短文从来没坏过。细节二:出问题的位置是任意的中间位置,从不在开头结尾——而且坏掉的永远是「一个字」,不是一段。这两个特征合起来,基本可以把前端和网关排除了:如果是编码设置错,短文也该坏,且坏得有规律。真正的嫌疑落在服务端接收数据的代码上。
我的 Node.js 服务当时是这么收请求体的:
// 错误写法:逐块转字符串再拼接
let body = '';
req.on('data', chunk => { body += chunk.toString('utf8'); });
req.on('end', () => save(body));
看着挺合理对吧?网上半数教程都这么写。但它有个致命假设:每个 chunk 恰好是完整的字符边界。真相是,TCP 传输会把数据切成块,切哪里它说了算——它完全不认识 UTF-8。一个汉字在 UTF-8 里占 3 个字节,网络分块完全可能从第 2 和第 3 个字节之间剁下去:前一块的末尾剩半个字,你对它调 toString('utf8'),解不出来,Node 就塞一个 U+FFFD 进去。这就是为什么只有长内容中招(内容够长才会跨多个分块)、坏的位置随机(分块位置由网络状况决定)、每次坏一个字(正好半截字节)。定位之后,修复反而只需换一种攒数据的方式:
// 正确写法:先攒原始字节,收完一次性解码
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => save(Buffer.concat(chunks).toString('utf8')));
一字之差:别在中间过程把字节变成字符串,攒原始 Buffer,最后 concat 完统一解码一次。字节流在没拼完整之前,它就不该被「翻译」。改完上线,U+FFFD 再没出现过。
bufio.Scanner 读流时对超长行会静默截断,逐段 string 拼接同理;Python 里对分块数据提前调 decode(errors='replace') 是一模一样的错误。语言在变,教训不变:多字节编码的边界,只在数据收完之后才成立。创新点
说「创新」有点大,这个 bug 更值得聊的是为什么教科书不讲它。你看所有教程示例:处理的都是几十字节的小字符串,一个 TCP 包就装下了,永远不存在「字符被剁开」这回事。要触发它需要三个条件同时成立:多字节字符 + 内容足够长 + 真实网络分块恰好剁在字符中间。开发环境几百字试一下就通过的代码,上了生产遇到长文稿才爆。这类「只有真实世界的规模才能复现」的坑,是教程和实战之间最贵的一道鸿沟——教程负责教你 API 怎么用,不负责教你在数据被网络剁碎时怎么活下来。所以我把这类问题单独记了一页:凡是「偶发、只坏长的、每次位置不一样」的数据问题,先去查字节层,别急着怀疑业务代码。
价值与复盘
这次排查的直接收益是站点乱码清零,但更大的价值是我把它变成了一个自查标准:所有收流式请求、长文本上传、大文件分片的服务,都应该默认自己有这个 bug,直到自检证明没有。自检只要三行,你可以在任何环境里跑:
const buf = Buffer.from('中文'.repeat(10000), 'utf8'); // 全是3字节汉字
for (let i = 0; i < buf.length; i += 7) sink.write(buf.subarray(i, i + 7)); // 每7字节切一块,必剁开汉字
// 接收端跑完后 grep �(U+FFFD):有,就是踩了这个坑
原理:故意用 7 字节的块去切 3 字节的汉字,让每一块都对不齐字符边界——这比等真实网络施舍你一次复现快得多。七字节数字可以换成任何不被 3 整除的值,越刁钻越好。
如果重来我会怎么做:
- 服务写完第一天就加「乱序分块」集成测试——上面三行代码放进测试套件,这个 bug 根本活不到生产。
- 把「逐块 toString」当作和 SQL 注入同级别的常识来防:code review 见到一次说一次,不解释第二遍。
- 排查时更早地看字节层面。我在「以为是前端」上耗掉的几个小时,本可以在拿到第一份坏数据的 hex 视图时就省掉——半截字节躺在那里,答案早就在了。
- 给日志加一个 U+FFFD 的监控计数,让这类问题从「用户偶尔发现」变成「告警先发现」。
留言交流
做过类似的东西、有不同做法、或者想拍砖,都欢迎。留言会显示在本页。