文章目录
- [Redis 常见问题及解决思路](#Redis 常见问题及解决思路)
-
- [Redis 的单线程模型与 I/O 多路复用](#Redis 的单线程模型与 I/O 多路复用)
- 缓存雪崩:防线的全面崩溃
- 缓存击穿:热点的过载
- 缓存穿透:幽灵请求
- [分布式锁:Lua 脚本与 Value 的奥秘](#分布式锁:Lua 脚本与 Value 的奥秘)
-
- [为什么 Value 必须是唯一标识?](#为什么 Value 必须是唯一标识?)
- [为什么要用 Lua 脚本?](#为什么要用 Lua 脚本?)
- 看门狗机制:防止业务未跑完锁就过期
- 缓存一致性:极端情况下的脏数据流程
Redis 常见问题及解决思路
Redis 的单线程模型与 I/O 多路复用
很多人对 Redis 的第一印象是"单线程",从而质疑其高并发能力。其实,Redis 的"单线程"指的是处理命令请求的执行模块是单线程的,而不是整个服务器只有一个线程。
为什么选择单线程?
Redis 的所有数据都存储在内存中,内存读写速度极快(纳秒级)。相比之下,CPU 的处理速度并不是瓶颈。如果引入多线程来处理内存读写,反而会因为线程上下文切换和锁竞争带来巨大的性能损耗。因此,Redis 利用单线程实现了极其高效的串行命令处理,天然避免了原子性并发问题。
真正的性能瓶颈:I/O
真正的瓶颈在于网络 I/O(等待数据从网络传输过来)。为了解决一个线程无法同时处理多个连接的问题,Redis 采用了 I/O 多路复用机制。 这就好比一个餐厅只有一个厨师(单线程 CPU),但他不需要一直站在窗口等顾客点餐。他使用了一个智能叫号系统(I/O 多路复用,如 epoll/kqueue)。当有顾客点好餐(Socket 就绪)时,系统通知厨师,厨师立刻过去处理。这样,一个厨师就能同时服务成百上千个顾客。
Memory
Redis_EventLoop
Clients
Send Request
Send Request
Send Request
Socket Ready
Fetch Task
Read/Write
Send Response
Send Response
Client 1
Client 2
Client 3
I/O Multiplexing
Selector
Task Queue
Single Thread
Event Loop Processor
Key-Value Store
缓存雪崩:防线的全面崩溃
原理
缓存雪崩指的是在某一特定时间段,缓存集中大量过期失效,或者 Redis 实例本身发生宕机。此时,原本由 Redis 承担的海量流量瞬间全部"穿透"直达后端数据库。数据库的吞吐能力远低于 Redis,这种瞬时流量洪峰会直接导致数据库瘫痪,进而导致整个服务链路雪崩。
解决方案
- 错峰过期: 在设置过期时间时,增加一个随机值(如
TTL + random(1, 300)),避免大量 key 在同一时间集体失效。 - 高可用架构: 使用 Redis Sentinel 或 Cluster 搭建高可用集群,防止单点故障。
- 限流降级: 当缓存失效请求激增时,启动限流策略,拒绝部分请求或返回默认降级数据,保护数据库。
数据库 Redis缓存 大量并发请求 数据库 Redis缓存 大量并发请求 大量Key同时过期 或Redis宕机 数据库负载瞬间飙升 导致连接超时或宕机 查询数据 1 Miss (未命中) 2 查询数据 (洪峰冲击) 3 Error / Slow Response 4
缓存击穿:热点的过载
原理
缓存击穿与雪崩不同,它针对的是某一个极度热点的 Key。该 Key 访问极其频繁,但在某一秒过期了。这一瞬间,成千上万的请求同时发现缓存未命中,同时涌向数据库查询,可能导致数据库瞬间卡死。
解决方案:互斥锁
当缓存失效时,不是让所有请求都去查库,而是只允许一个线程去查库并回写缓存,其他线程等待片刻后重试。这就保证了数据库只会被查询一次。
请求洪峰
检测过期
检测过期
检测过期
获取锁成功
查询并回写
释放锁
获取锁失败
获取锁失败
重试
命中
命中
线程1
线程2
线程3
线程N
分布式锁
Redis Cache
Database
休眠/重试
缓存穿透:幽灵请求
原理
缓存穿透是指查询一个根本不存在 的数据。缓存中没有,数据库中也没有。如果有人恶意构造大量不存在的 ID(如 id = -1)发起请求,请求会绕过缓存直接打到数据库。由于数据库无法通过索引优化这种查询,大量穿透会导致数据库压力过大。
解决方案:布隆过滤器
布隆过滤器是一种极高效的数据结构,能告诉你一个 Key 一定不存在 ,或者可能存在 。
在访问缓存前,先经过布隆过滤器校验。如果它说 Key 不存在,那就一定不存在,直接拦截;如果它说可能存在,再去查缓存或数据库。
优化后:缓存空对象
不存在
可能存在
Miss
查询为空
存入 Null
Hit Null
恶意请求
id = -1
布隆过滤器
直接拦截返回空
Redis
Database
Value: Null
TTL: 短时间
返回空
分布式锁:Lua 脚本与 Value 的奥秘
在微服务架构下,JVM 级别的锁已无法满足跨服务的互斥需求。我们需要依赖 Redis 实现分布式锁。Redis 实现分布式锁的核心命令是 SET key value NX PX timeout。
- NX:只有 key 不存在时才设置成功(加锁)。
- PX:设置过期时间(防止死锁)。
- 解锁: 必须使用 Lua 脚本确保"判断锁归属"和"删除锁"操作的原子性,防止误删别人的锁。
服务实例 B Redis 服务实例 A 服务实例 B Redis 服务实例 A 执行业务逻辑... SET lock_key "uuid_A" NX PX 30000 1 OK (加锁成功) 2 SET lock_key "uuid_B" NX PX 30000 3 nil (加锁失败) 4 执行 Lua 脚本 (get判断 & del删除) 5 1 (解锁成功) 6 SET lock_key "uuid_B" NX PX 30000 7 OK (A释放后,B加锁成功) 8
为什么 Value 必须是唯一标识?
很多初级实现将锁的 Value 设为 "1" 或 "True",这是极其危险的。
- 场景还原: 客户端 A 加锁成功(Value="1"),但业务执行时间过长,导致锁自动过期。此时客户端 B 加锁成功(Value 也是 "1")。当 A 终于执行完业务去释放锁时,它会执行
del key,此时它删除的其实是 B 的锁。 - 正确做法: Value 必须设为 UUID + 线程ID 这样的全局唯一标识,确保只有锁的持有者才能释放锁。
为什么要用 Lua 脚本?
为了实现"只有持有者才能解锁",逻辑是:
java
if (get(key) == uuid) {
del(key);
}
在 Redis 中,GET 和 DEL 是两条独立的命令。如果在 GET 之后、DEL 之前,网络发生抖动或 GC 停顿,锁可能刚好过期并被别人获取。此时 DEL 执行,你就会误删别人的锁。Lua 脚本 的特性是原子性。Redis 在执行 Lua 脚本时,不会插入其他命令。我们将判断和删除逻辑打包发给 Redis,确保万无一失。
lua
-- Lua 脚本内容
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
客户端 B (新请求) Redis 客户端 A (持有旧锁) 客户端 B (新请求) Redis 客户端 A (持有旧锁) 业务逻辑卡顿... Key 自动过期 Lua 原子执行中 判断 value != "uuid_A" return 0 SET key "uuid_B" NX PX 30000 1 OK (B 加锁成功) 2 发送 Lua 脚本 (get key == "uuid_A" ? del : 0) 3 0 ("拒绝删除") 4 安全运行中 5
看门狗机制:防止业务未跑完锁就过期
即使设置了 30 秒的过期时间,如果业务发生阻塞(如数据库慢查询)跑了 35 秒,锁在第 30 秒就自动释放了。这会导致第二个线程拿到锁,两个线程同时操作业务数据,造成并发安全问题。Redisson 等客户端库通过 Watchdog(看门狗) 解决了这个问题。 看门狗是一个后台定时任务。
- 加锁时: 如果不指定租约时间,默认锁过期时间为 30 秒。
- 续约: 一旦加锁成功,后台线程启动,每隔 10 秒(过期时间的 1/3)检查一次:只要当前线程还持有锁,就重置 Redis 中 Key 的过期时间为 30 秒。
- 停止: 只有当持有锁的机器主动调用
unlock,或者机器宕机,续约才会停止。
这样,只要业务没跑完,锁就会一直"续命",直到业务结束。
业务线程 加锁成功 默认 TTL 30s 执行业务逻辑 (耗时 50s) ... 业务完成 主动 Unlock 看门狗线程 (后台) 定时任务 第 10s 锁还在吗? 在 -> 续期到 30s 第 20s 锁还在吗? 在 -> 续期到 30s 第 30s 锁还在吗? 在 -> 续期到 30s 第 40s 锁还在吗? 在 -> 续期到 30s Unlock 收到 停止续约任务 看门狗自动续约原理
缓存一致性:极端情况下的脏数据流程
既然有了缓存,就不可避免地面临数据一致性问题:当数据库更新了,缓存怎么更新?是先删缓存还是先更新数据库?为了保证缓存与数据库的一致性,业界通用的策略是 Cache Aside Pattern(旁路缓存模式):
- 先更新数据库
- 再删除缓存。
为什么是删除缓存而不是更新缓存?
这个问题其实包含了两层含义:并发安全(脏数据问题) 和 资源利用(性能开销问题)。
并发更新导致脏数据
在并发环境下,如果你选择"更新缓存",也就是把新的值 set 到 Redis 中,很容易出现"写线程"的执行顺序乱掉,导致数据库和缓存的数据不一致。 假设我们有两个线程 A 和 B,同时修改同一条数据,原本的值是 10。
- 线程 A 想把值改成 11。
- 线程 B 想把值改成 12。
如果是更新缓存策略,时序可能如下:
缓存 数据库 线程 B (改 12) 线程 A (改 11) 缓存 数据库 线程 B (改 12) 线程 A (改 11) 初始状态: DB=10, Cache=10 此时数据库是 12,正确 最终状态: DB=12, Cache=11 【脏数据产生!】且很难自动修复 1. 更新数据库 = 11 1 2. 写库成功 2 3. 更新数据库 = 12 3 4. 写库成功 4 5. 更新缓存 = 12 5 OK 6 6. 更新缓存 = 11 (网络慢,晚到了) 7 OK 8
因为网络延迟,线程 A 虽然先更新了数据库,但它更新缓存的操作却晚于线程 B。这就导致缓存里留下了"旧值 11",而数据库里是"新值 12"。如果不及时发现,这个脏数据会一直存在。如果是删除缓存策略:删除操作是幂等的(删多次和删一次效果一样)。
- 线程 A 删除缓存。
- 线程 B 删除缓存。
- 无论谁先删谁后删,结果都是"缓存没了"。
- 下一个读请求进来时,发现缓存没数据,会去数据库读取最新值(12)并回填缓存。系统会自动恢复一致。
频繁更新导致开销巨大(资源浪费)
这主要针对的是**"写多读少"**的业务场景。 假设有一个配置数据,一秒钟被修改了 100 次,但在这一秒钟内,没有任何用户来读取它。
-
如果你用"更新缓存 : 你不仅要在数据库执行 100 次写操作,还要在 Redis 里执行 100 次
set操作。如果这个缓存数据的计算逻辑很复杂(比如包含大量列表、哈希结构),这 100 次 Redis 写操作会消耗大量 CPU 和带宽。关键是,没人读,你更新了也是白更新,纯属浪费资源。 -
如果你用"删除缓存: 你在数据库执行 100 次写操作,但在 Redis 里,你只需要执行 1 次删除(或者最后一次删除)。这是一种懒加载 思想:只有当有人真正来读的时候,我才去查库并把最新数据写进缓存。这样就把计算成本平摊到了读请求上,极大地节省了写请求的开销。
极端场景:并发读写冲突
虽然旁路缓存模式这种策略发生问题的概率极低,但在极端的并发场景下,依然可能产生脏数据。
- 如果是"先删缓存,再更库",在并发下可能导致旧数据回填缓存。
- 如果是"先更库,再删缓存",在极少数情况下(如数据库主从延迟),读请求可能读到旧库数据并回填缓存。
假设缓存刚好过期,且发生数据库主从延迟或读请求排队:
- 线程 A(写): 更新数据库 = 100。
- 线程 A(写): 删除缓存。
- 线程 B(读): 读取缓存。
- 线程 B(读): 缓存未命中。
- 线程 B(读): 读取数据库。此时如果主从延迟未完成,读到旧值 90(或者仅仅是读请求在写请求之前进入了数据库队列)。
- 线程 B(读): 将旧值 90 回填缓存。
- 结果: 数据库是 100,缓存是 90。数据不一致直到缓存过期。
读线程 Redis 缓存 数据库 写线程 读线程 Redis 缓存 数据库 写线程 缓存已过期 网络波动... 导致读请求在删缓存后、感知到更新前 此时缓存中是脏数据 90 直到下次过期 1. 更新数据库 = 100 1 2. 更新成功 2 3. 删除缓存 3 4. 读取数据库 4 5. 返回旧数据 90 (主从延迟 / 读取旧库) 5 6. 将旧数据 90 写入缓存 6
终极解决方案:延时双删
为了消除上述极低概率的脏数据,可以采用 延时双删 策略:
- 先删除缓存。
- 更新数据库。
- 休眠一段时间(如 500ms,需大于读请求的执行时间)。
- 再次删除缓存。
为什么? 因为这 500ms 是为了等待那个可能读到旧数据并回填的"笨"读线程跑完。等它把脏数据写进去后,我再补删一次,把脏数据清除掉。
脏数据产生并被消除
延时双删时序
发生在 Sleep 期间
清理读线程写入的旧值
- 删除缓存 2. 更新数据库 Sleep 500ms
等待读线程回填
3. 再次删除缓存 读线程回填旧数据
缓存清除