1 分钟

Go + Postgres 性能调优:面向 API 的实战手册

针对 AI 生成 API 的 Go + Postgres 性能调优实战:合理配置连接池、检查查询计划、智能建索引、安全分页并加速 JSON 生成。

Go + Postgres 性能调优:面向 API 的实战手册

在 Postgres 上 Go API 的“慢”是什么样子

AI 生成的 API 在早期测试中可能感觉很快。你调用某个端点几次,数据集很小,请求是逐个到达的。然后真实流量来了:混合端点、突发负载、更冷的缓存,以及比预期更多的行。相同的代码可能开始表现出随机的缓慢,尽管实际上没有东西坏掉。

慢通常以几种方式出现:延迟峰值(大多数请求正常,但有些请求会慢 5x 到 50x),超时(少数请求失败),或者 CPU 占用高(Postgres 因查询工作 CPU 升高,或 Go 因 JSON、goroutine、日志和重试而占用 CPU)。

一个常见场景是一个带灵活搜索过滤的列表端点返回大型 JSON。测试数据库中它扫描几千行并很快完成。在生产中它可能扫描几百万行,先对它们排序,然后才应用 LIMIT。API 仍然“工作”,但 p95 延迟爆炸,并且在突发时一些请求超时。

要把数据库慢和应用慢区分开,保持心中模型简单。

如果是数据库慢,Go 处理器大部分时间都在等待查询返回。你可能还会看到很多请求“在途”,而 Go 的 CPU 看起来正常。

如果是应用慢,查询很快结束,但时间在查询之后流失:构建大型响应对象、序列化 JSON、为每行执行额外查询,或每个请求做太多工作。Go 的 CPU 上升、内存上升,延迟随响应大小增长。

“足够好”的性能在发布前并不要求完美。对于许多 CRUD 端点,目标是稳定的 p95 延迟(不仅仅是平均值)、在突发时的可预测性,以及在预期峰值下没有超时。目标很直接:当数据和流量增长时不要出现意外慢请求,并且当某些东西漂移时有清晰信号。

基线优先:关键的少量数字

在调优任何东西之前,先决定 API 的“好”是什么。没有基线,很容易花数小时改设置但不知道是否真的改进或只是移动了瓶颈。

通常有三个数字能讲清大部分故事:

  • p95 请求延迟(不要只看平均值)
  • 错误率(HTTP 5xx、超时、取消请求)
  • 每请求的数据库时间(每个请求在 Postgres 上等待的时间)

p95 是“糟糕那天”的指标。如果 p95 高但平均值正常,说明有一小部分请求做了过多工作、被锁阻塞或触发了慢计划。

尽早使慢查询可见。在 Postgres 中,在预发布测试时启用慢查询日志并设置较低阈值(例如 100–200 ms),并记录完整语句以便复制到 SQL 客户端。保留这设置为临时。生产中记录每个慢查询会很快变得嘈杂。

接下来,用真实场景的请求测试,而不是仅仅测试一个“hello world”路由。一小组请求就足够了,只要它们匹配用户实际的使用方式:带过滤和排序的列表调用、带几个 join 的详情页、带验证的创建或更新,以及带部分匹配的搜索型查询。

如果你是从规范生成端点(例如用像 Koder.ai 这样的工具),用一致的输入反复运行同一小组请求。这能让索引、分页调整和查询重写等改动更容易衡量。

最后,选一个能说出口的目标。例如:“大多数请求在 50 个并发用户下 p95 保持在 200 ms 以下,错误率低于 0.5%。”具体数字取决于产品,但明确目标能避免无休止地调整。

保持 Postgres 稳定的连接池

连接池维护有限数量的打开数据库连接并重用它们。没有连接池时,每个请求可能会打开一个新连接,Postgres 会花时间和内存管理会话而不是执行查询。

目标是让 Postgres 忙于有用的工作,而不是在过多连接之间上下文切换。对于容易变成多聊端点的 AI 生成 API,这通常是首个显著的优化点。

简单的起始设置

在 Go 中,通常需要调整最大打开连接数、最大空闲连接数和连接生命周期。对许多小型 API 来说,一个安全的起点是基于 CPU 核心数的一个小倍数(通常总连接数 5 到 20),保持类似数量的空闲连接,并周期性回收连接(例如每 30 到 60 分钟)。

如果运行多个 API 实例,请记住连接池会相乘。10 个实例每个 20 个连接就是 200 个连接同时打到 Postgres,这就是团队意外遇到连接限制的常见原因。

如何判断池是问题所在

池的问题和慢 SQL 给人的感觉不同。

如果池太小,请求在到达 Postgres 之前就开始等待。延迟出现峰值,但数据库 CPU 和查询时间可能看起来正常。

如果池太大,Postgres 会显得不堪重负:活跃会话多、内存压力大,并且各端点的延迟不均匀。

一个快速区分的方法是把数据库调用分为两部分计时:等待连接的时间 vs 执行查询的时间。如果大部分时间在“等待”,那就是池的瓶颈。如果大部分时间在“查询中”,就着重 SQL 和索引。

有用的快速检查:

  • 记录池统计(open、in-use、idle)并观察 in-use 是否卡在最大值上。
  • 在获取连接上添加超时,以便在预发布时等待失败快一些。
  • 监控 Postgres 中的活动连接以及离 max_connections 的距离。
  • 确认每个请求都及时关闭 rows 并释放连接。
  • 使用与你计划运行的相同数量的应用实例进行负载测试。

pgx pool 与 database/sql

如果你使用 pgxpool,你会得到一个以 Postgres 为中心的连接池,带有清晰的统计和对 Postgres 行为的良好默认值。如果使用 database/sql,你会得到一个跨数据库工作的标准接口,但需要显式设置池参数并关注驱动的行为。

一个实用规则:如果你完全依赖 Postgres 并希望直接控制,pgxpool 往往更简单。如果你依赖期望 database/sql 的库,就用它,明确设置池并测量等待时间。

示例:一个列订单的端点在空闲时可能 20 ms 完成,但在 100 个并发用户下跃升到 2 s。如果日志显示有 1.9 s 在等待连接,在池和总 Postgres 连接数正确调整之前,查询调优不会有帮助。

查询计划:快速读懂 EXPLAIN 输出

当端点感觉慢时,检查 Postgres 实际在做什么。对 EXPLAIN 的快速阅读常常能在几分钟内指出解决办法。

在你 API 发送的精确 SQL 上运行:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, created_at
FROM orders
WHERE user_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;

有几行最关键。看顶层节点(Postgres 选择的执行方式)和底部的总耗时。然后比较估计行数与实际行数。大的差异通常意味着 planner 估计错了。

关键行通常意味着什么

如果你看到 Index ScanIndex Only Scan,Postgres 使用了索引,通常是好事。Bitmap Heap Scan 对中等大小的匹配也可以接受。Seq Scan 表示读了整个表,这只有在表小或几乎每行都匹配时才可以接受。

常见红旗:

  • 大表上的 Seq Scan
  • 估计行数与实际行数差距很大(例如估计 10、实际 10000)
  • 排序占用了大部分时间(通常与 ORDER BY 一起出现)
  • “Filter:” 在扫描后移除大量行
  • BUFFERS 中共享读取块很高(大量数据读取)

计划出错的原因(以及简单修复)

慢计划通常来自几种模式:

  • 缺少匹配你 WHERE + ORDER BY 模式的索引(例如 (user_id, status, created_at)
  • 类型不匹配(例如把 UUID 列与 text 参数比较),这会阻止使用索引
  • WHERE 中使用函数(例如 WHERE lower(email) = $1),除非添加匹配的表达式索引,否则会强制全表扫描

如果计划看起来异常并且估计严重偏差,通常是统计信息过时。运行 ANALYZE(或让 autovacuum 更新)让 Postgres 学到当前行数和分布。这在大量导入后或新端点开始快速写入数据时尤其重要。

围绕实际查询构建索引

部署并使用域名
托管你的应用并在准备好后连接自定义域名。

索引只有在匹配 API 查询方式时才有用。如果凭猜测建立索引,会导致写入变慢、存储变大而提升不明显。

把索引想成针对特定问题的捷径。如果你的 API 问了不同的问题,Postgres 会忽略这条捷径。

围绕筛选和排序构建索引

如果一个端点按 account_id 筛选并按 created_at DESC 排序,一个复合索引通常优于两个独立索引。它能帮助 Postgres 更少工作就找到合适行并按正确顺序返回。

通常有效的经验规则:

  • 给最常筛选的列建索引,然后添加排序列。
  • 保持复合索引小。两列常见;三列有时可接受;更多通常是个异味(smell)。
  • 把最具选择性的过滤列放在第一位(那个能最大缩小结果的列)。
  • 避免用几个被更好复合索引完全覆盖的独立索引。
  • 宁可有一个精选索引,也不要多个“也许有用”的索引。

示例:如果 API 有 GET /orders?status=paid 并且总是按最新显示,那么 (status, created_at DESC) 是个合适的索引。如果大多数查询也按 customer 过滤,(customer_id, status, created_at) 可能更好,但前提是这确实是端点在生产中的运行方式。

常用过滤上的部分索引

如果大部分流量命中某个窄的行切片,部分索引更便宜也更快。例如,如果应用大多数读取都是活跃记录,只索引 WHERE active = true 可以让索引更小、更容易驻留内存。

确认索引有用的快速方法:

  • 运行 EXPLAIN(或在安全环境下 EXPLAIN ANALYZE)并查找匹配查询的索引扫描。
  • 比较有无索引时的耗时和读取的行数。
  • 注意 “Rows Removed by Filter” 是否仍然很高。那通常意味着索引不匹配你的过滤条件。

小心移除未使用的索引。查看使用统计(例如索引是否被扫描过)。在低风险窗口逐个删除,保留回滚计划。未使用的索引并非无害,它们会在每次写入时拖慢插入和更新。

随着时间不会变慢的分页模式

分页常常是一个在数据库健康时 API 开始感觉慢的地方。把分页当成查询设计问题,而非仅仅是 UI 细节。

为什么 LIMIT/OFFSET 会变慢

LIMIT/OFFSET 看起来简单,但深页代价更高。Postgres 仍然得跳过(并常常排序)被跳过的行。第 1 页可能触及几十行,第 500 页可能迫使数据库扫描并丢弃成万行以返回 20 条结果。

它还可能在行插入或删除之间造成不稳定结果。用户可能看到重复或漏项,因为“第 10000 行”的含义随着表的变化而变。

用“最后看到的”示例说明键集分页(游标)

键集分页问的是不同的问题:“给我最后看到行之后的下一批 20 行。”这让数据库在一个小且一致的切片上工作。

一个简单的版本用递增 id

SELECT id, created_at, title
FROM posts
WHERE id > $1
ORDER BY id
LIMIT 20;

你的 API 返回一个 next_cursor,等于页面中最后一个 id。下一次请求把该值作为 $1

对于基于时间的排序,使用稳定的顺序并断开平局。单独的 created_at 不够用,如果两行有相同时间戳就会出问题。使用复合游标:

WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;

一些规则可防止重复和漏项:

  • ORDER BY 中始终包含唯一的决胜列(通常是 id)。
  • 在各次请求中保持排序相同。
  • 将游标对客户端不透明(把 created_atid 一起编码)。
  • 如果用户可以筛选,每页都应用相同的过滤条件。
  • 尽量使用不可变的排序字段(创建时间)而非可变字段(状态、分数)。

JSON 塑形:更快的响应与更小的负载

无畏实验
尝试索引或查询变更,测量 p95,如果影响其他路由可以快速回退。

一个令人惊讶的常见原因是 API 感觉慢的并不是数据库,而是响应体。大 JSON 更耗时去构建、更耗时发送、客户端也更耗时解析。最快的提升往往是返回更少内容。

从你的 SELECT 开始。如果端点只需要 idnamestatus,就只请求那些列。SELECT * 会随着表加入长文本、JSON blob 和审计列而悄然变重。

另一个常见的变慢原因是 N+1 响应构建:你取回 50 条列表项,然后再发 50 次查询去附加关联数据。测试时可能通过,但在真实流量下会崩溃。优先使用单个查询返回所需内容(谨慎使用 join),或用两次查询并在第二次按 ID 批量查询。

保持负载更小而不破坏客户端的几种方法:

  • 使用 include= 标志(或 fields= 掩码),让列表响应保持精简,详情响应按需包含额外字段。
  • 限制嵌套数组(例如只返回最新 10 条事件),并提供单独端点获取完整历史。
  • 如果客户端只需要某些键,就不要返回原始的内部 JSON 列。
  • 使用短码代替重复的长标签。

在 Postgres 还是在 Go 中构建 JSON?

两者都可以很快。选择基于你优化的目标。

Postgres 的 JSON 函数(jsonb_build_objectjson_agg)在你想减少往返且从一个查询得到可预测形状时很有用。Go 中塑形在需要条件逻辑、重用结构体或保持 SQL 易于维护时更方便。如果在 SQL 中构建 JSON 变得难以阅读,也会变得难以调优。

一个好的规则是:让 Postgres 负责过滤、排序和聚合。然后让 Go 负责最终呈现。

如果你快速生成 API(例如使用 Koder.ai),尽早添加 include 标志可以避免端点随时间膨胀。它也在你添加字段时提供一种安全方式,而不会让每个响应变重。

在首批用户到来前的逐步调优流程

你不需要一个庞大的测试实验室就能发现大多数性能问题。一个简短且可重复的流程能显现那些在流量出现时会演变成宕机的问题,尤其是当起点是你打算发布的生成代码时。

在改动前先写下一个小基线:

  • 最繁忙端点的 p95 和 p99 延迟
  • 错误率和超时
  • 数据库 CPU 和活动连接数
  • 按总耗时排序的最慢 5 个查询(不是只看最差那条)

调优步骤

一次只改一件事并在每次改动后重新测试。

  1. 运行一个 10–15 分钟的负载测试,模拟真实使用。命中首批用户会用到的相同端点(登录、列表页、搜索、创建)。然后按 p95 延迟和总耗时对路由排序。

  2. 在调优 SQL 之前检查连接压力。池太大会压垮 Postgres,池太小会造成长时间等待。观察获取连接等待时间上升和突发时连接数峰值。先调整池和空闲限制,然后重新运行相同负载。

  3. 对最慢的查询运行 EXPLAIN 并修复最明显的红旗。常见罪魁是大表全表扫描、对大结果集的排序、以及导致行数爆炸的 join。挑出最差的一条,让它变得无聊(boring)。

  4. 添加或调整一个索引,然后重新测试。索引在匹配你的 WHEREORDER BY 时有用。一次别加太多索引。如果慢端点是“按 user_id 列表订单并按 created_at 排序”,在 (user_id, created_at) 上建复合索引常常能让差别从瞬时到痛苦。

  5. 精简响应和分页,然后再次测试。如果端点返回 50 条带大量 JSON blob 的行,数据库、网络和客户端都会付出代价。只返回 UI 需要的字段,优先使用不会随表增长而变慢的分页方式。

保持一个简单的变更日志:改了什么、为什么、p95 有什么变化。如果某次改动没有改善基线,就回退并继续下一项。

常见错误与陷阱

快速生成 Go API
描述你的端点并获得可在真实流量下调优的 Go + Postgres 服务。

大多数 Go + Postgres API 的性能问题都是自找的。好消息是一些检查能在真实流量到来前发现大多数问题。

一个经典陷阱是把池大小当作速度旋钮。“尽可能设大”常常会让一切变慢。Postgres 会花更多时间在会话、内存和锁上调度,你的应用会成波次超时。一个较小且稳定的池在可预测并发下通常更好。

另一个常见错误是“给所有东西建立索引”。额外索引能帮助读取,但也会拖慢写入,并可能以意想不到的方式改变查询计划。如果你的 API 经常插入或更新,每多一个索引都会增加开销。在添加索引前后进行测量,并在添加索引后重新检查查询计划。

分页债务会悄然积累。Offset 分页早期看起来没问题,但随着时间 p95 上升,因为数据库要跳过越来越多的行。

JSON 负载大小是另一个隐藏税。压缩有助于带宽,但不能消除构建、分配和解析大型对象的成本。裁剪字段、避免深度嵌套、只返回屏幕所需内容。

如果你只看平均响应时间,你会错过真实用户痛点。p95(有时 p99)是池饱和、锁等待和慢计划最先显现的地方。

一个快速的预发布自检:

  • 在小负载测试时观看池的等待时间和 Postgres 连接数。
  • 比较同一端点的平均值与 p95。
  • 验证当表增大 10 倍时分页不会退化。
  • 检查列表端点的响应大小(字节量很重要)。
  • 在添加索引或改变过滤后重新运行 EXPLAIN

发布前的快速清单与后续步骤

在真实用户到来前,你需要证据表明 API 在压力下保持可预测。目标不是完美数字,而是发现那些会导致超时、峰值或数据库停止接受新工作的问题。

在类似生产的 staging 环境中运行检查(相似的 DB 大小、相同的索引、相同的池设置):在负载下测量每个关键端点的 p95 延迟,捕获按总耗时排序的最慢查询,观察池等待时间,用 EXPLAIN (ANALYZE, BUFFERS) 检查最差查询是否使用了你期望的索引,并对最忙路由的响应大小做合理性检查。

然后做一次最坏情况运行,模拟产品会如何崩溃:请求深页、应用最宽泛的过滤,并在冷启动(重启 API 并首次请求同样的请求)下再试一次。如果深度分页每页都变慢,在发布前改用基于游标的分页。

把默认值写下来,团队以后好做一致选择:池限制与超时、分页规则(最大页大小、是否允许 offset、游标格式)、查询规则(只选需要列、避免 SELECT *、限制昂贵过滤)、以及日志规则(慢查询阈值、样本保留时间、如何标记端点)。

如果你用 Koder.ai 构建并导出 Go + Postgres 服务,发布前做一个短的规划流程有助于让筛选、分页和响应形状更有意图。一旦你开始调优索引和查询形状,快照与回滚能让你更容易撤销那些“修复”——它可能帮了一个端点却伤了其他端点。如果你想要一个集中处迭代该工作流的地方,Koder.ai 在 koder.ai 上设计用于通过聊天生成并精化这些服务,然后在准备好时导出源码。

常见问题

如何快速判断我的 Go API 慢是由 Postgres 导致还是由我的代码导致?

开始通过把 数据库等待时间应用处理时间 分开来判断。

  • 如果是数据库慢,处理器大部分时间在等查询返回。Go 的 CPU 往往看起来正常,但请求会堆积为“在途”。
  • 如果是应用慢,查询返回快,但在构建对象、为每行做额外查询、序列化大量 JSON 或记录日志时消耗时间。Go 的 CPU 和内存通常会随响应大小上升。

在数据库调用处加上“等待连接”和“执行查询”的简单计时,就能看出哪个占主导。

在调优之前我应该首先跟踪哪些指标?

用一组可重复的小基线来开始:

  • 每个关键端点的 p95 延迟(不要只看平均值)
  • 错误率(5xx、超时、取消)
  • 每请求的 DB 时间(等待 Postgres 的时间)

选个明确目标,例如 “在 50 个并发用户下 p95 在 200 ms 内,错误率低于 0.5%”。然后一次只改一件事,使用相同的请求组合重新测试。

我应该开启 Postgres 慢查询日志吗?实用的阈值是多少?

在预发布测试中开启慢查询日志并设置较低阈值(例如 100–200 ms),并记录完整语句以便把它复制到 SQL 客户端中进行分析。

注意保持临时启用:

  • 在生产中太多日志会很吵。
  • 如果记录过多会增加开销。

找到最慢的罪魁后,改用采样或提高阈值。

Go API 在 Postgres 上的良好起始连接池设置是什么?

一个实用的默认是 每个 API 实例按 CPU 核心数的小倍数,通常 最大打开连接数 5–20,并设置相似的最大空闲连接,周期性回收连接(例如每 30–60 分钟)。

两种常见失败模式:

  • 池太小:请求在获取连接上等待,即使 Postgres 查询时间看起来正常。
  • 池太大:Postgres 被大量活动会话压垮,延迟变得不均匀。

记住池会随实例数相乘(20 × 10 = 200 连接)。

如何确认连接池是瓶颈(而不是 SQL)?

把数据库调用分成两部分计时:

  • 等待连接的时间(池等待)
  • 执行查询的时间(Postgres 工作)

如果大部分时间是池等待,调整池大小、超时和实例数量。如果大部分时间是查询执行,聚焦 EXPLAIN 和索引优化。

同时确认你始终及时关闭 rows,以便连接能返回池中。

当某个端点变慢时,我在 EXPLAIN 中应该先看什么?

对 API 发出的精确 SQL 运行 EXPLAIN (ANALYZE, BUFFERS),并注意:

  • 大表上的 Seq Scan
  • 估计行数与实际行数差距巨大
  • ORDER BY 导致的 Sort 占用大部分时间
  • “Rows Removed by Filter” 很高
  • BUFFERS 中大量共享读取块(重读)

先修最明显的问题,不要一次处理所有细节。

如何为带筛选和排序的列表端点选择合适的索引?

索引应匹配端点真实的查询行为:筛选条件 + 排序顺序

好的默认做法:

  • 为常见的 WHERE + ORDER BY 模式建一个 复合索引
  • 保持索引短小(常见是 2 列,3 列有时可接受)。
  • 将选择性最高的过滤列放在前面,然后放排序列。

示例:如果按 user_id 过滤并按最新排序,索引 (user_id, created_at DESC) 常能把 p95 从爆炸级别降到稳定。

Postgres 中何时值得使用部分索引?

当大部分流量都访问某个可预测子集时,使用部分索引是值得的。

示例场景:

  • 大量读取集中于 active = true
  • 不活跃行很少被查询

... WHERE active = true 的部分索引更小、更可能驻留内存,并比索引所有行带来更低的写入开销。

EXPLAIN 确认 Postgres 在高流量查询中实际使用了该索引。

为什么 LIMIT/OFFSET 随着时间会变慢,我应该用什么代替?

LIMIT/OFFSET 在深页时会变慢,因为 Postgres 需要跳过(并常常排序)那些被跳过的行。第 1 页可能只触及几十行,但第 500 页可能需要扫描并丢弃成万行才能返回 20 条结果。

更好的方式是 键集(游标)分页

  • 使用稳定的排序并包含唯一的决胜列(通常是 id)。
  • 在请求间保持相同的 ORDER BY
  • (created_at, id) 等编码到游标中。

这样每页的成本在表增长时基本保持恒定。

我的 DB 查询很快,但响应还是慢——我应该裁剪 JSON 有助吗?

通常应该裁剪列表端点的 JSON。最快的响应是不发送的响应。

实用改进:

  • 只选择需要的列(避免 SELECT *)。
  • 增加 include=fields=,让客户端按需选择重字段。
  • 限制嵌套数组大小(例如只返回最近 10 条),完整历史通过专门端点获取。
  • 避免 N+1 模式(50 条主记录再发 50 次额外查询);使用连接或按 ID 批量查询。

通常通过缩小负载就能减少 Go 的 CPU、内存压力和尾延迟。

Related posts