5414 字
27 分钟
FastAPI 与 PostgreSQL 在 Kubernetes 中的性能优化及生产验收复盘

前言#

这次优化来自一个很直接的疑问:服务已经部署在 Kubernetes 上,为什么认证接口的压测成绩仍然不理想?

最初的报告里,认证数据库读取在 10、50、100 并发下都只有约 160 QPS。并发继续增加,吞吐基本不动,p95 却从约 96ms 增长到 758ms。健康检查能跑到两千多 QPS,但那条路径没有同样的鉴权、数据库与审计成本,不能拿来证明业务容量。

最终交付的是现有应用优化和单 Pod、2 核 CPU 的源码部署方案。2026 年 10 月 11 日的生产确认测试以固定 200 RPS 分别访问普通用户和管理员的 /api/v1/users/me,60 秒稳定段 p95 分别为 5.63ms 和 50.57ms。普通用户全部成功,管理员有 1 次传输错误,错误率约 0.0083%。

这并不意味着最大容量就是 200 QPS,也不能把新旧数字直接换算成精确提升倍数。下面会把代码改动、隔离实验、失败尝试、实际发布与验收边界分别讲清楚。

先确认 Kubernetes 实际提供了什么#

应用使用 FastAPI、SQLAlchemy/SQLModel、同步与异步数据库连接池,生产数据库为 PostgreSQL。管理员请求额外执行持久化审计,成绩、电费等业务还可能经过外部爬虫服务。

最初仓库部署清单是单 Pod、单 Uvicorn 进程,CPU request 为 100m、limit 为 1 核,没有已验证的 HPA。Kubernetes 负责调度、健康探测和发布,并不会自动减少一次请求的 SQL 数量,也不会自动把单实例变成多实例。

用于生产对照的链路可以简化为:

压测生成器
  → SSH 管理链路
  → kubectl port-forward
  → 固定生产 Pod
  → PostgreSQL

这条链路包含转发开销。它适合比较固定版本的表现,但不等于公网入口、Service 负载分配或多副本容量。

还有几个不能跳过的条件:后台任务不能随着 Pod 数量重复执行,进程内 WebSocket 状态需要跨实例协调,数据库连接预算必须覆盖滚动发布中的临时副本。否则增加 replicas 可能只是在复制瓶颈。

建立能够解释结果的基线#

旧报告的现象。 2026 年 10 月 7 日使用闭环并发,每组发送 3000 次请求:

认证读取并发QPSp95 msp99 ms错误率
10152.195.8112.10%
50162.0422.0496.50%
100159.7758.0845.30%

吞吐平台和延迟增长说明排队正在发生,但不能直接判断排队在 CPU、线程池、连接池还是数据库。旧测试没有服务资源采样,测试期间 Pod 还发生了更新,账号角色也没有记录,因此它只能作为问题线索。

后续改用固定到达率。 闭环测试中,请求变慢会拖慢下一次发送,实际负载随服务速度下降。开放到达率测试则按照计划时间发送请求,把发送不出去的请求也计入错误,能够暴露排队和生成器上限。

报告同时保存以下信息:

  • 计划到达、实际发送、完成、超时和漏发数量,以及漏发原因。
  • 从计划到达到响应完成的延迟、实际发送到响应完成的延迟、调度迟到时间。
  • 普通用户与管理员身份,固定提交、Pod UID、release、重启次数。
  • 生成器 CPU、连接复用策略、应用 CPU 与实际节流增量。
  • 数据库连接获取、连接持有、SQL、线程池等待和事件循环延迟。

隔离容量实验使用固定源码、合成数据、独立测试数据库,常用 30 秒预热、180 秒稳定段和三轮重复。生产验证则采用更短、受控的阶段,遇到停止门槛立即退出,不在生产环境直接探索容量上界。

进程状态也是实验记录的一部分。 长时间实验由独立用户级 systemd 服务运行,设置最长运行时间和停止宽限期。观察超时不表示进程已经结束,旧报告也不表示可以重启。后续操作先查询原服务的 InvocationID、MainPID、journal 和退出状态,确认清理完成后才开始下一轮负载,避免多个实验互相污染。

减少认证与审计路径的重复工作#

请求内复用已验证身份。 原路径中,审计 middleware 要识别用户,路由依赖又查一次用户。同一请求重复执行用户查询,增加了数据库往返和连接获取。

改动让 middleware 在验证成功后,将身份放进 request.state,后续依赖仅在同一个请求、同一个 token 下复用:

cached = getattr(request.state, "authenticated_identity", None)
if cached is not None and cached[0] == token:
    return cached[1]

这里不是跨请求缓存。新请求仍然校验签名、账号存在性、session_version、禁用状态和权限,避免用户被撤销后继续使用陈旧身份。回归检查确认普通与管理员用户读取都只执行一次 User SELECT。

鉴权先释放连接。 身份识别使用短 Session,取出必要信息后结束数据库访问,再进入路由。管理员身份从 Session 分离,避免后续代码依赖一个已经关闭的连接。

审计完成改为条件 UPDATE。 audit.finish 不再先读整条记录再修改,而是按记录 ID 更新状态、HTTP 状态码和完成时间,减少一次查询。

update(AuditLog).where(AuditLog.id == record_id).values(
    status="success" if status_code < 400 else "error",
    status_code=status_code,
    finished_at=get_datetime_utc(),
)

开始审计仍然先持久化,再执行业务;开始记录失败时返回 503,管理操作不执行。结束记录失败也必须保留错误日志。性能优化没有通过关闭审计、取消事务持久化或允许审计失败后继续操作来换取数字。

缩短连接占用,复用外部 HTTP 连接#

一个容易被忽略的成本是:SELECT 已经完成,但 Session 的读事务仍然持有连接,随后代码等待几秒钟的外部教务或爬虫响应。

处理方式是拆分数据库读取、外部等待和结果写入:

短事务读取必要值
  → 结束读事务并释放连接
  → 等待外部 HTTP
  → 新事务保存结果

拆分之后必须重新检查并发刷新、状态机、回滚和取消行为,不能把需要原子完成的操作任意拆开。写入前需要再次确认的状态,也不能直接相信外部等待之前的旧值。

爬虫 HTTP client 改为由应用 lifespan 管理并复用,同时保留连接数、超时、取消和关闭约束。避免每次请求都重新建 client,减少重复建连;不同学校配置和凭据隔离仍需回归验证。

两套数据库池的参数也变成显式配置。连接预算的基本计算方式是:

总连接上限
  = Σ(各角色副本数 × 每副本进程数 × 每进程两套池上限)
  + 发布迁移与维护连接
  + 其他服务连接
  + PostgreSQL 预留

副本数必须包含滚动发布的 surge。连接池可以限制压力,但配置过小会制造新的排队;配置过大则可能把等待转移到 PostgreSQL。

优化随数据量增长的查询#

这一阶段不是给每条慢查询随手加索引,而是先减少重复访问和无界返回。

电费有效快照。 把有效性筛选、排序和 LIMIT 放到 SQL 中,避免拉取大量历史记录后在 Python 中逐条寻找结果,同时保留原有“有效快照”的业务含义。

批量历史。 请求内去重校区别名解析,批量读取目标房间,把日期范围下推到数据库。输出仍然保留 first/latest 等元信息,避免为了减少 SQL 改坏历史语义。

成绩同步。 一次读取已有记录,再批量比对和增改,减少按成绩逐条查重的访问放大。新成绩数量、旧学期保护和删除语义分别测试。

目录与刷新协调。 目录查询增加可失效缓存,刷新操作使用租约协调;测试覆盖失效、竞争和依赖故障。源码包含这些能力,不代表所有可选共享运行时都已经在生产启用。

工作台会话摘要。 增加摘要表、有界回填和分页路径,避免每次从完整消息历史聚合会话。该可选路径默认关闭,没有把实现完成写成生产切换完成。

随后在隔离 PostgreSQL 上构造两组数据,检查真实 SQL、返回行数与执行计划:

数据规模电费快照消息会话
当前规模实验4400018500100
增长规模实验720000500000100000

用 EXPLAIN (ANALYZE, BUFFERS) 记录计划时间、执行时间、缓存命中、磁盘读取和临时排序,再单独执行真实 SELECT,测客户端取数时间。命令只应针对受控测试库执行,因为 ANALYZE 会真实运行查询:

EXPLAIN (ANALYZE, BUFFERS)
SELECT ...;

增长规模里,最新快照的服务器执行中位数约 0.117ms;某个校区别名目录查询检查了大量快照,最终只返回 3 行,执行中位数约 355ms。旧会话聚合返回 100000 行,服务器约 1149ms、客户端取数约 1982ms;摘要首页只返回 50 行,服务器约 35.8ms。

这些结果指出了扫描、排序和返回量的成本。但 100000 行与 50 行不是等量 A/B,SQL 执行时间也不是 HTTP p95。规范化目录、复合索引和游标分页被列为候选,后续停止继续优化时,没有把它们顺手加入发布。

连接池缩小后,管理员反而变慢#

这是整个过程里最有价值的一次失败。

隔离 PostgreSQL 对照中,普通读取 400 RPS 的三轮 p95 从基线约 7.7–9.1ms 降到约 5.5–5.7ms;管理员 200 RPS 却从约 39–41ms 回退到约 429–488ms。管理员 100 RPS 仍然通过,不能用低档成功掩盖高档回退。

管理员请求有同步审计写事务。为了限制连接,曾把同步池改成 size 5、overflow 0,但这使写提交等待期间的请求更容易堵在连接获取和线程调度上。

为确认这个因素,冻结同一应用源码,保持管理员 200 RPS、15 秒预热、180 秒稳定段和三轮重复,只改变同步池预算:

同步池最大连接三轮 p95 ms三轮含漏发错误率池获取均值
5643.95 / 624.37 / 637.456.408% / 5.350% / 6.614%约 43–49ms
1547.16 / 41.65 / 43.660.0194% / 0.0389% / 0.0194%约 0.13–0.18ms

SQL 执行和连接持有时间相近,线程排队在恢复预算后明显下降。两组都观察到 WALSync/WALWrite 等待,数据库 CPU 并未饱和。这些证据支持:缩小连接池放大了同步审计提交期间的排队,而不是“数据库 CPU 不够”。

单实例默认恢复两套池各 size 5、overflow 10,即每池最多 15。拆分模式仍按角色和总预算单独限制,不能机械地复制单实例池大小到每个 Pod。

最大连接 5 的失败轮次还触及生成器并发槽上限,因此不能据此宣布精确容量上界。恢复预算也只解决了此次排队放大,没有关闭 PostgreSQL 的同步持久化。

压测工具本身也需要验证#

临时数据库编码。 第一轮查询计划实验在迁移前失败:SQL_ASCII 模板创建的数据库导致驱动返回 bytes,SQLAlchemy 版本解析抛出 TypeError。修复临时库编码为 UTF-8 后,重新验证中文查询、迁移、实际行数、计划与临时库删除,旧失败报告保留。

生成器迟到与并发上限。 600 RPS 的隔离实验中,单进程生成器在高在途请求下出现漏发。换 uvloop 的控制组仍有 305 次迟到漏发,占计划 108000 次的约 0.2824%,超过 0.1% 门槛,所以不能宣布问题解决。

双进程生成器候选通过共同未来起点、全局交错序号和 ready barrier 分摊调度,并合并原始样本重新计算分位数,不平均两个进程的 p95。它属于测试工具,不是生产应用优化。工具有效性、服务端延迟和副本分配必须分别检查。

三 API 的 600 RPS 候选实验即使已发送请求全部返回 200,仍出现约 596ms 的 p95,超过 200ms 门槛。服务分配核对还暴露预热状态没有正确聚合的问题;修正工具计数也不能把真实的延迟失败改成通过。后续没有继续调池、放宽门槛或把这个候选投入生产。

失败报告不能覆盖。 每轮使用独立报告名,保存工具来源和服务句柄。finally 清理只是第一层,还要独立查询剩余测试库、进程和转发监听,不能把“已执行清理代码”等同于“资源已经清理”。

CPU 配额调整与正常 CI/CD 发布#

连接池回退修复之后,生产管理员 200 RPS 的测试仍曾在单核 CPU 配额下触及并发上限,实际观测到 CFS throttling。源码部署最终把 CPU request 与 limit 调整为 2 核。

需要区分 request 和 limit:request 影响调度和资源保障,limit 决定配额约束。只增加 request,并不会自动解除原来的 CPU limit。增加资源也不能代替数据库等待的排查。

生产仍然是单 Pod、all 角色、源码部署。共享事件 outbox、跨实例命令、WebSocket 协调、worker/realtime 角色、迁移 Job 和 HPA 清单已经做过代码或隔离验证,但没有据此启用完整拆分方案。

用户选择过 GHCR 作为镜像仓库,然而选择仓库不代表集群已经具备拉取凭据。采用镜像模式前仍要实际核实管理员配置的 server-registry、固定摘要拉取、共享运行时与连接预算;HPA 还依赖 Metrics API。监控服务存在不等于 Metrics API 可用。

发布遵循仓库 GitHub Actions:先看触发条件和实时变量,再提交、推送;main 推送可能自动触发生产部署。不要因为自己没有手动运行部署脚本,就说“没有发布”。

以下是在具有仓库权限的开发机上检查发布状态的示例,仓库与 run ID 需要替换为自己的值:

gh variable list -R <owner>/<repo>
gh run list -R <owner>/<repo> --commit <commit-sha>
gh run view <run-id> -R <owner>/<repo> --json headSha,jobs,conclusion
gh run view <run-id> -R <owner>/<repo> --log-failed

只在需要排查失败时执行最后一条。判定发布成功需要对应提交的 CI 与 deploy job 成功,并通过 rollout/readiness,不能把 image 构建成功、deploy skipped 或旧提交的绿色状态当作本次部署成功。

本次收尾核实的生产提交为 0d7390e0f25b81b5bb074c636f2719d41ce9d8ec,Actions 38037442787 的 ci/deploy 成功,image skipped。生产 release 的 app 与 uv.lock 共 134 个文件逐一 SHA256 对比,全部与该提交一致;Deployment 的 observedGeneration 追上当前 generation,副本 ready/available 都为 1,Pod 重启次数为 0。

还有一次容易误判的状态:本地 origin/main 已经过期,使用它计算差异会把已经上线的应用改动重新列为“待发布”。刷新引用并与 GitHub 实时 main 核对后,草稿 PR 的剩余差异只是测试工具、测试和文档,没有额外应用源码或部署参数要上线。因此没有为了合并工具而再发布一次生产。

最终生产验收,以及一次必须保留的失败#

2026 年 10 月 11 日先按工具原在途上限 100,执行普通用户与管理员各 15 秒 smoke、60 秒稳定段,固定 200 RPS。普通用户稳定段 p95 为 5.571ms,通过。

管理员稳定段计划 12000 次,实际发送 11740 次,260 次因 concurrency_full 漏发,含漏发错误率 2.1667%。已发送请求全部返回 200,p95 为 116.341ms,但测量无效,测试按停止门槛结束。长尾最大约 1.44 秒,不能只看 p95 低于 200ms 就判通过。

负载停止后,live/ready 正常,数据库连接归还。对应管理员窗口的 14740 条审计记录全部 success/200;审计开始后至完成的 p95 约 39.3ms、最大约 1061ms。这个时间不包含审计开始前的完整排队,不能据此确定全部根因。

随后做必要确认:只把生成器在途上限从 100 提到 200,保持 200 RPS、相同版本、相同超时、200ms p95 SLO 和 0.1% 含漏发错误率门槛。服务端配置没有变化,也没有降低负载来获得通过。

身份时长p95 msHTTP 200传输错误漏发
普通15 秒5.588300000
普通60 秒5.6301200000
管理员15 秒25.088300000
管理员60 秒50.5701199910

四段测量有效并通过原门槛,报告 completed 与 forward_cleanup 都为 true,前后 Pod 身份与重启次数一致。管理员稳定段错误率为 1/12000,约 0.0083%;普通和管理员稳定段 p99 分别为 6.22ms、76.27ms。

稳定段 CPU 约为 44.70% 和 87.08% 单核,所有段 CFS 节流增量为 0。容器 memory.current 峰值约 218.6MiB,包含缓存,不是应用独占 RSS。

这轮成功不能证明此前长尾完全消失,也不能把两轮延迟差异全部归因于提高生成器上限。原失败记录没有覆盖。新的 200 RPS 是已验证负载,不是最大容量结论。

负载结束后再串行验证业务功能:管理员消息列表和单日消息统计返回 200,响应结构有效;普通用户访问管理员消息列表返回 403,自己的账号列表返回 200。首次检查误用了 /api/v1/web,得到 404;核对当前路由挂载后改为 /api/v2/web,才完成真实业务检查。鉴权接口仍然位于 /api/v1/users/me,两个版本前缀不能混用。

最后独立确认生产健康、Prometheus target 为 up、远端临时 port-forward 已退出、本地端口没有监听。凭据只在进程内存传递,报告不保存 token、账号记录或业务响应正文。

如何理解优化前后的差异#

项目旧生产报告最终生产确认
发压方式10/50/100 闭环并发固定 200 RPS,在途上限 200
认证读取约 160 QPS普通与管理员分别验证 200 RPS
p95100 并发时 758ms普通 5.63ms,管理员 50.57ms
身份角色未记录普通与管理员分开
版本测试期间 Pod 更新固定 UID/release,前后核验
资源没有服务资源采样CPU、节流、内存采样

两次生成器位置和负载模型不同,无法给出严格同条件的提升倍数。新测试证明当前版本能在这条固定 Pod 路径下满足本次受控负载门槛,并不证明公网入口、所有业务、长时间生产稳定性或横向扩容已经验收。

隔离环境曾完成普通读取 280 RPS、30 分钟稳定性测试,以及多种业务内容预检;这些各有自己的数据库、数据分布、后台任务和网络条件,不能把它们拼成一份“生产全业务容量已通过”的报告。

复用这套步骤时怎么做#

  1. 先锁定版本与环境。 记录应用提交、依赖、数据规模、身份、请求体、资源和网络路径;把管理员与普通路径分开。
  2. 先补指标再改代码。 连接获取、连接持有、线程等待、SQL、CPU 与节流共同解释排队,低 CPU 也可能意味着正在等数据库。
  3. 一次验证一个主要变量。 身份复用、连接生命周期、池预算和 CPU 配额分别对照;资源不足时不要同时加副本、放大池和改查询。
  4. 用隔离环境承担探索。 随机测试库、可控外部依赖、有限运行时间,失败也保留原报告;不要把维护命令套到生产库。
  5. 用实际状态判断发布。 提交推送会触发什么必须先知道;发布后检查准确 SHA、job、rollout/readiness 与监控。
  6. 验收完成再结束。 报告指标、功能、清理和局限。需要回滚时走已有 Actions 发布入口,先核对数据库迁移兼容性与旧版本适配,避免直接恢复不兼容源码。

经验小结#

Kubernetes 解决的是运行和编排条件,业务吞吐仍然取决于请求工作量、连接占用、数据库提交和资源预算。这次有效的改动是减少重复工作、释放不必要的连接占用,并根据真实等待和节流证据修正配置。

同样重要的是保留失败:缩小连接池引入的回退、SQL_ASCII 临时库错误、生成器漏发、管理员长尾和过期 main 引用,都改变了后续判断。最终停止继续探索,只发布并验收已经定好的方案,避免把未验证的镜像、多副本和 HPA 一起带进生产。

性能复盘的价值不是挑出最好看的数字,而是让每个结论都能对应到固定版本、原始报告和明确边界。

FastAPI 与 PostgreSQL 在 Kubernetes 中的性能优化及生产验收复盘
https://hyperbola.cc/posts/tech/linux_ops/fastapi-postgresql-kubernetes性能优化与生产验收/
作者
Hyperbola
发布于
2026-10-11
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取