Python 基础体系 · 第 112/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 生产交付:进程模型、容量、配置、迁移、灰度和回滚
生产交付不是“把 Python 文件放到服务器上并启动”这么简单。一个可运行的 Python 服务至少包含以下对象:
- 制品:某个确定版本的源码、依赖、解释器和系统库。
- 进程:正在运行的 Python 解释器实例。
- 监听器:接收网络连接并把请求交给应用的服务器。
- 配置:决定服务如何连接数据库、调用下游、限制并发和暴露能力。
- 数据变更:数据库结构、数据修复和索引等不随代码自动回滚的状态变化。
- 流量策略:决定哪些请求进入新版本。
- 恢复策略:发现错误后如何停止新版本、切回旧版本并验证恢复。
FastAPI 通常运行在 Uvicorn 等 ASGI 服务器上。ASGI 定义了协议服务器与 Python 应用之间的异步接口,应用通过 scope、receive 和 send 处理连接与事件;HTTP 请求通常对应一个连接作用域,而 WebSocket 作用域则持续到连接关闭。(asgi.readthedocs.io)
因此,生产交付的核心问题可以表述为:
在给定 CPU、内存、数据库连接、网络和变更风险的条件下,让正确版本的服务持续处理请求,并且在失败时可以判断、隔离、恢复。
一、先建立完整的运行模型
1. 程序、进程、线程和事件循环不是同一个概念
程序是磁盘上的代码和可执行文件;进程是操作系统正在运行的程序实例;线程是进程内部的执行单元;事件循环则是异步任务调度器。
一个典型的 FastAPI 服务可以抽象成:
客户端
│
▼
负载均衡器 / 反向代理
│
▼
ASGI 服务器
│
├── Worker 进程 1 ── 事件循环 ── FastAPI 应用
├── Worker 进程 2 ── 事件循环 ── FastAPI 应用
└── Worker 进程 3 ── 事件循环 ── FastAPI 应用
ASGI 服务器负责终止 socket、把网络协议转换为事件,并调用应用;ASGI 应用负责消费事件并发送响应事件。ASGI 规范保证的是接口和消息模型,不保证某个服务器一定采用哪种进程管理、连接复用或负载均衡算法。(asgi.readthedocs.io)
这一区分决定了几个生产事实:
- 一个进程可以通过异步 I/O 同时处理多个请求。
- 多个进程可以利用多个 CPU 核心,并隔离进程级崩溃。
- 进程之间默认不共享 Python 堆对象。
- 每个进程通常都要单独创建数据库连接池、缓存客户端和线程池。
- 进程数增加后,吞吐能力可能增加,但内存和下游连接数也会增加。
“异步”不等于“无限并发”。如果一个 async def 函数内部执行阻塞式文件 I/O、同步数据库调用或长时间 CPU 计算,它仍然会阻塞所在事件循环。
import time
from fastapi import FastAPI
app = FastAPI()
@app.get("/bad")
async def bad_endpoint() -> dict[str, str]:
time.sleep(3) # 阻塞事件循环
return {"status": "ok"}
假设一个 worker 的事件循环正在处理 /bad。在 time.sleep(3) 期间,其他本应由该事件循环调度的请求也可能无法及时推进。改用异步库只能解决“等待 I/O 时主动让出执行权”的问题,不能把 CPU 密集型工作自动变成并行计算。
2. ASGI 请求的生命周期
ASGI 3.0 应用的基本形态是:
async def application(scope, receive, send):
...
其中:
scope描述当前连接或请求;receive()异步获取输入事件;send(message)异步发送输出事件。
对于 HTTP,应用通常会接收请求事件并发送响应开始、响应正文等事件;对于 WebSocket,则会持续处理连接期间的多次接收和发送事件。ASGI 应用是异步可调用对象,服务器在事件循环中等待它完成。(asgi.readthedocs.io)
FastAPI 把这些底层事件封装成路由、依赖注入、请求对象和响应对象。例如:
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
async def get_item(item_id: int) -> dict[str, int]:
return {"item_id": item_id}
请求经过的主要路径是:
TCP / TLS
→ HTTP 解析
→ ASGI scope
→ FastAPI 路由匹配
→ 参数解析和校验
→ 依赖解析
→ 路由函数
→ 响应序列化
→ ASGI send
→ HTTP 响应
FastAPI 使用类型标注描述路径参数、查询参数和请求体,并据此完成转换、校验和 OpenAPI 文档生成。(fastapi.tiangolo.com)
生产排障时必须知道故障位于哪一层:
| 现象 | 可能层次 |
|---|---|
| TCP 连接失败 | 进程未监听、容器网络、负载均衡或安全组 |
| 连接成功但返回 502 | 代理无法连接上游或上游提前关闭 |
| 返回 500 | 应用异常、依赖异常或响应序列化失败 |
| 返回 422 | 请求参数不满足 FastAPI/Pydantic 校验 |
| 延迟逐渐升高 | 队列积压、下游变慢、连接池耗尽或 CPU 饱和 |
| 只有重启后恢复 | 泄漏、连接池坏死、缓存状态错误或资源未释放 |
二、进程模型:谁负责监听,谁负责处理
1. 单进程并不等于单请求
一个 ASGI worker 进程通常包含一个事件循环。只要请求在等待异步 I/O,事件循环就可以切换去处理其他请求:
@app.get("/remote")
async def remote_call() -> dict[str, str]:
result = await async_http_client.get("https://example.com")
return {"result": result.text}
这里的并发来自任务交错,而不是多个 Python 线程同时执行 Python 字节码。它适合:
- 网络请求;
- 异步数据库访问;
- WebSocket;
- 长连接;
- 大量等待时间明显高于计算时间的接口。
它不适合直接承载长时间 CPU 密集型任务:
@app.get("/compute")
async def compute() -> dict[str, int]:
total = 0
for i in range(300_000_000):
total += i
return {"total": total}
这个函数虽然写成了 async def,但没有 await,执行期间不会主动让出事件循环。正确的处理方式通常是:
- 把计算拆到独立任务队列;
- 使用进程池;
- 使用专门的计算服务;
- 或者在确认任务很短时,使用受控线程池。
不要简单地把所有函数都改成 async def,因为函数声明本身不会改变阻塞操作的性质。
2. Worker 进程的边界
在常见部署中,worker 是运行应用的进程。FastAPI 文档将多个运行相同 API 的进程称为 workers,并指出多进程会带来独立的内存占用;例如每个进程都加载一个大型模型时,模型内存通常会按进程数近似增加。(fastapi.tiangolo.com)
一个常见启动命令是:
uvicorn myapp.main:app --host 0.0.0.0 --port 8000 --workers 4
其概念结构通常是:
Uvicorn 管理进程
├── worker 1:监听并处理请求
├── worker 2:监听并处理请求
├── worker 3:监听并处理请求
└── worker 4:监听并处理请求
具体的 socket 继承、进程启动和请求分发方式属于服务器实现细节;不能仅凭 ASGI 规范推断。FastAPI 文档给出的部署原则是:多个 worker 需要由某个组件统一接收端口流量,再把请求分发到 worker;也可以由 Kubernetes 等外部系统运行多个容器,每个容器只运行一个 Uvicorn 进程。(fastapi.tiangolo.com)
在容器环境中,常见模型是:
负载均衡器
├── Pod / 容器 A ── 一个 Python worker
├── Pod / 容器 B ── 一个 Python worker
└── Pod / 容器 C ── 一个 Python worker
这种模型把复制交给容器编排系统,优点是:
- 进程生命周期由外部系统管理;
- 单个实例崩溃容易被检测和替换;
- 可以按实例扩缩容;
- 灰度时可以单独控制新旧版本实例数量。
但“一个容器一个进程”是部署策略,不是 ASGI 规范要求。小型虚拟机、非容器环境或已有进程管理器的系统,也可以使用一个进程管理器加多个 worker。
3. 进程内状态不能当作共享状态
下面的代码在单 worker 中看似可用:
request_count = 0
@app.post("/count")
async def count() -> dict[str, int]:
global request_count
request_count += 1
return {"count": request_count}
启动两个 worker 后,实际上会得到两个独立的 request_count:
worker 1: 1, 2, 3, 4, ...
worker 2: 1, 2, 3, 4, ...
因此,以下对象不应仅保存在进程内:
- 登录会话;
- 订单状态;
- 分布式锁;
- 任务队列;
- 限流计数;
- 跨实例缓存;
- 需要在发布后继续存在的状态。
可以保存在进程内的通常是:
- 只读配置;
- 本地正则表达式;
- 连接池对象;
- 短期缓存;
- 可重建的客户端;
- 不影响一致性的性能优化数据。
进程内缓存必须有明确的失效策略。否则灰度期间会出现:
旧实例缓存:商品价格 = 100
新实例缓存:商品价格 = 120
用户访问哪个实例,结果就可能不同。若业务要求跨实例一致,应把权威状态放到数据库、共享缓存或其他明确的外部存储中。
4. 启动、就绪和关闭
应用的生命周期至少有三个状态:
未启动
│ 初始化配置、连接池、模型
▼
启动中
│ 初始化成功
▼
就绪
│ 接收请求
▼
停止中
│ 拒绝新流量、等待请求、关闭资源
▼
已停止
ASGI Lifespan 协议用于在事件循环上下文中执行启动和关闭逻辑。规范要求:在应用发送 lifespan.startup.complete 之前,服务器不应开始处理连接;关闭时,服务器应等待 lifespan.shutdown.complete 后再终止。多进程环境中,每个处理请求的事件循环都可能执行自己的 lifespan,因此连接池通常应在各自 worker 内创建,而不是跨事件循环共享。(asgi.readthedocs.io)
FastAPI 推荐使用 lifespan 处理启动和关闭资源:
from contextlib import asynccontextmanager
from dataclasses import dataclass
from fastapi import FastAPI, Request
@dataclass
class Runtime:
client: object
@asynccontextmanager
async def lifespan(app: FastAPI):
client = await create_client()
app.state.runtime = Runtime(client=client)
try:
yield
finally:
await client.aclose()
async def create_client() -> object:
# 实际项目中替换为异步 HTTP 客户端、数据库连接池等
class Client:
async def aclose(self) -> None:
pass
return Client()
app = FastAPI(lifespan=lifespan)
@app.get("/health/live")
async def live() -> dict[str, str]:
return {"status": "alive"}
@app.get("/health/ready")
async def ready(request: Request) -> dict[str, str]:
if not hasattr(request.app.state, "runtime"):
return {"status": "not-ready"}
return {"status": "ready"}
这个例子中:
create_client()在应用开始接收请求前运行;- 初始化失败时,应用不应被标记为 ready;
yield之后的代码只在关闭阶段执行;aclose()释放连接和后台资源;app.state保存的是当前应用实例的运行时对象,而不是跨进程共享存储。
实际项目中,初始化失败不应被吞掉:
@asynccontextmanager
async def lifespan(app: FastAPI):
client = await create_client()
try:
await client.check_connection()
except Exception:
await client.aclose()
raise
app.state.client = client
try:
yield
finally:
await client.aclose()
启动探针和就绪探针必须区分:
- 存活检查:进程是否还能响应,通常不访问数据库。
- 就绪检查:实例是否能够正确处理业务请求,可以检查关键依赖。
- 业务检查:业务路径是否真实可用,通常由监控或合成测试完成。
如果存活检查访问数据库,而数据库短暂故障,编排系统可能同时重启所有实例,造成故障扩大。反之,如果就绪检查永远返回成功,数据库不可用时实例仍会持续接收流量。
三、容量规划:从请求率推导进程数和连接数
1. 容量不是“CPU 核数等于 worker 数”
容量规划需要同时考虑:
- 请求到达率;
- 单请求服务时间;
- CPU 计算量;
- I/O 等待时间;
- 并发连接数;
- 数据库连接;
- 内存;
- 下游限额;
- 发布期间的额外实例。
最基本的关系来自 Little 定律:
变量含义:
- :系统内平均请求数,也可以理解为平均并发数;
- :平均到达率,单位为请求/秒;
- :请求在系统中的平均耗时,单位为秒。
例如:
- 每秒 200 个请求;
- 平均响应时间 100 ms,即 0.1 秒。
则平均并发约为:
这表示系统平均有 20 个请求处于处理中。但它不能直接推出需要 20 个 worker,因为异步 worker 可以同时等待很多 I/O,请求也可能在代理、数据库和网络连接池中排队。
2. 用 CPU 时间估算计算容量
假设每个请求平均消耗 8 ms CPU 时间,请求率为 200 req/s,则每秒需要的 CPU 时间为:
理论上至少需要 1.6 个满载 CPU 核心才能完成计算。若希望 CPU 使用率不超过 70%,则:
因此至少需要 3 个可用 CPU 核心承载该工作负载。
这里的 8 ms 必须是测量或压测得到的 CPU 时间,而不是端到端响应时间。一个请求可能耗时 100 ms,但其中只有 8 ms 使用 CPU,其余时间在等待数据库或网络。
反例:
请求平均耗时:100 ms
CPU 时间:2 ms
请求率:500 req/s
端到端并发为:
但 CPU 需求仅为:
如果错误地把“100 ms”当成 CPU 时间,会严重高估 CPU worker 数。
3. 内存容量的推导
设:
- :基础进程内存;
- :每个请求的峰值额外内存;
- :单进程同时处理的请求数;
- :共享或缓存数据;
- :worker 数;
- :日志、代理、系统和安全余量。
粗略上界可以写成:
例如:
- 每个 worker 基础内存 180 MiB;
- 每个并发请求最多额外使用 2 MiB;
- 每个 worker 目标并发 30;
- 本地缓存 100 MiB;
- 4 个 worker;
- 其他和余量 500 MiB。
则:
如果容器限制是 2 GiB,理论上还有约 188 MiB 余量,仍然可能因为 Python 分配器、第三方库、内存碎片、突发请求和监控组件而触发 OOM。
大型只读对象可能受操作系统写时复制影响,但不能把它当作稳定的节省保证:
- 只读页有机会在进程间共享物理内存;
- 任意写入都会产生私有页;
- 某些库会在初始化后修改内部结构;
- 运行时缓存可能使共享逐渐失效。
所以,生产容量预算应以“每个 worker 的实际 RSS 和峰值”验证,而不是只看理论上的共享内存。
4. 数据库连接池是容量的第二个乘数
设:
- :worker 数;
- :每个 worker 的连接池最大连接数;
- :实例数;
- :发布或运维期间的额外实例数。
数据库最大连接数近似为:
如果每个实例有 4 个 worker,每个 worker 的池上限为 10,生产有 6 个实例,则:
如果灰度期间旧版本和新版本同时存在,实例总数短时间变成 8:
若数据库最大连接数只有 300,灰度本身就可能导致连接失败,即使正常流量没有增加。
因此,worker 数、实例数和连接池大小必须一起规划,不能分别设置:
worker 越多越好
连接池越大越好
实例越多越安全
这三个结论同时成立时,数据库通常会先成为瓶颈。
5. 队列、超时和背压
当到达率超过处理能力时,请求不会凭空消失,而会进入某个队列:
客户端
→ 负载均衡队列
→ socket backlog
→ ASGI/服务器队列
→ 应用并发限制
→ 数据库连接池等待
→ 数据库内部队列
若服务时间为 ,有效并发上限为 ,理想吞吐上限可粗略估计为:
例如:
- 最大应用并发 100;
- 平均服务时间 200 ms,即 0.2 秒。
则理想吞吐约为:
但这不是承诺值,因为还要扣除 CPU、下游限流、尾延迟、锁竞争和错误重试造成的额外负载。
当队列变长时,平均响应时间会升高;当客户端和代理都有超时,可能出现:
请求已经在数据库排队
→ 代理先超时
→ 客户端重试
→ 新请求再次进入队列
→ 数据库压力继续上升
这就是典型的重试风暴。重试必须带有:
- 最大重试次数;
- 指数退避;
- 抖动;
- 只对幂等操作重试;
- 总截止时间;
- 对下游错误分类。
四、配置:把运行时选择变成可验证输入
1. 配置不是环境变量本身
环境变量只是配置的一种传输方式。配置的完整流程应是:
环境变量 / 配置文件 / Secret
→ 读取
→ 类型转换
→ 默认值处理
→ 约束校验
→ 生成不可变运行配置
→ 创建客户端和应用
配置最危险的状态是“读取成功但语义错误”:
MAX_CONNECTIONS=ten
TIMEOUT_SECONDS=-1
DATABASE_URL=postgres://...
FEATURE_ENABLE_PAYMENT=maybe
如果这些值直到第一次请求才被使用,问题会在生产流量中暴露。应在启动阶段失败,让实例保持 not-ready 或直接退出。
FastAPI 的配置文档展示了使用环境变量和 Pydantic Settings 管理配置的方式;配置模型可以把字符串环境变量转换成 Python 类型,并集中处理默认值和校验。(fastapi.tiangolo.com)
一个可运行的配置示例:
from functools import lru_cache
from pydantic import Field, SecretStr, AnyUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="APP_",
extra="ignore",
)
environment: str = "dev"
database_url: AnyUrl
database_pool_size: int = Field(default=10, ge=1, le=100)
request_timeout_seconds: float = Field(default=3.0, gt=0, le=60)
payment_enabled: bool = False
api_key: SecretStr
@lru_cache
def get_settings() -> Settings:
return Settings()
对应环境变量:
export APP_ENVIRONMENT=prod
export APP_DATABASE_URL='postgresql://app:password@db/app'
export APP_DATABASE_POOL_SIZE=10
export APP_REQUEST_TIMEOUT_SECONDS=2.5
export APP_PAYMENT_ENABLED=false
export APP_API_KEY='replace-me'
读取后,database_pool_size 是整数,request_timeout_seconds 是浮点数,payment_enabled 是布尔值。缺少必填项或违反约束时,Settings() 应在启动阶段抛出验证错误。
2. 配置优先级必须固定
常见的配置优先级可以定义为:
代码默认值
< 通用配置文件
< 环境配置文件
< 环境变量
< Secret 注入
关键不在于使用哪种层级,而在于层级必须可解释。例如:
APP_REQUEST_TIMEOUT_SECONDS=2.5
应明确覆盖:
request_timeout_seconds: float = 3.0
但不应让某个隐藏在容器镜像内的 .env 文件悄悄覆盖生产 Secret。
生产环境通常不应把敏感配置烘焙进镜像:
# 错误示例:Secret 进入镜像层或构建缓存
ENV APP_API_KEY=real-production-secret
镜像应包含代码和非敏感运行依赖,Secret 在部署时注入。这样做还便于同一个镜像在测试、预发布和生产使用不同配置。
3. 启动时配置和动态配置
配置可以按是否允许运行中改变分为两类。
启动时配置:
- 数据库地址;
- 监听端口;
- worker 数;
- 连接池大小;
- TLS 证书路径;
- 必须在初始化时建立的客户端。
动态配置:
- 灰度比例;
- 限流阈值;
- 功能开关;
- 某些业务规则;
- 采样率。
启动时配置改变通常需要重启,因为已经创建的资源依赖旧配置。例如把数据库连接池上限从 10 改成 30,不代表现有池会自动重新构建。
动态配置则必须解决一致性和缓存问题:
控制面修改配置
→ 配置存储更新
→ 实例轮询或订阅
→ 本地缓存刷新
→ 新请求使用新值
必须定义:
- 刷新延迟;
- 更新失败时保留旧值还是停止服务;
- 配置版本号;
- 配置回滚方式;
- 对 Secret 的最小权限。
不要让一个请求在执行中途读取两次动态配置并得到两个版本,否则一次请求可能前半段按旧规则、后半段按新规则执行。
五、数据库迁移:代码回滚不等于数据回滚
1. 迁移改变的是持久化状态
代码发布通常可以切换镜像标签:
app:v1 → app:v2
数据库迁移则改变共享持久化状态:
schema version 12 → schema version 13
旧代码和新代码都会访问同一个数据库,因此迁移必须考虑时间窗口内同时存在的新旧代码。
如果新代码先上线而数据库没有新列:
user.display_name
会出现列不存在错误。
如果数据库先删除旧列而旧代码仍在运行:
SELECT username, legacy_name FROM users;
旧实例会立即失败。
因此,迁移的关键条件不是“新代码能访问新结构”,而是:
在发布和回滚期间,旧代码与新代码都能与当前数据库结构兼容。
2. Expand–Migrate–Contract
一种通用的兼容迁移分为三个阶段。
阶段一:Expand
先添加新结构,但不破坏旧结构:
ALTER TABLE users
ADD COLUMN display_name TEXT NULL;
这一阶段要求:
- 可重复执行或有迁移版本锁;
- 新列允许旧代码忽略;
- 不删除旧列;
- 不强制立即回填大表。
阶段二:Migrate
新代码同时写旧字段和新字段:
async def update_user(user_id: int, name: str) -> None:
await db.execute(
"""
UPDATE users
SET legacy_name = :name,
display_name = :name
WHERE id = :user_id
""",
{"user_id": user_id, "name": name},
)
读取时优先新字段,必要时回退旧字段:
display_name = row["display_name"] or row["legacy_name"]
然后通过后台任务分批回填历史数据:
UPDATE users
SET display_name = legacy_name
WHERE display_name IS NULL
AND id > :last_id
AND id <= :next_id;
分批回填的原因是避免一次事务锁住整张表、产生大量 WAL、占满 I/O 或拖慢在线请求。批大小应根据锁等待、事务耗时和数据库负载调整,而不是固定套用某个数字。
阶段三:Contract
确认以下条件后再删除旧结构:
- 所有实例都不再读取旧列;
- 所有写路径都不再写旧列;
- 历史数据已回填;
- 监控中不存在旧列访问;
- 已经过足够观察窗口;
- 删除操作有单独的备份和恢复方案。
ALTER TABLE users
DROP COLUMN legacy_name;
这一步通常不应和普通应用代码发布绑定在同一次不可逆操作中。
3. 迁移必须单实例执行
FastAPI 部署文档特别强调,启动前步骤例如数据库迁移通常只应执行一次;如果多个 worker 同时执行,可能重复工作或产生冲突。(fastapi.tiangolo.com)
错误方式:
uvicorn myapp.main:app --workers 4
并在 Python 模块导入时执行:
run_database_migration()
这样做的问题是:
- 每个 worker 都可能导入并执行;
- 多个实例发布时会进一步放大;
- worker 启动顺序不代表迁移锁;
- 迁移失败可能表现为部分实例已启动、部分实例未启动。
更清晰的流程是:
构建制品
→ 运行测试
→ 执行一次迁移任务
→ 验证迁移
→ 启动新版本应用
例如容器入口脚本:
#!/usr/bin/env bash
set -euo pipefail
python -m myapp.migrate
exec uvicorn myapp.main:app --host 0.0.0.0 --port 8000
exec 的作用是让 Uvicorn 替换 shell 成为 PID 1,使信号更直接地传递到应用进程。迁移命令本身仍然必须有数据库级锁、迁移版本表或部署平台的单例保证。
如果迁移采用数据库锁:
SELECT pg_advisory_lock(123456789);
-- 执行迁移
SELECT pg_advisory_unlock(123456789);
应用层仍应设置超时和错误处理,避免持锁进程异常退出后长期阻塞其他部署流程。具体锁语义取决于数据库,不能把 PostgreSQL 的语法直接当作所有数据库的通用方案。
4. 迁移失败时如何恢复
迁移失败有三种不同情况:
事务尚未提交
如果数据库支持事务性 DDL,并且迁移在一个事务中执行,可以回滚事务:
BEGIN
ALTER TABLE ...
CREATE INDEX ...
ROLLBACK
但不是所有 DDL、索引创建方式或数据库都具有相同事务语义。
部分步骤已提交
此时不能简单执行“回滚脚本”,应:
- 停止继续部署;
- 记录已执行迁移版本;
- 判断旧代码是否仍兼容当前结构;
- 使用补偿迁移修复;
- 必要时从备份恢复;
- 重新验证读写路径。
数据已经被新逻辑改写
代码回滚只能让旧代码重新运行,不能自动恢复已经写入的新数据。比如新版本把货币单位从“分”写成“元”,旧版本重新上线后仍可能读取到已改变的数据。
所以迁移设计要把以下内容分开:
- 结构迁移;
- 数据回填;
- 数据语义转换;
- 代码切换;
- 数据恢复。
六、灰度发布:控制流量,不是只启动一台新机器
1. 灰度的定义
灰度发布是让新版本只接收一部分符合条件的流量,同时保留旧版本作为对照和回退目标。
灰度路由可以按以下维度切分:
- 随机比例;
- 用户 ID 哈希;
- 租户;
- 地域;
- 请求头;
- Cookie;
- 内部账号;
- 指定 API 路径。
按用户 ID 哈希时,应保持同一用户在观察窗口内尽量稳定地落到同一版本:
当 bucket < 5 时进入新版本,表示大约 5% 的用户进入灰度组。
不能只按请求随机分流来测试有状态业务:
同一用户第一次请求 v1
同一用户第二次请求 v2
同一购物车状态出现不一致
如果必须随机分流,应确保状态存储在共享系统,且新旧版本协议兼容。
2. 灰度流量路径
flowchart LR
C[客户端] --> G[网关或负载均衡]
G -->|95%| O[旧版本 v1]
G -->|5%| N[新版本 v2]
O --> DB[(共享数据库)]
N --> DB
G --> M[指标与 Trace]
O --> M
N --> M
灰度期间需要分别统计 v1 和 v2:
- 请求量;
- 成功率;
- 4xx、5xx;
- p50、p95、p99 延迟;
- 超时;
- 数据库错误;
- 下游错误;
- CPU、内存和重启次数;
- 业务指标;
- 关键操作的幂等冲突。
只看全局平均值会掩盖新版本问题。例如:
v1:99.99% 成功率,95% 流量
v2:98.00% 成功率,5% 流量
全局:99.89% 成功率
全局指标看起来仍然很好,但 v2 已经明显异常。
3. 灰度必须有停止条件
灰度不是“观察一下”,而是一个带状态转换的控制流程:
候选版本
→ 预发布验证
→ 1% 流量
→ 5% 流量
→ 25% 流量
→ 50% 流量
→ 100% 流量
每一步都应有:
- 最小观察请求数;
- 最小观察时间;
- 允许的错误率;
- 允许的延迟变化;
- 业务正确性校验;
- 自动暂停条件;
- 回滚负责人和命令。
例如,可定义相对阈值:
这两个条件只是示例,阈值必须依据业务基线确定。低流量服务不能只看百分比,因为 1 次错误 / 2 次请求会得到 50%,但统计意义很弱;应同时考虑绝对错误数和样本量。
4. 功能开关与灰度不是同一件事
灰度控制请求进入哪个版本;功能开关控制某个版本内部是否启用一段逻辑。
例如:
if settings.payment_enabled and user.is_in_payment_group:
return await new_payment_flow()
return await old_payment_flow()
两者可以组合:
v2 实例只接收 5% 流量
→ v2 内部支付功能只对 1% 用户开启
这种组合可以缩小风险面,但也增加状态空间。必须记录:
- 当前版本;
- 功能开关值;
- 用户是否命中灰度;
- 请求最终执行的业务路径。
否则出现错误时,日志只能说明“请求进入 v2”,无法说明是否执行了新支付逻辑。
七、回滚:把代码、配置和数据分开处理
1. 回滚的三种对象
生产回滚至少包含三个维度:
- 代码回滚:把流量切回旧镜像或旧版本。
- 配置回滚:恢复旧的开关、阈值、下游地址或策略。
- 数据回滚:恢复被错误写入的数据或补偿业务操作。
其中,代码回滚通常最快;数据回滚最危险,也最难自动化。
一个安全的回滚优先级通常是:
先关闭新功能
→ 再停止新版本流量
→ 让旧版本继续服务
→ 评估数据库和业务数据
→ 执行补偿或恢复
不要一发现 500 就立刻恢复数据库备份。恢复备份可能丢失回滚点之后的合法写入。
2. 回滚的兼容性条件
代码从 v2 回到 v1 必须满足:
例如 v2 新增了 display_name,并同时写入 legacy_name 和 display_name,那么回滚到 v1 仍然可行。
但如果 v2 删除了 legacy_name,或者只写新字段:
UPDATE users SET display_name = :name
v1 读取 legacy_name 时就会出现错误或读到旧数据。此时“镜像回滚”并不等于“服务恢复”。
3. 一个可执行的回滚流程
假设当前版本为 v2.4.0,上一稳定版本为 v2.3.1:
# 1. 暂停自动扩大灰度
deployctl rollout pause users-api
# 2. 关闭新功能开关
configctl set users-api.payment_enabled=false
# 3. 将流量切回旧版本
deployctl traffic set users-api \
--version v2.3.1 \
--weight 100
# 4. 验证旧版本就绪状态
curl -fsS https://api.example.com/health/ready
# 5. 验证关键业务路径
curl -fsS -H 'Authorization: Bearer ...' \
https://api.example.com/v1/users/me
# 6. 检查错误率、延迟、数据库连接和业务指标
observectl query users-api --window=10m
# 7. 暂时保留 v2.4.0,禁止立即删除
deployctl rollout hold users-api --version v2.4.0
每条命令都应有明确的预期结果:
rollout pause:不再自动提升新版本流量;configctl set:配置中心返回新版本号;traffic set:网关显示旧版本权重为 100%;health/ready:HTTP 200 且响应内容表示 ready;- 业务请求:不仅进程活着,而且关键逻辑正确;
- 指标查询:确认错误率恢复,而不是只确认命令执行成功。
如果回滚命令本身失败,不能重复盲执行。应先读取当前流量权重和版本状态:
deployctl traffic get users-api
deployctl rollout status users-api
这是为了避免“操作已经成功但客户端超时”,随后重复操作导致状态进一步变化。
八、容器和进程管理:让外部系统负责失败恢复
FastAPI 的部署概念文档把自动启动、崩溃重启、多个进程、内存和启动前步骤视为独立的部署问题;Docker、Kubernetes、systemd 等可以负责启动和重启,但应用本身仍必须正确处理生命周期和资源释放。(fastapi.tiangolo.com)
一个简单的生产镜像:
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY myapp ./myapp
USER nobody
CMD ["uvicorn", "myapp.main:app", "--host", "0.0.0.0", "--port", "8000"]
这个镜像的关键点:
- 固定 Python 主版本为 3.14;
- 依赖通过
requirements.txt或锁定文件确定; - 日志输出到标准输出;
- 不以 root 用户运行;
- 容器只负责启动一个应用进程;
- worker 数由编排层或明确的启动参数决定。
依赖应在构建阶段锁定并测试,而不是启动容器时临时安装。否则同一个镜像在不同时间可能解析出不同依赖版本,破坏制品可复现性。
如果平台已经按容器副本扩展服务,不要在每个容器内再盲目启动多个 worker:
4 个容器 × 4 个 worker = 16 个进程
这可能导致:
- CPU 竞争;
- 内存乘法;
- 数据库连接乘法;
- 每个实例重复初始化大型模型;
- 监控指标难以归因。
最终 worker 数应由压测和资源预算决定,而不是依据一个固定公式。
九、发布前验证:验证的是运行路径,不只是测试通过
1. 制品验证
一个生产制品至少应包含可追踪信息:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health/version")
async def version() -> dict[str, str]:
return {
"service": "users-api",
"version": "2.4.0",
"build": "git-sha-abc123",
"python": "3.14",
}
版本信息不能依赖手工修改。通常应在构建阶段由 Git 提交号、构建号或制品元数据生成。
发布前验证:
python --version
python -m pip check
python -m pytest
python -m compileall myapp
它们分别验证:
- 解释器版本;
- 已安装依赖之间是否存在冲突;
- 测试是否通过;
- Python 文件是否能编译。
这些检查不能替代真实启动测试:
uvicorn myapp.main:app --host 127.0.0.1 --port 8000 &
pid=$!
trap 'kill "$pid"' EXIT
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8000/health/ready; then
break
fi
sleep 1
done
curl -fsS http://127.0.0.1:8000/health/version
启动测试能发现:
- 配置缺失;
- 导入错误;
- lifespan 初始化失败;
- 端口绑定失败;
- 依赖库在 Python 3.14 下不兼容;
- 健康检查路径配置错误。
2. 迁移验证
迁移至少需要在以下状态下验证:
旧结构 + 旧代码
旧结构 + 新代码
新结构 + 旧代码
新结构 + 新代码
其中最容易被忽略的是:
新结构 + 旧代码
它决定代码回滚是否安全。
一个最小测试流程可以是:
# 启动旧版本
docker compose up -d db old-api
# 执行 expand 迁移
docker compose run --rm migrate-expand
# 确认旧版本仍能读写
./scripts/smoke-old-api.sh
# 启动新版本
docker compose up -d new-api
# 确认新旧版本都能读写
./scripts/smoke-both-versions.sh
# 执行数据回填
docker compose run --rm migrate-backfill
# 只有确认旧版本不再需要旧字段后,才执行 contract
docker compose run --rm migrate-contract
迁移脚本应输出:
migration=20260901_add_display_name
status=applied
duration_ms=842
rows_affected=0
失败时要输出迁移名称、失败步骤、数据库错误和是否已提交,不能只输出一个堆栈尾部。
十、可观测性:让版本、进程和流量可区分
生产治理中的日志、指标和 Trace 必须包含能够回答以下问题的信息:
- 请求进入了哪个版本?
- 由哪个实例、哪个进程处理?
- 使用了什么配置版本?
- 是否命中了灰度?
- 调用了哪个下游?
- 在哪一步超时?
- 回滚后是否恢复?
一个结构化日志字段集合可以是:
{
"timestamp": "2026-09-01T10:00:00.123Z",
"service": "users-api",
"version": "2.4.0",
"instance": "users-api-7d9c",
"pid": 314,
"trace_id": "abc123",
"route": "/v1/users/me",
"status": 200,
"duration_ms": 42,
"gray": true
}
不要只记录 URL,因为:
/v1/users/1001
/v1/users/1002
/v1/users/1003
会造成高基数指标。指标中的路由应使用模板:
/v1/users/{user_id}
版本维度也要控制基数。短期保留每个发布版本通常有价值,但不能把完整用户 ID、订单号和 Trace ID 作为指标标签。
发布期间最有用的指标对比是:
新版本 vs 旧版本
实例维度
路由维度
下游依赖维度
例如:
rate(http_requests_total{version="v2.4.0",status=~"5.."}[5m])
/
rate(http_requests_total{version="v2.4.0"}[5m])
真实监控系统的指标名称和标签取决于具体框架、导出器和平台,不能直接假设所有服务都存在上述名称。核心原则是:在应用入口处注入版本、实例和 Trace 上下文,并在错误、下游调用和业务结果中保持关联。
十一、常见错误与失败表现
错误一:把开发服务器用于生产
开发模式通常包含自动重载和更宽松的开发体验。FastAPI 文档明确区分了 fastapi dev 和生产使用的 fastapi run;开发模式默认启用自动重载。(fastapi.tiangolo.com)
失败表现:
- 代码变更触发额外进程;
- 内存占用升高;
- 进程树复杂;
- 信号和关闭行为不符合生产预期;
- 日志中出现 reloader 进程。
生产应使用明确的启动命令,并由容器编排或进程管理器负责重启。
错误二:在每个 worker 中执行一次性迁移
失败表现:
worker 1 获得迁移锁
worker 2 等待
worker 3 超时
worker 4 启动失败
或者多个迁移没有锁,导致:
duplicate column
deadlock detected
relation already exists
迁移应从应用 worker 启动流程中分离出来,并由平台保证单次执行。
错误三:把本地缓存当作一致性存储
失败表现:
v1 实例显示订单状态为 paid
v2 实例显示订单状态为 pending
诊断方法:
- 查看请求命中的实例和版本;
- 查看本地缓存命中日志;
- 查询数据库权威状态;
- 比对缓存更新时间和失效时间;
- 禁用缓存后重试。
修复方式通常是让权威状态进入共享存储,并把本地缓存明确为可丢失的优化层。
错误四:只增加 worker 解决延迟
如果瓶颈在数据库连接池或下游服务,增加 worker 可能使情况更糟:
worker 增加
→ 数据库连接池总数增加
→ 数据库连接耗尽
→ 请求排队
→ 超时重试
→ 负载进一步上升
增加 worker 前应判断请求是:
- CPU 饱和;
- 事件循环阻塞;
- 数据库等待;
- 下游等待;
- 连接池等待;
- 锁等待;
- 代理队列等待。
没有分层延迟指标时,不能可靠地选择扩容方向。
错误五:灰度时只看 HTTP 成功率
HTTP 200 不代表业务正确。例如新版本返回:
{"status": "ok", "balance": 0}
但实际余额被错误清零,HTTP 层仍然是成功的。
灰度验证必须同时观察:
- 业务结果;
- 数据写入;
- 对账;
- 异步任务积压;
- 用户投诉;
- 关键领域指标。
对于支付、库存、权限和订单等业务,业务正确性比单纯的 5xx 更早暴露问题。
十二、一个可落地的生产交付顺序
可以将一次发布拆为以下状态机:
stateDiagram-v2
[*] --> Built
Built --> Tested: 单元/集成/制品验证通过
Tested --> Migrated: Expand 迁移完成
Migrated --> Ready: 新实例启动并通过就绪检查
Ready --> Canary: 进入小比例流量
Canary --> Expanded: 指标和业务验证通过
Canary --> Rollback: 超过停止阈值
Expanded --> Completed: 全量并完成观察窗口
Expanded --> Rollback: 新问题出现
Rollback --> Recovered: 旧版本流量恢复
Recovered --> Compensate: 需要数据补偿
Compensate --> Completed
Completed --> [*]
每个状态都必须有可验证条件:
| 状态 | 验证条件 |
|---|---|
| Built | 镜像摘要、依赖锁定、Git 提交号确定 |
| Tested | 测试通过,Python 3.14 启动测试通过 |
| Migrated | 迁移版本正确,旧代码仍兼容 |
| Ready | lifespan 初始化完成,就绪检查成功 |
| Canary | 新旧版本指标可分组比较 |
| Expanded | 所有灰度阶段达到观察条件 |
| Rollback | 流量切换成功,旧版本业务检查通过 |
| Recovered | 错误率、延迟和业务指标恢复 |
| Compensate | 数据补偿脚本可审计、可重试、可验证 |
最终,一个 Python 服务的生产交付质量可以用下面的条件描述:
其中任意一项缺失,系统都可能“能够上线”,但无法在生产变化和故障中保持可控。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 容器与 CI:镜像、依赖缓存、测试、制品和供应链
- 延伸:Python 应用架构:模块边界、依赖方向、领域层和可替换适配器
- 延伸:Python 可观测性:日志、指标、Trace、Context 和故障定位
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论