一个字符串从内存到对方手里要过三道手:字符变字节(编码)、字节变少(压缩)、结构变字节(序列化)。线上多数"乱码""体积暴涨""升级后反序列化失败",都是把这三件事混成了一件。逐个拆:解决什么问题、代价在哪、出错先看哪层。
1. 三件事的区别
| 变换 | 输入 | 输出 | 目的 | 可逆 | 体积影响 |
|---|---|---|---|---|---|
| 编码 | 字符 / 结构化值 | 字节序列 | 让字符能存进字节通道 | 是 | 通常变大(如 UTF-8 对中文 3 字节/字) |
| 压缩 | 字节序列 | 更短的字节序列 | 减少传输与存储量 | 是(无损) | 变小,压缩率取决于内容冗余度 |
| 序列化 | 内存对象/结构 | 字节序列 | 让结构能跨越进程与语言 | 是 | 取决于格式,文本格式通常更大 |
顺序上要先分清层次:编码管"字符怎么变字节",序列化管"结构怎么变字节",压缩管"这些字节能不能更短"。压缩作用在序列化结果之上,而不是反过来。
2. 字符编码:UTF-8 的关键事实
| 码点范围 | UTF-8 字节数 | 首字节形态 |
|---|---|---|
| U+0000–U+007F | 1 | 0xxxxxxx |
| U+0080–U+07FF | 2 | 110xxxxx |
| U+0800–U+FFFF | 3 | 1110xxxx(中日韩常用字在此) |
| U+10000–U+10FFFF | 4 | 11110xxx(emoji 多在此) |
UTF-8 是变长编码:同一个"字符数"在不同内容下字节数差异很大,所以按字符数估算存储与带宽必然偏差。ASCII 兼容是它最大的优势——纯英文文档与 ASCII 逐字节相同。
$ file -i INPUT.txt # 启发式猜测,短文件常判错
$ iconv -f GB18030 -t UTF-8 INPUT.txt -o OUT.txt
$ python3 -c "print(len('中文'.encode()), len('中文'))"
file -i 靠启发式判断,短文件经常判错;确定编码最可靠的办法是问数据来源,而不是靠猜。
3. 乱码的两种成因
| 成因 | 机制 | 典型现象 |
|---|---|---|
| 用错编码解码 | 字节本身没坏,解码器选错 | 中文变 䏿–‡(UTF-8 被当 Latin-1)或 ??? |
| 有损转换 | 从宽字符集转到窄字符集,映射不上就替成 ? |
中 变 ?,且不可逆 |
区别很关键:第一种能救——拿原始字节用正确编码重解一次即可;第二种救不回来,信息在转换那一刻就丢了。
# 典型的"UTF-8 被当 Latin-1"复原
$ python3 -c "print('䏿–‡'.encode('latin-1').decode('utf-8'))"
判断依据是"还原后是否得到可读文本"。若字节里已经出现 0x3F(?)成片出现,基本可以判定是有损转换。
4. Base64:把二进制塞进文本通道
Base64 用 64 个可打印字符表示任意字节,每 3 字节编码为 4 字符,体积按 4/3 增大(约 +33%),再加上换行与填充符。
| 用途 | 为什么需要它 | 代价 |
|---|---|---|
| 在 JSON/XML 里传二进制 | 文本协议不接受裸字节 | 体积 +33%,且不可直接索引 |
| HTTP Basic 认证 | 头部只能是文本 | 只是编码,不是加密 |
| Data URL 内嵌小图 | 省一次请求 | 阻塞解析、无法单独缓存 |
| 邮件附件(MIME) | 早期通道只保证 7bit | 同上 |
$ base64 -w0 blob.bin | wc -c # 与原始大小对比
$ echo 'YWRtaW46MTIz' | base64 -d # 一眼看穿:这不是加密
把 Base64 当"混淆"是常见误解:它没有密钥,任何人都能解。真要保密必须走加密,Base64 只解决"通道能不能过"的问题。
5. 压缩:无损与有损,算法各有适用面
| 算法 | 类型 | 压缩比量级 | CPU 代价 | 适用面 |
|---|---|---|---|---|
| gzip(DEFLATE) | 无损 | 中 | 低 | 通用 HTTP 响应、日志归档 |
| brotli | 无损 | 比 gzip 高(文本类更明显) | 高(尤其高质量等级) | 静态资源预压缩 |
| zstd | 无损 | 介于两者,可调等级 | 可调,低等级很便宜 | 需要兼顾速度与比值的场景 |
| LZ4 | 无损 | 低 | 极低 | 内存/临时数据的高速压缩 |
| JPEG / H.264 | 有损 | 很高 | 编解码成本高 | 图像、视频 |
"压缩比更高"永远伴随"CPU 更贵",没有免费的午餐。选择依据是链路带宽与 CPU 谁是瓶颈:带宽紧张选高压缩比,CPU 紧张选低等级。
$ curl -sI -H 'Accept-Encoding: br,gzip' https://TARGET/ | grep -i content-encoding
$ zstd -3 -o OUT.zst INPUT.log
6. 压缩的粒度与时机
| 内容类型 | 压缩收益 | 说明 |
|---|---|---|
| JSON / XML / HTML / JS / CSS | 高 | 文本冗余度大,典型能压到原体积的几分之一 |
| 日志文本 | 高 | 重复模式多 |
| JPEG / PNG / MP4 / zip | 接近 0 | 已是压缩格式,再压只烧 CPU |
| 已加密数据 | 接近 0 | 密文近似随机,不可压 |
| 极小的响应(几百字节) | 可能为负 | 压缩头与字典开销超过收益 |
顺序上有一条硬规则:先压缩,再 TLS 加密。加密后的数据不可压缩,反过来做等于白费。这也是为什么 TLS 层不再提供压缩(早期 DEFLATE 压缩还引入了 CRIME 类侧信道问题)。
gzip on;
gzip_min_length 1024; # 太小的响应不压
gzip_comp_level 5; # 折中等级
gzip_types text/plain text/css application/json application/javascript;
gzip_vary on; # 让缓存按 Accept-Encoding 区分
# 静态资源更适合离线预压缩 + brotli_static
brotli on;
brotli_static on;
brotli_comp_level 6;
brotli_types text/css application/javascript application/json;
7. 序列化:四种格式的对照
| 格式 | 可读性 | 体积 | 跨语言 | 模式演进 | 典型场景 |
|---|---|---|---|---|---|
| JSON | 高 | 大(键名重复) | 极好 | 宽松:加字段安全,类型靠约定 | 对外 API、配置 |
| Protobuf | 低(需 schema) | 小 | 好(需 .proto) |
靠字段编号与 optional/reserved |
内部 RPC、高吞吐链路 |
| Avro | 低(需 schema) | 小 | 好 | 靠 schema 解析方/写方匹配 | 数据管道、批处理落盘 |
| Java 原生 | 无 | 大 | 差(仅 Java) | 靠 serialVersionUID,极脆弱 |
仅限同版本进程内临时用途 |
message Order {
int64 id = 1; // 编号一旦发布就不能改用途
string sku = 2;
int32 qty = 3;
reserved 4, 7; // 下线字段占位,防止被复用
reserved "legacy_flag";
}
Protobuf 的体积优势来自不传键名,只传编号 + 变长整数;代价是必须有 schema 才能解读,抓包看到的是一串不可读字节——这是排障成本,不是缺陷。
8. 兼容性三问
| 变更 | Protobuf | JSON(消费方宽松) | Avro |
|---|---|---|---|
| 加字段 | 安全(新编号 + 默认值) | 通常安全,旧客户端忽略 | 安全(读方用 default) |
| 删字段 | 需 reserved 占位,否则编号会被复用 |
通常安全,但旧客户端可能仍发送 | 需给默认值 |
| 改类型 | 危险(int32↔string 直接错乱) |
危险(隐式转换静默出错) | 受 schema 校验拦截 |
三条判断标准可以背下来:加字段默认安全、删字段要留墓碑、改类型等于换协议。第三种在生产上最难查——数据不报错,只是含义变了。
9. 为什么不该用 Java 原生序列化做对外协议
| 问题 | 具体表现 |
|---|---|
| 与类结构强绑定 | 改个字段名/加个字段就可能 InvalidClassException |
| 体积大 | 携带类元数据,远超等价的 JSON |
| 性能不占优 | 反射与对象图遍历开销明显 |
| 跨语言不可能 | 只有 JVM 能读 |
| 反序列化面过大 | 可控字节流能构造任意对象图,历史上多个 CVE 源于此 |
需要二进制且要跨语言,选 Protobuf/Avro;需要人可读,选 JSON;Java 原生序列化只适合"同一进程组、同版本、短生命周期"的场景,比如本地缓存落盘。
10. 端到端定位顺序
出问题时按固定顺序走,别跳步:
# 1. 先看声明的编码
$ curl -sI https://TARGET/api/x | grep -i content-type
# 2. 再看实际字节
$ curl -s https://TARGET/api/x | xxd | head -5
# 3. 对比服务端内部值
$ curl -s https://TARGET/api/x | iconv -f UTF-8 -t UTF-8 >/dev/null && echo "UTF-8 合法"
# 4. 若是压缩层问题,关掉编码再取一次
$ curl -s --compressed https://TARGET/api/x | wc -c
| 步骤 | 判断依据 |
|---|---|
看 Content-Type 的 charset |
声明与实际是否一致 |
| 看首字节 | UTF-8 中文首字节在 0xE4–0xE9 区间;0xC4/0xD6 开头更像 GBK |
| 尝试严格解码 | 抛错说明字节本身非法 |
| 分层剥离 | 关压缩、换客户端,逐层排除 |
定位顺序的意义在于避免在错误的层调参:把编码问题当压缩问题处理,只会得到"压了还是乱码"。
11. 常见坑
| 坑 | 现象 | 正解 |
|---|---|---|
混用 Content-Encoding 与 Content-Type |
客户端按错的层解释 | Content-Encoding 描述压缩,charset 在 Content-Type 里 |
| 只压静态文件不压 API 响应 | JS 很小、JSON 很大 | 对 application/json 也开压缩 |
| 改了 Protobuf 字段编号 | 线上字段串值、类型错乱 | 编号只增不改,下线用 reserved |
| 以为 Base64 是加密 | 一眼被 base64 -d 解出 |
保密走加密,Base64 只解决通道 |
| 压缩已压缩内容 | CPU 上升、体积不变 | 按内容类型判断,图片视频不压 |
用 String.getBytes() 不指定字符集 |
平台默认编码导致跨环境不一致 | 显式传 StandardCharsets.UTF_8 |
| 用字符数估算带宽 | 中文与 emoji 严重低估 | 按字节数统计 |
12. 边界
| 需求 | 更合适的做法 | 理由 |
|---|---|---|
| 需要人直接读的接口 | JSON | 可读性与工具链优先 |
| 高吞吐内部调用 | Protobuf + 长连接 | 体积与解析成本更低 |
| 落盘后要长期演进 | Avro + schema registry | schema 演进有正式规则 |
| 内存内高速压缩 | LZ4 | 压缩比不是首要目标 |
| 需要保密 | 加密(TLS / AEAD) | 编码与压缩都不提供机密性 |
一句话:编码回答"字符怎么变字节",序列化回答"结构怎么变字节",压缩回答"能不能更短"——三者的错不能互相顶替。
13. 自测
- UTF-8 里同一个"字符数"为什么不能用来估算字节数?
- 乱码的两种成因,哪一种不可逆?判断依据是什么?
- Base64 让体积增大多少?它为什么不提供保密性?
- 为什么必须先压缩再 TLS 加密,反过来会怎样?
- Protobuf 删除字段时为什么必须用
reserved占位? - 拿到一段乱码响应,第一步该看
Content-Type还是直接看字节?为什么?