干了几年 Python,大部分项目里我都是json一把梭。直到某次处理一批几 GB 的日志文件,单条 JSON 解析耗时直接让整个管道卡到怀疑人生,我才认真把simdjson和orjson拉进来做了个深度对比。这篇文章不打算写成那种"哪个快用哪个"的结论帖,而是把底层原理、API 差异、实测数据和那些文档里不会告诉你的坑,一次性讲清楚。
如果你正在写 API 服务、数据采集管道、日志解析,或者只是觉得json.loads在大流量下不太够用,那这篇值得花十分钟看完。
1. 标准库 json 的真实瓶颈:不只是"慢"这么简单
大多数场景下,Python 自带的json模块其实够用,而且它的 API 设计得很规整,配合json.dump和json.load处理文件流也非常顺手。但在高并发接口、批量任务、大规模日志清洗这类场景里,它往往是最先暴露问题的环节。
1.1 解析器本身的性能开销
json.loads的本质是用 C 实现的解析循环,但它在每个 token 上都要做 Python/C 的边界传递,字符串解码、dict 构建、类型判定都是逐层进行的。字符一多,CPU 密集的下限就压在了这个循环上。而且它没法做指令级并行,因为它是一个字符一个字符顺序扫描的。
一个典型对比:一段 1MB 左右的 JSON 文本,标准库解析大概要几十到上百毫秒(取决于机器),而orjson通常能把时间压到十分之一左右。这个差距在高 QPS 服务里会直接反映在 P99 延迟上。
1.2 ensure_ascii 是最大的隐形杀手
很多人写"JSON 序列化"时根本没意识到,json.dumps默认的ensure_ascii=True会把所有非 ASCII 字符转成\uXXXX转义序列。这个行为有两个代价:一是序列化时每个中文字符都要做转义计算,二是在网络传输时体积明显变大。
>>> import json >>> obj = {"message": "你好,世界"} >>> json.dumps(obj) '{"message": "\\u4f60\\u597d\\uff0c\\u4e16\\u754c"}' >>> json.dumps(obj, ensure_ascii=False) '{"message": "你好,世界"}'从性能角度说,ensure_ascii=False能省掉大量转义操作;从效果上说,传输到下游再也不用先 unescape 一遍。但我见过不少线上服务,明明下游是 UTF-8 环境,还是开着默认的ensure_ascii=True硬扛,纯粹是没意识到这里有个开关。
1.3 数据类型的"粒度"坑
标准库json在解析数字时,会把1.0解析成float,把1解析成int。听起来很合理,但实际业务里有大量"本以为是 float 结果是 int"的案例。比如你在处理价格字段时,某个字段在数据库里是10.00,但 JSON 里传的是10,标准库直接给你个int,下游做减法时类型就出问题了。
>>> json.loads('{"price": 10.0}')['price'] 10.0 >>> json.loads('{"price": 10}')['price'] 10 # int,不是 float这个问题看似小,但在数据处理管线里经常是"半夜告警"级别的隐患。我在某个跨平台系统里就踩过这种坑:上游接口返回的价格字段偶尔不带小数位,消费端直接拿int去算钱,最后对账差了几分钱,排查了大半天才发现是类型粒度的问题。
所以,当你考虑换库时,不只是"换个更快的东西",还要重新审视 API 行为、默认参数和类型转换规则。下面这两个库,恰好在这几个维度上提供了不同的答案。
2. simdjson 入场:SIMD 到底快在哪
simdjson的名字已经说明了一切:它最大的特点是利用 CPU 的 SIMD(单指令多数据)指令,让处理器一次能同时处理 64 字节甚至更多数据,而不是一个字符一个字符地走。
2.1 结构索引与字符串扫描的分离
simdjson的解析逻辑分为两步:第一步是"结构索引",它先通过 SIMD 快速找到所有{、}、[、]、"、:、,这些结构字符的位置;第二步才是真正构建对象。这样最大好处是,字符串内容的扫描和 JSON 结构的识别被分开了,CPU 在处理字符串时不需要反复判断"我现在在哪个括号里"。
对应到 Python 绑定上,常见用法大致是:
import simdjson parser = simdjson.Parser() data = parser.parse(b'{"key": "value", "list": [1, 2, 3]}') print(data["key"]) # valuesimdjson返回的data对象并不完全是 Python 原生 dict,它内部有惰性求值的影子。你用data["key"]访问时,它会按需把对应部分转换成 Python 原生类型。这意味着如果你只取大 JSON 里的一小部分,可能比完整解析省大量开销。
2.2 ONDEMAND 模式的意义
simdjson还有一套 ONDEMAND 解析概念:你可以在解析过程中只提取你需要的字段,跳过其余部分。对超大 JSON 来说,这比一次性构建全部对象省太多内存和时间。
import simdjson with simdjson.ondemand.parse(b'{"a": 1, "b": {"c": 2}}') as doc: print(doc["b"]["c"]) # 只走了需要的路径不过这里的性能优势不是绝对的。ONDEMAND 模式下每次访问字段都要重新定位,如果同一个字段被访问很多次,反而可能比直接构建完整对象更慢。所以不要迷信"惰性一定快",要根据访问模式取舍。
2.3 处理大文件时的内存映射用法
simdjson在 Python 侧真正好用的一点是支持通过内存映射解析文件,而不是一次性把整个文件读进内存再 parse。
import simdjson import mmap parser = simdjson.Parser() with open("large.json", "rb") as f: mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) doc = parser.parse(mm) # 按需取字段对大日志文件来说,这种方式能把内存占用压得非常低。我自己的体验是,解析以"GB"为单位的数据时,这个特性确实让以前不敢碰的"全量读取 + json.loads"变成了轻松完成的任务。
3. orjson 的取舍:API 最接近 json,但细节别忽略
如果说simdjson的核心优势在"按需扫描",那orjson走的是另一条路:保持和标准库json几乎一样的 API 习惯,但在底层用 Rust 实现序列化和反序列化,并且通过 native 结构直接写字节流,省去了大量中间转换。
3.1 序列化性能高的原因
orjson.dumps在序列化时,会直接把 Python 对象转换成 JSON 字节,而不是像标准库那样先构建一个字符串再编码。它对dict、list、str、int等常见类型都做了高度优化的路径。另外它默认不做 ASCII 转义,直接输出原始 UTF-8,这在大段中文或 emoji 场景下优势很明显。
import orjson obj = {"message": "你好,世界"} res = orjson.dumps(obj) print(res) # b'{"message":"你好,世界"}'注意orjson.dumps返回的是bytes而不是str。这一点如果从标准库迁移,必须调整代码逻辑。比如你之前把json.dumps(obj)直接塞进数据库字段,orjson就需要先.decode()才能存字符串,否则类型会不一致。
3.2 loads 的类型边界
orjson.loads在多数场景下和json.loads基本兼容,但有几个细节容易踩:
- 它只接受
bytes和str,不接受文件对象,所以必须配合read()使用。 - 它不会把
1.0当float,而是根据实际文本决定类型,这一点和标准库一致。 - 对于重复键,它默认保留最后一个,和标准库一致;但如果你需要保留所有重复键,就得先做预处理。
>>> import orjson >>> orjson.loads(b'{"a": 1, "b": 2}') {'a': 1, 'b': 2} >>> orjson.loads('{"price": 10}') {'price': 10}3.3 datetime 与 numpy 的特殊支持
orjson一个很让数据工程师开心的点,是它对datetime、date、time、uuid有原生序列化支持,不需要再写default函数。
import orjson from datetime import datetime obj = {"time": datetime.now()} res = orjson.dumps(obj) print(res)不过它的输出格式是 ISO 8601 的默认形式,如果你需要时间戳或自定义格式,还是得自己处理。另外numpy的数组或标量,虽然在一些版本里能直接序列化,但我建议还是显式转换,避免版本升级后行为变化。
4. 安装与基础用法速览,附几个扎手问题
这两个库的安装都比较简单,pip install simdjson和pip install orjson就能搞定。但有几个平台相关的问题值得提前说。
4.1 二进制 wheel 覆盖情况
orjson基本上是全平台提供 wheel 的,从 Linux 到 macOS 到 Windows 都有现成的二进制文件,装起来很省心。simdjson的 wheel 覆盖稍微慢一点,某些较老的 Linux 发行版可能需要源码构建。
如果你在 CI 或 Docker 里构建,建议给它们单独加一层缓存。我在某次部署时遇到过:基础镜像里装simdjson需要编译器,结果每次构建都要重新编译一遍,白白拖慢流程。后来把依赖打进基础镜像,才彻底解决。
4.2 标准 API 对照
下面这张表是我平时切换时经常参考的对应关系:
| 操作 | json | orjson | simdjson |
|---|---|---|---|
| 解析 bytes | json.loads(s) | orjson.loads(s) | simdjson.loads(s) |
| 解析文件对象 | json.load(f) | orjson.loads(f.read()) | simdjson.load(f) |
| 序列化 | json.dumps(obj) | orjson.dumps(obj) | simdjson.dumps(obj) |
| 输出类型 | str | bytes | bytes或str |
| 文件流写入 | json.dump(obj, f) | f.write(orjson.dumps(obj)) | f.write(simdjson.dumps(obj)) |
| 默认 ASCII 转义 | 是 | 否 | 否 |
对应的示例代码:
import json import orjson import simdjson # json data1 = json.loads('{"name": "张三"}') out1 = json.dumps(data1, ensure_ascii=False) # orjson data2 = orjson.loads(b'{"name": "张三"}') out2 = orjson.dumps(data2) # simdjson data3 = simdjson.loads(b'{"name": "张三"}') out3 = simdjson.dumps(data3)4.3 文件读写不要直接套 json.dump 的习惯
orjson和simdjson都没有dump方法,你需要手动处理文件写入。很多人刚切换时会下意识写orjson.dump(obj, f),结果直接报AttributeError。改成f.write(orjson.dumps(obj))就好。
还有一个细节:simdjson.dumps默认返回的是bytes,但在某些版本里可以传ensure_ascii=False之类的参数得到str。如果你的下游强制要求字符串,建议统一.decode("utf-8"),避免不同版本反复横跳。
5. 一次性能对比实测的过程与差点翻车的细节
为了直观感受三个库的差距,我搭了一套简单的对比实验。数据是模拟项目X里的典型负载:一个嵌套结构,包含订单、用户、商品列表、时间戳等字段,总大小大概 1MB 左右,每条记录大约 200 个字段。
5.1 测试思路
我复制了同一份 JSON 文本,分别用json.loads、orjson.loads、simdjson.loads循环解析 100 次,取平均值;序列化则用各自对应的dumps循环 100 次。这样能避免单次运行的抖动。
import time import json import orjson import simdjson with open("sample.json", "rb") as f: raw = f.read() for name, func in [ ("json", json.loads), ("orjson", orjson.loads), ("simdjson", simdjson.loads), ]: start = time.perf_counter() for _ in range(100): func(raw) elapsed = time.perf_counter() - start print(f"{name} loads avg: {elapsed / 100 * 1000:.2f} ms")5.2 结果概览和我的印象
在我当时那台还不错的 CPU 上,大致结果是:orjson的loads比标准库快 5 到 10 倍,simdjson的loads在纯解析场景下又比orjson快一些,尤其在文本更大、嵌套更深的时候。序列化端则是orjson一骑绝尘,比标准库快十倍以上,simdjson的dumps反而没有那么突出的优势。
借用一句话总结:如果你主要瓶颈是"读",试试simdjson;如果瓶颈是"写",orjson优势最大。但别把这个结论当成恒定真理,不同数据形状、不同字段类型,比例会有变化。
5.3 翻车点记录
这次实测里我差点被带偏的两个地方:
- 第一次测
simdjson时,我直接用simdjson.loads(raw)返回的对象去循环取所有字段。结果发现它返回的是"类 dict"结构,每个字段访问都重新定位到原始 buffer。如果我反复取同一个字段,性能反而比json.loads慢。后来改用simdjson.loads后先转成原生 dict,或者直接调用simdjson.loads的完整解析(本质上已经构建了 DOM),才拿到正确的比较结果。 - 测
orjson时,早期版本在处理超大数字时和标准库行为不一致,输出可能带科学计数法。这个问题在新版本里已改善,但如果你负责的接口正好传超长数字 ID,建议先做好字段校验,甚至考虑把这些字段当字符串传。
6. 选型建议和一份易踩坑清单
回到最初的问题:到底什么时候该用哪个库?我个人的判断标准很简单:
- 项目小、流量低、团队只想用标准库:继续用
json,别给自己加戏。 - 接口写得多、JSON 序列化频繁:优先
orjson,API 迁移成本最低,序列化又快。 - 日志解析、离线批处理、超大 JSON:优先
simdjson,尤其是你只需要提取少量字段的场景。 - 既要读也要写,还不想写两套代码:
orjson是最省心的默认项,性能均衡,API 更贴近json。
6.1 迁移时的行为差异对照
我整理了一份即便切库也不会出大错的对照表:
| 关注点 | json | orjson | simdjson |
|---|---|---|---|
ensure_ascii默认 | 开启 | 关闭 | 关闭 |
| 返回类型 | str | bytes | bytes/str |
对datetime原生支持 | 不支持,需 default | 支持 | 不支持,需 default |
| 对文件对象 | 支持load/dump | 不支持 | 部分支持 |
| 典型强项 | 兼容性 | 序列化速度 | 超大文件解析 |
| 典型弱项 | 性能 | 类型转换需小心 | API 偏离标准较多 |
6.2 你一定会遇到的细节问题
- 键顺序:这三个库默认都保留插入顺序。但如果你用了
simdjson的 ONDEMAND 模式,结果对象的顺序就不一定和原始文本一致。下游如果依赖字段顺序,要多留神。 - UTF-8 校验:
orjson默认会对输入做严格 UTF-8 校验,遇到非法字节会直接抛异常。如果你的数据来源很脏,建议先清洗或捕获异常,而不是让它打到线上日志里。 - 超大整数:
simdjson和orjson在某些版本里对超大整数的处理方式和标准库不太一样。标准库会把超出int范围的数字转成float,高性能库更倾向于保留原样。如果接口涉及位数很长的 ID,建议前置统一规则。 - 嵌套极深的对象:
json和orjson对递归深度通常有限制,极深的嵌套结构可能触发RecursionError。遇到这种情况,先想想这个 JSON 设计是否合理,不要强行改递归限制。
6.3 其他值得留意的备选
除了这三个,社区里偶尔还会提到ujson和rapidjson。ujson的 API 很接近标准库,但在某些 Python 版本下维护不够活跃,不建议新项目引入。rapidjson的绑定也比较老旧,性能和orjson没有明显优势。所以我个人现在基本只在这三个里做选择。
最后分享一个我自己经常用的小技巧:在项目的工具模块里做一层薄封装,比如:
import os if os.environ.get("JSON_BACKEND", "orjson") == "orjson": import orjson as impl loads = impl.loads dumps = lambda obj: impl.dumps(obj).decode("utf-8") else: import json as impl loads = impl.loads dumps = lambda obj: impl.dumps(obj, ensure_ascii=False)这样一来,业务代码里的loads和dumps调用方式完全不变,切换底层实现只需要改一行环境变量。我靠这套做法在不少项目里做到了"先上线、后优化",等到压力测试出来再换库,也不用到处改函数名。
JSON 处理这种看起来基础得不能再基础的东西,一旦数据量和 QPS 上来,选型差异会被放得很大。与其等报警,不如花一下午把这些库的脾气摸清楚。