搞了个数据缓存,把Python对象直接塞进文件,再读出来发现是一堆乱码,后来才明白序列化这个环节被我省了。类似的坑我见过不少人踩过,所以想把这部分内容彻底讲透——序列化与反序列化在Python里是绕不开的基础技能,写接口要传数据、存缓存要落盘、跨进程通信要传对象,全部靠它。
这篇文章适合刚入门Python的开发者,也适合写过一阵代码但对序列化一直一知半解的人。看完之后你会明白序列化到底在做什么、有哪些主流方案、各自的使用边界在哪里,以及最容易踩的坑和对应的排查思路。不堆概念,全部按实操来讲。
1. 序列化到底是什么:先搞清楚它在解决什么问题
1.1 一个生活化类比:打包与拆包
把序列化和反序列化类比成寄快递,会好理解很多。你在电脑上写好一个Python对象,比如一个包含用户信息的字典,这个对象活在内存里,是“活”的状态。但内存不是持久化存储,进程一结束数据就没了,更别说要把这个对象通过网络传给另一台机器上的程序。
所以你要做的第一件事,就是把它“打包”成一种可以传输、可以存储的格式——这就是序列化。对方拿到打包好的数据后,再做一次“拆包”,恢复成自己程序里能用的对象——这就是反序列化。
这个类比还帮我们理解一件事:打包用的“箱子”是有规格的。你不能随便拿个塑料袋装精密仪器,同样,不是所有格式都能装下所有Python对象。选错“箱子”,轻则装不下,重则拆包的时候数据直接变形。
1.2 Python对象在内存里长什么样,为什么不能直接存
Python里的一切都是对象。一个字典、一个列表、一个自定义类的实例,在内存中保存的不仅仅是它的“值”,还有类型信息、引用关系、内部结构等一大堆运行时数据。这些内存布局是CPython解释器定义的,直接把它二进制地倒到磁盘上,也只能给同一个版本的CPython读,换台机器、换个Python版本,甚至换个平台,都可能是乱码。
更关键的是,我们在实际业务里要传递的,往往是对象的逻辑表示,而不是它在内存里的物理状态。比如我要发给前端的用户信息,逻辑上就是一个包含name、age、email的字典结构,对方根本不关心这个字典在Python内存里是怎么布局的。所以我们需要的是一种通用的、可解析的中间格式——这个格式就是序列化协议做的事。
1.3 序列化与反序列化的等价关系
序列化的核心要求就一条:序列化之后的结果,能完整地反序列化回原来的对象。这里有个关键词叫“完整”。
完整包含两层含义:一层是数据值不能变,数字1不能变成字符串"1";另一层是类型信息不能丢,整数就该还原成整数,列表就该还原成列表。但这里要提前打一个预防针:不同的序列化方案,对“完整”的定义不一样。JSON只认JSON自己的那套数据类型,Python的元组会被转成列表,反序列化回来就是列表,不是元组——这在JSON的语义下不算错误,但它确实改变了数据类型。所以在选型阶段就要想清楚,你的场景能不能接受这种等价关系的偏移。
2. 主流序列化方案选型:JSON、pickle,到底该用哪个
2.1 JSON:跨语言通用,但数据类型是“瘦身版”
JSON是应用最广泛的序列化格式,没有之一。它天生就是文本格式,人类可读、跨语言通用,几乎所有编程语言都有解析JSON的标准库。
但JSON的通用性是有代价的:它只支持少数几种基础数据类型——对象(字典)、数组(列表)、字符串、数字、布尔值和null。Python里的元组、集合、datetime、自定义类对象,它都不认识。
这意味着你在使用JSON序列化时,经常要做一层“翻译”。把元组转成列表、把datetime转成字符串,反序列化时再手动转回来。这层翻译工作需要你自己维护,稍不注意就会在某个字段上漏掉。
即便如此,只要你的数据本身是字典、列表、字符串、数字的嵌套组合,JSON依然是首选。跨语言场景、日志输出、配置文件、Web接口数据交换,全部无脑选JSON就行。
2.2 pickle:Python专属,功能全但有安全底线
pickle是Python内置的另一个序列化模块,和JSON是完全不同的思路。它可以把几乎任何Python对象序列化成字节流——包括自定义类实例、函数、生成器等。这是它的能力优势,也是它的风险源头。
pickle的文档里有句很直白的话:pickle不能保证跨Python版本兼容。你在Python 3.8里pickle出去的文件,拿到3.11里可能读不出来,反过来也一样。如果你做数据持久化,用pickle等于给自己埋了一颗版本的雷。
更严重的是安全问题。pickle反序列化的过程本质上是允许被反序列化的数据中携带“指令”的,也就是说,一个恶意构造的pickle数据流,可以在反序列化时执行任意代码。所以安全红线非常明确:永远不要反序列化来自不可信来源的pickle数据。网络上传输的pickle数据更要直接拒绝。
我的建议是,pickle只用在两类场景:一是Python进程内部的临时数据中转,比如多进程任务分发;二是对安全要求很低、又确实需要保存复杂Python对象的本地缓存。除此之外,尽量用JSON或者其他安全格式。
2.3 补充方案:msgpack、protobuf什么时候用
除了JSON和pickle,还有两个常见的补充方案值得知道。
msgpack是一个二进制序列化格式,接口设计上特别像JSON,支持类似loads和dumps的操作。它的特点是比JSON更紧凑、解析速度更快,同时保留了类似JSON的动态类型结构。适合对传输体积和解析速度都有要求的内部接口,不太在意数据可读性的场景。因为它是二进制格式,出了问题排查起来会比JSON麻烦一些。
protobuf是另一种选择,它的核心思路是先定义数据结构(.proto文件),再生成对应语言的代码,序列化时按预定义结构进行。它的体积最小、性能最强,但引入成本最高,适合大规模微服务架构中的内部通信。小项目用protobuf属于杀鸡用牛刀,因为改了数据结构之后还需要重新生成代码,迭代成本不低。
一句话总结选型思路:没有特殊需求就JSON,临时内部中转才pickle,性能和体积敏感用msgpack,大规模RPC体系内才考虑protobuf。
3. 实操:从基础用法到进阶细节
3.1 JSON基础操作:dump、dumps、load、loads的关系
先建立一个基础认知:JSON模块四个核心函数,带s的处理字符串,不带s的处理文件。dumps是把Python对象转成JSON字符串,dump则是把Python对象序列化后直接写入文件对象。loads是把JSON字符串解析成Python对象,load是从文件对象里读取内容再解析。
import json data = { "name": "测试用户", "age": 28, "tags": ["python", "backend"], "active": True } # 对象 -> JSON字符串 json_str = json.dumps(data, ensure_ascii=False, indent=2) print(json_str) # JSON字符串 -> 对象 data_back = json.loads(json_str) print(data_back["name"])这里有两个参数值得特别说明。
第一个是确保中文正常显示。json.dumps默认会把非ASCII字符转成\uXXXX的转义序列,从传输角度这没问题,但从可读性角度看满屏\uXXXX非常痛苦。加上ensure_ascii=False后中文才会原样输出。
第二个是indent。实测下来,线上传输的数据不需要缩进,因为缩进会增加体积,但在调试和写配置文件时建议把indent设为2或4。这样日志、文件里看到的数据结构是清晰的,肉眼排查问题效率高很多。
文件操作的写法建议用with语句管理上下文:
# 写入JSON文件 with open("user.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) # 读取JSON文件 with open("user.json", "r", encoding="utf-8") as f: data_loaded = json.load(f)有个细节我经常看到有人忽略:打开文件时如果没指定encoding="utf-8",Windows平台上很容易因为默认编码不是UTF-8导致写入乱码或读取报错。这个坑很小,但排查起来很费时间,建议手动写编码参数形成肌肉记忆。
3.2 处理JSON无法序列化的对象
JSON处理不了的典型对象有三种:datetime类型、Decimal类型、自定义类实例。处理方式分为两步走:序列化时告诉json怎么转,反序列化时告诉json怎么还原。
import json from datetime import datetime def datetime_to_str(obj): if isinstance(obj, datetime): return obj.isoformat() raise TypeError(f"Type {type(obj)} not serializable") json_str = json.dumps({"time": datetime.now()}, default=datetime_to_str) print(json_str)反序列化方向则用parse参数配合datetime.fromisoformat还原:
def str_to_datetime(data): if "time" in data: data["time"] = datetime.fromisoformat(data["time"]) return data data_back = json.loads(json_str, object_hook=str_to_datetime) print(type(data_back["time"]))这种手动转换的方式,核心价值在于你可以精细控制哪些字段需要转换。实际项目中更规整的做法是使用pydantic这类数据校验库,它们自带的序列化机制已经把datetime、Decimal这些常见类型的转换封装好了,不需要自己写转换函数。但从原理角度,理解default和object_hook这两个参数依然是基础,因为任何第三方库底层都跑不掉这套机制。
顺便说一个我在项目中反复用到的经验:时间字段统一存成ISO 8601字符串格式,前后端、各服务之间都用它做交换格式。ISO 8601带时区信息,可以避免不同服务器时区不一致导致的时间错乱。这个约定在团队里越早立下来越好,等数据量大了再改时间格式,迁移成本非常高。
3.3 pickle的基础操作与使用边界
pickle的操作方式和json几乎一样,只是文件模式变成了二进制。
import pickle class User: def __init__(self, name, level): self.name = name self.level = level user = User("测试用户", 3) # 序列化到字节 data_bytes = pickle.dumps(user) # 反序列化还原 user_copy = pickle.loads(data_bytes) print(user_copy.name, user_copy.level)pickle对自定义类的序列化,靠的是类本身的定义。反序列化时,Python会尝试导入并找到这个类的定义,然后创建实例并恢复属性。所以如果类定义改了(比如属性层次变了),老数据可能还原失败。你的代码里如果用了pickle做持久化,类结构的变更就要慎重,最好维护一个明显的版本标记。
实践里面我在使用pickle时都会加一个包装函数,把版本号和业务数据包在一起。这样后续如果结构变更,我可以在load的时候根据版本号做一次数据迁移,而不是直接崩溃:
def save_user(obj, path): with open(path, "wb") as f: pickle.dump({"version": 1, "data": obj}, f) def load_user(path): with open(path, "rb") as f: payload = pickle.load(f) if payload["version"] == 1: return payload["data"] else: raise ValueError("unknown version")这个模式本质上是给自己的数据留后路。不要觉得多余,等到线上出现一批旧数据读不出来的问题时,你会感谢当初这个版本号。
3.4 大文件与流式序列化
序列化不只有“全部读进来、全部写出去”这一种玩法。当对象非常大,比如一个包含几百万条记录的列表,一次性dumps再写文件很吃内存,因为dumps会先在内存里构建一个完整的字符串或字节串。
应对大文件的思路是流式处理。JSON场景下可以用迭代方式逐行写,不保留完整数据:
import json with open("large_data.jsonl", "w", encoding="utf-8") as f: for item in generate_items(): # 假设这是分批产生的数据源 f.write(json.dumps(item, ensure_ascii=False) + "\n")这样每行是独立的JSON对象,写的时候只保留当前一条记录在内存里,内存占用是常数级的。读取时用逐行解析的方式:
with open("large_data.jsonl", "r", encoding="utf-8") as f: for line in f: item = json.loads(line) process(item)这种每行一个JSON对象的格式叫JSONL,处理日志、导出数据文件、做离线任务分批处理时非常好用。它比排成一整个多层的JSON更容易增量追加,也更容易用命令行工具按行排查问题。
更夸张的数据量,比如单个文件好几个GB,这时候JSONL都不是最优解了,要考虑的是列式存储格式或者数据库层面的方案。序列化方案不是银弹,它能解决的是“对象到字节”的转换问题,解决不了“海量数据如何高效存取”的问题。
4. 常见问题与排查技巧实录
4.1 高频报错:XXX is not JSON serializable
这个报错是所有用JSON的人都会遇到的,几乎每天都在发生。它的含义是:json模块遇到了它不支持的数据类型,而你的default参数函数又没有处理这个类型。
import json from datetime import date data = {"today": date.today()} json.dumps(data) # TypeError: Object of type date is not JSON serializable排查思路非常直接:顺着报错堆栈找到触发序列化的那个对象,然后看它是什么类型,再决定是用default转成基础类型,还是在构造数据前就把类型换好。
我的排查顺序是:先看这个类型是不是datetime或Decimal这类常见类型,如果是,直接用标准转换方式处理;如果不是,而是自定义类型,先问一句这个对象真的需要完整序列化吗?很多时候你只需要某个字段值,比如只需要obj.id,那就不要序列化整个对象,直接把需要的数据抽出来做成字典,这会简化大量问题。
还有一种隐蔽情况是列表或字典内部混入了不可序列化对象。如果用default回调处理,记得在函数里对所有未知类型统一抛出TypeError,让问题暴露在开发和测试阶段,不要在生产环境里自己吞掉异常。序列化失败应该越早暴露越好,线上数据写了一半才发现类型不对,比报错更麻烦。
4.2 pickle的版本兼容问题
pickle数据很难跨Python版本迁移,这个问题的根源在于pickle协议本身在逐步演进。Python 3.8用的协议版本和3.11用的不完全一样,旧版本默认协议生成的数据,新版本可能读不了。
排查时先看协议版本。可以用pickle.HIGHEST_PROTOCOL查看当前解释器支持的最高协议版本,用loads读的时候如果确认数据格式没问题、但还是报错,就要怀疑是不是跨版本生成的。
应对策略分三层:第一层是目录里对数据文件加一个协议版本或Python版本标识;第二层是pickle.dumps时显式指定协议版本,比如pickle.dumps(obj, protocol=4),这样至少在可控范围内保持一致;第三层是用dataframe库的专用存储格式(如同一列式格式)替代pickle做持久化,它们内部有完整的版本迁移机制,至少比原生pickle稳。
我在一个长期任务里吃过pickle的亏。当时用Python 3.7的进程写入了一批pickle文件,后来环境升级到3.10后读取直接失败,只能回到旧环境重新导出。那之后我就给自己定了一条规矩:任何需要长期保存的数据都不放pickle,优先考虑直接用数据库存结构化数据,或者用JSON存储纯文本数据。
4.3 安全红线:不要反序列化不可信数据
这是序列化主题下最重要的一条,值得反复强调。pickle反序列化不等于解析数据,它更像是执行一段可能包含指令的字节码。攻击者构造一个特殊的pickle流,里面嵌入恶意函数调用,如果你用loads去解析,就等于帮攻击者执行了这段代码。这不是理论上的风险,而是真实存在的利用链。
安全建议很明确:
- 不要从公网接收pickle数据,二不要对用户上传的pickle文件执行loads,三不要在日志或错误信息里直接打印反序列化后的内容。
- 如果确实需要传输Python对象,改用更安全的格式——JSON或msgpack,它们只做纯数据解析,不包含代码执行路径。
- 如果业务必须用pickle且对象来自多个潜在不可信方,考虑在独立的权限受限环境里做反序列化,并且这仍然不是一个值得推荐的做法。
同样的道理也适用于使用json.loads的场景,安全性上虽然好很多,但也别把来自不可信来源的JSON直接反序列化后当作可信数据用,中间必须加校验。类型对不对、字段有没有、边界值合理不合理,这些校验逻辑要放在反序列化之后、使用数据之前。
4.4 文件编码与缩进细节
JSON文件相关的编码问题主要集中在Windows平台。用记事本编辑过的文件可能带了UTF-8 BOM头,json.load可能直接报错。解决办法是用utf-8-sig编码读:
with open("file.json", "r", encoding="utf-8-sig") as f: data = json.load(f)utf-8-sig能自动吞掉BOM,不会影响正常UTF-8文件的读取。做兼容时可以先读写都统一用utf-8,文件首行不保留BOM,交叉排查时再考虑utf-8-sig的引入。
缩进相关的问题只在可读性层面。dump时设置indent太多会让文件体积膨胀明显,indent=2在体积和可读性之间比较均衡。线上传输的短小JSON不要用indent,保持单行压缩格式。这个习惯养成之后,排查线上问题时能少踩很多坑。
5. 性能与实战建议
5.1 数据量多大时需要考虑性能
很多人对序列化的性能问题有误解,要么几KB的数据就在那苦恼性能,要么几十MB的数据还全链路转JSON毫无察觉。
先给一个直观的判断坐标:几十KB到几MB的数据量,JSON序列化的耗时基本在毫秒级,不需要删除优化空间。速度瓶颈十有八九不在序列化本身,而在网络IO和磁盘IO。真正要考虑性能的数据量,至少是几十MB级别,且需要高频读写的场景。
一种实测有效的验证方式:写一个小的基准脚本,用相同数据跑多次,统计每次dumps和loads的耗时分布。
import json import time import random # 构造一个中等规模的数据集,比如20万条记录 records = [ {"id": i, "value": random.random(), "text": f"item_{i}"} for i in range(200000) ] start = time.perf_counter() blob = json.dumps(records) print(f"dumps耗时: {time.perf_counter() - start:.3f}s") start = time.perf_counter() back = json.loads(blob) print(f"loads耗时: {time.perf_counter() - start:.3f}s")跑完之后你会发现,20万条结构简单的记录,dumps和loads耗时都很低。要是你的数据压到几万条就觉得卡,那问题大概率在业务逻辑本身,比如循环里做了耗时的格式转换,比如每条记录都打开了文件句柄。
5.2 常用的优化手段
第一是参数层面的精细控制。json.dumps里有个separators参数,默认是(', ', ': '),带空格,改成(',', ':')能省掉大量空格字符。对大JSON做传输时,这个优化立竿见影。之前存几GB的JSON日志,单纯调整separators就能减少不小体积。
compact_json = json.dumps(data, separators=(",", ":"))第二是数据层面的精简。很多接口返回的字段里,大量前端根本用不到的信息也一起序列化了。该做的数据裁剪不要省,每少一个字段,传输和解析都省一分成本。这个优化在数据量大的时候效果最明显。
第三是替代方案的选择。如果数据结构比较稳定,且性能确实成为瓶颈,用msgpack替换JSON是平滑的路径,接口风格几乎一样,但体积和时间都明显下降。如果已经是数据库存储方案,就不要再试图通过序列化做优化,让数据库引擎去处理索引和查询,比什么都快。
第四是持久化层面,压缩算法可以叠加使用。大JSON文本默认压缩率不错,选择合适的压缩策略后,磁盘占用能降不少。但压缩只解决存储体积,不解决解析性能,再需要快速随机访问的场景,要尽量避开大文件的整体压缩加整体解析。
5.3 我自己常用的“序列化约定”
长期和序列化打交道之后,我总结了一套自己的约定,写下来供参考。
JSON只做数据交换格式,不做长期存储格式。配置文件、接口返回、跨语言通信用JSON,存数据库用数据库原生类型或者专门的设计。
datetime统一用时间字段存储,带时区标记,统一转成UTC后再序列化,展示层再做时区转换,可以省掉大量时间错乱问题。
持久化需要保存复杂Python对象时,优先确认是否有替代方案。优先考虑将对象拆解成若干基础字段存入数据库,或者单独存成一个JSON文件加嵌套结构。只有对象内部确有其自带的复杂逻辑且不可拆分时,才考虑pickle这类方案,且一定要加版本号。
每次对接外部系统或改造传输协议时,先用一个小脚本验证序列化结果能被正确解析,再铺开对接改动。哪怕只是改了某个字段类型,也要做一次往返验证,即序列化后立刻反序列化比对。这个习惯能滤掉绝大多数低级错误。
最后再补一句:序列化模块的API并不复杂,几个小时就能摸熟。真正决定一个方案成败的,往往是你对数据形态、数据生命周期和安全边界的判断。把选型和边界想清楚,比背会几个API函数有意义得多。这篇文章看到这里,你至少已经知道各种方案的底牌是什么,遇到具体问题时,按照这个思路去选型、去写代码、去排查,基本不会走弯路。