Python pickle 与对象序列化深度解析:把对象连结构带指令一起冻住再复活
一、引言:为什么需要序列化
你写好的对象活在内存里,进程一关就没了。可真实工程里,我们常常要把内存里的对象存盘、跨进程传、丢进缓存、或者发到另一台机器——这就离不开"序列化":把活的 Python 对象变成一串能落盘/能传输的字节,再在另一端"复活"成等价对象。
Python 标准库给的方案是 pickle:一行 dumps 把对象冻成字节串,一行 loads 再把它 thaw 回来,几乎零样板。
1 | import pickle |
1 | 类型: bytes |
C++ 对照:C++ 没有内建序列化。一个 struct 想落盘,要么自己手写 serialize()/deserialize(),要么上 protobuf / flatbuffers / boost::serialization 这类框架(后面会比对)。Python 的 pickle 胜在"基本不写样板就能把任意对象冻住",代价是它只认 Python,而且暗藏一处危险(第三、四节详说)。
二、核心概念:pickle 是一台"栈式虚拟机"
很多人以为 pickle.dumps 是把对象"拍扁成字节",其实没那么简单。它生成的是一段字节码(opcode 流),由反序列化时的栈式解释器逐条执行,从而"重建"出原对象。
1 | import pickle, pickletools |
1 | 0: \x80 PROTO 2 |
可以看到:pickle 记录的是"去哪找类 C → 压入参数 7 → 用 REDUCE 调 C(7)"这样一串指令。反序列化时,解释器照着执行,对象就重建回来了。
pickle 序列化 / 反序列化全过程的示意:
1 | 对象 a (内存里) |
C++ 对照(关键差异):C++ 的 struct 没有"运行时反射 + 指令"这种东西。你想要可序列化,得显式声明 schema:
- protobuf:写
.proto定义字段,工具生成代码,SerializeToString()/ParseFromString()是 schema 驱动、跨语言、二进制紧凑; - flatbuffers:零拷贝,直接读一块内存,不需要先解析成对象,性能极致;
- boost::serialization:最接近 pickle 的"按类型自动分流"思路,但仍是 C++ 模板在编译期做的,不跨语言。
一句话:pickle 是"按对象结构 + 运行时指令"的 Python 专属方案;C++ 这边要么手写、要么靠协议,都得先把格式定清楚。
三、深入:可序列化边界、协议版本与致命安全坑
3.1 对象靠 __reduce__ 告诉 pickle "怎么重建我"
绝大多数内置类型(int、dict、list、set、甚至嵌套结构)pickle 都认识。自定义类的实例之所以也能冻住,是因为 pickle 会去调它的 __reduce__ / __reduce_ex__:返回一个 (callable, args) 元组,意思是"用 callable(*args) 就能把我造出来"。第二节那段字节码里的 REDUCE 就是在执行这个约定。
你可以自己接管这个过程:
1 | import pickle |
3.2 协议版本(protocol 0~5)
pickle.dumps(obj, protocol=N) 可以指定协议号,越高越新:
protocol 0:纯文本、人能读,体积大;protocol 2+:二进制、更紧凑、支持新版类型;protocol 5:支持带外数据(out-of-band data),适合超大数组零拷贝传输。
1 | import pickle |
3.3 安全红线:pickle.loads 会执行任意代码
这是 pickle 最阴险的一点:loads 不是"解析数据",而是执行字节码。如果字节流是别人精心构造的,它就能在你进程里跑任意命令。
1 | import pickle |
1 | # (示意)真实执行会调用系统命令, 切勿对不可信输入做此操作 |
C++ 对照:protobuf / flatbuffers 的解析只是"按 schema 填字段",不执行任何代码,面对不可信输入安全得多。这就是为什么——pickle 只该用在"你能完全信任的数据源"(自己进程、同版本同代码),绝不对外部 / 用户上传的数据 loads。
四、实战:自定义序列化、真实踩坑与跨语言取舍
4.1 用 __getstate__ / __setstate__ 精细控制
想让某个字段不参与序列化,或重建时做点额外处理,可以定义这两个钩子:
1 | import pickle |
4.2 一个匿名化的真实踩坑:把对象 pickle 进缓存,版本一变就崩
某次排查一个常驻服务:它把"服务配置对象"用 pickle 塞进 Redis 做跨进程缓存。最初配置类有 3 个字段:
1 | import pickle |
后来部署新版本,配置类加了一个必填字段 pool_size。老缓存里那条 pickle 还是旧结构,新代码一 loads:
1 | TypeError: __init__() missing 1 required positional argument: 'pool_size' |
反过来也一样危险:删字段后,旧 pickle 反序列化会得到一个"缺字段的僵尸对象",运行到一半才在别的模块炸出来。根因:pickle 把"类定义在哪、长什么样"也默默写进了数据(还记得第二节字节码里那个 c __main__ C 吗?它存的是类的路径引用,不是类本身)。所以 pickle 数据跟代码版本强耦合,跨版本极其脆弱。
修法有两条路:要么给配置加 __getstate__/__setstate__ 做字段兼容,要么干脆改用 JSON / protobuf 这种"显式 schema、向后兼容"的格式。
4.3 跨语言:pickle 是 Python 私有的
pickle 字节流里存的是 __main__.C 这种 Python 类路径,换一门语言根本没法解释。需要跨服务、跨语言传数据,请上 JSON(通用、人读)或 protobuf / flatbuffers(紧凑、强 schema、跨语言)。pickle 的舒适区是:进程内缓存、同版本 Python 之间的数据搬运。
五、对比总结:Python pickle vs C++ 序列化方案
| 维度 | Python pickle | C++(protobuf / flatbuffers / 手写) |
|---|---|---|
| 易用性 | 几乎零样板,dumps/loads 即用 |
需写 schema 或手写 serialize/deserialize |
| 跨语言 | 仅 Python(按类路径引用) | protobuf / flatbuffers 跨语言;手写仅同语言 |
| 安全性 | 危险:loads 执行任意代码 |
解析不执行代码,更安全 |
| 版本演进 | 与代码版本强耦合,易碎 | schema 演进有显式规则(字段号、optional) |
| 性能 | 快但字节通常偏大;协议 5 + 压缩更优 | flatbuffers 零拷贝最快;protobuf 紧凑 |
| 适用场景 | 进程内缓存、同版本 Python 间传数据 | 跨服务、跨语言、不可信输入 |
一句话收束:pickle 是"把 Python 对象连结构带指令一起冻住再复活"的便利工具,但它是 Python 私有的、且会执行代码的——缓存别跨越代码版本、绝不对不可信输入 loads。需要跨语言或面对外部数据,请上 JSON / protobuf。

