1. 先从一次线上乱码事故说起字符编码格式这件事平时没人提起一出问题就是一整条链路跟着遭殃。前段时间一套后台系统上线导出功能程序里跑得好好的报表运营同学在本地表格软件里一打开凡是中文全变成一片方块和问号同一批数据走接口同步到另一个系统那边收到的 JSON 开头多出三个不可见字节直接解析失败。两个现象看着不相干根子却是同一个数据从产生到落地中间至少经过四次编码与解码变换任何一次用错了字符编码格式链条就断了。这篇内容想解决的问题很具体把工程里最常打交道的 11 种字符编码格式掰开揉碎讲清楚每种编码是什么、为什么存在、什么场景该用、什么场景绝对不能用再补上从数据库到接口到日志文件的配置方法和排查手段。适合刚接手数据清洗、接口联调、日志处理的后端和数据分析同学也适合写过几年代码但对乱码只能靠猜的老手——很多所谓的“玄学乱码”一旦把编码格式对上号定位时间能从半天缩短到五分钟。需要提前说清楚一个容易被混淆的点这 11 种里有的是真正的字符编码比如 ASCII、UTF-8、GBK有的是编码之上的传输表示比如 Base64、URL 百分号编码。它们不是同一层的东西但工程里几乎总是一起出现所以放在一起讲。理解了分层你才知道该在哪一层动手。2. 编码的基础逻辑字符、码点、字节三层结构2.1 字符集和字符编码不是一回事很多人说“UTF-8 字符集”这句话严格来说是错的。字符集Character Set回答的是“有哪些字符、每个字符的编号是多少”字符编码Character Encoding回答的是“这个编号怎么变成字节序列”。Unicode 是字符集给每个字符分配一个唯一的码点比如汉字“中”的码点是 U4E2D数字 20013。UTF-8、UTF-16、UTF-32 是同一套码点的三种不同落地方式同样一个“中”UTF-8 写成三个字节E4 B8 ADUTF-16 大端写成两个字节4E 2DUTF-32 写成四个字节00 00 4E 2D。搞清楚这个分层很多困惑就自动消失了。为什么同一个文件换个编辑器打开字符数统计不一样因为计数方式可能盯的是字节数也可能盯的是码点数还可能盯的是用户眼里的字字素簇。为什么 UTF-8 文件里一个汉字占 3 字节UTF-16 里占 2 字节UTF-32 里占 4 字节因为它们的编码规则不同跟“汉字本身有多大”没关系。再往下还有一层传输编码。Base64、URL 百分号编码、Quoted-Printable 都不改变字符的码点只负责把字节序列转换成“在特定通道里能安全通过的 ASCII 字符”。邮件正文只能走 7 位通道所以附件要 Base64URL 里不能出现空格和中文所以要百分号编码。这三层各管一段混着理解就会写出一堆似是而非的代码。2.2 编码选型的三条判断线实际项目里选编码我一般只问三个问题基本能覆盖九成场景。第一条线是数据要不要跨系统、跨语言、跨平台流通。只要答案是“要”UTF-8 就是默认答案没有之一。它的兼容性最好英文零成本中文三字节全世界的解析库都认。代价是非 ASCII 字符体积变大但这个代价在现代存储和带宽面前基本可以忽略。第二条线是数据是否受限于历史系统。有些老系统只认单字节编码有些遗留数据库建库时用的就是 GBK有些设备固件只支持 ASCII。这时候不是“选哪个更好”而是“必须迁就谁”。迁就的办法通常是在边界做转换而不是整条链路跟着降级。第三条线是数据要落在什么介质上。文件、数据库列、HTTP 头部、URL 参数、Cookie、日志每种介质的编码约束都不一样。HTTP 头部按规范只允许可见 ASCII往里面塞中文就必须先做编码转换URL 路径里的中文必须先百分号编码Cookie 值同理。介质约束比个人偏好优先级更高。把这三条线画出来选型基本就变成填空题了。下面逐个拆这 11 种编码我会重点讲每种编码“为什么会长成这个样子”因为理解了设计动机你才能判断它在你手里会不会出问题。3. 11 种常见字符编码逐一拆解3.1 ASCII一切兼容性的起点ASCII 是最早被广泛标准化的字符编码用 7 个二进制位表示 128 个字符范围 0x00 到 0x7F。其中 0x00 到 0x1F 是控制字符换行 LF 是 0x0A回车 CR 是 0x0D制表符 TAB 是 0x090x20 是空格0x30 到 0x39 是数字 0-90x41 到 0x5A 是大写字母0x61 到 0x7A 是小写字母。它的设计动机非常朴素早期设备只有 7 位可靠传输能力128 个位置刚好够放英文大小写、数字、标点和一批控制符。也正因为如此ASCII 成为了后来所有主流编码的“最小公共子集”。UTF-8 完全兼容 ASCIIGBK 的低半区也兼容 ASCIIISO-8859-1 的前 128 个位置同样兼容。这个特性极其重要它意味着一段纯英文文本无论你用哪种主流编码去解码结果都一样不会乱。ASCII 的典型使用场景是协议层面的元数据HTTP 方法名、状态行、头部字段名、MIME 类型、JSON 的结构符号、代码里的变量名、配置文件里的键。这些东西按规范都该是 ASCII。真正的坑在于有人把 ASCII 当成“能存一切”的编码把中文塞进只声明 ASCII 的字段结果就是字节被截断或者被替换成问号。注意ASCII 只定义了 128 个码位0x80 到 0xFF 这一段在 ASCII 里是未定义的。很多系统“扩展 ASCII”的说法其实指的是 ISO-8859-1 或厂商自定义的代码页不是 ASCII 本身。3.2 ISO-8859-1永不报错的“万能垃圾桶”ISO-8859-1 又叫 Latin-1把单字节的 256 个位置全部用满0x00 到 0xFF 一一映射到 Unicode 的 U0000 到 U00FF。它覆盖了西欧大部分语言的字母包括带重音的字符但不包含中文、日文、韩文也不包含欧元符号后来的 ISO-8859-15 才补上。这个编码在工程里有一个非常特殊的地位它是唯一一个“任何字节序列都能解码成功”的单字节编码。你给它任意一串字节它都会老老实实映射成对应的 U0000 到 U00FF 字符绝不抛异常。这个特性被大量用于“字节搬运”场景——比如把二进制数据临时塞进只接受字符串的接口先用 ISO-8859-1 解码成字符串取出来再用同样的编码还原成字节一个字节都不会丢。但它也是最容易被误用的编码。有些老系统默认用 ISO-8859-1 处理请求体中文提交上去就变成一堆ä¸Â这样的怪字符本质是 UTF-8 的三字节被当成三个 Latin-1 字符各解了一次。这种“二次误解码”是乱码里最常见的一类后面排查章节会专门讲怎么识别。使用场景上ISO-8859-1 适合两类地方一是纯西欧语言的遗留系统二是需要字节无损穿透字符串容器的技术手段。业务数据存储绝对不要用它中文进去就是永久损坏。3.3 GB2312简体中文的第一套国家标准GB2312 是简体中文场景下最早成体系的编码标准采用双字节表示汉字收录了 6763 个汉字和 682 个非汉字图形符号。它把汉字分成两级一级汉字 3755 个按拼音排序二级汉字 3008 个按部首笔画排序。GB2312 的编码规则设计得很巧两个字节都落在 0xA1 到 0xFE 之间这样就天然避开了 ASCII 的 0x00 到 0x7F 区间。也就是说一段 GB2312 文本里凡是小于 0x80 的字节一定是 ASCII 字符凡是大等于 0xA1 的字节一定是汉字的一半。这个设计让 GB2312 可以和 ASCII 混排而不产生歧义也是后来所有中文双字节编码的共同思路。它的局限也很明显6763 个汉字覆盖不了人名、地名、古籍和生僻字繁体字和日韩文字更是完全不在范围内。所以 GB2312 在现代系统里基本只剩历史包袱新建系统不会再选它。但你在处理一些老版本的打印模板、老式嵌入式设备的字库、二十年前的文档时还是会撞上它。提示遇到 GB2312 编码的文件如果解码时报错先别急着换编码大概率是文件里混进了 GBK 才有的字符。用 GB18030 去解通常能过。3.4 GBK向下兼容的扩容方案GBK 是对 GB2312 的扩展“K”是“扩展”的意思。它同样用双字节但第二字节的取值范围从 0xA1-0xFE 放宽到 0x40-0xFE去掉 0x7F可用空间一下子大了很多收录字符数增加到 21886 个包含了繁体字、日文假名、俄文字母、希腊字母以及大量生僻汉字。GBK 完全向下兼容 GB2312所有 GB2312 能表示的字符在 GBK 里的编码完全一样。这个兼容性非常关键它意味着把 GB2312 文件按 GBK 解码不会出错反之则可能失败。所以在不知道具体是哪个版本的情况下按 GBK 去解是更保险的选择。GBK 在 Windows 系统里的代码页是 936很长一段时间内是简体中文 Windows 的默认编码。这也是为什么很多老项目里到处是 GBK不是有人主动选的是操作系统默认给的。它的问题在于不是国际标准跨平台支持不一致Linux 下很多工具默认按 UTF-8 处理一旦文件用了 GBK 就会显示成乱码。3.5 GB18030强制标准里的“全覆盖”方案GB18030 是目前简体中文场景下的强制性国家标准最大的特点是变长编码单字节、双字节、四字节三种长度混用。单字节部分兼容 ASCII双字节部分兼容 GBK四字节部分用来覆盖 Unicode 的全部码位。四字节的编码规则很有意思第一字节 0x81-0xFE第二字节 0x30-0x39也就是数字 0-9第三字节 0x81-0xFE第四字节 0x30-0x39。因为第二字节落在数字区间而 GBK 的双字节编码第二字节不会落在 0x30-0x39所以解析器可以靠这个特征无歧义地判断“这是四字节字符还是双字节字符”。这个设计在编码学上属于很聪明的做法在不破坏旧编码的前提下扩展了空间。GB18030 能表示 Unicode 全部码位包括各种生僻字、少数民族文字、古文字。如果你的系统必须支持简体中文且要处理人名生僻字GB18030 是双字节体系里最稳的选择。但现实是新建系统几乎都直奔 UTF-8 了GB18030 更多出现在有明确合规要求或者需要兼容老数据的场合。踩过的坑有些数据库驱动声称支持 GB18030实际只实现了 GBK 部分遇到四字节字符直接报错或者静默替换成问号。上线前务必拿几个四字节字符测一遍。3.6 Big5繁体中文的双字节体系Big5 是繁体中文语境下使用最广的编码同样是双字节第一字节 0x81-0xFE第二字节 0x40-0x7E 或 0xA1-0xFE收录约 13053 个繁体汉字和一批符号。它的设计思路和 GBK 类似都是为了在双字节空间里塞下尽可能多的本地文字同时兼容 ASCII。差别在于收录的字符集完全不同Big5 里有很多繁体特有的字形而 GBK 里有很多简体特有的字形。这就导致一个很现实的问题同一段文字用 GBK 编码再用 Big5 解码会得到一堆完全不相干的汉字而且这些汉字往往看起来“像那么回事”不会立刻让你察觉错了。这个特性让 Big5 和 GBK 的混淆成为排查难点。因为两种编码的字节范围高度重叠自动探测工具经常判错。判断方法后面会讲核心思路是看高频字符的分布特征。Big5 的使用场景非常明确繁体中文内容的处理。如果你的系统只处理简体理论上碰不到它但现实是数据来源往往比想象中杂导入一份外部数据时先看一眼编码能省掉很多麻烦。3.7 UTF-8现代系统的默认答案UTF-8 是 Unicode 的一种变长编码用 1 到 4 个字节表示一个码点。规则很规整单字节0xxxxxxx覆盖 U0000 到 U007F也就是完整的 ASCII双字节110xxxxx 10xxxxxx覆盖 U0080 到 U07FF三字节1110xxxx 10xxxxxx 10xxxxxx覆盖 U0800 到 UFFFF中日韩文字大多在这一段四字节11110xxx 10xxxxxx 10xxxxxx 10xxxxxx覆盖 U10000 到 U10FFFFEmoji 和生僻字在这里这个规则有个非常好的性质从首字节就能判断出这个字符占几个字节。首字节以 0 开头是单字节以 110 开头是双字节以 1110 开头是三字节以 11110 开头是四字节。后续字节一律以 10 开头。这意味着解析器可以从任意位置开始扫描并重新同步不会因为丢了一个字节就整段崩掉——这个特性在流式传输和网络分包场景里极其重要。UTF-8 最大的优势是兼容 ASCII。纯英文文本在 UTF-8 里和 ASCII 完全一致字节数一样这意味着大量只处理英文的老系统天然能正确处理 UTF-8 文本。加上它没有字节序问题、跨平台支持一致、是互联网事实标准新建系统选它几乎是唯一合理的选择。代价有两个。一是中文体积变大一个汉字从 GBK 的 2 字节变成 3 字节纯中文文本体积增加 50%。这在绝大多数场景下无所谓但在存储成本敏感的冷数据归档里值得算一算。二是有 BOM 的问题UTF-8 本身不需要 BOM但 Windows 平台习惯在文件开头加EF BB BF三个字节这个 BOM 在 JSON 解析、脚本执行、字符串比较时经常引发莫名其妙的错误。注意Python 读文件时用encodingutf-8-sig可以自动跳过 BOM用encodingutf-8则会把 BOM 当成\ufeff留在字符串开头做首字符判断时会出错。3.8 UTF-16固定 16 位宽度的老派选择UTF-16 用 2 个字节或 4 个字节表示一个码点。基本平面U0000 到 UFFFF用 2 字节直接表示超出基本平面的用 4 字节的代理对表示高位代理在 0xD800 到 0xDBFF低位代理在 0xDC00 到 0xDFFF。UTF-16 最麻烦的地方是字节序。同样是“中”这个字大端写法是4E 2D小端写法是2D 4E。为了区分文件开头通常加 BOMFE FF表示大端FF FE表示小端。没有 BOM 的 UTF-16 文件解码器只能靠约定俗成猜猜错就是全篇乱码。那为什么还要用 UTF-16因为在基本平面内它比 UTF-8 省空间一个汉字 2 字节对 3 字节一段纯中文文本用 UTF-16 反而比 UTF-8 小三分之一。这个优势在 Windows 平台内部被充分利用——Windows 的 API 大量使用宽字符wchar_tJava 的 String 内部也是 UTF-16JavaScript 的字符串索引同样基于 UTF-16 码元。这个设计带来一个广为人知的坑JavaScript 里.length返回 2 而不是 1因为这是一个四字节代理对字符。做字符串截断、长度校验、按索引取值时必须用Array.from()或者码点遍历否则截出来的可能是半个代理对落库时直接报错。UTF-16 适合场景平台内部字符串表示、需要和 Windows 或 Java 原生接口打交道的场合。不适合场景跨语言的文本文件、网络传输、数据库存储——这些地方一律用 UTF-8。3.9 UTF-32简单粗暴的定长方案UTF-32 用固定 4 个字节表示一个码点最高位基本是 0。它的优点只有一个定长随便索引随机访问 O(1)不用考虑代理对和变长解析。缺点也很直接体积是 UTF-16 的两倍是 UTF-8 的四倍左右绝大多数位置的字节都是零存储和传输效率极低。现实中使用 UTF-32 的地方非常少主要出现在操作系统内部的字符处理 API、某些文本处理库的中间表示、以及编码转换工具的中间格式。作为业务数据存储格式基本见不到作为网络传输格式更是没有。了解它的价值在于理解“定长 vs 变长”这个权衡。很多同学在讨论编码时纠结“为什么 UTF-8 这么好还要有别的”答案就在这变长编码省空间但索引复杂定长编码索引简单但费空间不同场景的最优解不同。UTF-32 把定长这条路走到极致恰好说明了它在工程上的不可行。3.10 Base64把二进制塞进文本通道Base64 严格说不是字符编码而是一种二进制到文本的传输编码。它把每 3 个字节24 位拆成 4 个 6 位组每个 6 位组映射到 64 个可打印 ASCII 字符之一A-Z、a-z、0-9、、/不足 3 字节时用填充。编码后体积膨胀约 33%。它的设计动机是解决“纯文本通道传不了二进制”的问题。邮件协议最初只支持 7 位 ASCII图片、附件、压缩包想发出去必须先转成安全字符。Base64 就是为此诞生的后来被广泛用于各种场景JSON 里嵌图片、HTTP 基本认证的用户名密码、JWT 的载荷部分、数据 URL、配置文件里存密钥。关键认知Base64 是编码不是加密任何人拿到字符串都能还原出原始内容。把敏感数据用 Base64 存起来然后觉得“安全了”是很典型的误区。另一个常见坑是变体混乱。标准 Base64 用和/URL 安全的变体用-和_有的实现带填充有的去掉MIME 版本每 76 字符插入换行标准版本不插。这些差异导致同一个内容在不同库里编码出来的字符串不一样做签名校验时会莫名失败。import base64 raw 中文.encode(utf-8) print(base64.b64encode(raw).decode()) # 5Lit5paH print(base64.urlsafe_b64encode(raw).decode()) # 5Lit5paH本例无特殊字符 # 还原时的经典错误忘记先做 URL 安全字符替换 s 5Lit5paH print(base64.b64decode(s).decode(utf-8)) # 中文提示做 URL 参数传递时把标准 Base64 的换成-、/换成_再去掉末尾的可以避开 URL 解码把当空格处理的问题。3.11 URL 编码与 Quoted-Printable应用层的转义规则URL 百分号编码规定URL 里只能出现 ASCII 的一个子集其余字符必须转换成%XX形式XX 是该字符在指定字符集下的字节的十六进制。中文在 UTF-8 下是三个字节所以“中”编码成%E4%B8%AD。空格有两种处理路径里用%20表单数据里用这个差异是无数接口联调事故的源头。Quoted-Printable 是邮件领域的类似方案把不可打印字符转换成XX形式可打印字符原样保留行尾用表示软换行。它的优势是可读性比 Base64 好适合以 ASCII 为主、夹杂少量非 ASCII 的文本。这两个编码的共同点是它们都是“按字节转义”必须先确定底层字符编码才能正确还原。同样一个“中”字如果底层用 GBK 编码百分号形式就是%D6%D0和 UTF-8 的%E4%B8%AD完全不同。排查这类问题时先确认服务端按什么编码解析百分号序列往往比对着字符串反复看更有效。const s 中文 测试; console.log(encodeURIComponent(s)); // %E4%B8%AD%E6%96%87%20%E6%B5%8B%E8%AF%95 console.log(encodeURI(s)); // %E4%B8%AD%E6%96%87%20%E6%B5%8B%E8%AF%95encodeURIComponent会转义、、?等分隔符encodeURI不会。拼接查询参数时用前者处理整条 URL 时用后者用反了就会出现参数被截断的问题。3.12 11 种编码横向对比把上面这些整理成一张表选型时可以直接对照。编码单元长度覆盖范围兼容 ASCII典型场景主要风险ASCII7 位128 字符本身协议元数据、代码存中文必然损坏ISO-8859-11 字节256 字符前 128 兼容西欧遗留系统、字节穿透中文误解码成怪字符GB23122 字节6763 汉字兼容老式简体文档生僻字缺失GBK2 字节21886 字符兼容Windows 简体环境非国际标准跨平台差GB180301/2/4 字节全 Unicode兼容需生僻字的简体系统驱动实现不完整Big52 字节约 13053 字兼容繁体中文内容与 GBK 混淆难辨UTF-81-4 字节全 Unicode完全兼容网络、存储、跨平台BOM 干扰、截断风险UTF-162/4 字节全 Unicode不兼容平台内部字符串字节序、代理对UTF-324 字节全 Unicode不兼容内部中间表示体积过大Base644/3 膨胀任意二进制输出全 ASCII文本通道传二进制变体不一致、误当加密URL/QP 编码按字节转义任意字节输出全 ASCIIURL、表单、邮件底层编码不一致4. 按场景选型把编码配到正确的位置上4.1 数据库层建库、连接、列三级都要对上数据库是乱码问题的重灾区原因在于它有三级编码设置服务端默认字符集、数据库/表字符集、客户端连接字符集。三级不一致数据写进去的那一刻就已经损坏了后面怎么改都救不回来。MySQL 这里有个流传很广的坑参数名叫utf8实际只支持最多 3 字节存 Emoji 和部分生僻字会报Incorrect string value错误。要用utf8mb4才是完整的 UTF-8。建库时就该定死CREATE DATABASE app_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; ALTER TABLE user_profile MODIFY COLUMN nickname VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;连接层同样要声明。Python 的驱动里加charsetutf8mb4Java 的 JDBC URL 里加characterEncodingUTF-8useUnicodetrue。这两处漏了即使表是 utf8mb4客户端按 latin1 发过来的数据照样会乱。排查时用这条语句看当前连接的实际编码SHOW VARIABLES LIKE character_set%;character_set_client、character_set_connection、character_set_results三个值必须和你的预期一致。有一个不对读写就会出问题。注意已经用错误编码写入的数据改字符集是救不回来的。改字符集只影响之后写入的数据。修复历史数据必须先把原始字节导出来按正确编码重新解释再写回去。4.2 接口与传输层声明清楚别靠猜HTTP 请求和响应里编码信息通过两处传递Content-Type头部的charset参数以及响应体的实际字节。规范上 JSON 默认就是 UTF-8不需要声明也不该带 BOM。但现实中总会遇到带 BOM 的 JSON某些解析器会直接报“Unexpected token”。POST /api/v1/report HTTP/1.1 Content-Type: application/json; charsetutf-8 Accept-Charset: utf-8请求体里如果一个中文都没有charset写不写都无所谓一旦有中文显式写上比什么都强。反过来服务端解析请求体时必须按声明的编码解不能一律按默认值处理。我见过一个服务默认用 ISO-8859-1 解析请求体前端发 UTF-8结果“张三”存进库里变成“å¼ ä¸‰”这种数据一旦落库修复成本极高。Cookie 和 HTTP 头部是另一类情况。规范上头部只允许 ASCII 可见字符中文必须先编码。常见做法是 URL 编码或 Base64服务端读出来再解码。直接把中文塞进 Cookie 值不同浏览器和中间件的处理方式不一致有的丢弃有的截断行为完全不可预测。4.3 文件与日志BOM 和换行符是两个独立问题文件场景的问题集中在两处有没有 BOM以及换行符是\n还是\r\n。这两件事经常被混为一谈其实是完全独立的两回事。BOM 只在 UTF-8、UTF-16、UTF-32 里出现。UTF-8 的 BOM 是EF BB BF作用是标识“这是 UTF-8”但规范上并不推荐加。带了 BOM 的 CSV 在表格软件里打开能正确识别中文不带 BOM 就会乱码——这就是导出功能里那个经典矛盾给程序读的文件不该带 BOM给人用表格打开的文件最好带 BOM。Python 里处理这个很简单# 给人看的 CSV带 BOM表格软件能正确识别 with open(report.csv, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([姓名, 部门, 金额]) # 给程序读的文件不带 BOM with open(data.json, w, encodingutf-8) as f: json.dump(payload, f, ensure_asciiFalse)newline这个参数容易被忽略。不加它Python 在 Windows 上会把\n自动转成\r\nCSV 里就会出现空行。加上它换行符的处理权交还给 csv 模块行为才可预期。日志文件的编码问题更隐蔽。应用输出 UTF-8 日志日志采集端按系统默认编码读一旦默认值是 GBK采集到的中文就是乱的。这类问题往往在出故障需要查日志时才发现平时完全无感。稳妥做法是在日志配置里显式写上编码并在采集端做一次编码校验。5. 乱码排查实录从现象倒推原因5.1 三类典型症状对照表乱码的样子看着千奇百怪但归归类其实只有三种。症状表现可能原因验证方法修复方向锟斤拷反复出现UTF-8 字节被按 GBK 解码失败后替换看原始字节是否含EF BF BD源头按 UTF-8 解别让它被替换ä¸Â类西欧怪字符UTF-8 被按 ISO-8859-1 解了一次用 Latin-1 还原再按 UTF-8 解修正解析层编码声明??????全问号目标编码无法表示该字符被替换检查目标字符集覆盖范围换 utf8mb4 或 GB18030中文变繁体乱字GBK 与 Big5 互解用两种编码分别试解看哪个通顺确认内容语种后定编码段落整体错位一字多字节字符被截断检查字段长度限制是否按字节算长度按字符计或留足余量第一类锟斤拷的成因值得单独说。它不是一个编码而是“UTF-8 解码失败后用替换字符 UFFFD 顶上再被 GBK 编码”的产物。UFFFD 在 UTF-8 里是EF BF BD连续两个就是EF BF BD EF BF BD按 GBK 两两组合正好解出“锟斤拷”三个字。看到这个词基本可以断定链路上至少发生了两次错误的编码转换而且原始信息已经不可逆地丢失了。第二类是可以救的。ä¸Â这种字符全部落在 U00C0 到 U00FF 区间说明原始 UTF-8 字节被逐字节当成了 Latin-1 字符。修复方法就是反向操作把字符串按 Latin-1 编码回字节再按 UTF-8 解码。broken 䏿–‡ fixed broken.encode(iso-8859-1).decode(utf-8) print(fixed) # 中文这个方法只在“原始字节完整保留”的前提下有效。如果中间经过了有损替换比如变成了问号就救不回来了。5.2 探测与定位的实操手法拿到一个不知道编码的文件我的处理顺序是固定的。先看字节特征。命令行下用file -i 文件名能给出初步判断但它对中文编码的识别率一般GBK 和 Big5 经常搞混。更可靠的是看前几个字节有没有 BOMEF BB BF是 UTF-8 BOMFF FE是 UTF-16 小端FE FF是 UTF-16 大端。有 BOM 的话编码基本就定了。没有 BOM 就用排除法。先按 UTF-8 严格模式解一遍能过就是 UTF-8——UTF-8 的自校验能力很强随机字节蒙混过关的概率极低。def guess_encoding(path): with open(path, rb) as f: raw f.read() if raw.startswith(b\xef\xbb\xbf): return utf-8-sig if raw.startswith(b\xff\xfe) or raw.startswith(b\xfe\xff): return utf-16 try: raw.decode(utf-8) return utf-8 except UnicodeDecodeError: pass for enc in (gb18030, big5): try: text raw.decode(enc) # 简单启发式常见汉字比例高就认为猜对了 common sum(1 for ch in text if \u4e00 ch \u9fa5) if common / max(len(text), 1) 0.3: return enc except UnicodeDecodeError: continue return unknown这个启发式不完美但够用。GB18030 因为覆盖范围最广几乎不会解码失败所以不能只看“能不能解”还要看解出来的内容像不像正常中文。定位链路上哪一环出错最有效的手段是打日志看原始字节。在数据进入和离开每个模块的位置把字节的十六进制打出来对比。如果入口是E4 B8 AD出口变成了C4 E3中间那一层就是问题所在。这个方法看着笨但比对着字符串猜快得多。提示排查编码问题时不要用编辑器打开文件看多数编辑器会自动猜测编码并静默转换你看到的可能已经是转换后的结果。用十六进制查看工具看原始字节最可靠。5.3 几个反复踩到的坑字段长度按字节算而不是按字符算。数据库里VARCHAR(20)在不同版本里含义不同有的按字符有的按字节。UTF-8 下一个汉字占 3 字节按字节算的 20 长度只能存 6 个汉字。超长部分被截断截在多字节字符中间就产生半个字符落库时报错或者变成乱码。建表时确认清楚长度语义或者在应用层按字符数校验。字符串截断在多字节边界。做摘要、做预览、做日志截断时text[:50]这种写法在 UTF-8 下可能切在字符中间。Python 的字符串切片按码点走不会切坏但如果在字节层面做截断比如读取固定长度的字节流就必须处理边界。稳妥做法是解码成字符串再截截完再编码。不同语言的默认编码不一致。Python 3 的open()默认编码取决于系统 locale在有些环境是 UTF-8在有些环境是 GBK。Java 的FileReader用平台默认编码。这些“默认值”是跨平台事故的高发区。养成习惯所有涉及编码的 API 都显式传参不依赖默认值。表单提交的和%20。前端用encodeURIComponent编码空格得到%20服务端按表单规则解码时把解成空格、%20也解成空格看起来没问题。但如果原始内容里真的有一个号编码后是%2B解码回这没问题可如果某处漏了编码会被解成空格数据就悄悄变了。这类问题不会报错只会让数据对不上。大小写敏感的编码名。UTF-8、utf-8、Utf-8在多数库里等价但有些配置文件解析器严格区分。写配置时统一用大写标准写法能省掉一类低级问题。6. 我在实际项目里沉淀的几条习惯踩过的坑多了之后慢慢形成几个固定动作执行成本很低但能挡掉大部分编码事故。新项目一律从 UTF-8 起步数据库用 utf8mb4文件读写显式声明编码接口的Content-Type带上 charset。这套组合不是因为它最先进而是因为它最少需要解释——任何人接手代码看到 UTF-8 就知道该怎么处理不需要额外沟通。凡是跨边界的字符串都在边界处做一次编码确认。什么叫跨边界写文件、读文件、发请求、收请求、写数据库、读数据库每一次都算。在这些位置打一行日志记录字节长度和编码名出问题时能立刻定位是哪一段出的岔子。这行日志平时看着多余出事时能省几个小时。处理外部数据时先探测再解析绝不假设。别人给的文件用什么编码是别人的事我这边只负责识别。识别不出来的宁可报错让人确认也不猜一个编码硬解——猜错的代价是数据静默损坏比直接报错严重得多。还有一点体会比较深编码问题的排查成本和发现得早晚强相关。在数据入口处做校验成本是几行代码等数据落库、被下游消费、生成报表之后才发现成本就是数据修复加全链路回溯。所以凡是导入功能我都会在入口加一道编码校验宁可让用户重新导一次也不让脏数据流进去。最后分享一个具体的小技巧调试编码问题时把字符串转成十六进制打印出来看比看字符串本身有用得多。中.encode(utf-8).hex()得到e4b8ad中.encode(gbk).hex()得到d6d0两个值摆在一起用的是哪种编码一目了然。这个方法我用了很多年至今仍是最快的手段。
阅读完成 · 觉得有帮助?