Go Gin × MongoDB 性能优化记:把 p99 从 480ms 拉到 92ms
这是一篇来自独立开发者后端的一线笔记:Molived(沉浸式观影榜单)与「不背成语」两个 App 共用同一套 Go + Gin + MongoDB 的 API。在一次晚高峰的抖动后,我们给这套系统做了系统性体检与优化。本文复盘方法、过程与落地方案,避免“经验之谈”的空泛,尽量用可复用的技术细节说话。
0. 故事的起点:晚八点的抖动
晚高峰(20:00–22:00)是 Molived 的自然流量峰值。一次发布后,观察到两条接口 p99 从 ~180ms 升到 480–600ms:
- /v1/movies/hotlist:带排序与筛选的内容列表。
- /v1/idioms/review/next:「不背成语」按 nextReviewAt 拉取下一个待复习批次。
表面看起来是“数据库慢了”。但优化不是猜谜。我们的路线是:先度量 → 再定位 → 小步验证 → 渐进固化。
1. 先立度量与边界:没有观测,优化等于玄学
1.1 指标与追踪
- 延迟分位:p50 / p90 / p95 / p99,接口与下游(Mongo)分别记录。
- 资源指标:连接池(CheckedOut/CheckedIn)、等待队列、CPU/GC、goroutine。
- 追踪:OpenTelemetry(Gin 中间件 + Mongo 驱动 hook)串起一次请求的全链路(反向代理 → Gin → 业务 → Mongo)。
1.2 生产可用的最小观测集
- pprof:只在灰度与紧急定位时启用,避免常开带来的开销。
- Explain Plan:所有慢查询存证(抽样保存计划、扫描量、索引命中)。
用这些,才能将“感觉”变成“证据”。
2. Gin 层:把“便宜的部分”先拿满
2.1 统一超时与取消
请求必须带上 context.WithTimeout,并向下传递到 Mongo。配合网关的重试策略,避免“超时后还在 DB 干活”。
func withTimeout(c *gin.Context, d time.Duration) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
c.Request = c.Request.WithContext(ctx)
return ctx, cancel
}
r := gin.New()
// 顺序:RequestID → 恶意流量过滤/速率 → 恢复/日志 → 超时 → 业务
r.Use(RequestID(), RateLimit(), gin.Recovery(), ServerTimeout(1200*time.Millisecond))
要点:超时与重试是边界,不是优化手段,但没有边界,任何优化都难以稳定。
2.2 JSON 编解码与响应
- 统一使用 json.Encoder 并 SetEscapeHTML(false),减少无意义转义。
- 响应通过 sync.Pool 复用 bytes.Buffer,降低临时分配。
- 压缩(gzip/br)在网关层做,应用层只需设置合适的 Content-Type / Cache-Control / ETag。
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func JSON(c *gin.Context, status int, v any) {
b := bufPool.Get().(*bytes.Buffer)
b.Reset()
defer bufPool.Put(b)
enc := json.NewEncoder(b)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
c.Data(status, "application/json; charset=utf-8", b.Bytes())
}
备注:是否换 jsoniter 不是教条。我们在该场景下的提升不稳定(-2%~+7% 波动),因此保留 stdlib。
2.3 绑定与体积控制
- GET 使用 ShouldBindQuery;POST 使用 BindJSON 但 限制 Body 大小(http.MaxBytesReader)。
- 分页入参做上限保护:pageSize ∈ [1, 100];禁止“全量拉取”。
2.4 连接与服务配置
- 合理设置 ReadHeaderTimeout、IdleTimeout,把“坏连接”尽早清理。
- Gin 中间件精简:每条链路只做一件事;日志聚合到网关,应用只记录结构化关键字段(traceID、userID、uri、p99/p50)。
3. MongoDB:从数据模型到查询路径
3.1 连接池与超时
官方 Go 驱动内置连接池,关键参数:
client, _ := mongo.Connect(ctx, options.Client().
ApplyURI(uri).
SetMaxPoolSize(300). // 上限与业务并发&CPU核数相关,观察等待队列再调
SetMinPoolSize(20).
SetMaxConnIdleTime(60 * time.Second).
SetServerSelectionTimeout(2 * time.Second). // 选主/拓扑超时
SetSocketTimeout(1 * time.Second)) // 单次 I/O 超时
观察点:ConnectionPoolWaitQueueSize 如果出现锯齿,说明池子不够或泄露;先查代码是否忘记消费游标、是否卡在网络层,再盲目加大池子。
3.2 查询与索引设计(两类核心路径)
A. Molived:热榜与筛选列表
接口形态:根据 category/lang 过滤,按 hotScore 或 publishAt 排序,分页返回。
索引(覆盖查询 + 有序遍历):
// 只索引发布中的内容,减小索引集合
db.movies.createIndex(
{ category: 1, lang: 1, hotScore: -1, publishAt: -1, _id: 1 },
{ partialFilterExpression: { status: "published" } }
);
// 覆盖所需字段,响应使用投影
db.movies.find(
{ category: "movie", lang: "zh", status: "published" },
{ projection: { _id: 1, title: 1, cover: 1, hotScore: 1, publishAt: 1 } }
).sort({ hotScore: -1, publishAt: -1 }).limit(20)
注意:
- 排序字段必须在索引里,且方向一致。
- 需稳定分页:二级排序 publishAt + _id,避免“翻页抖动”。
- 复杂筛选尽量编译为前缀范围($gte/$lt),避免 regex 前缀以外的扫描。
B. 不背成语:下一批复习
接口形态:按用户 userId 拉取 nextReviewAt <= now 的若干条,状态需 active。
索引:
db.idioms.createIndex(
{ userId: 1, status: 1, nextReviewAt: 1, _id: 1 },
{ partialFilterExpression: { status: "active" } }
)
查询示例(覆盖 + 限制):
db.idioms.find(
{ userId: U, status: "active", nextReviewAt: { $lte: NOW } },
{ projection: { _id: 1, text: 1, hint: 1, nextReviewAt: 1 } }
).sort({ nextReviewAt: 1, _id: 1 }).limit(30)
效果:Explain 中 IXSCAN → SORT 变为纯 IXSCAN;文档扫描量从数千降至几十。
3.3 写路径:避免读-改-写的热点
- 计数/分数类更新使用 原子操作:$inc、$max、$addToSet。
- 批量写入使用 BulkWrite,减少 RTT:
models := make([]mongo.WriteModel, 0, len(updates))
for _, u := range updates {
m := mongo.NewUpdateOneModel().
SetFilter(bson.M{"_id": u.ID}).
SetUpdate(bson.M{"$inc": bson.M{"reviewCount": 1}}).
SetUpsert(false)
models = append(models, m)
}
res, err := coll.BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false))
- 新增文档用 upsert + $setOnInsert,避免先查后插:
db.users.updateOne(
{ _id: U },
{ $setOnInsert: { createdAt: NOW }, $set: { lastLoginAt: NOW } },
{ upsert: true }
)
3.4 模型层面的“形状”优化
- 控制文档增长:避免把“无限追加”的数组塞进同一文档(单文档 16MB 限制)。时间序列/日志类改用 时间序列集合 或“按月分片”的集合。
- 过期数据自动清理:会话/验证码等使用 TTL 索引。
db.sessions.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
3.5 读写隔离:按接口选择一致性级别
- 列表/非关键统计:readConcern: local + primaryPreferred,降低主节点压力与延迟。
- 用户关键操作(订单/支付/学习进度):writeConcern: majority,必要时在读路径上 afterClusterTime 保证“读到自己的写”。
注意:一致性降级必须标注在接口契约里,避免未来误用。
4. 并发与背压:让系统“稳住手”
4.1 业务级并发闸门
部分接口(例如批量聚合)理论上能开很多 goroutine,但数据库是有限的。为此我们引入加权信号量做“接口粒度的并发上限”。
type gate struct{ sem *semaphore.Weighted }
func (g *gate) Do(ctx context.Context, n int64, fn func(ctx context.Context) error) error {
if err := g.sem.Acquire(ctx, n); err != nil { return err }
defer g.sem.Release(n)
return fn(ctx)
}
var dbGate = &gate{sem: semaphore.NewWeighted(120)} // 根据连接池和观测调参
4.2 去重与批量化
避免 N+1:把同一请求内的多次用户画像查询,收敛成一次 $in 批量查;必要时做 5–10ms 的微批。
5. 可以量化的收益:三条接口的前后对比
数据为我们生产真实流量的近一周指标,应用与数据库与本文配置一致。
| 接口 | 主要改动 | p99(前) | p99(后) | 其他收益 |
|---|---|---|---|---|
| /v1/movies/hotlist | 复合索引(partial 覆盖排序)、稳定分页、响应池化 | 510ms | 118ms | CPU -21%,扫描量 -95% |
| /v1/idioms/review/next | 复合索引(partial)、IXSCAN 代替排序、limit 限制 | 280ms | 72ms | 连接池等待基本消失 |
| /v1/user/record/batch | BulkWrite + 原子操作 + 并发闸门 | 420ms | 156ms | 超时率 0.9%→0.06% |
系统总体 p99 从 ~480ms 降到 ~92ms(晚高峰),误报的“数据库慢”问题消失。
6. 难点与坑
- Explain 假阳性:某些执行计划在“低基数场景”表现很好,但高并发下暴露出锁/内存上的瓶颈 → 必须配合真实流量压测验证。
- 中间件顺序:日志在超时中间件之后可能拿不到完整字段;我们统一把 RequestID 与恢复放最前。
- 游标泄露:错误分支忘记 cur.Close(ctx),连接池等待队列“慢性充血”。上线前加 lint + 单测兜底。
- “万能缓存”误用:把不稳定的读路径全丢给 Redis 不是优化,是掩盖。我们只对 热榜第一页 做 30–60s 软缓存(L1 本地 + ETag),其余保留数据库真相。
7. 可直接复用的片段
7.1 Gin:请求超时中间件
func ServerTimeout(d time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
defer cancel()
c.Request = c.Request.WithContext(ctx)
done := make(chan struct{})
panicChan := make(chan any, 1)
go func() {
defer func() {
if r := recover(); r != nil { panicChan <- r }
close(done)
}()
c.Next()
}()
select {
case <-ctx.Done():
c.AbortWithStatusJSON(http.StatusGatewayTimeout, gin.H{"error": "timeout"})
case p := <-panicChan:
panic(p)
case <-done:
}
}
}
7.2 Mongo:慢查询日志(驱动事件)
cm := &event.CommandMonitor{
Succeeded: func(ctx context.Context, e *event.CommandSucceededEvent) {
if e.Duration > 150*time.Millisecond && e.CommandName == "find" {
slog.Warn("slow mongo",
"cmd", e.CommandName, "ms", e.Duration.Milliseconds(), "db", e.DatabaseName)
}
},
}
client, _ := mongo.Connect(ctx, options.Client().ApplyURI(uri).SetMonitor(cm))
7.3 稳定分页(游标式)
客户端带上 (lastScore, lastID) 作为游标,避免 skip 造成深分页退化。
filter := bson.M{"category": cat, "lang": lang, "status": "published"}
if lastScore != nil {
filter["$or"] = bson.A{
bson.M{"hotScore": bson.M{"$lt": lastScore}},
bson.M{"hotScore": lastScore, "_id": bson.M{"$gt": lastID}},
}
}
opts := options.Find().SetSort(bson.D{{"hotScore", -1}, {"_id", 1}}).SetLimit(20)
8. 落地后的“保养”与基线
- 每周:抽样对关键接口做 Explain 归档,对比索引命中与扫描量。
- 每次需求变更:先画“查询形状”,再建/调索引;不把“能查出来”当作“能跑得快”。
- 阈值告警:连接池等待、p99、超时率三条红线,触发就回滚或限流。
- 基线压测:固定数据规模与并发档位,记录“签名值”(如 /hotlist p99、QPS),作为版本对照。
9. 收尾
这次优化的关键,不在于某个“银弹”,而是把观测、建模、查询路径、并发控制和工程边界一件件对齐。Go + Gin + MongoDB 的组合并不稀奇,但当它服务于清晰的“查询形状”,再配上节制的工程实践,晚高峰就不再是赌运气。
如果你也在做内容列表与学习计划类的 API,上文的索引形状、稳定分页、批量写与原子更新,大概率能直接复用。剩下的,交给你的业务语义与数据分布来决定。