前言
这次优化来自一个很直接的疑问:服务已经部署在 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 次请求:
| 认证读取并发 | QPS | p95 ms | p99 ms | 错误率 |
|---|---|---|---|---|
| 10 | 152.1 | 95.8 | 112.1 | 0% |
| 50 | 162.0 | 422.0 | 496.5 | 0% |
| 100 | 159.7 | 758.0 | 845.3 | 0% |
吞吐平台和延迟增长说明排队正在发生,但不能直接判断排队在 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、返回行数与执行计划:
| 数据规模 | 电费快照 | 消息 | 会话 |
|---|---|---|---|
| 当前规模实验 | 44000 | 18500 | 100 |
| 增长规模实验 | 720000 | 500000 | 100000 |
用 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 | 三轮含漏发错误率 | 池获取均值 |
|---|---|---|---|
| 5 | 643.95 / 624.37 / 637.45 | 6.408% / 5.350% / 6.614% | 约 43–49ms |
| 15 | 47.16 / 41.65 / 43.66 | 0.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 ms | HTTP 200 | 传输错误 | 漏发 |
|---|---|---|---|---|---|
| 普通 | 15 秒 | 5.588 | 3000 | 0 | 0 |
| 普通 | 60 秒 | 5.630 | 12000 | 0 | 0 |
| 管理员 | 15 秒 | 25.088 | 3000 | 0 | 0 |
| 管理员 | 60 秒 | 50.570 | 11999 | 1 | 0 |
四段测量有效并通过原门槛,报告 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 |
| p95 | 100 并发时 758ms | 普通 5.63ms,管理员 50.57ms |
| 身份 | 角色未记录 | 普通与管理员分开 |
| 版本 | 测试期间 Pod 更新 | 固定 UID/release,前后核验 |
| 资源 | 没有服务资源采样 | CPU、节流、内存采样 |
两次生成器位置和负载模型不同,无法给出严格同条件的提升倍数。新测试证明当前版本能在这条固定 Pod 路径下满足本次受控负载门槛,并不证明公网入口、所有业务、长时间生产稳定性或横向扩容已经验收。
隔离环境曾完成普通读取 280 RPS、30 分钟稳定性测试,以及多种业务内容预检;这些各有自己的数据库、数据分布、后台任务和网络条件,不能把它们拼成一份“生产全业务容量已通过”的报告。
复用这套步骤时怎么做
- 先锁定版本与环境。 记录应用提交、依赖、数据规模、身份、请求体、资源和网络路径;把管理员与普通路径分开。
- 先补指标再改代码。 连接获取、连接持有、线程等待、SQL、CPU 与节流共同解释排队,低 CPU 也可能意味着正在等数据库。
- 一次验证一个主要变量。 身份复用、连接生命周期、池预算和 CPU 配额分别对照;资源不足时不要同时加副本、放大池和改查询。
- 用隔离环境承担探索。 随机测试库、可控外部依赖、有限运行时间,失败也保留原报告;不要把维护命令套到生产库。
- 用实际状态判断发布。 提交推送会触发什么必须先知道;发布后检查准确 SHA、job、rollout/readiness 与监控。
- 验收完成再结束。 报告指标、功能、清理和局限。需要回滚时走已有 Actions 发布入口,先核对数据库迁移兼容性与旧版本适配,避免直接恢复不兼容源码。
经验小结
Kubernetes 解决的是运行和编排条件,业务吞吐仍然取决于请求工作量、连接占用、数据库提交和资源预算。这次有效的改动是减少重复工作、释放不必要的连接占用,并根据真实等待和节流证据修正配置。
同样重要的是保留失败:缩小连接池引入的回退、SQL_ASCII 临时库错误、生成器漏发、管理员长尾和过期 main 引用,都改变了后续判断。最终停止继续探索,只发布并验收已经定好的方案,避免把未验证的镜像、多副本和 HPA 一起带进生产。
性能复盘的价值不是挑出最好看的数字,而是让每个结论都能对应到固定版本、原始报告和明确边界。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
最后更新于 ,距今已过 22 天
部分内容可能已过时