Python的序列化把我坑惨了,原来json和pickle的区别这么大

小冯那天写了一个缓存模块,要把计算好的结果存到磁盘上,下次启动时直接读回来,避免重新计算。

计算结果是一个字典,里面有一些复杂的数据:时间戳、元组键、还有自定义对象。他先试了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(键必须是字符串)
  • list
  • str
  • int
  • float
  • bool
  • None

就这些。其他类型一律报错:

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、frozenset
  • datetime、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是把文件打包成只有你和对方知道的密件,别人打不开,而且如果密件被掉包,拆开时可能炸。你要给谁看、数据可不可信,决定了你该用哪种方式。

相关推荐
没事写点啥1 小时前
WeKnora 回答溯源
前端·后端·ai编程
智驭未来掌门人1 小时前
在 CLion 里创建 Qt 项目,真正需要想清楚的是什么
后端
数据库小学妹1 小时前
MySQL MDL锁等待排查:DDL卡住与长事务定位
数据库·后端·mysql
全栈弄潮儿1 小时前
Python实战第13期:模块与包
后端·python·编程语言
Thneonl1 小时前
ClickHouse UPDATE:一秒返回,三小时才生效
数据库·后端
铁皮饭盒2 小时前
这6个小模型各有绝活, 且都可cpu/浏览器端运行
前端·后端
qq_406981032 小时前
大白话rust系列08 当变量遮蔽遇上引用,探究变量遮蔽的本质
开发语言·后端·rust
喵了几个咪3 小时前
GoWind ShortPlay 拆解:不用模型的推荐闭环——埋点回流、三路召回与已看去重
开发语言·后端·微服务·golang·gowind
SimonKing3 小时前
又找到一个纯Java的OCR项目,识别的格式还挺多
java·后端·程序员