一个服务的配置导出接口,导出的 JSON 里 timeout 从 30 变成了 30.0,而导入端把它当字符串处理,于是报"类型不合法"。同一份代码在另一个环境没出问题——因为那边的值是整数。而根源是 json.dumps 对浮点的默认处理,加上"30 被读成了 float"。
Python 的 JSON 处理在"默认行为"上有一堆细节:浮点、NaN、非 ASCII、日期、整数键、以及 __eq__ 的自定义对象。而"悄悄改"比"报错"更难查:报错会立刻指向问题,改值只会让下游算出不同的结果,而两边各自的日志都"正常"。
这些默认值在演示时都"能用",在真实数据上会变成"数据被悄悄改了"。
1. 五个默认行为
| 行为 | 默认值 |
|---|---|
| 非 ASCII | ensure_ascii=True(转义) |
| 浮点表示 | 最短往返表示(30.0 保留 .0) |
| NaN / Infinity | 输出非法 JSON |
| 键类型 | 非字符串键被强制转字符串 |
| 缩进/分隔符 | 无空格,紧凑 |
第三项最严重:json.dumps(float('nan')) 输出 NaN,而 NaN 不是合法 JSON第三项的来源很实际:数据库里的 NULL、缺失值、除零结果都可能变成 NaN,而它一路传到序列化层才爆。所以"NaN 出现在数据里"本身是一个应当被拦住的信号。
,很多解析器会拒绝。默认没有 allow_nan=False。
第四项在"整数键"上很常见:{1: 'a'} 序列化成 {"1": "a"},反序列化回来键是字符串,于是 d[1] 拿不到。
import json
print(json.dumps({"v": 30.0})) # {"v": 30.0} ← 不是 30
print(json.dumps({"v": float("nan")})) # {"v": NaN} ← 非法 JSON
print(json.dumps({1: "a"})) # {"1": "a"} ← 键类型变了
print(json.dumps({"名": "值"})) # {"\u540d": ...} ← 转义
2. 类型往返的丢失
| 类型 | 往返后 |
|---|---|
tuple |
list |
datetime |
直接报错 |
Decimal |
报错 |
set |
报错 |
int(超大) |
可能被 JS 截断 |
默认支持的只有 dict/list/str/int/float/bool/None,所以"错了就报错"其实是好事:报错会强迫你显式定义格式(ISO 8601 还是时间戳),而隐式转换会让格式在各处不一致。
第二项要显式处理:datetime 不在默认支持的六种类型里,需要 default= 或自定义编码器。
第五项在"跨语言"时出现:Python 的 int 可以任意大,而 JavaScript 的 number 只有 53 位精度,于是 20 位 ID 传过去就变了值。所以大整数应当序列化成字符串。
// 显式编码:日期、Decimal、大整数
def dumps_ext(o):
return json.dumps(o, default=lambda x: (
x.isoformat() if hasattr(x, "isoformat") else
str(x) if isinstance(x, (Decimal, set, frozenset)) else
raise_type(x)
), ensure_ascii=False, allow_nan=False, sort_keys=True)
// 大整数:序列化时先转字符串,或统一用字符串字段
3. sort_keys 与确定性
| 参数 | 影响 |
|---|---|
sort_keys=True |
输出稳定,可用于签名 |
| 默认(无排序) | 依赖插入顺序,不稳定 |
separators |
影响体积与哈希 |
indent |
影响体积与可读 |
第一项对"接口签名/缓存键"是必须的:不排序时,同一个字典在两次构造里可能顺序不同这个"顺序不同"在 Python 3.7+ 里不会因哈希随机化而变(字典保插入序),但不同代码路径构造出的字典顺序确实会不同——比如一个分支先放 a 后放 b,另一个反过来。所以"同一份数据"经不同路径到达时,字节不同。
,于是哈希不同,签名失效。
第三项同理:separators=(',', ':') 与默认的 (', ', ': ') 会产生不同的字节,而签名与哈希是对字节算的。
// 用于签名/哈希的稳定输出(三件必须固定)
payload = json.dumps(obj, sort_keys=True, ensure_ascii=False,
separators=(",", ":"), allow_nan=False)
sig = hmac.new(key, payload.encode("utf-8"), sha256).hexdigest()
// 任何一项变了 → 签名校验失败 → 排查会非常痛苦
4. 反序列化的安全问题
| 风险 | 说明 |
|---|---|
| 超大输入 | 内存耗尽 |
| 深度嵌套 | 递归耗尽栈 |
| 重复键 | 后者覆盖前者 |
| 类型混淆 | "1" 与 1 |
第三项还有一个"签名"版本:如果签名是对"解析后的对象"算的,重复键会让签名通过而值是攻击者想要的。所以签名必须对原始字节算,而不是对解析结果算。
第三项容易被忽略:{"a":1,"a":2} 在多数实现里取后者,而业务可能期望"报错"。这在与外部系统对接时会造成"值被悄悄改"。
第二项在恶意输入上是真实的 DoS 途径:json.loads 对深嵌套没有默认限制第二项的另一面是"合法但极端"的输入:某些导出数据真有几十层嵌套,此时该调高上限而不是直接拒绝。所以上限应当是"配置项"而不是硬编码常量。
,几千层嵌套就能让递归爆栈。
// 反序列化的三道限制
def safe_loads(s: str, max_bytes: int = 1_000_000, max_depth: int = 32):
if len(s.encode()) > max_bytes:
raise ValueError("payload too large")
# 深度检查:简易做法是先数括号层数,或用 parse_float/parse_int 钩子计数
...
// 关键:外部输入一律加上限;内部输入可用默认
还有一个"性能"层面的默认:json.loads 是纯 Python 实现的,在大负载下会成为瓶颈。换成 orjson/ujson 通常有 3–10 倍提升,而代价是"行为略有差异"(如对非法输入的处理),所以要配对测试。
// 性能替换:先测行为一致性,再换
import json, orjson, time
data = {"a": [1, 2, 3], "b": "中文", "c": 30.0}
assert orjson.dumps(data) == json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode()
// 不一致的项:NaN、非字符串键、Decimal —— 需要显式处理
5. 与其他格式的边界
| 场景 | 建议 |
|---|---|
| 配置 | JSON 够,但 YAML 更可读 |
| 日志 | 一行一个 JSON(NDJSON) |
| 大数值 ID | 序列化为字符串 |
| 二进制 | 不用 JSON(用 base64 会膨胀 33%) |
第二项(日志)有个细节值得说:NDJSON 的每一行必须"单行",所以序列化时不能带 indent,且字符串里的换行要被转义(JSON 默认会转义为 \n,这点是对的)。
第四项的理由很直接:base64 让体积增加三分之一,而且解析要多一步。传二进制应当用专门格式或分段上传。
协议设计上还有一条经验:不要把"有没有值"与"值为 null"混为一谈。JSON 里 {"a": null} 与 {} 是两件事,而很多客户端会把两者都当成"没有"。所以字段的"可缺省"语义要写进契约。
// 三种"无值"必须在契约里区分
{} 字段不存在(可能表示"没查")
{"a": null} 字段存在但为空(明确为空)
{"a": ""} 空字符串(类型是字符串)
// 反例:三者在客户端都被当成"没有" → 业务无法区分"未设置"与"设置为空"
6. 排错:五种"JSON 出问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 30 变成 30.0 | 浮点表示 | 显式 int() 或格式化 |
| 解析报"非法 JSON" | NaN/Infinity | allow_nan=False |
中文变 \uXXXX |
ensure_ascii 默认 |
设 False |
| 键取不到 | 整数键变字符串 | 统一字符串键 |
| 签名偶尔失败 | 无 sort_keys |
固定三项 |
第五项最耗时间:"偶尔"失败会被当成偶发问题,而它其实是"字典顺序不同"这个确定性原因。第四项(键取不到)的排查方式:打印反序列化后的 type(d.keys())——如果全是 str,那就是原始字典用了非字符串键。
最后一条实践建议:把"序列化参数"集中到一处工具函数,不要在各处直接调 json.dumps。因为五个参数一旦分散,就一定会出现"某处忘了 ensure_ascii=False"这类不一致——而它只在含中文的响应上暴露。
// 统一出口:所有对外 JSON 都走它
from app.util.jsonx import dumps # 内部固定 sort_keys/ensure_ascii/allow_nan
body = dumps(payload) # 参数不可改,避免不一致
// 规则:除 util 之外,禁止直接 import json 做序列化(用 lint 或评审约束)
7. 边界:什么情况不必精细控制
| 场景 | 为什么 | 做法 |
|---|---|---|
| 内部调试输出 | 不经解析 | 随意 |
| 前端展示的一次性数据 | 不参与计算 | 默认即可 |
| 已有严格的 schema 校验 | 错误会被拦 | 默认 + 校验 |
| 只在自己的服务间传递且类型简单 | 无跨语言 | 默认即可 |
最后一条与"协议"有关的边界:一旦 JSON 成了对外契约,五个参数就成了契约的一部分。改 ensure_ascii 或 separators 会让"字节不同"——如果对方做了字节级校验,这次改动就是破坏性变更。
判断口径一句话:这份 JSON 会不会被"另一个实现"解析,或参与"签名/哈希"。会,就必须固定五个参数;只在 Python 内部来回,默认够用。
8. 自测
- 五个默认行为里,哪一个会产生"非法 JSON"?为什么默认允许它?
- 整数键序列化后会发生什么?为什么会造成"键取不到"?
- 为什么"大整数"应当序列化成字符串?这与 JavaScript 的什么限制有关?
- 用于签名的输出必须固定哪三项?不固定会发生什么?
- 反序列化的"重复键"风险是什么?深嵌套为什么是 DoS 途径?
- 判断"是否需要精细控制"的那个问题是什么?