为什么需要redis?

MySQL 更适合做持久化存储,但数据主要存放在磁盘中,即使有 Buffer Pool 和索引优化,面对高并发、低延迟的访问场景时,性能仍然可能成为瓶颈。为了减少大量请求直接访问数据库,可以在应用和 MySQL 之间增加一层基于内存的高速存储,把经常访问的数据放到内存中,这就是 Redis 最常见的使用场景。

因此,Redis 的核心价值可以理解为:利用内存提供更快的数据访问速度,承担缓存和高并发读写压力,从而降低 MySQL 的负载。 在此基础上,Redis 还提供了丰富的数据结构、过期机制、持久化、高可用等能力,因此也常被用于计数器、排行榜、分布式锁、消息处理等场景。

Redis为什么这么快

Redis 之所以快,最核心的原因是它把主要数据放在内存中。相比磁盘,内存的访问延迟低很多,因此 Redis 的大部分读写操作都不需要等待磁盘 I/O,这一点是它高性能的基础。

但仅仅把数据放在内存里还不够,Redis 在数据结构和执行模型上也做了很多优化。它为不同场景提供了字符串、哈希、列表、集合、有序集合等数据结构,并针对这些结构设计了高效的底层实现,因此很多常见操作都可以在较低的时间复杂度内完成。

在命令执行方面,Redis 传统上采用单线程执行核心命令。这样做并不是因为单线程本身一定更快,而是因为 Redis 的主要瓶颈通常不在 CPU,而在网络和内存访问。单线程反而可以避免多线程之间的锁竞争、上下文切换和数据同步问题,让整个执行路径更简单。

Redis 传统上使用单线程执行核心命令,主要不是因为“单线程天然更快”,而是因为 Redis 的大部分操作都是内存访问,单次命令执行时间非常短,系统瓶颈通常并不首先出现在 CPU 计算能力上。 如果让多个线程同时执行 Redis 命令,就会出现多个线程同时读写共享数据结构的情况。为了保证数据正确性,就需要引入锁和其他同步机制。线程之间频繁竞争锁时,CPU 不仅要执行真正的数据操作,还要额外处理加锁、解锁、自旋、阻塞、唤醒、上下文切换以及不同 CPU 核之间的缓存同步。这些工作都会消耗 CPU 时间,但并不会直接提升 Redis 命令的处理结果。而 Redis 本身一次内存操作已经很快,如果命令执行只需要很短时间,那么多线程带来的并行收益可能还没有线程协调的额外开销大。因此,Redis 选择让核心命令按照顺序执行,避免复杂的锁竞争和线程同步,使执行模型保持简单,同时也天然避免了很多并发修改数据带来的问题。

所以 Redis 采用单线程核心执行模型,本质上是在做一个权衡:既然内存操作本身已经足够快,与其通过多线程增加锁竞争和调度成本,不如保持命令执行简单,把高并发连接交给 I/O 多路复用处理。

不过,如果只有一个线程,一个问题马上就会出现:一个线程怎么同时处理成千上万个客户端连接?Redis 通过 I/O 多路复用解决这个问题。操作系统可以同时监听大量连接,哪个连接有数据到达,就通知 Redis 处理哪个连接,而不是为每个连接单独创建一个线程。这样既减少了线程数量,又能同时管理大量客户端。因此,Redis 快并不是由某一个单独因素决定的,而是几个因素共同作用的结果:以内存作为主要存储介质,使用高效的数据结构,核心命令执行模型简单,同时通过 I/O 多路复用高效处理大量网络连接。

Redis 后来的版本也引入了多线程来处理部分网络 I/O,但核心命令执行仍然尽量保持简单,这样可以继续避免复杂的并发控制问题。

数据结构

  • String :最基础的数据类型,可以理解成“一个 key 对应一个值”。这个值既可以是字符串,也可以是数字或二进制数据,因此很适合用来缓存单个对象、保存计数器,或者存储一些简单状态。
  • Hash :可以理解成“一个 key 对应多个字段”。例如一个用户可以包含 name、age、email 等多个属性,所以 Hash 更适合表示一个对象内部的多个字段,而不是把整个对象作为一个完整字符串存进去。
  • List :一个有顺序、允许重复的数据集合。因为元素有前后顺序,所以比较适合表示队列、消息列表、时间线这类需要维护顺序的数据。
  • Set :一个无序且不允许重复的集合。它最重要的特点就是天然去重,因此适合用来保存标签、用户集合,或者进行交集、并集、差集等集合运算,例如共同好友这类场景。
  • Sorted Set(ZSet): 和 Set 类似,同样不允许成员重复,但每个成员还会额外关联一个 score,Redis 可以根据这个 score 对成员进行排序,因此非常适合排行榜、优先级队列等需要“去重并排序”的场景。

我们平时使用的是 Redis 提供的逻辑数据类型,而 Redis 内部为了节省内存和提高性能,会根据数据规模选择不同的底层编码结构。例如 String 底层会使用 SDS,Hash、List、Sorted Set 在数据较小时可能使用更紧凑的 listpack,而 Sorted Set 在数据量较大时通常会使用哈希表和跳表组合实现。也就是说,Redis 的“数据类型”和“底层数据结构”并不是一一对应的。

SDS(Simple Dynamic String,简单动态字符串) 是 Redis 自己实现的字符串结构。它不是直接使用 C 语言原生字符串,因为 C 字符串只靠 \0 判断结束,获取长度需要遍历,而且扩容时也不方便。SDS 会额外记录字符串长度、已分配空间等信息,所以获取长度可以做到 O(1),扩容也更方便,而且还能安全保存二进制数据。 listpack 则是一种非常紧凑的顺序存储结构,主要目标是节省内存。当 Hash、List、Sorted Set 里的元素比较少、比较小时,Redis 没必要一开始就使用哈希表、跳表这类相对“重”的结构,而是可以把多个元素紧密地连续存放在一块内存中,这就是 listpack 的思路。它牺牲了一部分随机访问和修改的便利性,换取更高的内存利用率。 Zset为什么不用B+树?Redis 是内存场景,跳表已经能很好满足排序、范围查询和动态更新,而且实现和维护成本更低;B+ 树为磁盘优化(树高低,磁盘随机I/O少)的优势在这里发挥不出来。

持久化

前面可以看到,Redis 之所以能够提供很高的读写性能,一个重要原因就是数据主要存放在内存中。但内存本身并不具备可靠的持久化能力,一旦 Redis 进程退出或者服务器宕机,内存中的数据就可能丢失。因此,如果希望 Redis 在重启后能够恢复数据,就需要把内存中的数据以某种方式保存到磁盘中,这就引出了 Redis 的两种主要持久化机制:RDB 和 AOF。

Redis 的持久化主要有两种思路:一种是定期把当前内存中的数据状态整体保存下来,这就是 RDB;另一种是把数据变化过程中的写操作不断记录下来,这就是 AOF。

RDB

RDB 可以理解成某个时间点的数据快照。Redis 会把当前内存中的数据按照自己的二进制格式写入 RDB 文件,因此它保存的是一份紧凑的二进制数据快照,而不是一条条 Redis 命令。Redis 重启时,可以直接读取这份文件,把数据重新加载到内存中。RDB 的优点是文件通常比较紧凑,备份和恢复速度也比较快;缺点是它是阶段性生成快照的,如果两次快照之间发生宕机,那么这段时间内新增或修改的数据可能会丢失。

RDB 可以通过 SAVE 或 BGSAVE 触发。SAVE 会由主线程直接生成快照,因此执行期间会阻塞 Redis,生产环境一般不常用;BGSAVE 则会通过 fork 创建子进程,由子进程负责把当前内存数据写成 RDB 文件,主进程继续处理客户端请求。这里会用到操作系统的 Copy-On-Write(写时复制):刚 fork 时父子进程共享相同内存页,只有当主进程继续修改某些数据时,相关内存页才会被复制,因此不需要在 fork 的瞬间把整个 Redis 内存完整复制一份。

AOF

AOF 的思路则不同,它主要记录 Redis 执行过的写命令。例如执行了 SET、HSET、INCR 等修改数据的操作,这些命令会被追加到 AOF 文件中。不过,写命令并不是每执行一次就一定立刻同步到磁盘。写命令通常会先进入 AOF 缓冲区,之后再根据配置决定什么时候执行刷盘。Redis 主要提供三种策略:

always:每次写命令都立即同步到磁盘。数据安全性最高,宕机时几乎不会丢已经确认的写入,但频繁执行磁盘同步会明显影响性能。

everysec:通常每秒进行一次刷盘。这是性能和数据安全之间比较常见的折中方案,即使发生宕机,一般也只可能丢失最近大约一秒的数据,因此生产环境中很常见。

no:Redis 只负责把数据写入操作系统缓冲区,什么时候真正刷到磁盘完全交给操作系统决定。性能最好,但发生宕机时可能丢失更多数据。

因此,这三种策略本质上是在做性能和持久性之间的权衡:刷得越频繁,数据越安全,但磁盘同步开销越大;刷得越少,性能越好,但能够接受的数据丢失窗口也越大。

Redis 重启时,可以通过重新执行这些写命令,把数据恢复到宕机前的状态。AOF 通常可以做到比 RDB 更小的数据丢失窗口,但日志会不断增长,而且其中可能包含很多已经没有意义的历史操作。

例如一个 key 可能被连续修改很多次:

1
2
3
4
SET age 10
SET age 20
SET age 30
SET age 40

从最终结果来看,其实只需要保留:

1
SET age 40

因此 Redis 会进行 AOF Rewrite(AOF 重写)。重写并不是简单地把旧日志做文本压缩,而是根据当前内存中的最终数据状态,重新生成一份能够恢复出相同结果、但更加精简的 AOF 文件,从而去掉大量已经失去意义的历史命令。

AOF Rewrite 为了避免重写过程中阻塞主进程,Redis 通常会通过后台子进程完成 AOF 重写。重写期间主进程仍然可以继续处理新的写请求,这些新的修改会被额外记录,等后台重写完成后,再把重写期间产生的新操作合并进去,最后用新的 AOF 文件替换旧文件。

RDB 和 AOF 本质上代表了两种不同的持久化思路。RDB 保存的是某一个时间点的数据快照,文件紧凑、恢复速度快,适合做备份和快速恢复,但两次快照之间的数据可能丢失;AOF 保存的是数据变化过程中的写操作,能够把数据丢失控制在更小范围,但文件通常更大,恢复速度也相对慢一些。因此,两者关注的重点不同:RDB 更偏向恢复效率和文件紧凑性,AOF 更偏向数据完整性和更小的数据丢失窗口。实际使用时,并不一定只能二选一,也可以同时开启两种持久化方式,让 RDB 负责周期性快照和快速恢复,让 AOF 提供更细粒度的数据保护。

Redis 后来的版本还支持将 RDB 和 AOF 的特点结合起来,在 AOF 重写时先保存一份 RDB 格式的基础数据,再追加后续的 AOF 增量命令。这样既可以减少 AOF 文件体积、提高恢复速度,又能继续保留 AOF 较小的数据丢失窗口。

缓存穿透、击穿和雪崩

Redis 作为缓存时,正常流程通常是:先查询 Redis,如果命中就直接返回;如果没有命中,再访问数据库,并把查询结果重新写入缓存。缓存本身的作用就是减少数据库压力,因此一旦大量请求同时绕过缓存直接访问数据库,就可能把数据库压垮。根据产生原因不同,常见的问题主要有缓存穿透、缓存击穿和缓存雪崩。

缓存穿透指的是请求的数据本身就不存在。比如有人不断请求一个根本不存在的用户 ID,那么每次查询 Redis 都不会命中,继续访问数据库后也查不到结果,于是请求会不断“穿过”缓存打到数据库。如果请求量很大,就会给数据库造成很大压力。最简单的解决方式是缓存空值:数据库查不到数据后,也在 Redis 中保存一个短时间的空结果,这样后续相同请求就不需要再次访问数据库。不过空值缓存无法很好处理大量随机不存在 key 的攻击,因此还可以使用布隆过滤器。布隆过滤器提前保存系统中可能存在的 key,请求到来时先判断这个 key 是否可能存在,如果布隆过滤器直接判断“不存在”,就可以在访问 Redis 和数据库之前直接返回。需要注意的是,布隆过滤器可能存在误判,即它说“可能存在”的数据实际上不一定存在,但它判断“不存在”的数据一定不存在。

缓存击穿指的是某一个访问量特别大的热点 key 在失效瞬间,大量请求同时访问数据库。比如某个热门商品信息平时每秒有十万次请求,这些请求正常都会被 Redis 拦住,但如果这个 key 突然过期,那么大量请求会同时发现缓存不存在,并一起查询数据库。这时问题不是“数据不存在”,而是热点数据在某个瞬间失去了缓存保护。常见解决方式是使用互斥锁,只允许第一个发现缓存失效的请求去数据库加载数据并重建缓存,其他请求等待缓存恢复;也可以采用逻辑过期,即 Redis 中的数据不真正删除,而是在 value 中保存一个逻辑过期时间,发现数据已经过期时先返回旧数据,同时由某个线程异步更新缓存,从而避免大量请求同时打到数据库。热点数据也可以直接设置为不过期,再通过后台任务主动更新。

缓存雪崩指的是大量缓存数据在短时间内同时失效,或者 Redis 整体不可用,导致大量请求一起回源数据库。比如系统在启动时一次性写入了一批缓存,并统一设置 30 分钟过期,那么 30 分钟后这些 key 可能同时失效,大量请求瞬间涌向数据库。解决方式之一是给缓存过期时间增加随机值,例如基础过期时间是 30 分钟,再随机增加几分钟,把不同 key 的失效时间打散;对于核心热点数据,可以使用不同的过期策略甚至不过期。同时,雪崩也可能来自 Redis 整体故障,因此还需要通过主从复制、哨兵或 Cluster 提高 Redis 可用性,并在应用层配合限流、熔断、降级,防止 Redis 故障时所有流量直接压到数据库。

因此,这三个问题虽然最终表现都是“请求大量访问数据库”,但原因并不一样:缓存穿透是数据本身不存在,缓存击穿是单个热点 key 突然失效,缓存雪崩则是大量 key 集中失效或者 Redis 整体不可用。 对应的解决思路也分别是:穿透重点防止无效请求继续访问数据库,击穿重点避免大量请求同时重建一个热点缓存,雪崩重点避免大面积缓存同时失效并提高整个缓存系统的可用性。

缓存与数据库一致性

Redis 和 MySQL 中保存的是同一份数据的不同副本,因此只要数据发生更新,就会产生一个核心问题:数据库和缓存之间如何保持一致。 但由于 MySQL 和 Redis 是两个独立系统,更新数据库和操作缓存无法天然组成一个原子事务,所以严格的强一致性通常很难保证,实际系统更多追求的是最终一致性。

最常见的方案是 旁路缓存(Cache Aside Pattern):读请求先查 Redis,缓存未命中时再查 MySQL,并把结果回填到 Redis;写请求则先更新 MySQL,再删除 Redis 中对应的缓存。之所以选择删除缓存而不是直接更新缓存,是因为缓存通常只是数据库的副本,直接更新缓存不仅增加维护成本,还更容易在并发写入时因为执行顺序不同而产生旧值覆盖新值的问题。删除缓存后,下一次读取会重新从数据库加载最新数据,逻辑相对简单。

但先更新数据库,再删除缓存并不能保证绝对一致。最明显的问题是:数据库更新成功后,删除缓存这一步可能失败,此时数据库已经是新值,而 Redis 仍然保存旧值。对于这种情况,通常需要对删除操作进行重试,例如把删除缓存的任务发送到消息队列,或者通过订阅 Binlog / CDC 监听数据库变更,再异步删除或更新缓存。这样即使第一次删除失败,也能保证后续继续尝试。

即使两步都没有失败,在极端并发情况下仍然可能产生不一致。比如一个读请求发现缓存未命中,于是从数据库读取到了旧值,但还没有来得及写入 Redis;此时另一个写请求更新了数据库并删除了缓存;随后前面的读请求才把刚刚读到的旧值重新写入 Redis。这样最终仍然可能出现“数据库是新值,缓存却是旧值”的情况。

为了进一步缩小这种并发窗口,可以使用延迟双删。思路是:更新数据库之前先删除一次缓存,更新数据库完成后等待一小段时间,再删除一次缓存。第一次删除是为了减少旧缓存继续被读取,第二次延迟删除则是为了把在更新过程中可能被并发读请求重新写入的旧缓存再次清除。

不过延迟双删也不是严格的一致性方案。首先,等待多久很难精确确定;其次,如果第二次删除失败,仍然可能存在旧缓存;而且它会增加系统复杂度。因此延迟双删更适合作为一种降低脏缓存概率的手段,而不是强一致保证。

另外一种思路是加锁或使用版本号。比如同一个 key 的读写操作通过分布式锁进行串行化(锁的是Redis + MySQL 这一整段业务操作),但这样性能太慢,严重违背了redis的初衷。另一种方式是在缓存中保存版本号,回填缓存时只有版本更新的数据才能覆盖旧版本。这样可以进一步减少并发导致的数据覆盖问题,但代价需要额外维护版本信息和一致的更新规则。

实际系统里缓存一致性通常依靠多层机制共同保证:以 MySQL 作为真实数据源,写操作采用更新数据库后删除缓存;删除失败通过重试、消息队列或 Binlog/CDC 保证最终执行;缓存设置合理的 TTL,即使出现极端情况下的旧数据,也会在一段时间后自动过期并重新从数据库加载。TTL 可以看作最后一道兜底,防止错误缓存长期存在。

所以缓存与数据库一致性的本质并不是找到一个绝对不会出错的更新顺序,而是:通过更新顺序、重试、延迟双删、版本控制和 TTL 等手段,不断缩小不一致窗口,并最终保证缓存能够回到与数据库一致的状态。

过期与淘汰策略

前面提到,为了避免缓存中的旧数据长期存在,通常会给缓存设置 TTL,让数据在一定时间后自动过期。但 key 到期并不意味着 Redis 会在这一瞬间立刻把它删除,因为如果专门为每个 key 定时删除,会带来较大的调度和性能开销。因此,Redis 需要一套过期删除策略来决定已经过期的 key 什么时候真正清理;而即使大量过期数据已经被清理,如果 Redis 使用的内存仍然达到上限,还需要进一步决定哪些还没有过期的数据应该被淘汰,把内存让出来,这就是内存淘汰策略。

过期删除策略

Redis 中的 key 即使设置了 TTL,到期后也不一定会立刻从内存中删除。因为如果为每个 key 都维护一个精确的定时任务,到期就马上执行删除,那么当 key 数量很多时,会带来较大的定时器管理和 CPU 开销。因此,Redis 主要通过惰性删除 + 定期删除来处理过期 key。

惰性删除是指,Redis 平时并不会主动检查所有 key,而是在客户端访问某个 key 时顺便判断它是否已经过期。如果已经过期,就把它删除,并按照 key 不存在来处理。这样做几乎不会额外消耗大量 CPU,但问题是,如果某些过期 key 之后再也没人访问,它们就可能继续占用内存。

因此 Redis 还会进行定期删除。后台会周期性地从带有过期时间的 key 中随机抽取一部分进行检查,并删除其中已经过期的 key。这里并不是抽样一次就一定结束:如果这一轮抽样发现过期 key 的比例比较高,说明当前很可能还存在大量已经过期但尚未清理的数据,那么 Redis 会继续进行下一轮抽样和删除;如果发现过期比例已经比较低,或者本轮清理消耗的时间达到一定限制,就会暂时停止,把 CPU 让给正常请求处理,之后再继续下一轮定期清理。

所以定期删除本质上是一种自适应清理机制:过期数据多的时候多清理一些,过期数据少的时候少花 CPU。它不会一次扫描所有 key,而是在内存回收效率和 CPU 消耗之间做平衡。

内存淘汰策略

当 Redis 使用的内存达到 maxmemory 上限后,如果还需要继续写入数据,就必须决定应该淘汰哪些 key,这就是内存淘汰策略。它和过期删除不同:过期删除针对的是已经过期的数据,而内存淘汰针对的是“内存已经不够了,现在应该删谁”。

Redis 常见的淘汰策略可以分成几类。noeviction 表示不主动淘汰任何数据,当内存达到上限后,新的写操作直接报错;allkeys-lru 会从所有 key 中优先淘汰最近最少使用的数据;allkeys-lfu 会优先淘汰访问频率最低的数据;allkeys-random 则从所有 key 中随机淘汰。除此之外,还有只针对设置了过期时间的 key 进行淘汰的策略,例如 volatile-lru、volatile-lfu、volatile-random,以及优先淘汰剩余 TTL 更短数据的 volatile-ttl。

其中比较常见的是 LRU 和 LFU。**LRU(Least Recently Used)**关注的是“最近一次什么时候被访问”。如果一个 key 很久没有被访问,就说明它当前可能已经变冷,更适合被淘汰。理论上的精确 LRU 通常需要维护一个全局链表,每次访问 key 都要调整它在链表中的位置,但这样会增加额外的内存和维护成本。因此 Redis 使用的是近似 LRU:每次需要淘汰数据时,从一部分 key 中进行采样,然后比较这些 key 的最近访问时间,优先淘汰其中最久没有被访问的 key。通过增加采样数量,可以让结果更接近真正的 LRU,但同时也会增加一点 CPU 开销。

**LFU(Least Frequently Used)**关注的则是“这个 key 被访问得频繁不频繁”。Redis 会为 key 维护一个近似的访问频率信息,访问越频繁,计数会逐渐升高;长期不访问时,这个频率还会随着时间衰减,避免一个曾经非常热门、但现在已经冷掉的 key 永远因为历史访问次数高而无法被淘汰。当需要释放内存时,Redis 同样会从候选 key 中进行采样,优先淘汰访问频率更低的 key。

因此,两者最大的区别是:LRU 更看重“最近是否被访问”,LFU 更看重“长期访问频率是否足够高”。 如果业务热点变化很快,LRU 往往比较合适;如果希望长期保留真正的高频热点数据,LFU 通常会更稳定。

分布式锁

前面提到,在某些对一致性要求更高的场景下,可以通过加锁让同一个业务资源的读写流程串行执行。由于应用通常是分布式部署的,本地线程锁无法约束其他机器上的实例,因此需要一个所有实例都能共同访问的锁服务,这就引出了分布式锁。Redis 本身具备高性能、集中式访问以及原子命令等特点,因此经常被用来实现分布式锁。

Redis 实现分布式锁最基础的能力来自 SETNX,也就是 SET if Not eXists。它的含义是:只有当指定的 key 不存在时,才允许把这个 key 设置进去;如果 key 已经存在,则设置失败。由于 Redis 会原子地完成“判断 key 是否存在”和“写入 key”这两个动作,因此多个服务实例同时尝试获取同一把锁时,最终只会有一个请求成功。

SETNX 的返回值也很直接:返回 1 表示设置成功,也就是成功获取锁;返回 0 表示 key 已经存在,获取锁失败。

例如多个实例同时执行:

1
2
3
SETNX lock:order:123 requestA
SETNX lock:order:123 requestB
SETNX lock:order:123 requestC

假设 lock:order:123 一开始不存在,那么最先执行成功的请求会返回 1,并创建这个 key;之后其他请求再执行 SETNX 时会因为 key 已经存在而返回 0。因此,这个 key 就可以表示“当前资源已经被某个请求占用”,从而实现分布式环境下的互斥访问。

这里之所以不能简单地先 GET 判断锁是否存在,再执行 SET,是因为这两个操作之间存在时间窗口。可能请求 A 和请求 B 都先执行 GET,并且都发现锁不存在,然后两者又分别执行 SET,最终就会同时认为自己获取到了锁。而 SETNX 把“判断不存在”和“设置成功”合并成一个原子操作,因此避免了这种并发竞争问题。

不过,仅仅使用 SETNX 还不够。如果某个服务成功获得锁之后突然宕机,没有机会主动删除这个 key,那么这把锁就会一直存在,其他请求以后都无法获取锁。因此,分布式锁还必须考虑锁过期的问题,也就是为锁设置一个 TTL,避免因为服务异常而产生永久死锁。但这样又会带来新的问题:如果业务执行时间超过了锁的TTL,锁可能在业务还没有执行完成时就提前失效。

看门狗机制

为了避免业务执行时间超过锁的 TTL,导致锁在业务还没有完成时就提前失效,实际项目中通常会引入自动续期机制。以 Redisson 的看门狗(Watchdog)为例,当线程成功获取分布式锁后,Redisson 会在后台定期检查这把锁是否仍然由当前客户端持有,并在锁即将过期之前延长它的 TTL。

它的核心思路是:锁仍然设置有限的过期时间,用来防止客户端宕机后形成永久锁;只要当前客户端还正常运行,并且这把锁仍然属于自己,就持续给锁续期。 当业务执行完成并主动释放锁后,看门狗 就停止续期;如果客户端进程直接宕机,那么续期任务也会停止,锁最终会在 TTL 到期后自动释放。

不过,看门狗 并不能真正判断业务线程是否“健康”。比如如果业务线程或 goroutine 卡死,但应用进程仍然存活,而且负责续期的后台任务还能正常运行,那么 Watchdog 仍然可能继续给锁续期。因此,看门狗 解决的是业务执行时间不可预估导致锁提前过期的问题,而不是业务线程卡死问题。所以在实际系统中,看门狗通常还需要配合业务超时、线程超时、熔断等机制使用,必要时也可以设置明确的最大持锁时间,避免因为业务异常导致锁被长期占用。 前面解决了锁过期和自动续期的问题之后,还需要考虑另一个很重要的场景:释放锁时,怎么保证删掉的一定是自己的锁,而不是别人的锁。

假设请求 A 获取了一把锁,但由于业务执行时间较长,这把锁在某个时刻已经过期;之后请求 B 又成功获取了同一个锁。此时如果请求 A 终于执行结束,并且直接执行 DEL lock:key,那么它删除的其实已经不是自己的锁,而是请求 B 刚刚获取到的锁。这样就会破坏分布式锁的互斥性。

因此,加锁时通常不能只保存一个固定值,而应该给每次加锁操作生成一个唯一标识,例如 UUID 或 requestId,并把这个唯一标识作为锁的 value。释放锁时,先判断当前 Redis 中锁的 value 是否仍然等于自己的唯一标识,只有确认这把锁仍然属于自己时,才能执行删除。

安全释放 Redis 分布式锁通常需要把“判断锁是否属于自己”和“删除锁”放到同一个原子操作中完成。常见做法就是使用 Lua 脚本,让 Redis 在一次不可被其他命令插入的执行过程中完成 value 比较和删除操作。

可重入锁

除了正确加锁和释放锁之外,分布式锁还经常需要支持可重入。所谓可重入,是指同一个线程或同一个业务执行上下文已经获取了一把锁之后,再次请求获取同一把锁时,不应该被自己阻塞,而是允许继续进入临界区。

例如一个业务方法已经获取了 lock:order:123,在执行过程中又调用了另一个同样需要这把锁的方法。如果锁不支持可重入,那么第二次加锁时就会发现锁已经存在,从而把自己阻塞住,最终造成死锁。 因此,可重入锁通常需要记录两个信息:这把锁当前属于谁,以及已经被同一个持有者重复获取了多少次。 第一次获取锁时,计数记为 1;同一个持有者再次获取时,不重新抢锁,而是把计数加 1;每次释放锁时计数减 1,只有当计数最终变成 0 时,才真正删除 Redis 中的锁。

所以可重入锁本质上是在普通分布式锁的基础上增加了一个“持有者身份 + 重入次数”的机制。像 Redisson 这类成熟实现(采用Hash结构),就会把这些细节封装起来,让同一个线程可以安全地重复获取同一把分布式锁。 所以可重入锁本质上是在普通分布式锁的基础上增加了一个“持有者身份 + 重入次数”的机制。像 Redisson 这类成熟实现,通常会使用 Hash 等结构记录当前锁的持有者以及重入次数,从而让同一个线程能够安全地重复获取同一把分布式锁。

但对于没有获取到锁的其他请求,还需要继续考虑一个问题:拿不到锁之后怎么办? 一种方式是直接返回失败,另一种方式则是进入等待,等锁释放后再重新尝试获取,这就是阻塞式加锁。

阻塞等待通常有两种思路。最简单的是轮询:没有拿到锁的请求隔一段时间再次尝试获取锁,但这种方式会产生大量无效的 Redis 请求,竞争越激烈,额外开销越明显。另一种方式是使用发布订阅机制:获取锁失败的请求订阅与这把锁相关的通知,持锁者释放锁时发布一条“锁已释放”的消息,等待中的请求收到通知后再重新尝试获取锁。这样可以减少频繁轮询带来的无效请求。

需要注意的是,发布订阅只是负责“通知锁已经释放”,并不意味着收到通知的请求就一定能够直接获得锁。多个等待者可能同时被唤醒,因此最终仍然需要重新执行原子的加锁操作进行竞争。

当多个请求同时等待同一把锁时,又会进一步产生一个问题:锁释放后,谁应该优先获得锁? 如果按照请求到达的先后顺序分配锁,就是公平锁;如果不保证顺序,而是让等待请求重新竞争,就是非公平锁。公平锁更关注等待顺序,可以减少饥饿问题,但需要维护队列和顺序,额外开销更大;非公平锁不保证先来先得,但实现更简单,吞吐量通常也更高。

这一部分的完整逻辑就是:可重入解决“同一个持有者能不能再次获取锁”,阻塞机制解决“没拿到锁之后怎么等待”,发布订阅解决“锁释放后怎么通知等待者”,公平锁和非公平锁则解决“多个等待者谁先获得锁”。

红锁

即使前面已经解决了锁过期、自动续期、安全解锁、可重入和阻塞等待等问题,Redis 分布式锁在主从架构下仍然存在一个更底层的问题:主从切换可能导致锁丢失。

Redis 的主从复制通常是异步进行的。假设请求 A 在主节点上成功获取了锁,但这条加锁数据还没有同步到从节点,此时主节点突然宕机,从节点被提升为新的主节点。由于新的主节点上并不存在这把锁,请求 B 就可以再次成功获取同一个锁。

此时请求 A 和请求 B 都可能认为自己持有这把锁,从而同时进入临界区,破坏分布式锁原本应该保证的互斥性。

因此,普通的单 Redis 主从架构虽然可以提高 Redis 的可用性,但无法从根本上保证分布式锁在故障切换场景下的严格安全性。对于一致性要求非常高的场景,还需要考虑更强的一致性方案,例如基于多个独立 Redis 节点的红锁,或者使用 ZooKeeper、etcd 这类基于一致性协议的协调系统。 为了解决单个 Redis 主从架构在故障切换时可能丢失锁的问题,Redis 提出了红锁思路。它不再依赖某一个主节点及其从节点,而是让客户端同时向多个彼此独立的 Redis 实例申请同一把锁。

假设一共有 5 个独立 Redis 节点,客户端会依次尝试在这些节点上创建相同的锁,并记录整个加锁过程所消耗的时间。只有当客户端在超过半数节点上都成功获取锁,而且整个加锁过程耗时没有超过锁的有效时间时,才认为真正获取到了这把分布式锁。例如 5 个节点至少需要成功锁住 3 个。

这样即使其中某一个 Redis 节点宕机,或者某个节点的数据发生丢失,只要客户端仍然无法在多数节点上重新获得同一把锁,就不会轻易出现两个客户端同时认为自己持有锁的情况。

如果客户端最终没有成功获得多数节点的锁,就需要把之前已经成功创建的那些锁全部释放,避免残留锁影响后续请求。

因此,红锁的核心思想就是:不再相信单个 Redis 节点,而是通过多个独立节点的多数派结果来判断锁是否获取成功。

Redlock 通过多个独立 Redis 节点和多数派机制,降低了单个节点故障或主从切换导致锁丢失的风险。但它并不意味着分布式锁从此就变成了严格意义上的强一致锁,实际使用中仍然存在一些限制。 首先,Redlock 获取和释放锁都需要和多个 Redis 节点通信,相比普通单节点 Redis 锁,会带来更高的网络开销和延迟,部署和运维成本也更高。其次,Redlock 本质上仍然依赖锁的有效期和网络时序,如果出现长时间进程暂停、GC、严重网络延迟或网络分区等极端情况,仍然可能出现某个客户端认为自己还持有锁,而锁实际上已经过期并被其他客户端重新获取的情况。 因此,Redlock 更像是在 Redis 体系内进一步提高分布式锁安全性的一种方案,而不是绝对意义上的强一致解决方案。对于普通业务互斥,单节点 Redis 锁或者 Redisson 提供的锁通常已经足够;而对于一致性要求非常高的场景,很多系统会更倾向使用 ZooKeeper、etcd 这类基于一致性协议的协调系统。 所以 Redlock 的实际使用并没有普通 Redis 分布式锁那么普遍,本质上是因为它增加了不少复杂度和性能成本,但带来的安全保证又未必能覆盖所有极端故障场景。Redlock 通过多个独立 Redis 节点和多数派机制,降低了单个节点故障或主从切换导致锁丢失的风险。但它并不意味着分布式锁从此就变成了严格意义上的强一致锁,实际使用中仍然存在一些限制。 因此,Redlock 更像是在 Redis 体系内进一步提高分布式锁安全性的一种方案,而不是绝对意义上的强一致解决方案。对于普通业务互斥,单节点 Redis 锁或者 Redisson 提供的锁通常已经足够;而对于一致性要求非常高的场景,很多系统会更倾向使用 ZooKeeper、etcd 这类基于一致性协议的协调系统。 所以 Redlock 的实际使用并没有普通 Redis 分布式锁那么普遍,本质上是因为它增加了不少复杂度和性能成本,但带来的安全保证又未必能覆盖所有极端故障场景。

高可用

主从机制

和 MySQL、RabbitMQ 这类中间件一样,如果 Redis 只部署一个节点,那么这个节点一旦宕机,整个服务就会不可用。因此生产环境通常会部署多个 Redis 节点,通过主从复制保存多份数据副本,提高可用性。

Redis 的主从复制通常采用“一主多从”的结构。主节点负责处理写请求,并把数据变化同步给从节点;从节点主要保存主节点的数据副本,也可以承担读请求。第一次建立主从关系时,从节点通常需要先进行一次全量同步:主节点生成当前数据的 RDB 快照发送给从节点,从节点加载这份快照;在生成和传输 RDB 的过程中,主节点仍然可能继续收到新的写请求,因此这些增量写操作还需要额外记录并继续发送给从节点,保证从节点最终追上主节点。

完成第一次全量同步之后,后续通常就不需要反复传整个 RDB,而是进行增量复制。主节点会把后续产生的写命令持续发送给从节点,从节点按照相同顺序执行这些命令,从而逐渐保持和主节点一致。如果主从之间只是短暂断开,重新连接后还可以根据复制进度继续同步缺失的那部分数据,而不一定重新做一次完整同步。

不过,主从复制本身只解决了“数据有副本”的问题,并不能自动解决“主节点挂了以后谁来接管”的问题。如果没有额外机制,主节点宕机后虽然从节点里还有数据,但仍然需要人工选择一个从节点并把它提升为新的主节点。

主从复制解决了 Redis 数据副本的问题,但如果主节点宕机,仅仅有从节点还不够,系统还需要自动判断主节点是否真的不可用,并从多个从节点中选出新的主节点。这个过程就是由 哨兵 来完成的。

哨兵会持续监控主节点、从节点以及其他哨兵的状态。如果某个哨兵发现主节点在一定时间内没有正常响应,它会先认为主节点可能出现故障,这通常称为主观下线。但单个哨兵自己的判断可能受到网络抖动影响,所以还不能马上进行故障转移。只有当多个哨兵都认为主节点不可用,并达到配置要求的数量后,才会把主节点判定为客观下线。

确认主节点确实故障后,多个哨兵之间还需要先选出一个负责本次故障转移的哨兵,由它来选择新的主节点。选新主时,并不是简单随机选择,而是会综合考虑从节点是否健康、复制进度是否足够新、优先级等因素,尽量选择一个数据较完整、状态较好的从节点提升为新的主节点。

新的主节点选出来后,哨兵会让其他从节点改为复制这个新主节点,同时更新整个主从拓扑,并把新的主节点信息通知给客户端。这样,客户端后续就可以继续向新的主节点发送请求。

哨兵本身也需要部署多个。原因有两个:第一,避免哨兵自身成为单点故障;第二,避免单个哨兵因为网络抖动或局部故障而错误地认为主节点已经下线。多个哨兵可以互相交换信息,当足够多的哨兵都判断主节点不可用时,才会进入后续的故障转移流程。

哨兵和 Redis 主从节点之间并不是“一个哨兵对应一个 Redis 节点”的关系。通常是一组哨兵共同监控同一套 Redis 主从集群,每个哨兵都会关注主节点和各个从节点的状态,同时哨兵之间也会互相发现、互相通信。

Cluster集群

主从复制和哨兵主要解决的是 Redis 的高可用问题:主节点挂了,可以自动切换到新的主节点。但无论怎么主从复制,一份完整数据通常还是要放在某个主节点上,因此单机内存容量和单机处理能力仍然存在上限。

当数据量继续增大,或者单个 Redis 节点的吞吐已经无法满足需求时,就需要把数据分散到多个 Redis 节点上,这就是 Redis Cluster 要解决的问题。

Redis Cluster 不再要求每个主节点都保存全部数据,而是把整个 key 空间划分成 16384 个哈希槽(slot)。每个主节点负责其中一部分槽,一个 key 会根据自身计算出对应的槽,再根据槽找到负责这个数据的主节点。这样不同 key 就会分散到不同主节点上,整个 Redis 的存储容量和处理能力也可以随着节点数量增加而横向扩展。

Redis Cluster 同样会给每个主节点配置从节点。当某个主节点发生故障时,它的从节点可以被提升为新的主节点继续负责原来的哈希槽。

Redis Cluster 没有直接采用“节点数取模”的方式来决定一个 key 属于哪个节点,而是先把整个 key 空间固定划分成 16384 个哈希槽。这样做的好处是,key 和节点之间多了一层 slot 映射。如果以后增加或删除节点,只需要把一部分 slot 从一个节点迁移到另一个节点,而不需要重新计算所有 key 的归属关系,因此扩容和缩容会更加灵活。

一个 key 属于哪个 slot,Redis 会先对 key 计算 CRC16,然后对 16384 取模:

1
slot = CRC16(key) % 16384

计算出 slot 之后,再根据集群维护的“slot → 节点”映射找到真正负责这个 key 的主节点。客户端通常也会缓存整个集群的 slot 分布,所以正常情况下可以直接把请求发送到正确节点,而不需要每次都先找一个固定的入口节点。

如果请求打到了错误的 Redis 节点,这个节点会发现目标 key 对应的 slot 并不属于自己,于是返回一个 MOVED 重定向,告诉客户端应该去哪个节点。客户端收到后会重新把请求发送到正确节点,并更新自己缓存的 slot 映射。这样即使集群中的 slot 发生迁移,客户端也能够逐渐感知新的拓扑。

在 slot 正在从一个节点迁移到另一个节点的过程中,还可能出现 ASK 重定向。MOVED 表示这个 slot 的归属已经正式改变,而 ASK 更像是在告诉客户端:“这个 slot 正在迁移,这一次请求先去另一个节点试一下。”所以面试里可以简单区分为:MOVED 是永久重定向,ASK 是迁移过程中的临时重定向。

Redis Cluster 中,每个主节点通常都会配置一个或多个从节点,用来保存主节点的数据副本,并在主节点发生故障时接管它负责的 slot。和哨兵模式不同,Cluster 模式下不需要额外部署哨兵,故障检测和主从切换由集群内部节点自己完成。

集群中的节点会通过集群总线周期性地交换 PING/PONG 等心跳消息,并利用 Gossip 机制传播自己了解到的其他节点状态。如果某个节点在规定时间内一直没有收到另一个节点的正常响应,它不会立刻认为对方已经彻底宕机,而是先把对方标记为“疑似下线”。这个判断随后会通过 Gossip 消息传播给其他主节点。

Gossip 协议可以理解成一种局部交换、逐步扩散的状态传播机制。它不是每次都把完整的集群状态广播给所有节点,而是每个节点周期性地选择少量其他节点进行通信,并在消息中顺带携带自己知道的一部分节点状态。 例如 A 当前知道 B、C 的状态,这一次可能把这些信息告诉 D;过一段时间,A 又可能和 E 通信,同时 D 也可能继续把自己知道的信息传播给 F。这样信息会像流言一样不断扩散,最终让整个集群逐渐了解到其他节点的状态。 Redis Cluster 中这种传播并不是固定按照某条链路进行的,而是会带有一定的随机性,同时还会对长时间没有通信的节点进行主动探测,避免某些节点因为一直没有被选中而长期失联。

如果越来越多负责 slot 的主节点也认为这个节点不可达,并达到多数条件,集群才会把它正式判定为下线。之所以需要多个主节点共同确认,是为了避免因为某两个节点之间短暂的网络抖动,就错误地触发主从切换。

主节点被确认下线以后,它的从节点就会开始准备故障转移。从节点并不是直接自动变成主节点,而是需要发起一次选举,请求集群中的其他主节点给自己投票。通常复制数据越新、状态越合适的从节点越有机会更早参与选举;一个主节点在同一轮故障转移中只会投出一票。某个从节点获得足够的主节点投票之后,就会被提升为新的主节点。它随后会接管原主节点负责的全部 slot,并把新的主从关系和 slot 归属通过集群消息传播出去。其他节点更新自己的集群拓扑后,就会认为这些 slot 已经由新的主节点负责。

Redis Cluster :用固定的 16384 个 slot 把 key 和节点解耦,通过 slot 实现数据分片和扩缩容;客户端根据 slot 直接路由请求,打错节点时通过 MOVED / ASK 重定向;主节点故障时,再由其从节点完成自动故障转移并继续接管原来的 slot。

常见问题补充

BigKey 与 HotKey

BigKey 指的是单个 key 占用的内存非常大,或者集合类型中包含了非常多的元素。比如一个 String 保存了几 MB 的内容,或者一个 Hash、List、Set 中包含几十万甚至上百万个元素。BigKey 的问题不仅是“占内存多”,还会让读取、删除、序列化、主从复制和网络传输的耗时明显增加,某些大 key 操作甚至可能长时间占用 Redis 主线程,影响其他请求。

处理 BigKey 的核心思路是避免一个 key 承载过多数据。对于大 String,可以考虑拆成多个较小的 key,或者把真正的大对象放到对象存储、数据库中,Redis 只保存索引或元数据;对于超大的 Hash、List、Set,可以按照业务维度或分片规则拆成多个 key。删除 BigKey 时也要避免直接执行可能阻塞主线程的同步删除,可以使用 UNLINK 这类异步释放内存的方式。发现 BigKey 时,可以通过 Redis 自带的 key 分析工具、内存统计以及采样扫描等方式定位。

HotKey 指的是某个 key 的访问量远高于其他 key。它本身可能很小,但如果大量请求都集中访问这个 key,就会让负责它的 Redis 节点出现 CPU、网络带宽或者请求处理压力。在 Redis Cluster 中,即使有很多节点,一个 HotKey 仍然只属于某一个 slot,最终请求还是会集中到某一个主节点,因此 Cluster 本身并不能自动解决 HotKey。

HotKey 的处理思路主要是把访问压力分散出去。对于读多写少的热点数据,可以在应用进程中增加本地缓存,形成“本地缓存 → Redis → 数据库”的多级缓存,让大量请求直接在本地完成;也可以把同一份热点数据复制成多个副本 key,例如 hot:1:1、hot:1:2、hot:1:3,客户端随机选择其中一个读取,从而把压力分散到不同 slot 和节点上。对于极端热点,还可以配合限流、请求合并、异步刷新等方式降低瞬时压力。

HotKey 还需要考虑写场景。如果某个热点 key 不仅读得多,而且更新也非常频繁,那么简单复制多个副本会带来副本一致性问题,此时更适合从业务上拆分数据、降低写入集中度,或者把热点写请求做聚合后再批量更新。

pipeline/lua

当客户端需要连续执行很多条 Redis 命令时,如果每条命令都单独发送一次请求,就会产生大量网络往返。即使 Redis 本身执行命令很快,网络 RTT 也可能成为主要开销。因此 Redis 提供了 Pipeline,客户端可以一次性把多条命令批量发送给 Redis,Redis 执行完成后再把结果批量返回,从而减少网络往返次数,提高吞吐量。

不过,Pipeline 只是“批量发送和批量接收”,并不能保证这一批命令整体原子执行。在 Pipeline 中,多条命令之间仍然可能穿插其他客户端的命令,所以它主要解决的是性能问题,而不是并发一致性问题。

如果希望多条 Redis 操作在逻辑上作为一个整体执行,就可以使用 Lua 脚本。Redis 会把一段 Lua 脚本作为一个整体执行,在脚本执行过程中不会穿插其他客户端命令,因此可以把“判断 + 修改”这种多步操作组合成一个原子操作。前面分布式锁安全解锁时提到的“先判断锁是不是自己的,再删除锁”,就是一个典型的 Lua 使用场景。

因此可以简单区分:Pipeline 解决的是多条命令网络往返太多的问题,Lua 解决的是多条命令需要原子执行的问题。 两者都可以一次处理多条命令,但目标完全不同。

pub/sub和stream

Redis 除了可以作为缓存和分布式存储之外,也可以用于简单的消息通信。最早比较常见的方式是 发布订阅(Pub/Sub):生产者把消息发布到某个频道,所有订阅这个频道的消费者都会收到这条消息。

发布订阅的特点是实时、简单,适合做通知、聊天室、实时广播这类场景。但它有一个很明显的问题:消息不会持久化保存。如果某个消费者在消息发布时不在线,那么这条消息之后就无法再收到;同时它也没有完善的消息确认、消费进度、消费组等机制。因此,发布订阅更像“广播”,而不是一个可靠的消息队列。

为了解决这些问题,Redis 后来提供了 Stream。Stream 可以理解成一个按时间追加的消息日志,生产者写入的消息会保存在 Stream 中,而不是发送完就消失。消费者即使晚一点上线,也可以从之前的位置继续读取消息,因此不会像发布订阅那样因为暂时离线就直接丢失消息。

Stream 还支持消费者组(Consumer Group)。同一个消费者组中的多个消费者可以共同处理一批消息,每条消息通常只交给组内某一个消费者处理,从而实现任务分摊。Redis 还会记录哪些消息已经被消费者取走但还没有确认,这些消息会进入待确认列表,消费者处理成功后再进行确认。如果某个消费者宕机,还可以把长时间未确认的消息交给其他消费者重新处理。

底层原理: Redis 会维护“频道 → 订阅者”的映射关系。客户端执行 SUBSCRIBE 后,就被登记到对应频道的订阅列表中;当某个客户端执行 PUBLISH 时,Redis 找到这个频道当前所有订阅者,把消息逐个推送过去。因为消息只是当场转发,并没有写入持久化队列,所以订阅者不在线时就收不到。 Stream 则不同,它本质上是一个可持久保存的追加日志结构。每条消息都会有一个递增的 ID,消息按照 ID 有序保存。消费者读取时不是“等 Redis 临时推一条消息过来”,而是根据 ID 去读取 Stream 中已经存在的消息。消费者组还会额外维护每个组的消费进度,以及已经投递但尚未确认的消息列表,也就是 Pending Entries List。这样 Redis 才能知道“哪些消息已经被谁拿走、哪些还没确认”,从而支持故障后的重新消费。

Redis 事务

Redis 也提供事务机制,但它和 MySQL 的事务并不是一个概念。Redis 事务的核心并不是完整的 ACID,而是把多条命令先排队,再一次性按照顺序连续执行。

事务通过 MULTI 开始。执行 MULTI 之后,后续命令不会立即执行,而是先进入事务队列;当执行 EXEC 时,Redis 才会按照进入队列的顺序依次执行这些命令。在执行过程中,其他客户端的命令不会插入到这组命令中间。如果中途不想继续,也可以通过 DISCARD 丢弃整个事务队列。

例如:

1
2
3
4
5
MULTI
INCR user:1:score
HSET user:1 status active
EXPIRE user:1 3600
EXEC

这种场景下,我们已经明确知道要执行哪几条命令,只是希望它们连续执行,因此 MULTI/EXEC 就比较合适。

不过,Redis 事务和 MySQL 最大的区别是:它没有完整的回滚机制。如果事务中的某条命令在真正执行时发生错误,前面已经成功执行的命令不会被撤销,后面的合法命令通常仍然会继续执行。因此 Redis 事务并不能提供 MySQL 那种“失败后全部恢复到事务开始前状态”的能力。

Redis 还提供了 WATCH 来实现类似乐观锁的并发控制。可以在执行事务前先监控某些 key,如果这些 key 在 WATCH 之后、EXEC 之前被其他客户端修改,那么当前事务的 EXEC 就会失败,从而避免基于旧数据继续执行更新。

因此,Redis 事务更适合固定的一组命令需要连续执行的场景;如果操作中存在先判断,再根据结果决定下一步这类逻辑,Lua 脚本通常会更灵活,因为 Lua 可以把判断、分支和修改放在一个原子脚本中完成。

所以可以简单理解为:MULTI/EXEC 解决的是多条固定命令连续执行的问题,WATCH 提供乐观并发控制,而 Lua 更适合复杂的条件判断和原子操作。