我如何通过修复一个隐藏的循环将 API 延迟降低 95% ❤️🔥
Source: Dev.to
The Trigger
“嘿,组织仪表盘对一些客户来说感觉真的很慢。大概慢了 3–4 秒。”
我叹了口气 🥴 – 又一个性能工单。
- 本地访问端点 → 一切正常。
- 检查了预发布环境 → “可能是他们的网络”,我想,然后关闭了标签页。
但投诉声仍然不断。
症状
- 没有错误,也没有崩溃。
- 没有异常的 CPU 峰值。
- 服务 技术上健康。
然而,在高峰时段,生产环境的延迟从原本体面的 200 ms 逐渐上升至令人不适的 3–4 秒。
这个端点的代码最近被修改过。它写得简洁、符合 Go 语言惯用法——那种你在审查时快速浏览就会立刻信任的代码。前任开发者也很可靠——没有明显错误,也没有红旗。
但仍然有种不对劲的感觉。
深入追踪
我打开了 SigNoz,按该端点过滤后,点进了一个最慢请求之一的跟踪。
我发现的不是 bug。
它是一个 模式——悄悄扼杀我们的数据库。
模式:N+1 查询
如果你从未听说过它,这里有简短的说明:
你的代码先执行 一次查询 获取项目列表,然后遍历该列表,对每个项目再执行 N 次额外查询——每个项目一次。
Go 代码(看起来完全没问题)
// Fetch all workspaces for an organization
workspaces, err := repo.GetWorkspaces(orgID)
if err != nil {
return err
}
// For each workspace, fetch its storage stats
for _, ws := range workspaces {
storage, err := repo.GetWorkspaceStorage(ws.ID) // 🔴 This is the problem
if err != nil {
return err
}
// ... do something with storage
}
它阅读起来很优雅,但在底层它等价于:
| 查询 | SQL(简化) |
|---|---|
| #1 | SELECT * FROM workspaces WHERE org_id = 123 (gets all 100 workspaces) |
| #2 | SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 1 |
| #3 | SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 2 |
| … | … |
| #101 | SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 100 |
- 每个单独的查询都很快——约 8–10 毫秒。
- 但是 200 次查询 × 10 毫秒 = 2000 毫秒 的纯数据库时间。 🥳
死胡同
1️⃣ 日志
- 过滤了慢速端点的日志。
- 每个查询在 8–12 ms 内完成。
- 日志没有撒谎,只是讲了一个 错误的故事。
2️⃣ 基本指标
- CPU:正常。
- 唯一异常:API 延迟。
我卡住了 😔
突破:追踪请求
- Filter 过滤有问题的端点。
- 加载 waterfall view。
健康的服务追踪看起来很平淡:
HTTP handler → short
Go application logic → tiny sliver
但我的追踪显示:
[DB] SELECT * FROM workspaces WHERE org_id = ? (15 ms)
[DB] SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 1 (9 ms)
…
[DB] SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 100 (9 ms)
向下滚动后发现 217 条数据库查询 —— 关键证据 🚨。
追踪让人无法忽视的事实
- 查询次数爆炸 – 超出 40 倍的异常。
- 延迟瀑布 – 应用代码约 45 ms,数据库耗时约 2 s。
- 模式 – 经典的 N+1 问题。
N+1 问题不会出现在慢查询日志中,因为没有单个查询很慢。你需要追踪。
我盯着追踪看了整整一分钟:217 条查询。
编写这段代码的开发者并不粗心;他们只在 5 个工作区 上进行过测试。
真正的陷阱
workspaces, _ := repo.GetWorkspacesByOrg(ctx, orgID)
for _, ws := range workspaces {
used, _ := repo.GetWorkspaceStorageUsed(ctx, ws.ID)
total += used
}
- 一次查询获取列表。
- 在循环内部,对 每个工作空间 执行一次查询。
在生产环境中,‘工作空间很少’的假设悄然失效。
修复
两个刻意的改动
- 停止在循环中查询。
- 一次性获取所有需要的数据(或至少批量获取)。
之前(概念)
// For each workspace, fetch its storage stats (N+1!)
for _, ws := range workspaces {
storage, _ := repo.GetWorkspaceStorage(ws.ID)
// …
}
之后(概念)
// Fetch workspaces
workspaces, _ := repo.GetWorkspacesByOrg(ctx, orgID)
// Fetch all workspace storage usage in ONE query
storages, _ := repo.GetAllWorkspaceStoragesByOrg(ctx, orgID)
// Map storages to workspaces and process
代码改动大约用了 20 分钟,效果立竿见影。
为什么数据库视图很重要 🥳
我们已经有了一个类似物化的视图:
CREATE OR REPLACE VIEW workspace_storage_view AS
SELECT
workspace_id,
COALESCE(SUM(size), 0) AS used_bytes
FROM files
WHERE type != 'folder'
GROUP BY workspace_id;
- 该视图 预先聚合 了对数百万行的繁重
SUM(size)操作。 - 如果没有它,“修复”只会把昂贵的聚合搬到每次请求的查询中,仍然会严重影响性能。
使用视图后,聚合只会 一次 完成,应用程序只需读取已准备好的结果。
要点
- N+1 查询在日志和基本指标中是不可见的,因为每个查询都很快。
- 分布式追踪 照亮了隐藏的模式。
- 始终质疑每次迭代都访问数据库的循环。
- 使用批量查询或预聚合视图以保持低延迟。
当我停止猜测并端到端跟踪单个请求时,问题变得不可否认。
已部署
然后我打开了监控仪表盘并观察。
端点的延迟曲线 骤然下降。
指标比较
| 指标 | 之前 | 之后 | 改进 |
|---|---|---|---|
| 每次请求查询数 | 217 | 12 | 少了 17 倍 |
| 平均响应时间 | ~2.8 秒 | ~80 毫秒 | 快了 35 倍 |
| P95 延迟 | ~4.2 秒 | ~120 毫秒 | 提升 35 倍 |
| 数据库 CPU 使用率 | ~65 % | ~12 % | 降低 82 % |
我为我们最大的客户刷新了端点:
- 78 毫秒。
- 再次刷新 – 82 毫秒。
我坐在那里看了五分钟,只是盯着图表。没有任何峰值。
最好的指标
“嘿,无论你昨天做了什么,仪表盘的投诉已经完全消失。用户又满意了。”
我没告诉他这只是一次结构性查询的更改。我只说:
“修复了一件事。” 😁 (顺便说一下,我之后告诉了他。)
解决 N+1 问题的感觉真棒。看到延迟从 3 秒降到约 80 毫秒,简直是瞬间的多巴胺冲击。
我学到的
我曾经认为只要代码干净就足够——可读的循环、恰当的错误处理、关注点分离。真正的教训并不是关于 JOIN 或数据库视图的。
数据库不以循环思考,而是以集合思考。
现在我用真实的业务量进行测试。
“我能只做一次吗?”
因为修复只需要改动一个查询。
避免 N+1 的最佳习惯
-
不要在循环中查询数据库。 永远不要。(极少数合法例外。)
-
用集合思考,而不是单行。
- 数据库擅长集合操作——
JOIN、IN子句、批量读取。 - 将 “我需要为每个项目获取数据” 转换为 “我一次性获取所有这些数据”。
- 数据库擅长集合操作——
-
在开发环境中开启查询日志。
// Go with GORM db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), // Shows all queries })使用
sqlx或database/sql时,将查询包装在日志中间件里,并把它作为本地开发环境的一部分。 -
为每个端点设定查询次数预算。
示例预算:详情页(单资源):≤ 3 条查询 -
编写断言查询次数的测试。
func TestGetOrgStorageUsage_QueryCount(t *testing.T) { // Setup test with 200 workspaces queryLog := &QueryLogger{} service := NewWorkspaceService(queryLog) service.GetOrgStorageUsage(ctx, orgID) if queryLog.Count() > 2 { t.Errorf("Expected ≤ 2 queries, got %d", queryLog.Count()) } } -
使用生产规模的数据进行性能分析。
// ⚠️ N+1 风险:在工作空间循环中 -
使用 APM 工具捕获遗漏的情况。
- 为 “每个请求的查询数 > 阈值” 设置告警。
-
了解你的 ORM 的数据获取模式。
优先使用IN子句而不是循环:SELECT * FROM table WHERE id IN (?, ?, ?); -
把 “查询次数” 纳入代码审查。
- 这段循环是否会进行数据库调用?
-
有疑问时,先测量。
记录每个请求的查询次数和响应延迟。
这些习惯并不能消除所有性能问题,但能捕获大多数 N+1 场景。
那 10 % 的漏网之鱼
如果你对 N+1 有不同的处理方式,或者认为有更好的办法,欢迎分享。
祝阅读愉快。 去检查你的查询日志吧——我等着。 ❤️