概述
一个接口在 10 行数据时很快,在 500 行时却可能崩溃,因为查询条数随数据量线性增长而不是保持常量,这正是 N+1 模式。只用一两条测试数据的单元测试完全看不到它。查询预算把这个问题变成可测试的硬性属性:无论返回多少行,一个请求最多执行 N 条语句。
步骤
- 为连接池加计数包装,测试通过它统计语句条数:
export function countedPool(pool) {
let executed = 0;
const query = pool.query.bind(pool);
pool.query = (...args) => { executed += 1; return query(...args); };
return { pool, reset: () => { executed = 0; }, count: () => executed };
}
- 分别用 10 行与 50 行数据测量。健康的列表接口在预加载后稳定在 2 到 3 条;存在 N+1 时会增长到 50 条以上。
- 把预算写成断言,并在失败信息中带上实际条数与数据规模:
assert.ok(counted.count() <= 5, `query budget exceeded: ${counted.count()}`);
- 在生产请求中通过
AsyncLocalStorage记录每个请求的语句条数并写入日志字段,便于仪表盘与慢请求排查。
验证
修复前 50 行订单应产生约 53 条语句,加上 include 或批量查询后应降为 3 条,测试通过。故意移除预加载,测试应立即失败并打印 “query budget exceeded: 53”。随后查看请求日志,确认 dbQueries 字段与测试计数一致。
注意事项
- 语句条数不等于开销,十条廉价查询可能优于一条昂贵连接,预算应与延迟、执行计划一起看。
- 只有经过被包装连接池的查询会被统计,直接新建 Client 或走外部缓存的路径不可见。
- 预算过紧会阻碍正常功能,应按接口分别设定,并在评审中确认变更原因。
- 导出类接口应按页或按批设置预算,否则单行查询的回归仍可能被总数掩盖。
- 不要为了让 CI 变绿而随意上调预算,先弄清楚新增语句来自哪里。