接口加了 pydantic 模型之后,P99 从 40ms 变成 90ms,CPU 曲线跟着抬起来。代码没多几行,只是把 dict 换成了模型。于是团队分两派:一派主张全删、"注解就够了",另一派主张全加、"数据不可信"。
两派错在同一个点:把运行期校验当成风格选择,而它是一笔按边界数量 × 数据体积计价的成本。注解、dataclass、pydantic 解决的不是同一个问题,混着谈吵不出结论。
1. 三个收益与三种成本
运行期校验买到三样东西,同时付出三样代价。收益是定性的,成本是定量的。
| 类别 | 内容 | 何时兑现 |
|---|---|---|
| 收益 | 外部数据不可信,越界即失败 | 处理外部输入时 |
| 收益 | 模型即文档,约束集中一处 | 有人读代码时 |
| 收益 | 早失败,停在边界而非深层 | 数据有问题时 |
| 成本 | 每个对象的构造与递归检查 | 高频路径 |
| 成本 | 模型库版本成为运行期契约 | 升级时 |
| 成本 | 模型常被绑到序列化层与框架 | 换框架时 |
序列化耦合的典型形态:模型字段名成了对外契约,重命名字段就成了破坏性变更。关键判断:收益只在不可信输入跨越边界时兑现,成本每次调用都付。 所以成本判据优先于收益口号——内部函数每次付出校验成本却永远碰不到不可信数据,这笔钱是纯亏。
2. 注解默认不做任何事
Python 的注解是元数据,CPython 不检查(from __future__ import annotations 下连求值都省)。它服务的是静态检查器与 IDE,不是运行时。
def parse(payload: dict[str, str]) -> User: # 传 list 也能跑
return User(payload["id"]) # 运行时只看这一次下标
# 真正拦得住的是显式构造校验
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str = "anon" # 默认值 + 缺失即报错,才是运行期语义
分工结论:静态可推导的交给 mypy/pyright,静态推不出的(长度、范围、正则、跨字段、外部字符串内容)交给运行期模型。 两边都要的那一份,通常是边界上的入口模型。
3. 哪些边界必须校验
| 边界 | 是否必须 | 理由 |
|---|---|---|
| HTTP/RPC 入参 | 必须 | 字符串来自外部,类型系统管不到内容 |
| 配置文件 | 必须 | 拼错、类型错、缺字段只在启动期暴露 |
| 消息队列消费 | 必须 | 生产方版本可能领先 |
| 数据库读出的行 | 视情况 | ORM 已转换,但历史数据可能违反新约束 |
| 跨服务返回值 | 必须 | 对方契约漂移编译期发现不了 |
| 内部函数之间 | 不必 | 同一进程、同一类型检查下,属自证 |
| 自己刚构造的对象 | 不必 | 构造时已校验 |
第二列的分界不是"重要不重要",而是数据是否跨越了信任边界。同进程调用没有边界,那里做校验等于用运行时弥补自己的类型设计。
这份清单要定期复核:新增一个 MQ topic、接一个第三方回调,都等于新增一个边界,也是最容易漏掉校验的地方。
4. 四种数据模型的取舍
| 方案 | 构造校验 | 序列化 | 可变性 | 继承 | 适用 |
|---|---|---|---|---|---|
dataclass |
无(自己写 __post_init__) |
需手写/第三方 | 可变,可 frozen |
简单 | 内部数据结构 |
NamedTuple |
无 | 天然转 tuple | 不可变 | 有限 | 轻量值对象、需解包 |
pydantic |
有,含类型转换 | 原生 | 可变,可配置 | 支持 | 边界模型、配置 |
attrs |
有 validators |
需第三方 | 可选 | 支持 | 要校验但不想引重依赖 |
选择顺序通常是:边界用 pydantic,内部值对象用 frozen dataclass,需要解包或与旧代码兼容用 NamedTuple。 attrs 介于两者之间,适合"要校验但嫌 pydantic 的序列化耦合重"的场景。
from dataclasses import dataclass
@dataclass(frozen=True)
class Range:
lo: int
hi: int
def __post_init__(self): # dataclass 的校验只能手写
if self.lo > self.hi:
raise ValueError(f"lo>hi: {self.lo}>{self.hi}")
dataclass 的 __post_init__ 有个已知代价:它在 frozen=True 下要通过 object.__setattr__ 赋值,且继承时父类校验先跑、子类字段尚未就绪,容易写出半初始化状态下的校验。
5. 开销落在哪:嵌套与容器逐元素
校验成本不在"模型有几个字段",而在被检查的对象个数。一个 5 字段的模型嵌在 1 万个元素的列表里,成本是 5 万次字段检查,而不是 5 次。
class Item(BaseModel):
sku: str
qty: int = Field(ge=1, le=999)
class Order(BaseModel):
items: list[Item] # 列表长度 N → N 次 Item 校验 + N 次 qty 范围检查
tags: dict[str, str] # 键值都要过类型
量级判断(按 pydantic 2.x 核对,数值随版本与字段类型变化):标量模型的构造成本通常是微秒级的几倍;嵌套列表近似线性于元素数;str 到 int 的转换比直接 int 校验贵,正则比长度约束贵。优化顺序是先减少被校验对象的数量(分页、流式、只在边界校验),再谈模型瘦身。
import timeit
# 先量再谈优化:与直接读 dict 的差值才是校验成本
print(timeit.timeit(lambda: Order(items=raw), number=1000))
6. 严格模式与宽松转换
"1" 能不能变成 1,是一个语义决定,不是一个配置偏好。
| 模式 | 行为 | 优点 | 风险 |
|---|---|---|---|
| 宽松(默认) | "1" → 1 |
兼容表单、查询串、旧客户端 | 非法数据被静默修正 |
| 严格 | 类型不符即拒绝 | 契约清晰,早暴露 | 旧客户端会突然 422 |
取舍判据:跨越进程边界的入口用严格模式,跨版本兼容期用宽松模式。宽松模式最危险的不是"转换错了",而是转换"对了"但语义变了——"01" 变成 1 后,业务里用来做前缀匹配的代码静默失效。
class Cfg(BaseModel):
model_config = dict(strict=True, extra="forbid") # 拒未知字段,避免静默丢弃
retries: int
extra="forbid" 值得单独提:默认忽略未知字段(ignore)会让"字段名拼错"变成一条永不生效的配置,排查成本极高。
7. 错误信息、文档漂移与双份模型
校验错误的质量决定它能否被对外使用。pydantic 的 ValidationError 带 loc(字段路径)、type(错误类型码)、msg(英文描述)。对外映射时只应暴露 loc + 自己的码,不要把 msg 直接透给用户:它随版本变化、是英文、且泄露内部结构。
try:
Order(**payload)
except ValidationError as e:
# e.errors() → [{'loc': ('items', 0, 'qty'), 'type': 'greater_than_equal', ...}]
raise BadRequest(code="ORDER_FIELD_INVALID", fields=[x["loc"] for x in e.errors()])
三个衍生成本:
- 校验即文档的漂移:模型是唯一事实源的前提是"文档由模型自动生成"。一旦有人手写 OpenAPI 或前端类型,就出现第二份定义;两份的对齐只能靠 CI 校验,靠人一定会漂。
- 与数据库模型双份定义:边界模型(可空、宽松)与表模型(非空、有默认)语义本就不同,强行复用一份会在两处都别扭。接受双份,但要让两者之间的转换只在一处发生。
- 大版本升级的默认行为变化:模型库大版本常改默认值(严格性、
Optional语义、校验顺序、错误结构)。升级前必须按目标版本核对 changelog,并把"默认行为"当成破坏性变更对待。
8. mypy 能替代运行期校验的边界
静态检查能替代运行期校验,只在三个条件同时成立时:数据来源在进程内、类型可静态推导、没有隐式转换。
| 场景 | mypy 够用 |
仍需运行期校验 |
|---|---|---|
| 内部函数调用 | 是 | 否 |
解析 json.loads 结果 |
否(Any) |
是 |
| 环境变量 | 否(恒为 str) |
是 |
| 第三方库返回值 | 部分(取决于类型存根) | 视存根质量 |
json.loads 返回 Any,Any 会让检查器闭嘴——这是"类型覆盖率"的经典盲区。用 cast 强行断言等于把校验删掉而不留证据,是最常见的自欺。
一条铁律:边界上的模型必须是"宽进严出"——入参模型接受字符串、宽松类型(表单、查询串就是这么来的),出参模型一律用已校验的强类型。反过来(严进宽出)会让内部到处是兼容分支,而且出参的错误没人负责。
9. 排错:五种"加了模型反而更糟"
| 现象 | 真实原因 | 处置 |
|---|---|---|
| 接口变慢,CPU 上升 | 大列表逐元素建模 | 分页/流式,或只对首层建模 |
| 校验通过但业务报错 | 宽松模式把非法值转成了合法值 | 入口改严格模式 |
| 字段拼错却毫无提示 | extra 默认 ignore |
设 extra="forbid" |
| 升级模型库后大量 422 | 大版本改了默认严格性/Optional 语义 |
按 changelog 逐项核对 |
| 错误信息对用户不可读 | 直接透出 msg |
只暴露 loc + 自有码 |
10. 边界:不必引入运行期校验的场景
| 场景 | 说明 |
|---|---|
| 一次性脚本 | 跑一次就扔,校验成本高于错误成本 |
| 内部工具、单机流程 | 无外部输入,出错重跑即可 |
| 来源可靠的批处理 | 输入己方导出、格式固定 |
| 已有强 schema 的通道 | Protobuf/Avro/Parquet 已在解码时校验 |
| 极热路径的标量参数 | 单字段判断用 if 比建模便宜一个量级 |
共同点仍是那条:没有跨越信任边界,就没有校验的必要性。剩下要守的只有不变量,而不变量用一个 if 表达往往比一个模型更便宜、更可读。
11. 自测
- 运行期校验的收益在什么条件下才兑现,成本何时都在付?
- 类型注解在运行时到底做了什么?
- 哪几类边界必须校验,内部函数间为何不必?
dataclass与pydantic的校验能力差在哪一步?- 校验开销随什么增长,优化顺序是什么?
extra="forbid"解决的是什么类型的问题?