我要提问
PROJECT CASE / 002

高并发接口服务后端实战

Go + Gin + Redis,万级 QPS 接口的架构设计与压测调优全链路。

高并发接口服务后端实战:Go + Redis 万级 QPS 架构调优

高并发接口服务后端实战项目示意图

项目背景

某内容平台的「内容详情接口」在晚高峰承载着点赞、阅读计数、推荐位拉取等多路请求,历史是用 PHP 单体 + MySQL 直查实现的。随着日活翻倍,晚高峰 QPS 飙到 8000+ 时数据库连接池被打满,接口 P99 一度突破 2s,甚至触发雪崩限流。运营侧的诉求很直接:支撑未来 3 万 QPS,P99 控制在 200ms 内,且不能靠堆机器硬扛。

团队最终决定用 Go 重写核心接口层,搭配 Redis 做多级缓存,把写流量异步化到 Kafka 由消费者落库。目标三件事:扛住峰值、隔离数据库、让计数与详情解耦。本拆解聚焦核心接口的架构与调优过程。

选 Go 而不是继续 PHP 的关键原因:协程调度成本低,单机可撑住数万并发连接;编译成单二进制后部署与回滚都轻;团队对 Gin 生态熟悉,迁移成本可控。

架构设计

整体分为接入层、服务层、缓存层、异步层四层。接入层用 Gin 处理 HTTP 与参数校验;服务层是纯业务逻辑,不直接碰 DB;缓存层用 Redis 做热点内容的本地+远端两级缓存;写流量(点赞、阅读数)全部走 Kafka 异步落库,DB 只承接读未命中与批量消费。

请求链路:
Client → Gin(API 网关/限流) → Service → [L1 本地缓存] → [L2 Redis] → DB
                                  ↓ miss
                              Kafka(异步写) → Consumer → MySQL

关键设计:读路径全程不阻塞在 DB 上,L1 用 sync.Map + 短 TTL 承接超热点,L2 Redis 兜底;写路径用「先扣减 Redis 计数 + 投递 Kafka」的合并写策略,消费端按 content_id 分桶聚合并落库,把万级写压成百级 SQL。

核心实现

1)两级缓存:L1 进程内缓存命中零网络开销,但要多实例间做好失效;L2 Redis 跨实例共享。读取顺序 L1 → L2 → DB,回源时用 singleflight 合并并发回源请求,防止缓存击穿。

// cache/two_level.go —— 两级缓存 + singleflight 防击穿
type Cache struct {
    l1   *sync.Map        // 进程内短 TTL
    rdb  *redis.Client
    g    singleflight.Group
    load func(ctx context.Context, key string) (string, error)
}

func (c *Cache) Get(ctx context.Context, key string) (string, error) {
    if v, ok := c.l1.Load(key); ok { return v.(string), nil }      // L1
    if v, err := c.rdb.Get(ctx, key).Result(); err == nil {        // L2
        c.l1.Store(key, v)
        return v, nil
    }
    // 合并并发回源
    v, err, _ := c.g.Do(key, func() (any, error) {
        val, err := c.load(ctx, key)
        if err == nil {
            c.rdb.Set(ctx, key, val, 5*time.Minute)
            c.l1.Store(key, val)
        }
        return val, err
    })
    return v.(string), err
}

2)限流:在 Gin 中间件层用 Redis + Lua 实现分布式令牌桶,按 uid 维度限流,避免单用户刷接口。Lua 脚本保证「取令牌」是原子的。

// middleware/rate_limit.go —— Redis 令牌桶限流
const luaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * rate)
if tokens < 1 then return 0 end
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, 60)
return 1`

func RateLimit(cap, rate float64) gin.HandlerFunc {
    return func(c *gin.Context) {
        key := "rl:" + c.GetString("uid")
        now := float64(time.Now().UnixNano()) / 1e9
        ok, err := rdb.Eval(ctx, luaScript, []string{key}, cap, rate, now).Int()
        if err != nil || ok == 0 {
            c.AbortWithStatusJSON(429, gin.H{"msg": "too many requests"})
            return
        }
        c.Next()
    }
}

3)异步化写:点赞、阅读计数先 INCR Redis 拿到实时值返回前端,同时投递 Kafka;消费端按 content_id 聚合,每 2 秒或满 500 条批量落库一次,大幅降低 DB 写压力。

  • 读路径平均 RT:430ms → 38ms(L1 命中率 68%)
  • DB 写 QPS:12000 → 240(批量聚合后)
  • P99 延迟:2.1s → 160ms
  • 单机承载:从 1200 QPS 提到 9000 QPS

技术难点

难点一:缓存一致性。计数走 Redis 后,Redis 与 MySQL 会短暂不一致。我们的策略是「读以 Redis 为准,写 Redis 成功即返回;MySQL 由消费端最终一致」,对点赞这种弱一致场景足够。对金额类强一致字段单独走同步双写 + 对账,不进异步通道。

难点二:热点 Key。某爆款内容上来就几万 QPS 打到单个 Redis 分片。解决方式是在 L1 进程内缓存层之上再加一层「分片 Key」——把 content:{id} 拆成 content:{id}:0~9 十个子 Key,读时随机选一个,写时全部更新,把热点分散到多个分片。

// cache/hotkey.go —— 热点 Key 分片打散
func shardKey(key string, shards int) []string {
    out := make([]string, shards)
    for i := 0; i < shards; i++ {
        out[i] = fmt.Sprintf("%s:%d", key, i)
    }
    return out
}
// 读:随机选一片;写:广播更新所有片
func (c *Cache) GetHot(ctx context.Context, key string) (string, error) {
    keys := shardKey(key, 10)
    pick := keys[rand.Intn(len(keys))]
    return c.Get(ctx, pick)
}

难点三:Goroutine 泄漏。早期版本在异步投递 Kafka 时用 go func() 裸起协程,压测时协程数一路涨到几十万不释放。排查发现是 Kafka 生产端阻塞在缓冲队列,外层协程全部卡住。改用带 buffer 的 channel + 固定数量 worker 消费投递,并加 context.WithTimeout 兜底,协程数稳定在 worker 数量级。

踩坑复盘

坑 1:Redis 连接池配置过小。默认 PoolSize=10 在高并发下直接打爆,表现为 P99 偶发尖刺。压测时把 PoolSize 调到 4 * runtime.NumCPUMinIdleConns 预热 20 后尖刺消失。教训:Redis 客户端连接池参数必须按峰值压测校准,不能默认。

坑 2:JSON 序列化吃 CPU。压测时 pprof 显示 encoding/json 占了 35% CPU。换成 json-iterator/go 后序列化耗时降到原来的 1/3。对热点接口,序列化库的选型也是实打实的性能收益。

坑 3:context 没透传导致缓存回源超时无感知。早期 load 函数用的是 context.Background(),DB 慢查询时协程长时间挂着,最终 OOM。统一改成透传请求 context 并加超时后,慢回源会被及时取消。教训:所有 IO 调用必须挂请求级 context。

高并发的本质不是堆技巧,而是「读不碰 DB、写不阻塞、热点打散、连接池校准」这四件基础事做扎实。

项目总结

重构上线后,单接口在 4 实例下稳定承载 3.2 万 QPS,P99 160ms,DB 写 QPS 从 1.2 万降到 240,机器成本反而比原 PHP 方案省了 40%。整个项目最大的认知更新是:高并发不是某一处神仙优化,而是把读路径、写路径、热点、连接池四条主线分别做对,再靠压测逐条验证。

  • 峰值 QPS:8000 → 32000(4 实例)
  • P99 延迟:2.1s → 160ms
  • DB 写 QPS:12000 → 240
  • 机器成本:相比原方案下降 40%
返回项目列表