Python 基础体系 · 第 112/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 生产交付:进程模型、容量、配置、迁移、灰度和回滚

生产交付不是“把 Python 文件放到服务器上并启动”这么简单。一个可运行的 Python 服务至少包含以下对象:

  • 制品:某个确定版本的源码、依赖、解释器和系统库。
  • 进程:正在运行的 Python 解释器实例。
  • 监听器:接收网络连接并把请求交给应用的服务器。
  • 配置:决定服务如何连接数据库、调用下游、限制并发和暴露能力。
  • 数据变更:数据库结构、数据修复和索引等不随代码自动回滚的状态变化。
  • 流量策略:决定哪些请求进入新版本。
  • 恢复策略:发现错误后如何停止新版本、切回旧版本并验证恢复。

FastAPI 通常运行在 Uvicorn 等 ASGI 服务器上。ASGI 定义了协议服务器与 Python 应用之间的异步接口,应用通过 scopereceivesend 处理连接与事件;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)

这一区分决定了几个生产事实:

  1. 一个进程可以通过异步 I/O 同时处理多个请求。
  2. 多个进程可以利用多个 CPU 核心,并隔离进程级崩溃。
  3. 进程之间默认不共享 Python 堆对象。
  4. 每个进程通常都要单独创建数据库连接池、缓存客户端和线程池。
  5. 进程数增加后,吞吐能力可能增加,但内存和下游连接数也会增加。

“异步”不等于“无限并发”。如果一个 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"}

这个例子中:

  1. create_client() 在应用开始接收请求前运行;
  2. 初始化失败时,应用不应被标记为 ready;
  3. yield 之后的代码只在关闭阶段执行;
  4. aclose() 释放连接和后台资源;
  5. 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 定律:

L=λWL = \lambda W

变量含义:

  • LL:系统内平均请求数,也可以理解为平均并发数;
  • λ\lambda:平均到达率,单位为请求/秒;
  • WW:请求在系统中的平均耗时,单位为秒。

例如:

  • 每秒 200 个请求;
  • 平均响应时间 100 ms,即 0.1 秒。

则平均并发约为:

L=200×0.1=20L = 200 \times 0.1 = 20

这表示系统平均有 20 个请求处于处理中。但它不能直接推出需要 20 个 worker,因为异步 worker 可以同时等待很多 I/O,请求也可能在代理、数据库和网络连接池中排队。


2. 用 CPU 时间估算计算容量

假设每个请求平均消耗 8 ms CPU 时间,请求率为 200 req/s,则每秒需要的 CPU 时间为:

200×0.008=1.6 CPU 秒/秒200 \times 0.008 = 1.6 \text{ CPU 秒/秒}

理论上至少需要 1.6 个满载 CPU 核心才能完成计算。若希望 CPU 使用率不超过 70%,则:

Ncpu1.60.72.29N_{\text{cpu}} \geq \frac{1.6}{0.7} \approx 2.29

因此至少需要 3 个可用 CPU 核心承载该工作负载。

这里的 8 ms 必须是测量或压测得到的 CPU 时间,而不是端到端响应时间。一个请求可能耗时 100 ms,但其中只有 8 ms 使用 CPU,其余时间在等待数据库或网络。

反例:

请求平均耗时:100 ms
CPU 时间:2 ms
请求率:500 req/s

端到端并发为:

500×0.1=50500 \times 0.1 = 50

但 CPU 需求仅为:

500×0.002=1 CPU 秒/秒500 \times 0.002 = 1 \text{ CPU 秒/秒}

如果错误地把“100 ms”当成 CPU 时间,会严重高估 CPU worker 数。


3. 内存容量的推导

设:

  • M0M_0:基础进程内存;
  • McM_c:每个请求的峰值额外内存;
  • CC:单进程同时处理的请求数;
  • MsM_s:共享或缓存数据;
  • NN:worker 数;
  • MotherM_{\text{other}}:日志、代理、系统和安全余量。

粗略上界可以写成:

MtotalN×(M0+C×Mc+Ms)+MotherM_{\text{total}} \approx N \times (M_0 + C \times M_c + M_s) + M_{\text{other}}

例如:

  • 每个 worker 基础内存 180 MiB;
  • 每个并发请求最多额外使用 2 MiB;
  • 每个 worker 目标并发 30;
  • 本地缓存 100 MiB;
  • 4 个 worker;
  • 其他和余量 500 MiB。

则:

Mtotal4×(180+30×2+100)+500=4×340+500=1860 MiBM_{\text{total}} \approx 4 \times (180 + 30 \times 2 + 100) + 500 = 4 \times 340 + 500 = 1860 \text{ MiB}

如果容器限制是 2 GiB,理论上还有约 188 MiB 余量,仍然可能因为 Python 分配器、第三方库、内存碎片、突发请求和监控组件而触发 OOM。

大型只读对象可能受操作系统写时复制影响,但不能把它当作稳定的节省保证:

  • 只读页有机会在进程间共享物理内存;
  • 任意写入都会产生私有页;
  • 某些库会在初始化后修改内部结构;
  • 运行时缓存可能使共享逐渐失效。

所以,生产容量预算应以“每个 worker 的实际 RSS 和峰值”验证,而不是只看理论上的共享内存。


4. 数据库连接池是容量的第二个乘数

设:

  • NwN_w:worker 数;
  • PP:每个 worker 的连接池最大连接数;
  • NiN_i:实例数;
  • RR:发布或运维期间的额外实例数。

数据库最大连接数近似为:

Cdb=Ni×Nw×PC_{\text{db}} = N_i \times N_w \times P

如果每个实例有 4 个 worker,每个 worker 的池上限为 10,生产有 6 个实例,则:

Cdb=6×4×10=240C_{\text{db}} = 6 \times 4 \times 10 = 240

如果灰度期间旧版本和新版本同时存在,实例总数短时间变成 8:

Cdb,gray=8×4×10=320C_{\text{db,gray}} = 8 \times 4 \times 10 = 320

若数据库最大连接数只有 300,灰度本身就可能导致连接失败,即使正常流量没有增加。

因此,worker 数、实例数和连接池大小必须一起规划,不能分别设置:

worker 越多越好
连接池越大越好
实例越多越安全

这三个结论同时成立时,数据库通常会先成为瓶颈。


5. 队列、超时和背压

当到达率超过处理能力时,请求不会凭空消失,而会进入某个队列:

客户端
  → 负载均衡队列
  → socket backlog
  → ASGI/服务器队列
  → 应用并发限制
  → 数据库连接池等待
  → 数据库内部队列

若服务时间为 SS,有效并发上限为 KK,理想吞吐上限可粗略估计为:

μKS\mu \approx \frac{K}{S}

例如:

  • 最大应用并发 100;
  • 平均服务时间 200 ms,即 0.2 秒。

则理想吞吐约为:

μ=1000.2=500 req/s\mu = \frac{100}{0.2} = 500 \text{ req/s}

但这不是承诺值,因为还要扣除 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. 停止继续部署;
  2. 记录已执行迁移版本;
  3. 判断旧代码是否仍兼容当前结构;
  4. 使用补偿迁移修复;
  5. 必要时从备份恢复;
  6. 重新验证读写路径。

数据已经被新逻辑改写

代码回滚只能让旧代码重新运行,不能自动恢复已经写入的新数据。比如新版本把货币单位从“分”写成“元”,旧版本重新上线后仍可能读取到已改变的数据。

所以迁移设计要把以下内容分开:

  • 结构迁移;
  • 数据回填;
  • 数据语义转换;
  • 代码切换;
  • 数据恢复。

六、灰度发布:控制流量,不是只启动一台新机器

1. 灰度的定义

灰度发布是让新版本只接收一部分符合条件的流量,同时保留旧版本作为对照和回退目标。

灰度路由可以按以下维度切分:

  • 随机比例;
  • 用户 ID 哈希;
  • 租户;
  • 地域;
  • 请求头;
  • Cookie;
  • 内部账号;
  • 指定 API 路径。

按用户 ID 哈希时,应保持同一用户在观察窗口内尽量稳定地落到同一版本:

bucket=hash(user_id)mod100bucket = hash(user\_id) \bmod 100

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% 流量

每一步都应有:

  • 最小观察请求数;
  • 最小观察时间;
  • 允许的错误率;
  • 允许的延迟变化;
  • 业务正确性校验;
  • 自动暂停条件;
  • 回滚负责人和命令。

例如,可定义相对阈值:

error_ratev2error_ratev1+0.5%error\_rate_{v2} \leq error\_rate_{v1} + 0.5\%

p99v21.2×p99v1p99_{v2} \leq 1.2 \times p99_{v1}

这两个条件只是示例,阈值必须依据业务基线确定。低流量服务不能只看百分比,因为 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. 回滚的三种对象

生产回滚至少包含三个维度:

  1. 代码回滚:把流量切回旧镜像或旧版本。
  2. 配置回滚:恢复旧的开关、阈值、下游地址或策略。
  3. 数据回滚:恢复被错误写入的数据或补偿业务操作。

其中,代码回滚通常最快;数据回滚最危险,也最难自动化。

一个安全的回滚优先级通常是:

先关闭新功能
  → 再停止新版本流量
  → 让旧版本继续服务
  → 评估数据库和业务数据
  → 执行补偿或恢复

不要一发现 500 就立刻恢复数据库备份。恢复备份可能丢失回滚点之后的合法写入。


2. 回滚的兼容性条件

代码从 v2 回到 v1 必须满足:

v1 能读取当前数据库结构v1 \text{ 能读取当前数据库结构}

v1 能理解当前数据语义v1 \text{ 能理解当前数据语义}

v1 能处理当前配置v1 \text{ 能处理当前配置}

v1 不会覆盖 v2 已写入的字段v1 \text{ 不会覆盖 v2 已写入的字段}

例如 v2 新增了 display_name,并同时写入 legacy_namedisplay_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

诊断方法:

  1. 查看请求命中的实例和版本;
  2. 查看本地缓存命中日志;
  3. 查询数据库权威状态;
  4. 比对缓存更新时间和失效时间;
  5. 禁用缓存后重试。

修复方式通常是让权威状态进入共享存储,并把本地缓存明确为可丢失的优化层。


错误四:只增加 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 服务的生产交付质量可以用下面的条件描述:

可交付=可启动可观测容量可解释迁移可兼容灰度可停止回滚可验证\text{可交付} = \text{可启动} \land \text{可观测} \land \text{容量可解释} \land \text{迁移可兼容} \land \text{灰度可停止} \land \text{回滚可验证}

其中任意一项缺失,系统都可能“能够上线”,但无法在生产变化和故障中保持可控。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。