本页目录

全栈 II · 后端工程

对标:Berkeley CS169 / FastAPI 文档 / DDIA(数据视角)| 前置:web-01、db 线、os-02(并发)、net-02(HTTP) 深入 web-01 那条链里的应用服务器层——后端。以你的 FastAPI(Medusa)为标本,讲清后端的核心构件:路由与请求处理、ORM 与数据库交互(及其经典陷阱)、异步/并发模型(为什么现代后端爱 async)、认证授权、错误处理与日志。这是把"能跑的后端"写成"可靠的后端"的工程知识。

学习层:事件循环何时救你,何时被你自己堵死?

1. 具体谜题:六个请求同时到达,谁在队列里等待?

一个 FastAPI worker 同时收到两个数据库读、一个远端 LLM 请求、一个本地 embedding 计算和两个健康检查。连接池只有两个槽,入口队列容量为四。先预测:

  1. await 挂起数据库读时,事件循环能否推进另一个请求?
  2. 本地 CPU 计算若直接写在 async def 中,会让健康检查怎样?
  3. 入口已经满载时,继续接收请求与立即返回 429,哪一个更能保护已有请求的尾延迟?

实验台固定任务顺序、I/O 时长、CPU 时长和队列容量;提交预测后再切换 async/线程池模型,观察完成时间、队列拒绝和连接池占用。先写下“会并行什么、会阻塞什么”,再看事件时间线。

2. 最小模型:任务状态、等待资源与有界队列

每个请求在 accepted → running → waiting(I/O) → resumed → finished 之间移动。事件循环只在当前回调主动让出控制权时切换;await 代表等待资源,不代表 CPU 工作自动并行。把入口看成容量为 Q 的队列,把数据库连接池看成容量为 C 的第二个队列,过载时必须选择排队、超时或拒绝。

对 I/O 密集请求,多个等待区间可以重叠;对 CPU 密集请求,单 worker 的执行时间仍近似相加。一个可核对的粗粒度账本是

\[ L_i=L_{queue}+L_{pool}+L_{io}+L_{cpu}+L_{serialize}, \]

其中只有等待 I/O 的部分能由事件循环把执行权交给别的请求。实验不模拟真实操作系统调度,只用确定性事件序列揭示这个分工。

3. 正式机制与不变量:吞吐不是无界并发

  • async 的机制是协作式让出,不是把同步 CPU 代码变成并行代码;任何长时间不让出的回调都会阻塞同一 worker 的所有连接。
  • 背压是把下游容量向上游传播:当连接池、外部 API 或队列到达上限,入口应限流、超时、取消或返回明确的 429/503,而不是无限积压。
  • 资源不变量:同时持有的数据库连接不超过池容量;被取消的请求释放连接;队列长度有上界;重试必须有预算,否则故障会被放大成重试风暴。
  • 边界校验仍在最前面:Pydantic 的 422 不是性能机制,但能让非法请求在占用数据库和 CPU 前退出。

所以“每个请求一个协程”不是“无限并发”的承诺。可靠后端的证据应同时记录活动请求、队列等待、池等待、超时和拒绝,而不只看平均响应时间。

4. 失败边界与迁移任务

真实服务还涉及线程池大小、数据库锁、网络重试、取消传播、GIL、优先级和 p99 尾延迟;本实验把这些折叠为固定成本,不能据此挑选生产参数。async 也不会修复 N+1 查询、慢 SQL 或不受控的外部依赖。

迁移任务:给 Medusa 的一个读端点定义队列上限、数据库池上限、超时、重试预算和 429/503 语义,并在结构化日志中区分 queue_ms、pool_ms、io_ms 与 cpu_ms。然后说明本地 embedding 应如何移出事件循环,以及这一选择如何影响 web-01 的请求账本。

JavaScript 失效时的静态读法:固定五个任务的总工作量不变;async 只重叠等待,CPU 段仍占住单 worker。队列容量不足时,拒绝是背压证据,不是“请求消失无需记录”。

模型 队列容量 I/O 等待 CPU 段 过载时的正确观察
async worker 4 可交错推进 阻塞事件循环 第 5 个入口请求应被拒绝或限流
线程池(2) 4 线程等待 可由另一线程推进 线程/连接池仍是有限资源
async + 无限队列 ∞ 表面吞吐平稳 尾延迟持续增长 没有背压,故障会积压放大

1. 后端的骨架:路由 → 处理 → 响应

后端骨架:路由→中间件→校验→处理→数据库→响应。

图 web-02.3后端骨架:路由→中间件→校验→处理→数据库→响应。

后端服务器的核心循环:接收 HTTP 请求 → 路由到对应处理函数 → 执行业务逻辑(常含数据库)→ 返回响应。FastAPI 里:

2. ORM 与数据库交互:方便背后的陷阱

N+1 查询:列表 1 次 + 循环每项 1 次 = N+1,改 JOIN 预加载 1 次。

图 web-02.2N+1 查询:列表 1 次 + 循环每项 1 次 = N+1,改 JOIN 预加载 1 次。

后端大量工作是与数据库对话。ORM(对象关系映射,如 SQLAlchemy)把数据库行映射成对象、让你用代码而非 SQL 操作——方便,但有必须懂的陷阱:

方法论:ORM 是便利层,不是 SQL 的替代品——你必须能看穿它生成的 SQL(db-02 的 EXPLAIN),否则性能问题无从查起。"懂 ORM 底下的 SQL"是后端工程师和调包侠的分水岭。

3. 异步与并发:现代后端为什么爱 async

异步事件循环:单线程遇 I/O 挂起去干别的(I/O 密集高效 vs 阻塞线程池)。

图 web-02.1异步事件循环:单线程遇 I/O 挂起去干别的(I/O 密集高效 vs 阻塞线程池)。

后端大量时间在等待——等数据库、等外部 API(Medusa 等 DeepSeek)、等磁盘。这是 I/O 密集(不是 CPU 密集)。两种应对模型(🔗 os-01 进程/线程、par 线):

关键认知:async 擅长 I/O 密集(大量等待),不加速 CPU 密集(CPU 满载时事件循环也堵)。Medusa 后端查数据库、调 LLM API 都是 I/O 密集——async 是对的选择;但若有重计算(如本地跑 embedding)要丢给线程池/进程池,别堵住事件循环。"async 处理等待、多进程处理计算"是后端并发的分工原则。

4. 认证、授权与会话

5. 错误处理、日志与可观测性

生产后端的成熟度体现在出错时的表现:

6. 练习与要点

例 1(抓 N+1) 写一段"取文章列表再循环取每篇的标签"的 ORM 代码,开 SQL 日志数查询次数,然后改成 JOIN 预加载——亲眼看 N+1 从几百次查询降到 1 次。后端最高频的性能修复。

例 2(async 判断) 判断三个后端任务(查数据库、调 LLM API、本地跑 embedding)哪些适合 async、哪个要丢进程池——把"async 治 I/O、进程治 CPU"用到 Medusa。

例 3(错误分层) 给"用户请求不存在的文章"和"数据库连接断了"设计不同的错误响应(404 vs 503,用户看到什么、日志记什么)——练"预期错误 vs 意外错误"的分层处理。\(\blacksquare\)

▶ 关联实验 L12(极简 HTTP 服务器):labs/L12-http-server/ 已在 net-02 引入——从 socket 到 HTTP,是本页 FastAPI 底下那一层。理解了它,框架就不是黑盒。


下一页:全栈 III——浏览器与前端工程:那条链的另一端,React 怎么把数据变成界面、浏览器怎么渲染、前端性能与工程化(你的 Vite 构建正在此列)。