☰
分布式系统——Redis缓存常见问题及分布式锁的实现
2026/10/24 7:34:28 网站建设 项目流程

文章目录

  • 一、概念简述
    • 1.1 为什么要用缓存
  • 二、缓存的更新策略
    • 2.1 定期生成
    • 2.2 实时生成
  • 三、内存淘汰策略
  • 四、缓存预热
  • 五、缓存常见问题
    • 5.1 缓存穿透
    • 5.2 缓存雪崩
    • 5.3 缓存击穿
    • 5.4 三个问题对比
  • 六、分布式锁
    • 6.1 概念
    • 6.2 基本实现:SETNX
    • 6.3 问题一:程序崩溃,锁没释放(死锁)
    • 6.4 问题二:误删别人的锁
    • 6.5 问题三:校验 + 删除不是原子的
    • 6.6 问题四:过期时间不好设置——看门狗
    • 6.7 问题五:Redis 本身挂了——Redlock
  • 七、总结

一、概念简述

Redis 最主要的用途有三个方面:缓存、存储数据(作为数据库)、消息队列。其中缓存是使用最广泛、也最容易出问题的场景,本文重点讲缓存的相关问题,以及在分布式系统中非常重要的分布式锁。

1.1 为什么要用缓存

  • MySQL的数据存在硬盘上,容量大,但读写速度慢,并发能力有限,而且数据库是非常"娇嫩"的,请求一多就容易扛不住。
  • Redis的数据存在内存上,速度快,并发高,但内存空间有限,存不下全部数据。

而实际业务中,数据的访问存在明显的热点:往往 20% 的数据,承载了 80% 的访问量(二八定律)。所以只需要把这部分热点数据放到 Redis 里,就能挡住绝大部分请求,保护后面的 MySQL。

那么问题来了:怎么知道哪些是热点数据?
这就涉及缓存的更新策略。

二、缓存的更新策略

缓存的更新策略有两种:定期生成和实时生成。

2.1 定期生成

以搜索引擎为例,通过日志统计出热点词,再把热点词对应的搜索结果提前放进缓存:

  1. 通过日志的形式,记录用户都搜索了哪些词。
  2. 日志存储在HDFS等分布式存储中。
  3. 对搜索词进行统计、排序,得到热点词。统计的周期可以是一天、一周或者一个月。
  4. 把热点词涉及的搜索结果拎出来,放到缓存中。
  5. 把上面的统计过程写成一套离线流程,然后通过定时任务来触发。
  • 特点:缓存里的内容是提前算好的,命中率有保证,但实时性差(只有定时任务跑完才会更新)。

2.2 实时生成

请求来了,先查 Redis:

  • 命中:直接返回。

  • 没命中:去查 MySQL,查到之后再写入 Redis,下次就可以命中了。

  • 特点:实时性好,不需要额外的统计流程,但是缓存刚开始是空的,所有请求都会打到 MySQL(这个问题在后面的"缓存预热"中解决)。

  • 随着数据越写越多,内存会不够用,这就需要内存淘汰策略来决定淘汰谁。

三、内存淘汰策略

Redis 的内存是有限的。当内存不足以容纳新写入的数据时,就需要把一些旧数据淘汰掉。常见的淘汰算法有四种:

  • FIFO(First In First Out)先进先出:把缓存中存在时间最久的(也就是先来的数据)淘汰掉。
  • LRU(Least Recently Used)淘汰最久未使用的:记录每个 key 的最近访问时间,把最近访问时间最老的 key 淘汰掉。
  • LFU(Least Frequently Used)淘汰访问次数最少的:记录每个 key最近一段时间的访问次数,把访问次数最少的淘汰掉。
  • Random 随机淘汰:从所有的 key 中抽取"幸运儿"随机淘汰掉。

Redis 提供了配置项来选择淘汰策略:

maxmemory 1gb# 最大内存maxmemory-policy allkeys-lru# 淘汰策略

也可以运行时动态修改:config set maxmemory-policy allkeys-lru。

一共有 8 种策略,名字由"淘汰范围 + 算法"组成:

策略淘汰范围说明
volatile-lru设置了过期时间的 key使用 LRU 算法淘汰
allkeys-lru所有 key使用 LRU 算法淘汰
volatile-lfu设置了过期时间的 key4.0 新增,使用 LFU 算法淘汰
allkeys-lfu所有 key4.0 新增,使用 LFU 算法淘汰
volatile-random设置了过期时间的 key随机淘汰
allkeys-random所有 key随机淘汰
volatile-ttl设置了过期时间的 key按剩余存活时间,越早过期的越先淘汰
noeviction——默认策略。内存不足时不淘汰,新的写入操作会报错
  • 细节:Redis 里的 LRU 和 LFU 都是近似算法。精确的 LRU 需要维护所有 key 的访问顺序,开销太大,所以 Redis 采用随机采样:每次随机抽几个 key(maxmemory-samples,默认 5 个),淘汰其中最合适的。

过期删除和内存淘汰是一回事吗?
不是。过期删除针对的是已经到了过期时间的 key(Redis 采用惰性删除 + 定期删除的组合);内存淘汰针对的是内存已经满了,哪怕 key 还没过期,也要挑一些淘汰掉,是内存不足时的兜底手段。

四、缓存预热

如果只用实时生成,Redis 服务器刚接入的时候是空的,没有任何数据,此时所有的请求都会打到 MySQL 上,MySQL 的压力会非常大。

刚开始怎么让 MySQL 的压力小一些?
把定期生成和实时生成结合起来:在系统上线前,先通过统计的途径,把已知的热点数据提前导入 Redis(这就叫缓存预热);系统运行后,再靠实时生成不断写入新数据,随着时间推移,让新数据淘汰旧数据。

五、缓存常见问题

缓存的使用中,有三个非常经典的问题:缓存穿透、缓存雪崩、缓存击穿。它们的共同点是:请求没有被缓存挡住,直接打到了数据库上,造成数据库压力骤增,甚至宕机。

5.1 缓存穿透

查询的某个 key,在 Redis 中没有,在 MySQL 中也没有。这个数据根本不存在,所以永远不会被写入 Redis,如果一直有人这么查,每一次请求都会打到 MySQL 上。

为什么会出现这种情况?

①业务设计不合理,或者缺少参数检查(比如查一个不可能存在的 id)。
②误操作,比如把数据库里的数据误删了。
③黑客恶意攻击,故意构造大量不存在的 key 来请求。

怎么预防?

  1. 缓存空值:如果发现 key 在 Redis 和 MySQL 中都不存在,仍然把它写到 Redis 中,value 设置成一个空值(如空字符串或者特殊的占位值)。下次再查就能在 Redis 里命中。
    • 注意:要给空值设置一个较短的过期时间,避免攻击者用海量不同的 key 把 Redis 内存撑满。
  2. 布隆过滤器:每次查询 Redis 和 MySQL 之前,先判断一下 key 是否存在于布隆过滤器中,不存在就直接返回。使用前需要把数据库中所有的 key 都插入到布隆过滤器里。
  3. 参数校验和限流:对明显非法的参数直接拦截;对异常高频的请求限流、拉黑。

布隆过滤器是什么?有什么特点?

它由一个很长的bit 数组和多个 hash 函数组成。插入 key 时,用多个 hash 函数算出多个位置,把这些位置都置为 1;查询时,这些位置全部为 1才认为"可能存在"。
特点:① 判断"不存在",那么一定不存在;② 判断"存在",可能是误判(hash 冲突导致);③ 占用空间非常小;④ 普通的布隆过滤器不支持删除。
用在防穿透上,误判只会让极少量的无效请求漏过去,不影响正确性。

5.2 缓存雪崩

在短时间内,Redis 上大规模的 key 失效,导致缓存命中率急剧下降,MySQL 的压力迅速上升,甚至宕机。

为什么会出现这种情况?

①Redis 宕机:缓存整个没了,所有请求直接打到 MySQL。
②大量 key 的过期时间相同:给 key 设置过期时间时,时间设得一样,到点之后同时失效。

怎么预防?

  1. 加强监控报警,提高 Redis 的可用性(主从 + 哨兵、集群,避免 Redis 整个挂掉)。
  2. 不给 key 设置过期时间:配合前面的内存淘汰策略,内存满了自动淘汰(适用于数据不会"过时"的场景)。
  3. 给过期时间添加随机因子:比如基础时间 + 一个随机值,让 key 的失效时间分散开,不要同一时刻集中失效。

5.3 缓存击穿

指某一个热点 key突然过期,导致大量并发请求同时打到数据库上。

它和雪崩很像,区别是:雪崩是大量 key同时失效,击穿只是一个(或少数几个)热点 key失效,但这个 key 的访问量特别大。

怎么处理?

  1. 热点 key 永不过期:基于统计的方式(参考上面的"定期生成")发现热点 key,把它们设置成永不过期。
  2. 互斥锁(分布式锁):key 过期后,让只有一个请求去查数据库并回填缓存,其他请求等待或重试。通过分布式锁来限制对数据库的访问频率(下一章详细讲)。
  3. 服务降级:适当关闭一些不重要的功能,只保留核心功能,类似手机、电脑里的"省电模式"。不是关闭服务,而是保证核心业务不被拖垮。

5.4 三个问题对比

问题现象根本原因主要对策
缓存穿透查的 key 在缓存和数据库里都不存在请求的数据根本不存在缓存空值、布隆过滤器、参数校验
缓存雪崩大量 key同时失效,或 Redis 宕机过期时间集中、Redis 不可用过期时间加随机值、高可用、监控
缓存击穿一个热点 key过期,并发请求打到数据库热点 key 失效永不过期、互斥锁、服务降级

六、分布式锁

6.1 概念

锁,本质是解决并发下的问题。但之前学的锁(比如mutex),只能在进程内部生效,无法对分布式系统中不同服务器上的进程产生制约。所以引入了分布式锁。

举个例子:有多台服务器处理订单,每一台都要先查余票、再扣减余票。这两步不是原子的,如果多台服务器同时操作,就会出现超卖。必须保证同一时刻只有一台服务器能执行这段逻辑。

所谓的分布式锁,也是一个/一组单独的服务器程序,给其他的服务器提供"加锁"这样的服务。

  • Redis是典型的可以用来实现分布式锁的方案,但不是唯一的。业界也会使用MySQL、ZooKeeper、etcd这样的组件来实现。

为什么不直接用 MySQL 事务来解决?
用 MySQL 事务,确实可以解决"查询 + 修改"这样的问题。但在分布式系统中,访问的共享资源不一定是 MySQL,也可能是其他没有事务功能的存储介质,或者是一些其他的特定操作,这时就需要一个通用的、独立于具体资源的锁。

6.2 基本实现:SETNX

Redis 中有一个setnx命令(set if not exists):key 不存在才设置成功。利用它就能实现加锁:

  • 加锁:setnx lock_key value。返回成功,表示加锁成功;返回失败,说明别人已经持有锁。
  • 解锁:del lock_key。

但这样简单的实现,有一系列的问题。下面一个一个解决。

6.3 问题一:程序崩溃,锁没释放(死锁)

如果加锁成功了,但后续逻辑中程序崩溃了,没有执行到解锁,怎么办?
这把锁就会一直存在,别人永远拿不到,形成死锁。(ZooKeeper的临时节点天然可以解决这个问题)

解决:给锁设置一个过期时间,到时间自动删除,不会一直锁着。

错误的方式:

setnx lock_key value expire lock_key30

这是错误的!这是两条命令,无法保证原子性:如果setnx成功后,在执行expire之前程序崩溃了(或网络断开),过期时间就没设上,锁仍然会一直存在。

正确的方式:使用一条命令同时完成设置值和过期时间,务必使用下面的方式:

setlock_key<唯一标识>NX EX30
  • NX:key 不存在才设置;EX 30:过期时间 30 秒。返回OK表示加锁成功,返回(nil)表示失败。

6.4 问题二:误删别人的锁

服务器 1 加的锁,被服务器 2 给解锁了,怎么办?
正常来说不应该有这样的操作,通常是代码有 bug。更常见的情况是:服务器 1 的业务执行太久,锁已经过期自动释放,被服务器 2 拿到了,服务器 1 执行完后又去del,把服务器 2 的锁给删了。

另外,一个服务器内部也可能是多线程的,两个线程在执行解锁时同样会出问题。来看一个具体的例子:

重复执行del好像问题不大?

实则不然!一旦引入一个新的服务器去加锁,就会出问题:
① 线程 A 执行完del,锁被释放。
② 服务器 2 的线程 C 正好要加锁(set),由于锁已经释放了,C 加锁成功。
③ 紧接着,线程 B 的del才到来,把刚刚服务器 2 加的锁给解开了。
④ 此时 C 以为自己还持有锁,实际锁已经没了,别人又可以加锁,互斥失效。

解决:引入唯一标识,解锁前先校验。

  • 加锁时,value 不再随便写,而是写入当前持有者的唯一标识(比如服务器编号 + 线程 id,或者一个 UUID)。
  • 解锁时,先检查 value 是不是自己的标识,是才能删除。

6.5 问题三:校验 + 删除不是原子的

校验的流程是:先get判断,再del。但这两步不是原子的:get判断通过之后、del执行之前,锁可能刚好过期,被别人拿到,这时del又会把别人的锁删了。

解决:让"判断 + 删除"变成原子操作。

  • 方案一:Redis事务(MULTI / EXEC)。
  • 方案二:Lua 脚本(实践中更常用)。Redis 会把整个 Lua 脚本当作一个整体来执行,期间不会被其他命令打断。
ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end

调用方式:

eval"上面的脚本"1lock_key<自己的唯一标识>

6.6 问题四:过期时间不好设置——看门狗

锁的过期时间,很难设置一个合适的值:

  • 设置太短:业务还没做完,锁就释放了,别人趁虚而入。
  • 设置太长:如果持有者崩溃,锁要等很久才能释放,其他人一直等待。

解决:动态续约(看门狗机制,Watch Dog)。

  • 初始情况下,设置一个比较短的过期时间(比如 30 秒)。
  • 如果当前任务还没有执行完,就在快到期时,把过期时间再续上(比如每隔过期时间的 1/3 检查一次,没做完就续约)。
  • 如果服务器中途崩溃了,自然就没有人负责续约了,此时锁就能在较短的时间内被自动释放。

这个机制需要专门的线程来负责续约,这种线程就被叫做看门狗。(比如 Java 里的Redisson就内置了看门狗)

6.7 问题五:Redis 本身挂了——Redlock

使用 Redis 作为分布式锁,Redis 本身有没有可能挂呢?
很有可能。所以需要保证 Redis 的高可用,做好预案。

分布式锁涉及的数据量并不大,所以可以用主从复制 + 哨兵来解决高可用。但是这里有一个非常严重的问题:主从复制是异步的,有延迟。

比如:

  1. 客户端 A 在主节点上加锁成功。
  2. 这个加锁的数据还没来得及同步给从节点,主节点就挂了。
  3. 哨兵把从节点提升为新的主节点,但这个节点上没有这把锁。
  4. 客户端 B 来加锁,也成功了。此时A 和 B 同时持有同一把锁。

Redis 的作者给出了解决方案:Redlock 算法。它的本质是==冗余==(通常提到冗余,都和高可用有关)。

做法:

  1. 部署多个(通常是 5 个)相互独立的 Redis 节点(它们之间不是主从关系,各自都是独立的主节点)。
  2. 客户端依次对这些节点都进行加锁操作(用的就是上面的set NX EX)。
  3. 某个节点挂了,不要卡住,继续给下一个节点加锁就行。
  4. 如果加锁成功的节点数量超过总数的一半(5 个里至少 3 个),并且总耗时没有超过锁的有效期,就视为加锁成功。
  5. 解锁同理:对所有节点都执行解锁操作。
  • 细节:Redlock 在业界是有争议的,比如它对时钟漂移、进程长时间停顿这类情况比较敏感。如果对锁的强一致性要求特别高,更推荐使用ZooKeeper或etcd这样的方案。

七、总结

本文讲了 Redis 最常见的两个应用:

缓存

  • 缓存通过热点数据挡住大部分请求,保护数据库。更新策略有定期生成和实时生成,内存满了靠淘汰策略,冷启动靠缓存预热。
  • 三大问题:穿透(数据不存在)、雪崩(大量 key 失效)、击穿(热点 key 失效)。

分布式锁:一步步演进的过程,也是一个"发现问题 → 解决问题"的过程:

问题解决办法
加锁后程序崩溃,死锁设置过期时间,用set NX EX一条命令保证原子性
误删别人的锁value 存唯一标识,解锁前校验
校验 + 删除非原子使用 Lua 脚本
过期时间不好设置看门狗动态续约
Redis 自身故障、主从切换丢锁Redlock 算法(多个独立节点,过半成功)

非常感谢您能耐心读完这篇文章。倘若您从中有所收获,还望多多支持呀!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询