引入
当有十万人同时刷新页面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)










