接口从 200ms 涨到 800ms,监控上没有报错,CPU 也不高。这种问题最容易让人凭直觉改代码,但真正有效的做法只有一个:先量化,再定位。

这篇记录一次典型的接口性能排查过程。

现象:慢,但不知道慢在哪

一个商品详情接口,平均耗时 800ms,P99 超过 1.5s。调用方开始超时重试,数据库连接数随之上涨。

先别急着看业务代码。用 curl 打几次,把时间拆开:

curl -o /dev/null -s -w \
'dns: %{time_namelookup}s\ntcp: %{time_connect}s\ntls: %{time_appconnect}s\nttfb: %{time_starttransfer}s\ntotal: %{time_total}s\n' \
'http://127.0.0.1:8080/api/products/42'

结果很稳定:网络和 TLS 都正常,时间几乎全部花在服务端处理上。问题被缩小到了应用内部。

第一步:让调用链说话

给接口加上分段计时,或者直接看 APM 的 span。这个案例里,一次请求里出现了 1 次主查询 + 3 次关联查询 + 1 次远程调用,其中三次关联查询是串行的。

伪代码大概是:

product = repo.get_product(product_id)
category = repo.get_category(product.category_id) # 30ms
shop = repo.get_shop(product.shop_id) # 40ms
stock = client.get_stock(product.sku) # 120ms
reviews = repo.get_review_summary(product_id) # 80ms

单看每一步都不算慢,但串起来、再乘以并发,就成了瓶颈。

第二步:把串行变并行

没有依赖关系的查询,没有必要排队。用线程池或异步任务把三次独立调用并发出去:

with ThreadPoolExecutor(max_workers=3) as pool:
category_f = pool.submit(repo.get_category, product.category_id)
shop_f = pool.submit(repo.get_shop, product.shop_id)
stock_f = pool.submit(client.get_stock, product.sku)

category, shop, stock = category_f.result(), shop_f.result(), stock_f.result()

耗时从相加变成取最大值,理论收益大约是 2/3。但要给线程池设置合理的队列和超时,避免把下游打垮。

第三步:检查 SQL 和索引

并发解决的是等待,数据库本身的慢查询还得单独处理。打开慢查询日志后,发现关联查询里有一张表走了全表扫描。

-- 优化前:status 区分度很低,优化器放弃索引
SELECT * FROM product_review WHERE product_id = ? AND status = 1;

-- 建立组合索引,并只取需要的列
CREATE INDEX idx_review_product_status ON product_review (product_id, status);

两个原则:

  1. 组合索引的顺序:区分度高的列放前面,但也要结合查询模式。
  2. 避免 SELECT *:减少回表、网络传输和序列化成本。

第四步:给热点数据加缓存

商品详情是典型读多写少。把商品基础信息、店铺信息和评论摘要放进 Redis,设置 5 分钟过期,并在更新时主动删除缓存。

cache_key = f"product:detail:{product_id}"
cached = redis.get(cache_key)
if cached:
return json.loads(cached)

data = build_product_detail(product_id)
redis.setex(cache_key, 300, json.dumps(data))
return data

缓存的关键不是“加上”,而是想清楚:什么时候失效、缓存穿透怎么办、数据不一致能接受多久。

结果

指标 优化前 优化后
平均耗时 800ms 90ms
P99 1.5s 210ms
数据库 QPS 偏高 下降约 60%

复盘

这次排查没有用到什么高深技巧,关键步骤是:

  1. 先把“慢”拆成可度量的时间段;
  2. 用调用链找到真正的等待点;
  3. 能并行的并行,能缓存的缓存,该建索引的建索引;
  4. 用数据验证,而不是凭感觉。

性能优化不是把代码写得更复杂,而是让每一次等待都有明确的理由。