项目背景
某内容平台的「内容详情接口」在晚高峰承载着点赞、阅读计数、推荐位拉取等多路请求,历史是用 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.NumCPU、MinIdleConns 预热 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%