接口变慢时,别急着加机器
一次接口排查最重要的不是“优化”,而是先找出时间究竟花在了哪里。
接口从 80ms 变成 800ms 时,第一反应往往是:“机器不够了吧?”
这句话有时没错,但多数时候太早。慢接口的第一步不是优化,而是拆时间。 浏览器在等什么、应用在算什么、数据库在做什么、外部服务卡在哪里——把这四段时间分开,问题通常就不再神秘。
下面是一条适合小项目的排查路线。它不追求工具齐全,重点是让你每次改动都有证据。
先确认:慢的是平均值,还是偶发的长尾
“接口很慢”至少可能是两种问题:
- 每次都慢,例如列表页稳定在 700ms;
- 偶尔很慢,例如大多数请求 100ms,少数请求突然超过 3 秒。
这两个问题的方向完全不同。前者多半是固定计算、查询或网络链路有浪费;后者更像锁等待、连接池耗尽、缓存失效、下游抖动。
因此别只看平均耗时。至少同时看三个数:
| 指标 | 它回答的问题 |
|---|---|
| P50 | 大多数用户的正常体验怎么样? |
| P95 | 最慢的那一小批请求是否已经难受? |
| P99 | 是否有值得警惕的尖刺或事故苗头? |
假设一个订单列表接口的 P50 是 90ms,P95 却是 1.8s。此时把平均值从 150ms 优化到 120ms,用户几乎感觉不到变化。更该查的是那 5% 请求为什么突然慢下来。
给一次请求一张“时间账单”
不要在每个函数里随手打日志。更有效的方式,是给请求一个 ID,并记下关键阶段的耗时:
const startedAt = performance.now();
const orders = await orderService.list(userId);
const afterQuery = performance.now();
const payload = serializeOrders(orders);
logger.info({
requestId,
dbMs: Math.round(afterQuery - startedAt),
serializeMs: Math.round(performance.now() - afterQuery),
totalMs: Math.round(performance.now() - startedAt),
resultCount: orders.length,
}, "list orders completed");
先从三到四个阶段开始就够了:鉴权、数据库、外部调用、组装响应。注意不要把手机号、令牌、完整请求体写进日志;排障需要上下文,不需要收集隐私。
有了这张账单,你就能区分几类常见情况:
- 数据库只用 20ms,但总耗时 900ms:多半在等外部 API,或服务端做了不必要的串行计算。
- 数据库稳定在 600ms:看 SQL、索引、返回行数和连接等待。
- 只有返回很大的列表时变慢:检查序列化、N+1 查询和没有分页的接口。
查数据库前,先看“取了多少不该取的数据”
很多慢查询不是因为数据库不够快,而是因为请求本身没有节制。
一个后台列表若一次读取所有历史订单、每行再查询一次用户信息,数据量一大就会很痛苦。比较常见的修复顺序是:
- 给列表加分页和合理的默认页大小。
- 只查询页面真正展示的字段,避免顺手
SELECT *。 - 把逐行查询改成一次关联查询或批量查询。
- 再根据实际筛选、排序条件补索引。
索引也不是“多建几个就好”。例如查询总是按 tenant_id 过滤,再按 created_at 倒序取最近 20 条,那么索引通常应围绕这两个条件设计。建完后要看执行计划:它有没有用到索引、扫描了多少行、排序有没有落到临时结果上。
如果查询只在某个租户变慢,顺手检查数据分布。测试环境里每个租户几十条记录,很难暴露生产环境里某个大客户几百万条记录时的差异。
外部调用最容易被忽略的两个坑
第一个坑是串行等待。下面的代码读起来顺,但耗时是三段之和:
const profile = await getProfile(userId);
const coupons = await getCoupons(userId);
const notices = await getNotices(userId);
如果三者没有依赖关系,可以并发:
const [profile, coupons, notices] = await Promise.all([
getProfile(userId),
getCoupons(userId),
getNotices(userId),
]);
第二个坑是没有超时。下游服务卡住时,请求会一直占着连接和工作线程,最后拖慢一大片正常流量。每个外部调用都应该有明确超时、有限重试和降级结果。这里的“有限”很重要:重试会放大高峰期的压力,不能把失败变成雪崩。
改完以后,别只说“感觉快了”
一次优化至少应留下三样东西:改动前后的 P50/P95、压测或真实流量样本、回滚方式。比如:
为订单列表增加
(tenant_id, created_at)索引,并将用户信息改为批量查询。100 条样本请求的 P95 从 1.6s 降到 210ms;若出现写入压力异常,可先通过迁移脚本回退索引,再恢复旧查询路径。
这段记录以后会很有用。它告诉后来的人:当时为什么改、改善发生在哪、风险在哪里。
最后,性能工作并没有一个“完成”的时刻。更实际的目标是让异常能被尽早看见,让每次优化都对应一个清楚的瓶颈。机器当然可以扩,但等你知道时间花在哪里以后,再扩也不迟。