首次招聘后端工程师前,先做 Go 压力测试
通过 Go 压力测试模拟真实流量,衡量 p95 延迟、数据库压力、内存和错误,再决定是否招聘。

每月 10,000 名活跃用户不是容量要求,只是计费或分析指标。请求很轻、又分散在整个月时,Go 服务能支持远多于这个数量的用户。每个会话都触发慢查询、上传和第三方调用时,几百名用户就可能让服务崩溃。
真正该问的是,在预期峰值流量下,生成的后端能否达到既定服务目标,并留出足够余量应对增长和一次普通故障。你可以在招聘后端工程师前回答这个问题,但前提是压测看起来像你的产品,并且同时记录 API、Go 运行时和 PostgreSQL 的状态。平均延迟图一片绿色,几乎说明不了什么。
在我建议创始人把生成的 Go 后端用于 10,000 月活跃用户前,我会要求完成下面这项测试。它会给出可重复的通过或失败结果,暴露第一个瓶颈,并区分容量问题和正确性问题。
月活用户必须换算成峰值请求
选择负载级别前,先把用户预测换算成每秒请求数。月活跃用户数掩盖了决定后端负载的两个变量:最繁忙时段有多少会话到来,以及每个会话会产生多少工作。
如果已有私测,先从实际数据开始。统计最繁忙 15 分钟内的会话数、每次会话的请求数和路由组合。如果还没有流量,就把假设写下来,让每个人都能质疑。比如,假设 10,000 名活跃用户每月产生 8 次会话,每次会话 15 个 API 请求,每日流量的 20% 集中在最繁忙的一小时。平均繁忙日大约是每秒 8 个请求。发布活动、通知、发薪截止日或共同的时区,都可能让实际峰值高出好几倍。
不要把这套算术变成虚假的精确度。用它定义三个测试级别:
- 预期峰值:目前预测的最高繁忙负载。
- 增长峰值:预期峰值的两倍,除非业务有更好的预测。
- 压力级别:持续增加流量,直到服务目标失败或资源饱和。
预期和增长测试回答计划发布是否有余量。压力测试告诉你最先坏掉的是什么,以及故障会如何呈现在用户面前。最后这一点很重要,因为能快速拒绝超额工作量的服务,比耗尽所有数据库连接、让无关路由也卡住的服务更容易运维。
容量测试应使用开放式工作负载模型。即使先前请求变慢,开放模型也会按固定到达率发起请求。使用固定虚拟用户数的封闭模型往往会掩盖崩溃:响应越慢,这些用户发起的新请求越少,服务最吃力时,施加的负载反而下降。Grafana 的 k6 文档通过到达率执行器说明了这种区别,并在生成器无法启动计划工作时报告 dropped_iterations。把丢弃的迭代视为测试生成器失败,而不是服务器成功。
预热后,每个稳定级别至少运行 30 分钟。五分钟测试发现不了连接频繁变动、垃圾回收周期、缓存淘汰、后台任务和内存缓慢增长。短测试通过后,再在预期峰值下单独做一次两小时浸泡测试。
把换算过程写成小型工作表,保留每个单位。月用户数乘以每用户会话数和每会话请求数,得到月请求数。只有在把流量分配到运营日和最繁忙小时后才做除法。然后加上重试流量、后台任务、webhook 和轮询,这些都可能不在用户分析中。每十秒轮询一次的前端,产生的 API 工作量可能比打开页面的点击还多。
把突发流量与稳定峰值分开建模。通知后的登录、导入完成,或短暂故障后客户端重试,都可能把工作集中在一分钟内。添加一个突发阶段,快速达到预期突发速率,保持足够久以填满队列,然后恢复正常。服务应能恢复,不能留下持续增长的积压或需要人工重启。记录恢复时间。系统可能通过稳定测试,却因一次短暂突发让连接池或工作线程卡住,仍然不安全。
不要把最终速率乘以任意安全系数后就称它真实。把余量与业务中的不确定性绑定:预测误差、计划中的活动、一个副本不可用,或增加容量所需的时间。测试每一个你打算依赖的假设。把工作表放在测试结果旁边,因为没人记得目标来自哪些预测和突发假设时,一次通过测试就失去了意义。
流量组合必须像真实会话
真实的压测应保留路由频率、负载大小、身份验证、数据分布、思考时间和写入争用。每秒请求 /health 100 次,测到的是健康检查处理器,不是应用。
尽量根据访问日志构建组合。按业务操作而非原始 URL 对路由分组,因为 /projects/123 和 /projects/456 是同一种模式。一个看似合理的早期 SaaS 组合可以是:45% 列表和详情读取、20% 搜索、15% 创建或更新、10% 登录和令牌刷新、10% 导出或其他重任务。你的数字应来自产品流程,而不是照搬这个例子。
使用许多测试账号和记录。重复使用同一账号可能制造不真实的热缓存、让更新串行落在同一行,或触发真实流量本会分散的限流。准备小型、中型和大型租户。加入不存在的记录、无效输入和授权失败,因为错误路径查询数据库或分配响应体的方式,常与成功路径不同。
如果大文件上传和长时间导出有不同服务目标,就为它们单独建立场景,但仍要与普通流量并发运行。否则测试会漏掉用户最容易感知的事故:某类导出占满连接池,简单的设置页面也只能在后面等待。
最终容量测试不要模拟 PostgreSQL、对象存储、队列或外部服务。模拟对象适合隔离处理器成本,但会移除最可能决定容量的依赖项。让测试指向预发布环境,其中实例规格、数据库设置、索引、连接限制和网络路径都应与生产一致。脱敏但形状接近生产的数据,比一千条完全相同的种子数据更好。
如果 API 正常情况下不会经过内容分发缓存,就不要通过它测试。反过来,生产中会用的真实缓存要保留在路径中。目的不是让后端看起来很忙,而是复现一次用户请求实际引发的工作。
可运行的 k6 测试应编码服务约定
把阈值和流量阶段放进版本控制,这样一次测试就不会变成事后凭截图解读。k6 把阈值视为通过或失败条件,失败时会以非零状态退出,因此可作为发布检查。
下面的框架以到达率驱动混合会话,检查响应语义,并对普通读取和重型导出设置不同延迟上限。请用产品已达成共识的路由、负载和目标替换它们。测试中的代码刻意保持直白,这样失败的检查能对应到用户操作。
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
const businessErrors = new Rate('business_errors');
export const options = {
scenarios: {
expected_peak: {
executor: 'ramping-arrival-rate',
startRate: 5,
timeUnit: '1s',
preAllocatedVUs: 40,
maxVUs: 200,
stages: [
{ target: 10, duration: '5m' },
{ target: 10, duration: '30m' },
{ target: 20, duration: '10m' },
{ target: 20, duration: '30m' },
],
},
},
thresholds: {
'http_req_duration{name:project_list}': ['p(95)<300'],
'http_req_duration{name:project_create}': ['p(95)<500'],
'http_req_duration{name:export}': ['p(95)<2000'],
http_req_failed: ['rate<0.01'],
business_errors: ['rate<0.005'],
dropped_iterations: ['count==0'],
},
};
export function setup() {
const response = http.post(`${__ENV.BASE_URL}/api/login`, JSON.stringify({
email: __ENV.TEST_EMAIL,
password: __ENV.TEST_PASSWORD,
}), { headers: { 'Content-Type': 'application/json' } });
check(response, { 'login succeeds': r => r.status === 200 });
return { token: response.json('token') };
}
export default function (data) {
const headers = { Authorization: `Bearer ${data.token}`, 'Content-Type': 'application/json' };
const list = http.get(`${__ENV.BASE_URL}/api/projects?limit=25`, {
headers,
tags: { name: 'project_list' },
});
businessErrors.add(!check(list, {
'list status is 200': r => r.status === 200,
'list has items': r => Array.isArray(r.json('items')),
}));
if (Math.random() < 0.25) {
const create = http.post(`${__ENV.BASE_URL}/api/projects`, JSON.stringify({
name: `load-${__VU}-${__ITER}`,
}), { headers, tags: { name: 'project_create' } });
businessErrors.add(!check(create, { 'create status is 201': r => r.status === 201 }));
}
if (Math.random() < 0.03) {
const runExport = http.post(`${__ENV.BASE_URL}/api/exports`, '{}', {
headers,
tags: { name: 'export' },
});
businessErrors.add(!check(runExport, { 'export accepted': r => r.status === 202 }));
}
sleep(Math.random() * 2 + 1);
}
示例目标只是起点,不是通用承诺。根据用户能接受的等待时间和产品自身要求,为每类路由设定 p95。不要用统一的 300 ms 阈值衡量异步导出和自动补全请求。
从不托管应用的机器运行脚本。确认生成器有充足 CPU,且没有丢弃迭代。保存确切提交、环境配置、数据快照标识、命令和原始测试输出。没有这些,后续比较大多只能依靠记忆和乐观判断。
信任长时间测试前,先校准生成器。让它请求一个没有数据库工作的极小处理器,把请求到达率提高到计划测试之上,确认生成器能持续保持速率,不耗尽自己的 CPU、套接字或网络。当 k6 增加虚拟用户或报告丢弃迭代时,负载机可能才是瓶颈。只有一台机器无法提供所需工作量时,才把生成任务分散到多台机器,并同步它们的时钟,让服务器和客户端图表对齐。
每次运行都要有安静的基线。停止迁移、数据导入和无关的预发布任务,除非这些任务在真实峰值时也会运行。然后安排第二次测试,启用真实后台工作。这一对测试会告诉你 API 的纯净容量,以及用户实际得到的运行容量。如果只有安静运行能通过,发布计划就依赖于预发布环境的虚构条件。
为单个用户旅程写第二个脚本,先用一个虚拟用户运行,再增加负载。检查每个响应、创建的记录和清理动作。这样能抓到错误令牌、永远返回 true 的检查,或第一次迭代后就发生冲突的测试数据。如果脚本实际在访问错误页或反复读取同一个缓存对象,容量结果毫无意义。
p95 需要路由级上下文
使用 p95 延迟,因为平均值会掩盖少数慢请求,但绝不能只看 p95。p95 为 800 ms 时,每 20 个请求中就有一个至少这么慢,这会让多请求页面持续显得缓慢。样本很少的路由,其百分位数也会不稳定,所以报告时要附上请求数量。
为每个具名业务操作记录 p50、p95、p99、最大值、吞吐量和错误率。p50 展示正常表现,p95 是实用的服务门槛,p99 暴露尾部情况,又不会让单一最大值主导讨论。按状态码拆分结果。快速返回的 500 不能让延迟表现看起来更好。
同时测量服务端处理器耗时和客户端观察到的耗时。二者差值包括连接建立、代理、网络时间和响应传输。若客户端 p95 上升,而处理器 p95 持平,应检查处理器之外的部分。若二者都上升,且数据库等待时间也增加,请求很可能在排队等连接或查询。
预热结果和稳定状态结果必须分开。已部署的 Go 二进制文件不会发生编译,但冷缓存、新数据库连接、惰性初始化和自动扩缩容会扭曲最初几分钟。用户仍会经历冷启动表现,所以应把它作为独立结果保留,而不是删掉。
平均值对资源核算仍然有用。数据库总时间除以调用次数,能找出单次中等偏慢但调用极频繁的查询。但它不能取代百分位服务目标。实践中常被混淆的是延迟与容量:延迟描述已完成工作花了多久,容量描述服务能在不让队列增长或错误增加的情况下,持续承受多少输入工作量。低实际请求率下的良好延迟,不足以证明容量。
在运行前定义失败条件。只要关键路由未达到 p95 目标、意外 HTTP 失败超出约定比例、业务检查失败、计划迭代被丢弃,或某项资源持续饱和,我就会判定发布测试失败。五道门通过四道,仍是一次失败测试,只是拥有有用的诊断数据。
连接等待会暴露隐藏的数据库队列
测试前先为 database/sql 加上监控,因为应用延迟无法告诉你是 PostgreSQL 变慢,还是应用在等待进入它。Go 的 DB.Stats() 会报告 OpenConnections、InUse、Idle、WaitCount 和 WaitDuration,以及连接关闭计数。每隔几秒把它们导出到指标系统。
func recordDBStats(ctx context.Context, db *sql.DB, g GaugeSet) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s := db.Stats()
g.Set("db_open_connections", float64(s.OpenConnections))
g.Set("db_in_use_connections", float64(s.InUse))
g.Set("db_idle_connections", float64(s.Idle))
g.Set("db_wait_count_total", float64(s.WaitCount))
g.Set("db_wait_seconds_total", s.WaitDuration.Seconds())
}
}
}
计算稳定窗口内累计计数器的变化量。WaitCount 增加说明请求必须等待空闲连接。WaitDuration 的变化量除以 WaitCount 的变化量,得到该时间段的平均连接池等待时间。把 InUse 与 MaxOpenConnections 放在同一图上看:连接数在上限处横盘,同时等待持续上升,就是连接池饱和。
不要看到图表好些就直接提高 SetMaxOpenConns。这个常见修复会把队列移到 PostgreSQL,可能增加争用、内存使用和查询延迟。先找出连接为何长时间忙碌:慢查询、网络调用期间仍持有事务、逐行读取,或忘记调用 Rows.Close()。再根据所有应用副本和工作进程共同占用的数据库连接预算来设置连接池大小。
Go 文档说明,SetMaxOpenConns 设置为非正值会让连接池保持无限制。多副本同时打开连接时,无限制是危险的生产默认值。明确设置上限,有意识地配置空闲和生命周期策略,并为迁移、管理和后台工作预留数据库容量。
单独跟踪事务持续时间。处理器可能在 200 ms 内返回,而延迟清理或泄漏的事务会更久地占用连接。连接池指标能显示压力,追踪或事务计时才能找到持有者。
慢查询证据必须来自 PostgreSQL
在测试环境启用 pg_stat_statements,并在每次运行前后保存快照。PostgreSQL 文档说明,它会跟踪标准化语句的规划和执行统计。其视图包含调用次数、行数、总执行时间和平均执行时间、块活动以及临时块活动。这种证据远胜于猜测某条追踪中碰巧出现的查询。
视图是累计数据,因此要比较两个快照之间的增量。只在隔离的测试数据库中重置它,因为重置会销毁其他工作的证据。下面的查询会找出干净测试窗口内消耗执行时间最多的语句:
SELECT
queryid,
calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows,
shared_blks_read,
temp_blks_written
FROM pg_stat_statements
WHERE calls > 20
ORDER BY total_exec_time DESC
LIMIT 20;
总时间能找出频繁执行、主导数据库工作量的查询。平均时间能找出单独执行很慢的语句。两者都不能给出查询 p95,因为 pg_stat_statements 聚合了调用记录。若单条语句的尾部延迟重要,应使用追踪或持续时间直方图。这也是另一种常被混淆的区别:慢查询可能指平均执行时间高、尾部时间高,或只是调用频繁导致总成本巨大。每一种都需要不同修复。
对于排名靠前的语句,在计时压测之外,使用安全且有代表性的参数运行 EXPLAIN (ANALYZE, BUFFERS)。ANALYZE 会实际执行语句,因此对会改数据的语句,要放在最终回滚的事务中执行,或在可丢弃的副本上检查。注意估算行数与实际行数差异很大、重复循环、在大型且选择性高的表上顺序扫描、排序溢出到磁盘,以及读取大量共享块等情况。
生成的代码常会产生一种在种子数据下看起来没问题的 N+1 模式:取 25 个项目,然后对每个项目发起一次所有者查询和一次计数查询。每秒 10 个请求时,其他路由还没开始,这一个端点就可能每秒产生超过 500 条数据库语句。修复方法可能是 join、批量 WHERE id = ANY($1),或预先计算计数。提高连接池上限并没有消除浪费。
也要捕获锁等待。单独执行很快的查询,在并发更新同一租户、账号或序列时可能停住。若延迟只在写入场景中飙升,请检查活跃等待和事务边界,不要条件反射地加索引。
内存必须在稳定负载下趋于稳定
判断内存要看随时间变化的形状,而不是单个峰值。Go 进程在预热时上升,随后围绕稳定水平波动,和它在两小时浸泡过程中每次垃圾回收后的基线持续上升,完全不同。
记录进程常驻内存、Go 堆分配、堆对象数、goroutine 数、垃圾回收频率、暂停时间和分配速率。容器内存很重要,因为操作系统会按容器限制杀掉进程,而不是只看 Go 堆。比较相近负载下垃圾回收后的内存,这能消除大部分正常锯齿形波动,让内存滞留更明显。
在预发布环境暴露标准 Go 运行时指标或受保护的性能分析端点。浸泡开始和结束附近各取一份堆性能分析文件,然后用 go tool pprof 比较仍被保留的分配位置。也获取 goroutine 性能分析文件。goroutine 数量增长可能暴露卡在 channel 上的请求、从未关闭的响应体,或没有取消机制就启动的后台工作。
不要把 GOMEMLIMIT 设得与容器限制完全相同。进程还需要为 goroutine 栈、可执行文件映射、数据库驱动缓冲区和其他非堆分配留出内存。预留余量,并在最大负载下证明它足够。一项只用很小 JSON 请求体的测试,对会把 20 MB 上传读入内存的端点几乎没有参考价值。
主动制造棘手场景:允许的最大请求体、大型查询结果、取消的客户端、超时和反复导出。确认工作完成后内存会回落。也要关注 CPU,因为频繁垃圾回收能让内存保持在限制以下,却摧毁延迟表现。
实用的通过条件结合上限和趋势。要求增长峰值下常驻内存远低于部署限制,再要求浸泡期间回收后的基线和 goroutine 数不再上升。没有通用安全百分比。根据平台重启行为、流量波动和另一个副本能否承接重启来选择余量。
故障必须包含错误答案和过载表现
把传输失败、HTTP 状态失败、超时、panic 和错误响应分开统计。http_req_failed 会根据 k6 的响应回调捕获失败的 HTTP 请求,但返回空列表、重复扣费或缺少记录的 200 响应仍然是失败。这就是示例通过内容检查发送 business_errors 的原因。
为无效输入或有意限流等预期拒绝打标签,避免它们污染意外错误率。然后断言其约定:状态正确、响应体大小受控、拒绝足够快。系统过载时,不应 30 秒后才返回 503。
关注服务器日志中的 panic 恢复、上下文截止时间错误、获取连接延迟、PostgreSQL 序列化失败和取消的查询。按稳定原因而不是完整消息给错误分组,避免标识符制造数千个类别。为首次出现和高延迟尾部保存有代表性的追踪。
完成干净的容量测试后,做一次降级测试。减少可用数据库连接、为某个外部依赖增加受控延迟,或在流量持续时重启一个应用副本。只在隔离测试环境做这些操作。目的是确认超时、取消和健康检查会限制故障,而不是让队列吞噬所有资源。
明确配置 HTTP 服务器。Go 的 net/http 文档指出,ReadTimeout、WriteTimeout 和 IdleTimeout 设为零或负值时,可能表示没有超时,具体取决于字段。生成的服务常常使用默认设置调用 http.ListenAndServe,从未做过这个决定。自定义 http.Server、请求范围内的截止时间和有上限的请求体,可以防止慢客户端和卡住的依赖无限期占用资源。
运行后复查正确性。统计创建的记录,在客户端重试的地方验证幂等性,检查后台任务只完成一次,并确认失败请求没有留下部分状态。在我的项目里,压测发现的重复工作 bug 比巧妙的代码审查还多。
招聘决定取决于第一个极限
当系统反复通过预期和增长测试,浸泡测试达到稳定内存基线,数据库队列受控,团队又能解释压力测试的第一个失败点时,你可以在没有后端工程师的情况下发布。一次侥幸变绿的运行不是证据。同一提交至少运行三次,并调查较大的波动。
为每次运行保留简洁结果记录:
- 提交和环境,包括副本规格和数据库配置。
- 数据集规模、流量组合、到达率和测试时长。
- 路由 p95 和 p99、实际吞吐量、业务失败和丢弃迭代。
- CPU 和内存峰值、回收后内存趋势和 goroutine 趋势。
- 连接池等待、按总时间排序的主要 SQL、锁等待和观察到的崩溃点。
如果没人能解释不断增加的连接池等待、持续占用的内存、锁争用或不一致写入,应在发布前招聘或聘请后端协助。如果唯一能运行测试的人无法安全地修改生成代码,也应该招聘。这是责任归属缺口,不是每秒请求数阈值的问题。
测试失败不一定意味着需要全职招聘。缺少索引、N+1 查询或无上限导出,可能是范围有限的修复。若事务设计、可观测性、取消和部署行为反复出问题,则说明需要持续的工程投入。区别在于,你是找到了一个缺陷,还是发现根本没人负责系统的行为。
Koder.ai 可以生成和导出 Go 后端、部署它,并保留快照用于回滚,但生成并不能取代容量规划。让压测脚本和可观测性改动与源代码一起保存,这样每次有意义的后端变更都必须通过同一份约定。
不要向业务承诺 10,000 月活跃用户是安全的。承诺经过测量的到达率、路由组合、延迟目标、错误预算和资源边界。产品变化时,更新这些输入,再运行测试。
常见问题
Go 后端能处理 10,000 月活跃用户吗?
通常可以,但月活跃用户数并不能说明后端负载。先把预测换算成峰值每秒请求数和路由组合,再用明确的延迟、错误、数据库和内存上限测试这类负载。
10,000 月活跃用户相当于每秒多少请求?
没有固定换算方式。你需要知道每位用户的会话次数、每次会话的请求数、最繁忙时段占全部流量的比例,以及是否有让用户集中到来的事件。
Go API 可接受的 p95 延迟是多少?
按用户操作来设目标,而不是按语言或框架。交互式读取可能需要控制在几百毫秒内,已接受的后台任务可以有不同目标,但团队必须在看到测试结果前先定好数字。
后端压测应持续多久?
预热后,每个稳定负载级别至少保持 30 分钟,再在预期峰值下做更长时间的浸泡测试。短测试可能漏掉连接频繁变动、持续占用的内存、后台任务和队列缓慢增长。
压测该用虚拟用户还是到达率?
要做容量判断,应使用到达率模型,因为服务变慢时它仍会持续发起工作。固定虚拟用户模型会在变慢时降低请求速率,掩盖队列开始增长的临界点。
如何发现 Go 数据库连接池耗尽?
导出 DB.Stats(),关注 InUse、OpenConnections、WaitCount 和 WaitDuration。当正在使用的连接数一直达到配置上限,而等待计数持续上升,说明请求正在排队等连接池。
请求在等待时,应该增大 Go SQL 连接池吗?
先弄清连接为何一直被占用,以及 PostgreSQL 还有多少容量。更大的连接池可能只是把队列移进数据库,让争用更严重。
如何在压测期间找出慢 PostgreSQL 查询?
在压测前后分别记录 pg_stat_statements 快照,再按查询增量的总执行时间和平均执行时间排序。若要看尾部延迟,请用有代表性的追踪或直方图,因为聚合视图不提供单条查询的 p95。
如何判断 Go 服务是否存在内存泄漏?
做稳定负载浸泡测试,并比较相近负载下垃圾回收后的内存。基线持续上升,特别是堆对象或 goroutine 数量也在增长时,应对比堆和 goroutine 性能分析文件。
初创公司何时该招聘后端工程师?
当性能故障暴露出数据库设计、可观测性、并发、正确性或运维方面长期无人负责的问题时,就该招聘。单个索引或查询问题可能不需要全职岗位,但负载下出现无法解释的行为需要有人持续负责。