2953 字
约 9 分钟
21
S02-X10 · 编码、压缩与序列化:数据的三种变换

一个字符串从内存到对方手里要过三道手:字符变字节(编码)、字节变少(压缩)、结构变字节(序列化)。线上多数"乱码""体积暴涨""升级后反序列化失败",都是把这三件事混成了一件。逐个拆:解决什么问题、代价在哪、出错先看哪层。


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. 自测

  1. UTF-8 里同一个"字符数"为什么不能用来估算字节数?
  2. 乱码的两种成因,哪一种不可逆?判断依据是什么?
  3. Base64 让体积增大多少?它为什么不提供保密性?
  4. 为什么必须先压缩再 TLS 加密,反过来会怎样?
  5. Protobuf 删除字段时为什么必须用 reserved 占位?
  6. 拿到一段乱码响应,第一步该看 Content-Type 还是直接看字节?为什么?
S02-X10 · 编码、压缩与序列化:数据的三种变换
https://www.clxhxhhr.top/posts/5902/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。