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. 再次删除缓存 读线程回填旧数据
缓存清除

相关推荐
APItesterCris31 分钟前
告别人工盯品!借助 Open‑Claw 快速搭建电商商品全自动监控与数据分析系统(完整实操代码)
java·大数据·前端·数据库
SKH.43 分钟前
网络(5)数据库
jvm·数据库
极小狐1 小时前
CI 流水线提速实战:缓存设计与依赖代理的工程实践
缓存·ci/cd·gitlab·devops
kyrie_sakura1 小时前
MySQL数据库学习笔记3--关联(联合)查询
数据库·学习·mysql
陈皮波比茶1 小时前
计算机二级MySQL笔记
java·数据库
莫得感情 o1 小时前
Redis 09 · 高可用:Sentinel 如何发现故障并完成切主
redis·缓存·sentinel
行业研究员1 小时前
腾讯云数据库 PostgreSQL 让相关子查询跑上多核
数据库·postgresql·腾讯云
zzzll11111 小时前
LangChain 1.3 新特性详解与实战指南
java·数据库·langchain
石小千2 小时前
MySQL 8.4设置密码策略
数据库·mysql