200ms的API,50万用户,凌晨零点准时挂

2025年双十一前夕,我们做了一个压力测试。核心API —— 用户商品推荐接口 —— 在并发5000的时候,P99延迟从200ms飙升到了3.2秒。数据库CPU飙到95%,连接池耗尽,整个服务濒临崩溃。

双十一当天预计并发是15000。我们只有两周时间。

下面是我和团队从200ms优化到2ms的完整过程,每一步都附了实测数据。

第一步:找到瓶颈,别猜

性能优化的第一铁律:不要猜,要测。

我们用pprof和Pyroscope做了完整的热点分析:

  • CPU Profile:JSON序列化占28%,数据库查询占22%,ORM开销占15%
  • 内存 Profile:数据库查询结果的反序列化分配了大量临时对象
  • 数据库慢查询日志:核心查询的N+1问题严重,一个请求触发了12次数据库查询

结论:瓶颈在三个地方 —— 数据库查询次数太多、数据序列化太慢、没有缓存。

第二步:消灭N+1(200ms → 80ms)

推荐接口的逻辑是:查出用户画像 → 根据画像查出候选商品ID → 逐个查商品详情。最后一步是典型的N+1问题:一次查出50个商品ID,然后循环调用50次SELECT * FROM products WHERE id = ?

优化方法:用WHERE id IN (?)一次查询所有商品,然后在应用层组装。

// 优化前:50次数据库查询
for _, id := range productIDs {
    product, _ := db.GetProduct(ctx, id)
    products = append(products, product)
}

// 优化后:1次数据库查询
products, _ := db.GetProductsByIDs(ctx, productIDs)
// 在内存中按ID索引
productMap := make(map[string]Product, len(products))
for _, p := range products {
    productMap[p.ID] = p
}

效果:数据库查询次数从12次降到3次,API延迟从200ms降到80ms。

第三步:加缓存,但别全加(80ms → 15ms)

接下来的优化方向是缓存。但缓存不能乱加,三个原则:

缓存热点数据,不是所有数据。 我们用Redis的LRU策略,只缓存访问频率最高的20%商品(它们贡献了80%的流量)。缓存命中率达到了85%,内存占用只有2GB。

缓存要设置合理的TTL。 商品信息变化频率低,我们设了5分钟TTL。用户画像变化频率高,设了1分钟TTL。不同的TTL避免了"缓存一致性问题"。

缓存穿透要用布隆过滤器。 对于不存在的商品ID,Redis返回空,请求穿透到数据库。我们用布隆过滤器在Redis前拦截了99%的无效请求。

效果:缓存命中率85%,API延迟从80ms降到15ms。

第四步:数据库查询优化(15ms → 5ms)

加了缓存后,剩余的15ms主要消耗在数据库查询上。我们做了以下优化:

加索引,但别乱加。EXPLAIN ANALYZE分析慢查询,只给真正需要的列加索引。优化后,关键查询从全表扫描变成了索引扫描,耗时从12ms降到2ms。

用连接池,但别用默认配置。 Go的database/sql默认连接池大小是0(无限制),但PostgreSQL的推荐连接数是CPU核数*2到4。我们设了max_open_conns=20,避免了连接过多导致的上下文切换。

用预编译语句。 Prepare + QueryRow替代QueryRow,避免了每次查询的SQL解析开销。

效果:数据库查询耗时从12ms降到3ms,API延迟从15ms降到5ms。

第五步:替换JSON序列化库(5ms → 2ms)

pprof显示JSON序列化仍然占了总时间的25%。Go标准库的encoding/json通过反射实现,性能一般。

测试了三个替代方案:

  • encoding/json:序列化50个商品对象耗时3.2ms
  • jsoniter:1.8ms
  • sonic(字节跳动开源):0.8ms

Sonic通过JIT编译和SIMD加速,将序列化时间从3.2ms降到了0.8ms。切换成本极低,只需要改import路径。

效果:API延迟从5ms降到2ms。

第六步:内存分配优化(2ms → 1.5ms)

到这一步,性能瓶颈已经变成了内存分配。每次请求都要分配大量的临时对象(商品结构体、JSON序列化缓冲区),导致GC压力。

优化手段:

  • sync.Pool对象复用。 把商品列表的slice和JSON序列化buffer放进sync.Pool,避免每次请求重新分配
  • 预分配slice容量。 make([]Product, 0, 50)替代var products []Product,避免了slice扩容时的多次分配
  • 减少指针。 把商品结构体中的指针字段改为值字段,减少堆分配

效果:GC停顿时间从15ms降到3ms,API延迟从2ms降到1.5ms。

最终结果

优化阶段延迟主要手段
原始200ms
消灭N+180ms批量查询
加缓存15msRedis + 布隆过滤器
数据库优化5ms索引 + 连接池 + Prepare
序列化优化2msSonic
内存优化1.5mssync.Pool + 预分配

双十一当天,峰值并发12000,API P99延迟稳定在3ms以内。没有加一台机器。

最后说一句

性能优化不是玄学,也不是"调参数"。它是一个系统化的工程:测量 → 定位瓶颈 → 优化最热路径 → 再测量 → 确认效果 → 找下一个瓶颈。

如果你的API延迟在200ms,别上来就加机器或换微服务。先找出瓶颈,90%的情况下,问题在一个慢查询或一个N+1上。