Redis缓存双写

引入

当有十万人同时刷新页面MySQL会直接挂掉,这个时候我们想到了Redis可以轻松抗住压力那么,我们能不能通过Redis来优化一下。

核心思路

我们先去Redis找数据找到了返回,找不到再去MySQL找,增删改则是先改MySQL的数据,然后删除Redis的缓存,下次读取时去DB拉最新的数据

Redis 的“特殊错误”:redis.Nil

在 Go 的 go-redis 库设计哲学中:

  • 网络错误、连接超时、Redis 挂了 -> 这些是真正的 Error
  • Key 不存在 -> 这在业务上不算错,但在 Redis 协议层面,这叫“空回复”。Go 把它封装成了一个特定的错误对象 redis.Nil

所以标准的 Go Redis 查数模板永远是三段式:

 val, err := rdb.Get(ctx, key).Result()
 ​
 if err == redis.Nil {
    // 1. 正常情况:缓存没命中 -> 去查 DB
 } else if err != nil {
    // 2. 异常情况:Redis 挂了 -> 报警,或者硬着头皮查 DB
 } else {
    // 3. 正常情况:缓存命中 -> 返回 val
 }

代码实现

 package main
 ​
 import (
     "context"
     "encoding/json"
     "fmt"
     "net/http"
     "time"
 ​
     "github.com/gin-gonic/gin"
     "github.com/redis/go-redis/v9"
     "gorm.io/driver/mysql"
     "gorm.io/gorm"
 )
 ​
 var (
     DB  *gorm.DB
     RDB *redis.Client
     ctx = context.Background()
 )
 ​
 type User struct {
     gorm.Model
     Name string `json:"name"`
     Age  int    `json:"age"`
 }
 ​
 func initData() {
     dsn := "root:123456@tcp(127.0.0.1:3306)/gin_rank?charset=utf8mb4&parseTime=True&loc=Local"
     var err error
     DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
     if err != nil {
         panic(err)
     }
     DB.AutoMigrate(&User{})
     RDB = redis.NewClient(&redis.Options{
         Addr:     "localhost:6379",
         Password: "",
         DB:       1,
     })
     fmt.Println("MySQl & Redis connect successfully")
 }
 ​
 func GetUserWithCache(c *gin.Context) {
     id := c.Param("id")
     key := "user:" + id
     val, err := RDB.Get(ctx, key).Result()
     if err == nil {
         var user User
         json.Unmarshal([]byte(val), &user)
         c.JSON(http.StatusOK, gin.H{
             "source": "redis",
             "data":   user,
         })
         return
     } else if err != redis.Nil {
         fmt.Println("Redis err:", err)
     }
     var user User
     if err := DB.First(&user, id).Error; err != nil {
         c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
         return
     }
     jsonBytes, _ := json.Marshal(user)
     RDB.Set(ctx, key, jsonBytes, time.Hour*1)
     c.JSON(http.StatusOK, gin.H{
         "source": "mysql",
         "data":   user,
     })
 }
 func UpdateUserWithCache(c *gin.Context) {
     id := c.Param("id")
     var input User
     c.ShouldBindJSON(&input)
     DB.Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
         "name": input.Name,
         "age":  input.Age,
     })
     key := "user:" + id
     RDB.Del(ctx, key)
     c.JSON(http.StatusOK, gin.H{"msg": "update successfully,cache has deleted"})
 }
 func main() {
     initData()
 ​
     r := gin.Default()
     r.GET("/users/:id", GetUserWithCache)
     r.PUT("/users/:id", UpdateUserWithCache)
     /* 为了方便测试,加个创建接口*/
     r.POST("/users", func(c *gin.Context) {
         user := User{Name: "RedisMaster", Age: 99}
         DB.Create(&user)
         c.JSON(200, user)
     })
 ​
     r.Run(":8080")
 }

设计哲学

1.Cache-Aside Pattern (旁路缓存模式)

核心思想是 “懒加载 (Lazy Loading)”,我们不主动把数据推给 Redis,只有当用户来读的时候,发现 Redis 没有,才去 DB 查并填入 Redis,这样节省内存。只有经常被查的数据才会留在 Redis 里,不常查的数据自然过期消失。

2.数据一致性:删除优于更新

注意 UpdateUserWithCache 里的逻辑:我们改完 DB 后,直接 DEL 删掉 Redis Key,而不是去 SET 更新它。

  • 并发环境下,计算新值并更新缓存极其容易出错(脏读),因为网络延迟,不能确保Redis早于数据库更新
  • 直接删除简单粗暴且有效。删了缓存,下次读取必然回源(Go Back to DB),天然保证拿到的肯定是新的。

缓存三大问题

缓存穿透

短时间内收到大量无意义的查询信息,比如说查询id为-1的数据,Redis查不到数据库也没有,所以也没有回写缓存,这时候再次查询,还是穿透缓存直击数据库,导致数据库崩溃

解决方案

缓存空值,如果MySQL查不到,就缓存一个空值,并且缓存较短时间。

 if err == gorm.ErrRecordNotFound {
         RDB.Set(ctx, key, "NULL", 5*time.Minute)
         c.JSON(404, gin.H{"error": "用户不存在(已缓存空值)"})
         return
    }

缓存击穿

某条热点新闻,在缓存刚好过期的一毫秒,大量请求发来,但是Redis没有数据,大量请求直接访问数据库,数据库,卒。

解决方案

发现Redis没有数据的时候,不要所有人去查,只要抢到的查并回填,其他的waiting。

 // Do 方法确保:针对同一个 key,同一时刻只有一个协程在执行 func 里面的代码
     // v 是返回值,err 是错误,shared 表示结果是否被共享了
     v, err, shared := g.Do(key, func() (interface{}, error) {
         fmt.Println("正在去 MySQL 查询数据 (高并发下只打一次)...")
         
         var user User
         if err := DB.First(&user, id).Error; err != nil {
             return nil, err
        }
         jsonBytes, _ := json.Marshal(user)
         RDB.Set(ctx, key, jsonBytes, time.Hour*1)
         
         return user, nil
    })
  if err != nil {
         c.JSON(404, gin.H{"error": "用户不存在"})
         return
    }
     user := v.(User)
     c.JSON(200, gin.H{
         "data": user,
         "shared": shared,
    })
 }

内存雪崩

缓存设置的太规律了,大量商品过期时间相同,那么大量的key同一时间消失,SQL接收到大量请求,曼波OUT。

解决方案:

随机过期时间,比如说1h+rand(1-10min)

 jitter := time.Duration(rand.Intn(600)) * time.Second 
 RDB.Set(ctx, key, jsonBytes, time.Hour * 1 + jitter)
暂无评论

发送评论 编辑评论


				
上一篇
下一篇