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,上文的索引形状、稳定分页、批量写与原子更新,大概率能直接复用。剩下的,交给你的业务语义与数据分布来决定。