对象与字节的三条通道: pickle 深而危险, JSON 通用而粗糙, msgpack/orjson 快而紧凑; 边界外永远是不可信输入
序列化就是把内存对象压成可传输的字节/文本, 三条通道各换一样东西: pickle 用安全性换"深" —— 类型、对象图、重复引用全保住, 但 unpickle 会执行字节流里携带的调用; JSON 用表达力换"通用" —— 只有 7 种类型, datetime 变 str、tuple 变 list, 却让任何语言都能读; msgpack/orjson 用可读性换"快与紧凑"。选型第一问永远是"对面是谁、可不可信": 外部边界上的数据一律先过 pydantic 校验再进内部对象。
loads 时按指令重建; 二进制、Python 专用、跨版本有兼容风险。 import pickle b = pickle.dumps({"ts": datetime.now()}, protocol=5) b[:1] # → b'\x80' = PROTOCOL opcode 头 pickle.loads(b) # → datetime 原样复活 # 关键: 二进制 opcode 流, Python 专用
__reduce__ 定义"重建时要调谁、传什么参"(正是 RCE 入口); __getstate__/__setstate__ 自定义快照与恢复 —— 版本迁移的标准钩子。 class Cfg: def __reduce__(self): # 重建 = Cfg(self.dsn) return (Cfg, (self.dsn,)) # ← RCE 入口也在这 def __getstate__(self): return {"dsn": self.dsn} # 自定义快照 def __setstate__(self, s): # 恢复 = 迁移钩子 self.__dict__.update(s)
class Evil: def __reduce__(self): # 字节流携带"重建指令" return (os.system, ("rm -rf /tmp/x",)) blob = pickle.dumps(Evil()) pickle.loads(blob) # → 真的执行了 os.system # 关键: loads 会执行 reduce 给出的 callable
joblib.dump/load: pickle 语义 + numpy 大数组分块压缩; 模型文件要和代码版本成对管理。 pipe = Pipeline([( "scaler", StandardScaler()), ("clf", LGBMClassifier())]).fit(X, y) joblib.dump(pipe, "m/churn_v7.joblib", compress="lz4") pipe = joblib.load("m/churn_v7.joblib") # 整体原子恢复 # 关键: 模型文件与 sklearn 版本成对管理
TypeError。 json.dumps({"ok": True, "n": None, "t": (1, 2)})
# → '{"ok": true, "n": null, "t": [1, 2]}'
json.dumps({"ts": datetime.now()})
# → TypeError: Object of type datetime is not ...
# 关键: 只有 7 种基本类型, 其余默认炸dumps(default=fn) 接住出口不认识的类型; loads(object_hook=fn) 在进口重建自定义类型 —— 两个官方扩展点, 别满世界找"第三个"。 def default(o): # 出口: 接住不认识的 if isinstance(o, datetime): return o.isoformat() raise TypeError(type(o)) def hook(d): # 进口: 重建自定义类型 if "ts" in d: d["ts"] = parse(d["ts"]) return d json.loads(s, object_hook=hook) # 两个官方扩展点
bytes; 不支持 indent, 美化用 opt=OPT_INDENT_2。 import orjson orjson.dumps({"ts": datetime.now(), "id": uuid4()}) # → bytes: 原生吃 datetime/UUID orjson.dumps(x, option=orjson.OPT_INDENT_2) # 美化 # 关键: 无 indent 参数, 输出是 bytes
packb/unpackb, MQ 与缓存的降带宽首选。 import msgpack b = msgpack.packb({"中文": [1, 2]}, use_bin_type=True) b # → 二进制, 无 \u 转义膨胀 msgpack.unpackb(b, raw=False) # → {'中文': [1, 2]} # 关键: int 按宽度编码, 比 JSON 小 30-50%
model_validate_json(进口校验) + model_dump_json(出口白名单序列化) 一步到位; model_config 管未知字段策略。 class Req(BaseModel): model_config = ConfigDict(extra="forbid") # 未知字段策略 uid: int ts: datetime req = Req.model_validate_json(body) # 进口: 校验+解析一步 out = UserView.model_dump_json() # 出口: 白名单序列化
__setstate__ 做字段迁移; 缓存 key 带 schema 版本号。 class Profile: def __setstate__(self, s): if "region" not in s: # 旧档迁移: 补默认值 s["region"] = "cn" self.__dict__.update(s) key = f"profile:v3:{uid}" # 关键: 结构变 → bump v4
ensure_ascii=True 中文全变 \uXXXX 体积膨胀数倍; separators=(",", ":") 去掉多余空格再省 10-15%。 len(json.dumps("支付")) # → 14 (两个 \uXXXX) len(json.dumps("支付", ensure_ascii=False)) # → 4 json.dumps(d, ensure_ascii=False, separators=(",", ":")) # 再省 10-15% 空格
parquet(压缩+跨语言), 中间缓存用 feather(mmap 秒读) —— 别拿 pickle 扛数据管道。 df.to_parquet("features/v7.parquet", compression="zstd") # 列存压缩, 跨语言 df = pd.read_parquet(p, columns=["f1"]) # 按需只读部分列 df.to_feather("tmp/scratch.feather") # mmap 秒读中间缓存 # 关键: 别拿 pickle 扛数据管道
模型上线要保证"标准化器 + 特征工程 + 分类器"作为一个整体原子保存, 单独存权重会版本错配:
import joblib from sklearn.pipeline import Pipeline pipe = Pipeline([ ("scaler", StandardScaler()), # 预处理与模型同进同出 ("clf", GradientBoostingClassifier()), ]).fit(X_train, y_train) joblib.dump(pipe, "models/churn_v7.joblib", compress="lz4") # 服务端加载: 与训练时同版本 sklearn, 否则 unpickle 可能炸 pipe = joblib.load("models/churn_v7.joblib") prob = pipe.predict_proba(feats)[0, 1]
模型文件名带版本 + 元数据登记 sklearn 版本: 线上模型与训练环境可追溯、可回滚。
用户画像对象每请求重建要 300ms, 想整对象缓存; 但直接 pickle 裸 key 上线, 改一次类定义缓存就静默错乱:
import pickle, redis r = redis.Redis() SCHEMA = "profile:v3" # 类结构一变就 bump: v3→v4 def get_profile(uid: int) -> Profile: key = f"{SCHEMA}:{uid}" if raw := r.get(key): # Redis 出入都是 bytes, 正配 pickle return pickle.loads(raw) # 来源=自己写入, 可信通道 p = build_profile(uid) # 300ms 的重建 r.setex(key, 3600, pickle.dumps(p, protocol=5)) return p # 事故教训: 不带版本号时, 旧 schema 的缓存命中后字段缺失, 线上 nil 错乱无感知
商品列表 4MB JSON 响应, 序列化占 80ms; 换 orjson 的 ORJSONResponse 三行收工:
from fastapi.responses import ORJSONResponse from fastapi import FastAPI app = FastAPI(default_response_class=ORJSONResponse) @app.get("/skus", response_class=ORJSONResponse) def list_skus(): return {"items": sku_repo.all()} # datetime/UUID 直接吃, 不用 default # 压测: 4MB 响应序列化 82ms → 11ms; P99 340ms → 265ms # 注意: ORJSONResponse 输出紧凑无缩进, 调试期想看格式再手动 orjson.dumps(..., option=OPT_INDENT_2)
接口里日期格式有的带 Z 有的不带、金额直接 float 漂移一分钱; 出口统一走一个 default:
import json from datetime import datetime, timezone from decimal import Decimal from uuid import UUID def json_default(o): if isinstance(o, datetime): return o.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" if isinstance(o, Decimal): return str(o) # 金额走字符串, 精度零漂移 if isinstance(o, UUID): return str(o) raise TypeError(f"not serializable: {type(o)}") # 别静默吞 out = json.dumps(order, default=json_default, ensure_ascii=False)
User ORM 对象直接 json 化返回, 把 password_hash/is_internal 字段一起漏了出去; 出口模型只放白名单:
from pydantic import BaseModel, ConfigDict class UserPublic(BaseModel): model_config = ConfigDict(extra="forbid") # 多余字段直接报错 uid: int name: str created_at: datetime def user_view(u: UserORM) -> str: # 从 ORM 挑选字段构造 → password_hash 之类根本进不来 return UserPublic.model_validate(u, from_attributes=True).model_dump_json() # 内部对象随便长, 出口只认视图模型: "序列化面"即攻击面
行情推送 5 万条/秒, JSON 文本 1.8KB/条把带宽打满; 换 msgpack:
import msgpack def publish(quote: dict): body = msgpack.packb(quote, use_bin_type=True) # str/bytes 严格区分 mq.send(routing_key="quote", body=body, content_type="application/msgpack") def consume(body: bytes): return msgpack.unpackb(body, raw=False) # raw=False: str 按 str 还原 # 实测: 平均 1.8KB → 0.9KB, 网卡出流量减半; # 嵌套数字数组 (行情快照) 收益最大, 比 JSON 小 50%+
风控参数要留档审计、diff 出变更单; pickle 是黑盒字节, JSON 一眼可读:
def snapshot(cfg: RiskConfig, path): with open(path, "w", encoding="utf-8") as f: json.dump(cfg.model_dump(), f, ensure_ascii=False, indent=2, sort_keys=True) # 排序=稳定 diff # git diff 两个快照: 参数谁改了、何时改的一目了然 # pickle 快照: 无法 diff、无法 review、还绑着类路径 —— 审计场景禁用
训练产物 FeatureStore v1 落盘的 pickle, 类升级到 v3 后 loads 全炸; 在恢复钩子里做迁移:
class FeatureStore: VERSION = 3 def __getstate__(self): return {"v": self.VERSION, "feats": self.feats, "norm": self.norm} def __setstate__(self, state): v = state.get("v", 1) # 旧档没有版本字段 = v1 if v < 2: state["norm"] = default_norm() # v1→v2: 补归一化参数 if v < 3: state["feats"] = migrate_keys(state["feats"]) self.__dict__.update(state) # 迁移完再落到实例上
旧档新代码照样读; 配合"读时迁移 + 写时升版", 数据文件可以横跨数个版本存活。
任务队列要传复杂对象(不跨语言), pickle 最省事但怕中间被改; HMAC 给它上封条(注意: 这是防篡改, 不是加密):
import pickle, hmac, hashlib from app.config import settings # KEY 走 K8s Secret 注入 KEY = settings.JOB_HMAC_KEY # 双端共享, 绝不入库入仓 def seal(obj) -> bytes: body = pickle.dumps(obj, protocol=5) sig = hmac.new(KEY, body, hashlib.sha256).digest() return sig + body # 前 32 字节 = 签名 def unseal(blob: bytes): sig, body = blob[:32], blob[32:] expect = hmac.new(KEY, body, hashlib.sha256).digest() if not hmac.compare_digest(sig, expect): # 恒时比较, 防时序侧信道 raise TamperedJobError("signature mismatch") return pickle.loads(body) # 验签通过才 unpickle
特征工程 200 万行 × 380 列, 有人用 pickle 整存: 文件 1.9GB、读回 40s、只能 Python 读; 换列存:
df.to_parquet("features/v7.parquet", compression="zstd") # 1.9GB → 420MB (zstd 列压缩), 读回 3.2s, 按需只读部分列 (columns=[...]) # 跨语言: Spark/Arrow/Rust 都能读 —— 数据不再绑死 Python df.to_feather("tmp/scratch.feather") # 流水线中间缓存: mmap 零拷贝, 下游进程秒级拿到同一份内存 # 一句话: 归档/共享 parquet, 短命中中间态 feather, pickle 不进数据管道
pickle.loads, 攻击者构造 __reduce__ 为 os.system 的 payload 即可 getshell。原因: loads 会执行字节流携带的调用。正解: 外部输入只收 JSON/msgpack + pydantic; pickle 永不接触不可信数据。 blob = request.data # 用户上传的文件 pickle.loads(blob) # 错: blob 的 __reduce__ 是 # os.system → 直接 getshell data = json.loads(blob) # 对: 外部输入只收 JSON/msgpack Req.model_validate(data) # + pydantic 校验
protocol=5 存的档, 3.7 环境加载报 unsupported pickle protocol。原因: 协议 4/5 需要 3.4+/3.8+。正解: 跨版本通道显式降 protocol=4; 部署环境矩阵写进发布检查单。 pickle.dumps(obj, protocol=5) # 3.12 存档 # 3.7 加载 → ValueError: unsupported pickle protocol: 5 pickle.dumps(obj, protocol=4) # 对: 跨版本显式降协议 # 部署环境矩阵写进发布检查单
app.models.Order 挪到 app.core.order, 半年后 loads 历史缓存抛 AttributeError: Can't get attribute。原因: pickle 按模块路径找类。正解: 老 sys.modules 别名兼容, 或缓存清空 + schema 版本号强制失效。 # 重构: app.models.Order → app.core.order.Order pickle.loads(cache) # 错: Can't get attribute 'Order' # on <module 'app.models'> sys.modules["app.models"] = shim # 对: 旧路径别名兼容 # 或: bump schema 版本号 → 缓存整体失效
json.dumps(order) 在第一个 datetime 上就炸 Object of type datetime is not JSON serializable。原因: 标准库只认 7 种类型。正解: default=str 应急, 生产用统一 default 函数(场景 4)或直接 orjson。 json.dumps({"ts": datetime.now()})
# 错: TypeError: Object of type datetime
# is not JSON serializable
json.dumps({"ts": ts}, default=str) # 应急
orjson.dumps({"ts": ts}) # 对: 原生支持Decimal("99.10") 经 float 变 99.09999999999999, 对账差一分。原因: 二进制浮点无法精确表示十进制小数。正解: 金额序列化一律 str(Decimal), 两岸约定字符串承载; JS 侧用 decimal 库。 json.dumps({"amt": float(Decimal("99.10"))})
# 错: → 99.1 — 尾零丢失, 大额/累加漂移差一分
json.dumps({"amt": Decimal("99.10")}, default=str)
# 对: → "99.10" 字符串承载, 两岸约定(1, 2), 对面 json.loads 回来是 [1, 2], 不可变性丢失, 当 set 元素/字典 key 直接 TypeError。原因: JSON 没有元组概念。正解: 两岸约定"数组即 list"; 需要元组语义就在 object_hook 里重建。 json.dumps({"pt": (1, 2)}) # → '{"pt": [1, 2]}'
pt = json.loads(s)["pt"] # → [1, 2] 是 list!
{pt: "x"} # 错: TypeError: unhashable type
tuple(pt) # 对: 读取侧重建元组语义{1: "a"} dumps 成 {"1": "a"}, loads 回来 d[1] 抛 KeyError。原因: JSON 对象 key 只能是字符串。正解: 读取侧统一 int(k) 转换; 或 key 本来就该用字符串设计。 json.dumps({1: "a"}) # → '{"1": "a"}'
d = json.loads('{"1": "a"}')
d[1] # 错: KeyError: 1
d[int("1")] # 对: 读取侧 int(k) 转换json.dumps(float("nan")) 默认输出字面量 NaN, 前端 JSON.parse 直接 SyntaxError。原因: Python 默认 allow_nan=True 是私货扩展。正解: 出口前 math.isnan 清洗成 null; 或 allow_nan=False 让它及早抛错。 json.dumps({"v": float("nan")}) # → '{"v": NaN}' 非标准!
JSON.parse('{"v": NaN}') # 错: JS 端 SyntaxError
json.dumps(x, allow_nan=False) # 对: 及早抛 ValueError
# 或出口前 math.isnan 清洗成 null"\u652f\u4ed8", 体积膨胀 3-6 倍, 日志/检索不可读。原因: 标准库默认转 ASCII。正解: ensure_ascii=False + UTF-8 编码写文件; orjson 默认就不转。 json.dumps({"msg": "支付"})
# 错: → '{"msg": "\u652f\u4ed8"}' 膨胀 3-6 倍
json.dumps({"msg": "支付"}, ensure_ascii=False)
# 对: → '{"msg": "支付"}'; orjson 默认就不转dumps(x, indent=2) 直接 TypeError; 个别类型(numpy)要 opt。原因: orjson 输出 bytes、参数模型完全不同。正解: 缩进用 orjson.OPT_INDENT_2; 迁移时按官方支持矩阵过一遍类型。 orjson.dumps(x, indent=2) # 错: TypeError: Unexpected # keyword argument 'indent' orjson.dumps(x, option=orjson.OPT_INDENT_2) # 对: 缩进 orjson.dumps(x).decode() # 输出是 bytes 要 decode
json.dumps(a) 抛 RecursionError: maximum recursion depth exceeded。原因: json 不记录已访问对象。正解: 关系建模改存外键 id; 或先 copy.deepcopy 断环/pickle(它支持循环引用)。 a["b"] = b; b["a"] = a json.dumps(a) # 错: RecursionError: maximum # recursion depth exceeded a["b_id"] = b["id"]; del a["b"] # 对: 改存外键 id # 或直接 pickle — 它支持循环引用
pickle.dumps 瞬间进程 RSS 冲到 4GB+, 容器 OOMKill。原因: 原对象 + 完整字节串同时在内存。正解: 用 pickle.dump(df, file) 流式写文件(joblib 分块更好); 别 dumps 到内存再写。 blob = pickle.dumps(df_2gb) # 错: 原对象+字节串双份 # → RSS 4GB+, OOMKill with open(p, "wb") as f: # 对: 流式写文件 pickle.dump(df_2gb, f) joblib.dump(df, p, compress="lz4") # 分块更好
blob = pickle.dumps(job) blob = blob[:-1] + b"\x41" # 错: 字节随手可改, pickle.loads(blob) # loads 照单全收 sig = hmac.new(KEY, body, sha256).digest() # 对: HMAC 签封 # 验签通过才 loads (场景 9)
@dataclass 加字段没给默认值, 旧 JSON 反序列化全抛 missing argument。原因: 构造函数签名变严。正解: 新增字段一律 field(default_factory=...); pydantic 侧配 extra 策略和默认值。 @dataclass class OrderV2: uid: int coupon: str # 错: 新字段无默认值, 旧档全抛 @dataclass # TypeError: missing argument class OrderV2: uid: int coupon: str | None = None # 对: 一律带默认值
Session/Engine/lambda, pickle.dumps 抛 TypeError: cannot pickle。原因: 绑定运行时状态的对象无法字节化。正解: __getstate__ 剔除不可序列化成员, __setstate__ 里重建连接。 class Svc: session: Session # 挂着连接/lambda pickle.dumps(Svc()) # 错: TypeError: cannot pickle def __getstate__(self): s = self.__dict__.copy() s.pop("session") # 对: 剔除后快照, return s # __setstate__ 里重建连接
unpackb(b"...") 出来的 key 全是 bytes(b"user"), d["user"] KeyError; 或忘了 use_bin_type 把 str 存成 bin。原因: msgpack 区分 str/bin, 默认行为随版本变过。正解: 固定 packb(..., use_bin_type=True) + unpackb(..., raw=False, strict_map_key=False) 并写进公共封装。 d = msgpack.unpackb(body) # 错: key 全是 bytes, d["user"] # → KeyError (要 d[b"user"]) d = msgpack.unpackb(body, raw=False, strict_map_key=False) # 对: str 按 str 还原 msgpack.packb(x, use_bin_type=True) # 固定进公共封装
str(obj)/repr(obj) 存库, 恢复时 ast.literal_eval 解析: 换行/引号/嵌套对象分分钟解析失败, 且完全无 schema。正解: 存结构化格式(JSON/msgpack); repr 只用于日志与调试展示。 db.save(str(obj)) # 错: repr 换行/引号/嵌套, ast.literal_eval(saved) # 解析分分钟失败, 无 schema db.save(json.dumps(obj)) # 对: 结构化格式存库 obj = json.loads(saved) # repr 只用于日志展示
it = Item(1); order.items, order.ref = [it], it o = pickle.loads(pickle.dumps(order)) o.items[0] is o.ref # → True: pickle 保引用 j = json.loads(json.dumps(order, default=obj_dict)) j["items"][0] is j["ref"] # 错: False — 独立拷贝 # 对: JSON 存 id, 读取侧按 id 再组装
key = f"profile:{uid}" # 错: 类字段改了, 旧缓存 p = pickle.loads(r.get(key)) # 命中 → 偶发 None/AttributeError key = f"profile:v3:{uid}" # 对: schema 版本号, # 结构变 → bump v4 整体失效
model_dump() 默认 python 模式, datetime 还是 datetime 对象; 直接塞给 json.dumps 又 TypeError。原因: mode="python" 与 mode="json" 输出类型不同。正解: 要 JSON 字节就用 model_dump_json(); 要 dict 给 json.dumps 就 model_dump(mode="json")。 u.model_dump() # datetime 还是 datetime 对象 json.dumps(u.model_dump()) # 错: 又 TypeError u.model_dump(mode="json") # 对: 要 dict 给 json.dumps u.model_dump_json() # 要 JSON 字节直接用这个