改完功能接口变慢:重复请求、N+1、全量拉取如何在评审阶段看出来
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
多数人把”改完功能接口变慢”归错因了——第一反应是”AI 写的代码质量差”或”模型今天状态不好”,于是去换模型、加要求、重写一遍。但这类退化里最常见的一种,根本不是生成质量问题,而是上下文缺失导致的结构性错误:模型看不到你项目里已有的批量查询封装、已有的缓存层、已有的分页约定,于是用最直白的写法把功能实现对了,代价是请求次数或数据量翻了一个量级。功能测试全绿,性能悄悄退了一档。
这也是本篇和站内两篇相邻文章的分工:AI 代码能上生产吗谈的是整体准入门槛和责任边界,模型退化排查谈的是模型自身输出质量的波动(同样的问法,答得比上周差),而这篇只管一件事——你合进去的代码让系统变慢了,怎么在代码评审阶段就把它拦下来,而不是等线上告警。
一、先确认”真的变慢了”,再找原因
在动手改任何一行代码之前,先把这一步做掉。性能问题最容易浪费时间的地方,是花两天优化了一个根本没退化的接口。
需要分清三种情况:
一是绝对变慢。同样的请求、同样的数据规模,响应时间比改动前明显长。这是真退化,往下查。
二是相对暴露。代码本来就有隐患,但之前数据量小、并发低,看不出来。这次改动只是把入口挪到了热路径上,或者顺手把某个列表的默认条数放大了。这时候你要修的不是这次的 diff,而是那段一直存在的写法。
三是环境噪声。测试机在跑别的任务、数据库缓存刚被冲掉、你连的是共享的预发环境。这类波动会反复误导你,先把它排掉。
判别方法很朴素:拿改动前后的两个提交,在同一台机器、同一份数据、同样的预热次数下各跑三轮,看中位数而不是单次值。命令层面只需要一个通用的计时工具:
curl -s -o /dev/null \
-w 'dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n' \
'https://your-host/api/orders?page=1'
这几个值都是累计耗时(从请求发起算起),所以要看的是相邻两段的差:tls 减 conn 是 TLS 握手,ttfb 减 tls 是服务端排队加处理,total 减 ttfb 是响应体传输。size 是响应体字节数,改动前后各记一次,量级变了就是全量拉取的直接证据。URL 记得用单引号包住,否则 ? 和 & 会被 shell 当成通配符或后台符号吃掉。
判读规则很短:如果 ttfb 正常而 total 明显更大,是响应体被撑大了——这直接指向全量拉取。如果 ttfb 本身就长,问题在服务端的查询或外部调用。这两个方向的后续动作完全不同,先分清能省掉一半功夫。
顺手提一句环境层面的干扰:公司内网走代理、证书链被中间设备替换时,握手耗时会异常放大,看起来像”接口变慢”。这类问题的信号是 conn 和 tls 两段异常,而服务端那一段(ttfb 减 tls)并没有变长,先按环境问题排,别往业务代码里找。
二、三类高频退化的成因判别
确认是真退化之后,按发生频率从高到低查。我的经验顺序是:请求次数 → 查询次数 → 数据量。前两类是次数问题,最后一类是体积问题。
重复请求。同一份数据在一次业务操作里被取了多次。典型场景:前端组件在渲染路径上调用了取用户信息的接口,父组件已经取过一次,子组件不知道,又取一次;服务端某个工具函数每次调用都去配置中心读一次开关;或者一段循环里对每个元素都调了同一个”取当前租户配置”的方法。这类代码单看每一处都合理,合在一起就是浪费。
判别方法是看调用计数,而不是看代码。给可疑的入口方法加一层计数,跑一次典型业务流,打印次数。次数与业务语义不匹配就是它——一次下单流程读了七次同一份租户配置,这个数字本身就是证据。
N+1 查询。取了一个列表,然后在遍历里逐个取关联对象。ORM 的惰性加载让这件事写起来毫无阻力:一次列表查询加 N 次逐行查询,N 是列表长度。数据量小的时候完全看不出来,数据量一涨就是线性放大。
判别方法是打开框架的 SQL 回显(各框架的开关名不同,看你项目里已有的日志配置),跑一次请求,把 SQL 按模板归并计数:
import re
from collections import Counter
pat = re.compile(r'(SELECT|INSERT|UPDATE|DELETE)\b.*', re.I)
counter = Counter()
with open('app.log', encoding='utf-8') as f:
for line in f:
m = pat.search(line)
if m:
# 把字面量归一化,让同结构的语句合并成一个模板
tpl = re.sub(r"\b\d+\b|'[^']*'", '?', m.group(0)).strip()
counter[tpl] += 1
for tpl, n in counter.most_common(10):
print(n, tpl[:120])
如果某一条模板的次数正好等于列表条数(或者是它的倍数),N+1 已经坐实。这个脚本不依赖任何具体框架,日志格式变了只用改正则。
无谓的全量拉取。分页参数被丢掉、SELECT * 把大字段一起捞出来、为了拿一个计数做了全表扫描、为了判断”有没有”取了整个集合再算长度。这类退化的特征是响应体积和数据库读取量增长,CPU 不一定高,但内存和网络会先扛不住。
判别方法是看响应体大小和数据库执行计划。前者用上面的 curl 就能看出端倪,后者用 EXPLAIN(各数据库语法略有差别,但都有这个能力)看是否走了预期的索引、扫描行数是否与业务预期一个量级。
三、判别表:现象对成因
把上面的内容压成一张表,评审和排查时对着看。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
ttfb 变长,响应体大小没变 | 服务端查询次数或外部调用次数增加 | SQL 日志按模板归并计数;给可疑方法加调用计数 | 批量化查询(一次 IN 取回)或在请求级别加一层缓存 |
total 远大于 ttfb,响应体明显变大 | 分页丢失、SELECT * 带出大字段 | 对比改动前后的响应字节数;检查查询是否带 LIMIT | 恢复分页、按需选列、大字段单独接口取 |
| SQL 条数与列表长度成正比 | N+1 惰性加载 | 归并计数后看是否等于列表条数 | 改用关联预加载或批量取回后在内存里拼装 |
| 同一份数据在一次操作里被读多次 | 缺少请求级缓存,多处独立取数 | 给取数入口打计数,跑一次典型流程 | 在请求上下文里缓存一次;把取数上提到调用链顶层 |
| 单条查询本身就慢,条数不多 | 索引没走上(字段被函数包裹、类型隐式转换) | EXPLAIN 看扫描行数与索引使用 | 改写条件让索引可用,或补合适的索引 |
| 并发一上来就退化,单请求正常 | 连接池或线程池被占满、外部调用没设超时 | 观察池的等待时间;看是否出现堆积 | 给所有外部调用设超时与上限;核对池容量与并发的关系 |
| 外部接口偶发 429,整体延迟抖动 | 调用频次被放大,触发对方限流 | 统计单位时间内的出站调用数 | 合并请求、加退避重试;各家限流规则不同且会调整,以官方最新说明为准 |
| 改动前后测不出稳定差异 | 环境噪声或问题本来就存在 | 同机同数据各跑三轮取中位数 | 先固定环境再测;若是老问题,另开一条修复项 |
表里没有一条需要你猜。每一行的”怎么验证”都是十分钟内能做完的动作,这是它的用处所在。
四、评审阶段的固定动作
上面是出了问题之后的排查。真正省时间的做法是在合并之前就看出来。我评审这类改动时有一套固定顺序,不依赖灵感。
第一步:只看 diff 的形状,不看逻辑。 新增的循环里有没有 I/O?这是命中率最高的一条。循环体内出现数据库调用、HTTP 调用、文件读写,几乎都值得停下来问一句”能不能批量”。
git diff --stat origin/main...HEAD
git diff origin/main...HEAD -- '*.py' '*.ts' \
| grep -E '^\+[^+]' \
| grep -E 'for |while |\.map\(|await '
第二条命令只筛新增行(^\+[^+] 排掉 diff 头部的 +++),再从里面挑出带循环或 await 的行。别在两级 grep 上都加 -n:后一级编的是前一级输出的序号,跟文件行号没关系,看了会误判位置。真要定位,用编辑器在 diff 视图里跳。
这两条命令给你的不是答案,是一份需要人眼确认的候选清单。先看新增行里的循环与 await 组合,再看有没有整块的取数逻辑被搬进了渲染路径或热路径。
第二步:查被绕过的既有封装。 项目里往常怎么取数据?有批量方法吗?有缓存装饰器吗?有统一的分页参数吗?如果新代码没用这些,而是自己写了一段原始查询,这就是上下文缺失的典型痕迹。这一点也是把项目约定写清楚能直接消掉的成本——把批量取数、分页、缓存的用法写进项目说明文件,模型下次就会照着用,CLAUDE.md 怎么写里讲的正是这类约定的落法。
第三步:算量级,不算细节。 对着改动问三个数:这段代码在一次请求里执行几次?每次取回多少条?每条带多少字段?三个数乘起来的量级如果比改动前大一个数量级,性能退化基本已经写进去了,不用等测。
第四步:让工具做初筛,人做定性。 静态检查、SQL 计数断言、接口响应体大小的基线断言,都可以进流水线。这类自动化的选型和落地,站内AI 代码评审工具有专门的展开。需要说清的是,如果你打算用海外厂商的评审服务或模型,官方对中国大陆有区域限制、不支持直连,市面上存在第三方中转,但我不背书也不给渠道,涉及源码外传时按公司规定走。
第五步:控制单次改动的范围。 一次改十个文件、顺手重构三处取数逻辑的 PR,是性能退化最好的藏身处,因为没人能在合理时间内看完。把改动范围压小,是所有评审技巧里性价比最高的一条:一次只让 AI 动一个关注点,取数逻辑的调整单独提一个改动,评审时才看得见量级变化。
五、什么情况下别再折腾
排查性能问题有个很坏的特点:它总让你觉得”再看一眼就找到了”。所以要提前定好收手的条件。
回滚点。 线上已经有用户受影响,而你的定位还没到”能说出哪一行”的程度,就回滚。回滚不是失败,是把时间买回来。带着完整的现场(日志、慢查询记录、一份能复现的请求)在测试环境慢慢查,比在生产上边猜边改安全得多。判断标准很清楚:定位所需时间明显长于回滚所需时间,就回滚。
止损点。 一个方向连续试了三次都没让指标动,这个方向大概是错的。常见的错误方向包括:反复调整循环内的写法而不改次数、给已经走了索引的查询继续加索引、在没确认瓶颈在数据库的情况下调连接池参数。指标不动就换假设,别继续加码。
换条路的判断依据。 如果查到最后发现瓶颈在一个你无权改的地方——上游接口本身慢、数据模型的关联方式决定了必须多次查询、第三方服务有自己的频次规则,那就不要在代码层面硬扛。这时候的正确动作是改交互方式:异步化、预计算、把实时查询换成定时刷新的快照、给用户一个”处理中”的状态。承认某个东西改不了,然后绕过去,比在原地优化两周更专业。
别把性能问题当质量问题继续磨模型。 反复让 AI 重写同一段代码,期待它自己写出批量查询,是在赌运气。它缺的是你项目的信息,不是能力。与其重写第五遍,不如把已有的批量方法签名、缓存用法、分页约定直接给它。如果连续两轮重写都没让指标动,就停下来补信息,别再让它猜。
六、避坑清单
只测单请求,不测并发。 为什么会踩:本地手点一次很快,你就以为没问题,而连接池耗尽、锁等待、外部限流全都只在并发下出现。怎么避:对写路径和热点读路径,至少做一次小并发的对比测试,看的是尾部延迟而不是平均值。
拿平均值判断退化。 为什么会踩:平均值会被大量快请求稀释,一小部分请求慢十倍,平均值可能只涨一点。怎么避:看中位数和尾部分位,两个都比平均值有信息量。
在有缓存的环境里测无缓存的路径。 为什么会踩:第一轮请求把缓存预热了,后面几轮测的是缓存命中,改动带来的额外查询完全被藏住。怎么避:明确你要测的是冷路径还是热路径,测冷路径就先清缓存,测热路径就固定预热次数。
修 N+1 时改出了另一个全量拉取。 为什么会踩:把逐条查询换成一次性预加载,但忘了限制关联数据的条数,于是一次请求把整张关联表拉进内存。怎么避:预加载必须带上条数上限和字段选择,把”批量”和”全量”两个概念分开。
给循环里的调用加缓存却没设失效。 为什么会踩:为了消掉重复请求随手加了个进程内缓存,测起来很快,上线后配置改了不生效,变成一个更难查的正确性问题。怎么避:请求级缓存只活在一次请求内,跨请求的缓存必须明确失效策略和过期时间。
性能优化和功能改动混在一个提交里。 为什么会踩:出问题时无法二分定位,回滚会把功能一起滚掉。怎么避:性能改动单独成提交、单独成 PR,让它可以被独立回滚和独立验证。
只在评审时看,不留基线。 为什么会踩:靠人眼盯,换个人评审就漏了,退化会一次次回来。怎么避:把关键接口的 SQL 条数和响应体大小写成断言进流水线,超阈值就红。阈值定得宽松点也行,它拦的是量级错误,不是几个百分点的波动。
把这次的退化当孤立事件处理。 为什么会踩:同一个上下文缺口会持续产出同类错误,你修的是症状。怎么避:每修一次,同步更新项目约定文件里的对应条目。这笔账不还,就会变成慢慢积起来的技术债。
收束:合并前的六问自检
性能退化在 AI 参与的开发里不是偶发意外,它是上下文缺失的必然产物,所以处理方式应该是流程化的,而不是每次靠人临场发现。上面这套顺序——先确认是否真退化,再按请求次数、查询次数、数据体积三个方向分因,验证之后才动手,动手前先定好回滚点——照着走一遍,大部分退化在合并前就被拦住了。
合并前对着这六个问题走一遍,不用打开性能工具:
- 新增的循环里有 I/O 吗?有就问能不能批量。
- 这次取数用了项目里已有的批量方法和分页约定吗?没用是为什么?
- 一次请求里,这段代码执行几次、每次取回多少条、每条多少字段?三个数的量级变了吗?
- 所有新增的外部调用都设了超时和重试上限吗?
- 这个提交能被独立回滚吗?性能改动和功能改动分开了吗?
- 这次发现的问题,有没有一条对应的约定被写回项目说明文件?
第六问最容易被跳过,也最值钱——它决定了下一次是不是还得从头查一遍。