← 返回博客
go2026-09-12 21:16:473 分钟 · 748 0

Gin + Redis 缓存:把热点查询从毫秒压到微秒

用 go-redis 接 Redis,落地 Cache-Aside 模式,做详情与列表缓存、写时失效、singleflight 防击穿,把热点读请求挡在数据库前。

#gin#Redis#缓存

前 8 篇的接口每次都打到 MySQL。读多写少的场景,同样的数据反复查库是浪费。Redis 把数据放内存,一次 GET 亚毫秒。这一篇把缓存接进 Gin,重点是缓存和数据库怎么配合才不会读出脏数据。

1. 连接 Redis

github.com/redis/go-redis/v9,连接池配好,否则高并发下反复建连扛不住:

import "github.com/redis/go-redis/v9"

var rdb *redis.Client

func InitRedis(addr, pass string) {
    rdb = redis.NewClient(&redis.Options{
        Addr:         addr,
        Password:     pass,
        DB:           0,
        PoolSize:     50,
        MinIdleConns: 10,
    })
}

func main() {
    InitRedis("127.0.0.1:6379", "")
    if err := rdb.Ping(context.Background()).Err(); err != nil {
        log.Fatalf("redis 连不上: %v", err)
    }
}

PoolSize 是最大连接数,按业务并发调。启动先 Ping 一次,连不上直接报错退出,别等第一个请求才暴露。

2. Cache-Aside:读时回填,写时删缓存

最稳的模式:读的时候先看缓存,没有再查库并把结果写回;写的时候删缓存,下次读自然回填新值。不更新缓存,避免并发下缓存和库对不上。

func (h *ArticleHandler) Get(c *gin.Context) {
    id := c.Param("id")
    ctx := c.Request.Context()
    key := "article:" + id

    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        var a Article
        json.Unmarshal([]byte(val), &a)
        OK(c, a)
        return
    }
    if err != redis.Nil { // Redis 真挂了,记日志,降级查库
        log.Printf("redis get err: %v", err)
    }

    a, err := h.repo.FindByID(id)
    if err != nil {
        FailErr(c, err)
        return
    }
    if b, _ := json.Marshal(a); b != nil {
        rdb.Set(ctx, key, b, 10*time.Minute)
    }
    OK(c, a)
}

区分 redis.Nil(缓存未命中,正常流程)和其他错误(Redis 故障)。故障不能让请求失败,降级走查库,用户无感。

3. 写时删缓存

更新接口在落库后删缓存,而不是重写缓存:

func (h *ArticleHandler) Update(c *gin.Context) {
    var a Article
    if err := c.ShouldBindJSON(&a); err != nil {
        Fail(c, CodeParamErr, err.Error())
        return
    }
    if err := h.repo.Update(&a); err != nil {
        FailErr(c, err)
        return
    }
    rdb.Del(c.Request.Context(), "article:"+c.Param("id"))
    OK(c, a)
}

删缓存省一次序列化,也避开"更新缓存和新写入库"之间的竞态。代价是删后第一次读会穿透一次,可接受。

4. 防击穿:singleflight 合并并发

热点 key 过期的瞬间,成百上千请求同时发现缓存没值,全打向数据库,可能把库打挂。用 golang.org/x/sync/singleflight 把同一 key 的并发请求合并成一次查库:

var group singleflight.Group

func (h *ArticleHandler) Get(c *gin.Context) {
    id := c.Param("id")
    ctx := c.Request.Context()
    key := "article:" + id

    if _, err := rdb.Get(ctx, key).Result(); err == redis.Nil {
        v, _, _ := group.Do(key, func() (interface{}, error) {
            a, e := h.repo.FindByID(id)
            if e != nil {
                return nil, e
            }
            if b, _ := json.Marshal(a); b != nil {
                rdb.Set(ctx, key, b, 10*time.Minute)
            }
            return a, nil
        })
        if err != nil {
            FailErr(c, err)
            return
        }
        OK(c, v)
        return
    }
    // 命中缓存走原逻辑
    ...
}

group.Do 保证同一 key 在同一时刻只有一个执行体在查库,其余直接拿它的结果。

5. 列表缓存别裸存整页

列表带分页和筛选,缓存 key 要把参数拼进去:articles:page:1:size:10:tag:go。TTL 设短(1 分钟)或写操作后清前缀。清前缀用 SCAN 遍历删除,别用 KEYS,后者在生产阻塞主线程:

func delByPrefix(ctx context.Context, prefix string) {
    iter := rdb.Scan(ctx, 0, prefix+"*", 0).Iterator()
    for iter.Next(ctx) {
        rdb.Del(ctx, iter.Val())
    }
    iter.Close()
}

6. 一个通用只读缓存中间件

对纯 GET 接口做整响应缓存,handler 完全不用改:

func Cache(ttl time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        key := c.Request.Method + ":" + c.Request.URL.RequestURI()
        if v, err := rdb.Get(c, key).Result(); err == nil {
            c.Data(200, "application/json", []byte(v))
            c.Abort()
            return
        }
        c.Next()
        if c.Writer.Status() == 200 {
            rdb.Set(c, key, c.Writer.(*responseSniffer).body(), ttl)
        }
    }
}

生产用要把响应体拷贝出来再写缓存,上面的 responseSniffer 是封装了 bytes.BufferResponseWriter。只挂到确定幂等、无用户态差异的接口上,带登录态、带随机数的接口别挂。

7. 上线前清单

只缓存读多写少的数据,写多的挂缓存反而增加不一致风险。TTL 必须设,否则脏数据常驻。写操作删缓存,不更新缓存。Redis 故障要降级查库,错误别透给用户。删大量 key 用 SCAN 不用 KEYS。热点 key 过期的瞬间用 singleflight 挡并发。

下一篇讲测试,保证加了缓存的 handler 改了也不会悄悄出错。

相关推荐

本文为原创文章,采用CC BY-NC-SA 4.0协议授权,转载请保留署名与原文链接。原文链接:https://www.wxbuluo.com/article/185