小冯那天写了一个缓存模块,要把计算好的结果存到磁盘上,下次启动时直接读回来,避免重新计算。
计算结果是一个字典,里面有一些复杂的数据:时间戳、元组键、还有自定义对象。他先试了json:
kotlin
import json
data = {
"created_at": datetime.now(),
"user_ids": {1, 2, 3},
"config": {"timeout": 30},
}
with open("cache.json", "w") as f:
json.dump(data, f)
第一行就报错了:
typescript
TypeError: Object of type datetime is not JSON serializable
他把datetime转成了字符串,又把set转成了列表,再跑一次。这次json能序列化了,但读回来的时候,时间变成了字符串,set变成了列表。他的代码后面依赖set做交集运算,依赖datetime做时间比较,读回来的数据全变了类型,下游逻辑一个个崩掉。
他换成了pickle:
python
import pickle
with open("cache.pkl", "wb") as f:
pickle.dump(data, f)
with open("cache.pkl", "rb") as f:
loaded = pickle.load(f)
一次通过。datetime还是datetime,set还是set,自定义对象还是原来的类型。小冯感叹:"pickle这么强,为什么还要用json?"
三个月后,他的服务需要从另一个语言写的服务里读取这份缓存。对方拿到.pkl文件,完全不知道这是什么格式,也找不到任何库能解析它。小冯这才意识到:pickle只认Python,出了Python的圈子,它就是一堆二进制乱码。
"原来json的'弱'和pickle的'强',都是有代价的。"
这个代价,就是两者最核心的区别:json是通用格式,pickle是Python专属格式。
先看一个最直观的对比
ini
import json
import pickle
data = {"name": "Alice", "age": 30, "scores": [85, 92, 78]}
# json 序列化
json_str = json.dumps(data)
print(json_str) # '{"name": "Alice", "age": 30, "scores": [85, 92, 78]}'
# pickle 序列化
pkl_bytes = pickle.dumps(data)
print(pkl_bytes) # b'\x80\x04\x95...' 一堆二进制
json的输出是人类可读的文本,用{}、[]、"、,、:这些符号组织数据。你可以直接打开看,可以用文本编辑器改,可以用任何语言的JSON库解析。
pickle的输出是二进制字节流。人类看不懂,只有Python的pickle模块能读懂。它记录的不只是数据,还有Python对象的类型信息、结构信息,甚至引用关系。
用一个生活场景来理解。你要把一份文件寄给别人。
json的做法是:把文件翻译成一种国际通用语言(比如英语),写成一份标准格式的文档。对方不管用什么工具,只要懂这种语言,就能读懂。
pickle的做法是:把文件原封不动地打包成一个只有你和对方知道的密件。对方必须用和你完全相同的打包工具才能拆开。如果对方没有这个工具,或者工具版本不一样,密件就打不开。
区别一目了然:一个通用,一个专属。
json:只认基本类型
json能序列化的类型非常有限:
dict(键必须是字符串)liststrintfloatboolNone
就这些。其他类型一律报错:
python
import json
from datetime import datetime
json.dumps(datetime.now()) # TypeError
json.dumps({1, 2, 3}) # TypeError
json.dumps(b"bytes") # TypeError
json.dumps((1, 2, 3)) # 可以,元组被转成列表
元组能被序列化,但读回来会变成列表,类型变了。这是一个容易忽略的细节:json不保留元组类型。
ini
data = (1, 2, 3)
loaded = json.loads(json.dumps(data))
print(type(loaded)) # <class 'list'>,不是 tuple
json的键必须是字符串。如果你用整数或元组当键,序列化时会报错,或者键被强制转成字符串:
css
json.dumps({1: "a"}) # '{"1": "a"}',键变成了字符串
json.dumps({(1, 2): "a"}) # TypeError: keys must be str...
这种类型丢失在简单场景下无所谓,但在需要精确还原数据结构的场景下,就是灾难。
pickle:什么都能序列化
pickle几乎能序列化任何Python对象:
- 基本类型(
dict、list、str、int、float、bool、None) tuple、set、frozensetdatetime、Decimal、UUID- 自定义类的实例
- 函数、类(有条件)
- 嵌套的复杂结构
lua
import pickle
from datetime import datetime
data = {
"created_at": datetime.now(),
"user_ids": {1, 2, 3},
"config": {"timeout": 30},
}
pkl = pickle.dumps(data)
loaded = pickle.loads(pkl)
print(type(loaded["created_at"])) # <class 'datetime.datetime'>,类型保留
print(type(loaded["user_ids"])) # <class 'set'>,类型保留
pickle不仅保存数据,还保存类型信息。读回来的时候,datetime还是datetime,set还是set,自定义对象还是原来的类。
这就是小冯最初觉得pickle"强"的原因。它不需要你手动转换类型,不需要你写自定义的编码解码逻辑,直接把对象原样存、原样取。
坑一:pickle 只能用于 Python
这是pickle最大的限制,也是小冯三个月后踩的坑。
pickle的二进制格式是Python特有的。它依赖于Python的对象模型、类定义、模块路径。其他语言没有这些概念,自然无法解析。
python
# 存了一个自定义对象
class User:
def __init__(self, name):
self.name = name
user = User("Alice")
with open("user.pkl", "wb") as f:
pickle.dump(user, f)
这个.pkl文件里不仅存了"Alice",还存了"这是User类的实例""User类定义在哪个模块"这些信息。换一个语言,或者换一个没有User类定义的Python环境,都读不了。
如果你需要跨语言交换数据,json是唯一的选择。json是国际标准(RFC 8259),几乎所有编程语言都有解析库。
坑二:pickle 的版本兼容性
pickle有不同的协议版本(protocol)。Python 3.8默认用协议4,Python 3.12默认用协议5。高版本Python序列化的文件,低版本Python可能读不了。
python
# Python 3.12 默认协议5
with open("data.pkl", "wb") as f:
pickle.dump(data, f)
# Python 3.7 读取,可能报错
with open("data.pkl", "rb") as f:
data = pickle.load(f)
makefile
ValueError: unsupported pickle protocol: 5
解决方案是序列化时显式指定较低的协议版本:
ini
pickle.dump(data, f, protocol=4) # 兼容 Python 3.4+
但即使协议版本兼容,类的定义也必须一致。如果User类的属性变了,或者类被重命名、移动了模块,旧文件也读不了。
坑三:pickle 的安全问题
这是pickle最危险的地方。
pickle.load()在反序列化时,会执行文件里记录的"重建对象"指令。恶意构造的pickle文件可以执行任意代码。
kotlin
import pickle
import os
class Evil:
def __reduce__(self):
return (os.system, ("rm -rf /",))
# 序列化一个恶意对象
evil_data = pickle.dumps(Evil())
# 反序列化时,os.system("rm -rf /") 会被执行
pickle.loads(evil_data)
这段代码在pickle.loads()的那一刻就会执行os.system("rm -rf /")。攻击者只需要让你加载一个恶意.pkl文件,就能在你的机器上执行任意命令。
所以有一条铁律:永远不要反序列化不可信来源的pickle数据。
不可信来源包括:用户上传的文件、网络传输的数据、第三方提供的缓存文件、从数据库读出的未经校验的二进制字段。
json没有这个问题。json.loads()只解析数据,不执行代码。即使数据里有恶意内容,最多是解析失败或者得到奇怪的值,不会导致代码执行。
坑四:json 的编码问题
json.dumps()默认会把非ASCII字符转义成\uXXXX:
kotlin
import json
data = {"name": "张三"}
print(json.dumps(data)) # '{"name": "\u5f20\u4e09"}'
文件里存的是一堆\u转义序列,人类可读性差。要保留原始字符,需要传ensure_ascii=False:
ini
print(json.dumps(data, ensure_ascii=False)) # '{"name": "张三"}'
写文件时还要注意编码:
python
with open("data.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
pickle没有这个问题,因为它本来就是二进制,不涉及字符编码。
坑五:json 的浮点精度
json序列化浮点数时,可能丢失精度:
kotlin
import json
data = {"value": 0.1 + 0.2}
print(json.dumps(data)) # '{"value": 0.30000000000000004}'
这是浮点数本身的精度问题,不是json的错。但读回来的时候,如果下游需要精确比较,可能会出问题。
pickle能完整保留浮点数的二进制表示,不会有额外的精度损失。但浮点数本身的精度问题依然存在。
性能对比
json是文本格式,解析需要词法分析、语法分析,速度相对慢。pickle是二进制格式,解析更快。
python
import json
import pickle
import timeit
data = {"items": list(range(10000))}
t1 = timeit.timeit(lambda: json.dumps(data), number=1000)
t2 = timeit.timeit(lambda: pickle.dumps(data), number=1000)
print(f"json.dumps: {t1:.4f}")
print(f"pickle.dumps: {t2:.4f}")
在我的机器上,json.dumps大约0.3秒,pickle.dumps大约0.05秒。pickle快6倍左右。
体积上,pickle通常也比json小,因为二进制更紧凑。
但性能差异不应该作为选择的主要依据。大多数场景下,序列化的瓶颈在磁盘I/O或网络传输,不在序列化本身。选择json还是pickle,应该基于格式兼容性和安全性,而不是速度。
什么时候用 json,什么时候用 pickle?
判断标准很清晰:
需要跨语言交换数据 :用json。这是唯一的选择。
需要人类可读、可手动编辑 :用json。配置文件、API响应、日志,都应该用json。
需要长期存储、跨版本兼容 :用json。json格式稳定,不会因为Python版本变化而失效。
需要存储Python特有的复杂对象 (datetime、set、自定义类实例):用pickle,但要确保数据来源可信、读取环境一致。
数据来源不可信 :绝对不能用pickle。用json,或者用pickle的替代品(如json加自定义编码)。
Python内部缓存、临时数据、进程间通信 :可以用pickle。这些场景数据来源可控,不需要跨语言。
一个折中方案:json + 自定义编码
如果既需要json的通用性,又需要处理datetime、set这些类型,可以写自定义的编码器和解码器:
kotlin
import json
from datetime import datetime
class CustomEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime):
return {"__type__": "datetime", "value": obj.isoformat()}
if isinstance(obj, set):
return {"__type__": "set", "value": list(obj)}
return super().default(obj)
def custom_decoder(dct):
if dct.get("__type__") == "datetime":
return datetime.fromisoformat(dct["value"])
if dct.get("__type__") == "set":
return set(dct["value"])
return dct
data = {"created_at": datetime.now(), "ids": {1, 2, 3}}
json_str = json.dumps(data, cls=CustomEncoder)
loaded = json.loads(json_str, object_hook=custom_decoder)
print(type(loaded["created_at"])) # <class 'datetime.datetime'>
print(type(loaded["ids"])) # <class 'set'>
这样既保留了json的通用性和安全性,又能处理Python特有的类型。代价是需要手动维护编码解码逻辑,每增加一种类型就要加一个分支。
回到小冯的故事
小冯最终的方案是:缓存文件用json,但加了一层自定义编码。
kotlin
class CacheEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime):
return {"__type__": "datetime", "value": obj.isoformat()}
if isinstance(obj, set):
return {"__type__": "set", "value": list(obj)}
if isinstance(obj, User):
return {"__type__": "user", "name": obj.name}
return super().default(obj)
def cache_decoder(dct):
if dct.get("__type__") == "datetime":
return datetime.fromisoformat(dct["value"])
if dct.get("__type__") == "set":
return set(dct["value"])
if dct.get("__type__") == "user":
return User(dct["name"])
return dct
def save_cache(data, path):
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, cls=CacheEncoder, ensure_ascii=False)
def load_cache(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f, object_hook=cache_decoder)
缓存文件是文本格式,跨语言可读,跨版本兼容,没有安全风险。datetime、set、自定义对象都能正确还原。
他后来在团队文档里写了一条:"json是通用格式,只认基本类型,但跨语言、跨版本、安全。pickle是Python专属格式,什么都能存,但只认Python、版本敏感、有安全风险。需要跨语言就用json加自定义编码,只在Python内部用且数据可信才用pickle。永远不要pickle.load不可信数据。"
这个区分看起来简单,但真到写代码的时候,很多人会因为pickle方便就随手用,然后在跨语言、跨版本、或者安全审计时突然发现踩了雷。
记住那个寄文件的比喻:json是把文件翻译成国际通用语言,谁都能读;pickle是把文件打包成只有你和对方知道的密件,别人打不开,而且如果密件被掉包,拆开时可能炸。你要给谁看、数据可不可信,决定了你该用哪种方式。