Redis:Redis 常见问题及解决思路

文章目录

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,这种瞬时流量洪峰会直接导致数据库瘫痪,进而导致整个服务链路雪崩。

解决方案

  1. 错峰过期: 在设置过期时间时,增加一个随机值(如 TTL + random(1, 300)),避免大量 key 在同一时间集体失效。
  2. 高可用架构: 使用 Redis Sentinel 或 Cluster 搭建高可用集群,防止单点故障。
  3. 限流降级: 当缓存失效请求激增时,启动限流策略,拒绝部分请求或返回默认降级数据,保护数据库。

数据库 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 中,GETDEL 是两条独立的命令。如果在 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(看门狗) 解决了这个问题。 看门狗是一个后台定时任务。

  1. 加锁时: 如果不指定租约时间,默认锁过期时间为 30 秒。
  2. 续约: 一旦加锁成功,后台线程启动,每隔 10 秒(过期时间的 1/3)检查一次:只要当前线程还持有锁,就重置 Redis 中 Key 的过期时间为 30 秒。
  3. 停止: 只有当持有锁的机器主动调用 unlock,或者机器宕机,续约才会停止。

这样,只要业务没跑完,锁就会一直"续命",直到业务结束。
业务线程 加锁成功 默认 TTL 30s 执行业务逻辑 (耗时 50s) ... 业务完成 主动 Unlock 看门狗线程 (后台) 定时任务 第 10s 锁还在吗? 在 -> 续期到 30s 第 20s 锁还在吗? 在 -> 续期到 30s 第 30s 锁还在吗? 在 -> 续期到 30s 第 40s 锁还在吗? 在 -> 续期到 30s Unlock 收到 停止续约任务 看门狗自动续约原理

缓存一致性:极端情况下的脏数据流程

既然有了缓存,就不可避免地面临数据一致性问题:当数据库更新了,缓存怎么更新?是先删缓存还是先更新数据库?为了保证缓存与数据库的一致性,业界通用的策略是 Cache Aside Pattern(旁路缓存模式)

  1. 先更新数据库
  2. 再删除缓存。

为什么是删除缓存而不是更新缓存?

这个问题其实包含了两层含义:并发安全(脏数据问题)资源利用(性能开销问题)

并发更新导致脏数据

在并发环境下,如果你选择"更新缓存",也就是把新的值 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 次删除(或者最后一次删除)。这是一种懒加载 思想:只有当有人真正来读的时候,我才去查库并把最新数据写进缓存。这样就把计算成本平摊到了读请求上,极大地节省了写请求的开销。

极端场景:并发读写冲突

虽然旁路缓存模式这种策略发生问题的概率极低,但在极端的并发场景下,依然可能产生脏数据。

  • 如果是"先删缓存,再更库",在并发下可能导致旧数据回填缓存。
  • 如果是"先更库,再删缓存",在极少数情况下(如数据库主从延迟),读请求可能读到旧库数据并回填缓存。

假设缓存刚好过期,且发生数据库主从延迟或读请求排队:

  1. 线程 A(写): 更新数据库 = 100。
  2. 线程 A(写): 删除缓存。
  3. 线程 B(读): 读取缓存。
  4. 线程 B(读): 缓存未命中。
  5. 线程 B(读): 读取数据库。此时如果主从延迟未完成,读到旧值 90(或者仅仅是读请求在写请求之前进入了数据库队列)。
  6. 线程 B(读): 将旧值 90 回填缓存。
  7. 结果: 数据库是 100,缓存是 90。数据不一致直到缓存过期。

读线程 Redis 缓存 数据库 写线程 读线程 Redis 缓存 数据库 写线程 缓存已过期 网络波动... 导致读请求在删缓存后、感知到更新前 此时缓存中是脏数据 90 直到下次过期 1. 更新数据库 = 100 1 2. 更新成功 2 3. 删除缓存 3 4. 读取数据库 4 5. 返回旧数据 90 (主从延迟 / 读取旧库) 5 6. 将旧数据 90 写入缓存 6

终极解决方案:延时双删

为了消除上述极低概率的脏数据,可以采用 延时双删 策略:

  1. 先删除缓存。
  2. 更新数据库。
  3. 休眠一段时间(如 500ms,需大于读请求的执行时间)。
  4. 再次删除缓存

为什么? 因为这 500ms 是为了等待那个可能读到旧数据并回填的"笨"读线程跑完。等它把脏数据写进去后,我再补删一次,把脏数据清除掉。
脏数据产生并被消除
延时双删时序
发生在 Sleep 期间
清理读线程写入的旧值

  1. 删除缓存 2. 更新数据库 Sleep 500ms

等待读线程回填
3. 再次删除缓存 读线程回填旧数据
缓存清除

相关推荐
xcLeigh2 小时前
Oracle 迁移 KingbaseES 避坑指南:工具选型、参数配置与性能调优
数据库·oracle·工具·性能·金仓·kingbasees
JY.yuyu2 小时前
SQL Server数据库
数据库
June bug2 小时前
【配环境】安装配置Oracle JDK
java·数据库·oracle
独自破碎E2 小时前
如何在MySQL中监控和优化慢SQL?
数据库·sql·mysql
数据库生产实战2 小时前
基础知识 | Oracle Index Split(索引分裂:你的数据库越来越慢可能与此有关!建议排查!
数据库·oracle
知识分享小能手2 小时前
Oracle 19c入门学习教程,从入门到精通,Oracle 控制文件与日志文件管理详解(8)
数据库·学习·oracle
走遍西兰花.jpg2 小时前
gaussdb的基础命令
数据库·gaussdb
阿杰 AJie2 小时前
MyBatis-Plus 的内置方法
java·数据库·mybatis
23124_802 小时前
Base64多层嵌套解码
前端·javascript·数据库