开发者用Claude Code配对编程分析checkout API间歇性高延迟,发现两处隐藏在「数据库嫌疑」背后的错误,最终将p99从2.1s降至900ms。
几周前,我们的 checkout API 在"慢接口"仪表盘上出现了。不是宕机,也不是报错——只是变慢了。p50 还好,维持在 180ms,但 p99 在大约一个月内悄悄爬升到了 2.1 秒。直到有客服工单提到"结账有时候感觉卡卡的",才被人注意到。
这个"有时候"才是烦人的地方。间歇性的尾延迟是软件调试中最棘手的问题之一,因为:
在实际动手之前,我们最先尝试的是给怀疑的列加了个数据库索引。结果 p50 只少了约 40ms,p99 纹丝不动。这通常说明你在解决错误层次的问题——如果痛点在尾延迟,那修复方案必须针对触发尾延迟的请求到底做了什么不同的事,而不是去优化平均情况的查询计划。
我平时会花一天加日志、部署、等待流量、看链路追踪。这次我决定把 Claude Code 当成一个真正的性能分析搭档来用,而不是仅仅当成写代码的工具——喂给它真实数据,让它形成假设,让它写出验证每个假设的插桩代码。
第一步是从链路追踪后端导出一批慢请求样本(p95+),以 JSON 格式连同接口源码一起交给 Claude Code:
Here are 40 traces where checkout took >1.5s. Here's the handler code.
Find the pattern — what do the slow ones have in common that the fast ones don't?
不到几分钟,它就标记出了一个我漏掉的东西:每条慢 trace 的 line_items 数量都超过了 12。超过一打商品的订单,总是那批超过 1.5 秒的。小订单始终很快。
这是一个比"数据库慢了"好得多的切入点。
Claude Code 写了一个快速的种子脚本,生成不同 line_items 数量(1、5、12、25、50)的订单,并在 checkout handler 外面套了一个小型的基准测试工具:
import time
def bench_checkout(order):
start = time.perf_counter()
process_checkout(order)
return time.perf_counter() - start
for count in [1, 5, 12, 25, 50]:
order = make_order(line_items=count)
elapsed = bench_checkout(order)
print(f"{count} items: {elapsed*1000:.1f}ms")
输出让问题立刻暴露了:
1 items: 42.1ms
5 items: 58.3ms
12 items: 210.4ms
25 items: 980.7ms
50 items: 2340.1ms
这不是线性增长——是二次方的。checkout 路径里有什么东西随着商品数量的平方而非线性增长。
我让 Claude Code 追踪 process_checkout 并标记所有对 line_items 嵌套迭代的地方。它大约一分钟就找到了罪魁祸首——一个折扣应用函数,对每个商品行都要重新扫描整个商品列表来检查是否有捆绑折扣:
# The bug: O(n²) — for each item, rescan all items
def apply_bundle_discounts(line_items):
for item in line_items:
for other in line_items:
if is_bundle_pair(item, other):
item.discount += bundle_discount(item, other)
return line_items
过去平均订单只有 3-4 件商品时这没问题。没人碰过这个函数好几个月了——它只是随着我们两个月前上线的"批量订单"功能带来的平均购物车大小增长而悄悄恶化。这是一个典型的系统某部分的变化(批量订单)暴露了完全无关部分(折扣逻辑)中潜伏 bug 的案例,没人想过要重新检查。
修复方案是把商品按 is_bundle_pair 实际检查的属性预先建立索引,这样每个商品做一次查找而不是全量重新扫描:
# O(n) — group once, then look up
def apply_bundle_discounts(line_items):
groups = index_by_bundle_key(line_items)
for item in line_items:
for other in groups.get(item.bundle_key, []):
if other is not item:
item.discount += bundle_discount(item, other)
return line_items
这就是有 agent 来做这件事真正值回票价的地方。我没有在找到这一个修复后就停下,而是让它在代码库里搜索相同形状的 bug——对同一个集合的嵌套循环:
Search the codebase for any function with a nested loop over the same
list variable (for x in items: for y in items:). Flag each one and
tell me if it looks intentional or accidental.
它找到了另外三处。其中两处确实没问题(小而有界的列表,比如 3 项配送选项列表)。有一处是库存预留路径中的第二个真实 bug,但因为还没被足够大的订单触发过所以没在 trace 里出现过——一颗等待引爆的地雷。我也把它修了。
这一步让我印象深刻的不是 agent 找到了 bug——grep 也能找到嵌套循环。真正有价值的是它对每个命中都给出了通俗易懂的说明,包括那两个假阳性,这样我就不必手动为每个匹配重新推导"这实际上是有界的吗"。这种分类筛选步骤通常是枯燥的部分,也是让人干脆跳过搜索的原因。
在收工之前,我重新跑了基准测试工具,也用一批真实的慢 trace 订单在修复后的代码上回放了一遍:
1 items: 41.8ms
5 items: 54.2ms
12 items: 71.6ms
25 items: 94.3ms
50 items: 138.9ms
50 件商品的订单从 2.3 秒降到了 139 毫秒。部署后,checkout 接口的 p99 在一天内稳定在 860ms——剩下的尾延迟大部分是来自支付提供商网络的正常延迟,不是我们的代码。
"肯定是数据库的问题"是一个假设,不是诊断。我已经记不清这句话有多少次被证明是错的了。先拉取真实的 trace 数据,让数据来替你挑选假设。
给 agent 喂真实的 trace 数据比用文字描述问题强得多。当我给 Claude Code 的是真实的慢 trace 而不是我自己总结的("结账有时候卡"),它找到 line_items 相关性的速度比我自己盯着同样数据看还要快。
二次方 bug 会藏在显眼的地方,直到你的数据形态发生变化。这个函数"没事"了两年,因为没人有过 50 件商品的购物车。一个完全无关的功能(批量订单)暴露了它。性能 bug 往往是潜伏的,不是新产生的。
一旦找到一个 bug 模式的实例,立刻搜索它的同类。库存预留中第二个嵌套循环 bug 就会是下个月的故障。模式匹配整个代码库正是 agent 擅长而我擅长(我在看完第三个文件之后就烦了)的枯燥、机械化搜索。
修复前后都要跑基准测试,用同一套工具。这样才能自信地说 p99 真的移动了,而不是靠猜。
在错误的列上加索引只是把瓶颈移走了,并没移除它。我们第一次那个"数据库形状"的修复尝试几乎没动 p99,因为实际成本在应用代码里,不在查询里。如果一个"修复"只移动了 p50,要小心——你可能只是在治标。
我正在把"每次修复后搜索同类 bug"这一步变成习惯而不是一次性的事——成本很低,而且已经抓住了一颗实际存在的地雷。下一个要做的就是对我们的搜索接口做同样的性能分析,它有类似的尾延迟投诉。
如果你也有一个"有时候慢"的接口而没人能定位,先拉取真实的慢 trace 再猜测。好奇大家都有什么二次方 bug 的故事——写在评论区吧。
如果这篇文章有用,在 Dev.to 上关注我——我定期写这类构建日志。