前 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.Buffer 的 ResponseWriter。只挂到确定幂等、无用户态差异的接口上,带登录态、带随机数的接口别挂。
7. 上线前清单
只缓存读多写少的数据,写多的挂缓存反而增加不一致风险。TTL 必须设,否则脏数据常驻。写操作删缓存,不更新缓存。Redis 故障要降级查库,错误别透给用户。删大量 key 用 SCAN 不用 KEYS。热点 key 过期的瞬间用 singleflight 挡并发。
下一篇讲测试,保证加了缓存的 handler 改了也不会悄悄出错。