线上又报警了,Redis连接数爆了。你的缓存又挂了?恭喜你,今天这篇文章就是为你准备的。
## 写在前面
上周五晚上,正在家里愉快的打着游戏,手机突然炸了。
「Redis连接数超过上限了!」
「服务响应超时!」
「用户登录全部失败!」
我裤子都来不及穿好,打开电脑就开始排查。一顿操作猛如虎,最后发现原因竟然是——**缓存过期了**。
没错,就因为一批缓存同时过期,导致大量请求直接打到了数据库,数据库表示压力山大,Redis表示连接数不够用,运维表示想杀人。
今天,小龙虾就把这些年踩过的Redis坑,全部分享出来。看完这篇文章,下次线上报警的时候,你至少知道该怎么排查了。
## 坑一:缓存雪崩——你的缓存是商量好一起过期的吗?
### 事故现场
那天是每周例行上线日。凌晨12点,准时发布。发布完成后,所有服务正常,大家各回各家。
凌晨2点,报警了。
Redis连接数飙升,数据库CPU 100%,服务响应时间超过30秒。
我当时就懵了。明明上线前测试得好好的,怎么一到凌晨就炸了?
后来一查,好家伙——**所有缓存的过期时间都是24小时,而上线时间恰好是凌晨12点。**
这意味着,凌晨12点到2点之间,所有缓存陆续过期。大批请求发现缓存没了,直接杀向数据库。数据库表示:我TM何德何能承受这么多请求?
这就是经典的**缓存雪崩**。
### 怎么解决?
**方案一:随机过期时间**
不要让所有缓存一起过期。设置过期时间的时候,加个随机值:
```go
// ❌ 错误示范
expireTime := 24 * time.Hour
// ✅ 正确姿势
expireTime := 24 * time.Hour + rand.Int63n(2*time.Hour)
```
这样缓存会在24-26小时之间随机过期,避免同时失效。
**方案二:永不过期**
对于一些基础数据(比如配置信息、省市区数据),干脆就不设置过期时间。或者用逻辑过期代替物理过期:
```go
type CacheItem struct {
Data interface{}
ExpireAt time.Time
}
```
查的时候判断一下,如果快过期了,异步更新缓存。
**方案三:多级缓存**
不要把所有缓存都放在Redis里。加一层本地缓存(Caffeine、Guava Cache),Redis挂了还有本地缓存能撑一会儿。
## 坑二:缓存击穿——那个男人是谁?
### 事故现场
有一次搞活动,凌晨0点准时开抢。结果0点刚过,Redis表示压力山大,数据库也快扛不住了。
查了一下监控,发现某个热点商品的详情页缓存失效了。然后那1000个用户跟商量好了似的,同时杀向了数据库。
这就是**缓存击穿**——某个热点数据过期的一瞬间,大量请求直接打到了数据库。
### 怎么解决?
**方案一:分布式锁**
缓存失效的时候,只允许一个请求去查数据库,其他请求等着:
```go
func GetProduct(id string) Product {
// 先查缓存
cacheKey := "product:" + id
if val, err := redis.Get(cacheKey); err == nil {
return parseProduct(val)
}
// 缓存没了,加锁去查数据库
lockKey := "lock:product:" + id
if redis.SetNX(lockKey, "1", 10*time.Second) {
defer redis.Del(lockKey)
// 查数据库
product := db.QueryProduct(id)
// 写回缓存
redis.Set(cacheKey, product, 24*time.Hour)
return product
}
// 没拿到锁,等一会儿再试
time.Sleep(100 * time.Millisecond)
return GetProduct(id)
}
```
这样只有一个请求去查数据库,其他请求等着拿结果。
**方案二:逻辑过期**
类似于缓存雪崩的方案,不设置物理过期时间,而是用逻辑过期。热点数据永远不过期,只是定期更新。
## 坑三:缓存穿透——那个男人真的存在吗?
### 事故现场
有段时间,接口老是被恶意请求。请求的都是一些不存在的商品ID,比如 `-1`、`999999999`。
这些ID在数据库里根本不存在,所以Redis里也没有。每次请求都会直接打到数据库。
攻击者一看,哎呦呵,这不错啊,持续请求一些不存在的ID,就能把你的数据库打挂。
这就是**缓存穿透**。
### 怎么解决?
**方案一:空值缓存**
查不到的数据,也在Redis里存一个空值:
```go
product, err := db.QueryProduct(id)
if err == ErrNotFound {
// 查不到?存一个空值,过期时间短一点
redis.SetEX("product:"+id, "NULL", 5*time.Minute)
return nil
}
redis.Set("product:"+id, product, 24*time.Hour)
return product
```
下次再有人来查,直接从Redis返回,不用打数据库。
**方案二:布隆过滤器**
如果你的数据量很大,空值缓存也撑不住。那就用布隆过滤器:
```go
// 启动的时候,把所有存在的商品ID加载到布隆过滤器
bloomFilter.Add("product:1")
bloomFilter.Add("product:2")
// 查询之前先判断一下
func GetProduct(id string) {
if !bloomFilter.Exists("product:" + id) {
return nil // 肯定不存在,别浪费感情
}
// 存在,去查缓存/数据库
}
```
布隆过滤器会告诉你「可能存在」或「一定不存在」。虽然有误判概率,但是能挡住绝大部分恶意请求。
## 坑四:Redis连接池——你真的会配置吗?
### 事故现场
有一次,运维兄弟跑过来跟我说:「虾哥,Redis连接数又爆了。」
我查了一下代码,好家伙,**每次请求都新建一个Redis连接,用完也不关闭**。
```go
// ❌ 错误示范:每次请求都新建连接
func getUser(id string) User {
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer client.Close()
val, _ := client.Get("user:" + id).Result()
return parseUser(val)
}
```
这谁顶得住?每个请求都新建连接,连接数分分钟爆炸。
### 怎么解决?
**正确姿势:全局复用连接池**
```go
// 初始化的时候创建连接池
var (
rdb *redis.Client
)
func InitRedis() {
rdb = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
PoolSize: 100, // 连接池大小
MinIdleConns: 10, // 最小空闲连接
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
})
}
// 复用连接池
func getUser(id string) User {
val, err := rdb.Get("user:" + id).Result()
if err != nil {
return User{}
}
return parseUser(val)
}
```
**连接池配置建议:**
- `PoolSize`:建议设置为CPU核心数的2-3倍
- `MinIdleConns`:根据实际情况设置,别太少
- 超时时间:一定要设置,别让连接死占着不还
## 坑五:Big KEY——你的Redis里藏了什么怪物?
### 事故现场
某天,线上报警说Redis响应超时。查了一下,发现有个KEY,里面存了**100万用户的收藏列表**。
每次读取这个KEY,都要传输几MB的数据。网络延迟直接爆炸。
这就是**Big KEY**问题。
### 怎么解决?
**方案一:拆分**
不要把100万个用户ID存在一个KEY里:
```
// ❌ 错误示范
user:favorites:123 -> ["item1", "item2", ..., "item1000000"]
// ✅ 正确姿势
user:favorites:123:page1 -> ["item1", ..., "item100"]
user:favorites:123:page2 -> ["item101", ..., "item200"]
```
**方案二:数据结构选型**
如果只是要存储列表,看看是不是真的需要List:
- 如果需要分页查询 → 用String + JSON
- 如果需要唯一性 → 用Set
- 如果需要排序 → 用Sorted Set
**方案三:定期清理**
用SCAN命令代替KEYS命令,避免阻塞:
```go
// ❌ 危险:KEYS会阻塞Redis
keys := redis.Keys("big:*")
// ✅ 安全:SCAN是渐进式的
iter := 0
for {
keys, iter = redis.Scan(iter, "big:*", 100)
for _, key := range keys {
// 处理每个KEY
}
if iter == 0 {
break
}
}
```
## 写在最后
Redis看似简单,其实坑特别多。
缓存雪崩、缓存击穿、缓存穿透、连接池配置、Big KEY——每一个坑都是用线上事故换来的经验。
希望这篇文章能帮你在工作中少踩几个坑。如果觉得有帮助,点个在看,转发给需要的朋友。
下次线上报警的时候,至少可以淡定一点。
毕竟,**该来的总会来的**。