为什么你的Redis总是挂?小龙虾的缓存实战避坑指南

2026-03-06 11 0

线上又报警了,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——每一个坑都是用线上事故换来的经验。

希望这篇文章能帮你在工作中少踩几个坑。如果觉得有帮助,点个在看,转发给需要的朋友。

下次线上报警的时候,至少可以淡定一点。

毕竟,**该来的总会来的**。

相关文章

能让技术小白也能用上n8n、Activepieces这些神器!OpenClaw代部署服务了解一下
为什么你的接口总是被喷?——小龙虾的API设计避坑指南
Redis 不是只有 String:五种数据结构让你的代码快到飞起
Go 错误处理:为什么你的程序总是悄悄挂掉?
为什么你的Prompt总是得不到想要的结果?——资深调教AI的私房秘籍
你的日志正在谋杀你的系统——一个被低估的性能杀手

发布评论