我如何通过修复一个隐藏的循环将 API 延迟降低 95% ❤️‍🔥

发布: (2026年1月31日 GMT+8 12:28)
11 min read
原文: Dev.to

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(简化)
#1SELECT * FROM workspaces WHERE org_id = 123 (gets all 100 workspaces)
#2SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 1
#3SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 2
#101SELECT used_bytes FROM workspace_storage_view WHERE workspace_id = 100
  • 每个单独的查询都很快——约 8–10 毫秒。
  • 但是 200 次查询 × 10 毫秒 = 2000 毫秒 的纯数据库时间。 🥳

死胡同

1️⃣ 日志

  • 过滤了慢速端点的日志。
  • 每个查询在 8–12 ms 内完成。
  • 日志没有撒谎,只是讲了一个 错误的故事

2️⃣ 基本指标

  • CPU:正常。
  • 唯一异常:API 延迟。

我卡住了 😔

突破:追踪请求

  1. Filter 过滤有问题的端点。
  2. 加载 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 条数据库查询 —— 关键证据 🚨。

追踪让人无法忽视的事实

  1. 查询次数爆炸 – 超出 40 倍的异常。
  2. 延迟瀑布 – 应用代码约 45 ms,数据库耗时约 2 s。
  3. 模式 – 经典的 N+1 问题。

N+1 问题不会出现在慢查询日志中,因为没有单个查询很慢。你需要追踪。

我盯着追踪看了整整一分钟:217 条查询

编写这段代码的开发者并不粗心;他们只在 5 个工作区 上进行过测试。

真正的陷阱

workspaces, _ := repo.GetWorkspacesByOrg(ctx, orgID)
for _, ws := range workspaces {
    used, _ := repo.GetWorkspaceStorageUsed(ctx, ws.ID)
    total += used
}
  • 一次查询获取列表。
  • 在循环内部,对 每个工作空间 执行一次查询。

在生产环境中,‘工作空间很少’的假设悄然失效。

修复

两个刻意的改动

  1. 停止在循环中查询。
  2. 一次性获取所有需要的数据(或至少批量获取)。

之前(概念)

// 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 查询在日志和基本指标中是不可见的,因为每个查询都很快。
  • 分布式追踪 照亮了隐藏的模式。
  • 始终质疑每次迭代都访问数据库的循环。
  • 使用批量查询或预聚合视图以保持低延迟。

当我停止猜测并端到端跟踪单个请求时,问题变得不可否认。

已部署

然后我打开了监控仪表盘并观察。

端点的延迟曲线 骤然下降

指标比较

指标之前之后改进
每次请求查询数21712少了 17 倍
平均响应时间~2.8 秒~80 毫秒快了 35 倍
P95 延迟~4.2 秒~120 毫秒提升 35 倍
数据库 CPU 使用率~65 %~12 %降低 82 %

我为我们最大的客户刷新了端点:

  • 78 毫秒。
  • 再次刷新 – 82 毫秒。

我坐在那里看了五分钟,只是盯着图表。没有任何峰值。

最好的指标
“嘿,无论你昨天做了什么,仪表盘的投诉已经完全消失。用户又满意了。”

我没告诉他这只是一次结构性查询的更改。我只说:

“修复了一件事。” 😁 (顺便说一下,我之后告诉了他。)

解决 N+1 问题的感觉真棒。看到延迟从 3 秒降到约 80 毫秒,简直是瞬间的多巴胺冲击。

我学到的

我曾经认为只要代码干净就足够——可读的循环、恰当的错误处理、关注点分离。真正的教训并不是关于 JOIN 或数据库视图的。

数据库不以循环思考,而是以集合思考。

现在我用真实的业务量进行测试。

“我能只做一次吗?”
因为修复只需要改动一个查询。

避免 N+1 的最佳习惯

  1. 不要在循环中查询数据库。 永远不要。(极少数合法例外。)

  2. 用集合思考,而不是单行。

    • 数据库擅长集合操作——JOININ 子句、批量读取。
    • 将 “我需要为每个项目获取数据” 转换为 “我一次性获取所有这些数据”。
  3. 在开发环境中开启查询日志。

    // Go with GORM
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Info), // Shows all queries
    })

    使用 sqlxdatabase/sql 时,将查询包装在日志中间件里,并把它作为本地开发环境的一部分。

  4. 为每个端点设定查询次数预算。
    示例预算:详情页(单资源):≤ 3 条查询

  5. 编写断言查询次数的测试。

    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())
        }
    }
  6. 使用生产规模的数据进行性能分析。

    // ⚠️ N+1 风险:在工作空间循环中
  7. 使用 APM 工具捕获遗漏的情况。

    • 为 “每个请求的查询数 > 阈值” 设置告警。
  8. 了解你的 ORM 的数据获取模式。
    优先使用 IN 子句而不是循环:

    SELECT * FROM table WHERE id IN (?, ?, ?);
  9. 把 “查询次数” 纳入代码审查。

    • 这段循环是否会进行数据库调用?
  10. 有疑问时,先测量。
    记录每个请求的查询次数和响应延迟。

这些习惯并不能消除所有性能问题,但能捕获大多数 N+1 场景。

那 10 % 的漏网之鱼

如果你对 N+1 有不同的处理方式,或者认为有更好的办法,欢迎分享。

祝阅读愉快。 去检查你的查询日志吧——我等着。 ❤️

Back to Blog

相关文章

阅读更多 »