☰
Redis高可用与原子性实战:从主从复制到分布式锁的架构进阶
2026/10/3 4:16:47 网站建设 项目流程

Redis 这个东西,会用的和用好的,中间隔着的距离比很多人想象的要大。我在生产环境里见过太多案例:单机 Redis 跑了两年风平浪静,结果流量一上来、主节点一宕机,整个链路直接断掉;也见过把分布式锁写在业务代码里,用setnx+expire凑合了一个多月,直到某次锁超时误删,订单数据错乱之后才回头补课。这篇内容就是围绕两条主线展开的——高可用集群怎么搭、原子性保障怎么落到实战里,中间会穿插很多我实际踩过的坑和最终沉淀下来的方案。适合已经能熟练操作 Redis 基础命令、但想在架构层面往前再走一步的开发者,也适合正在准备系统架构类面试、需要把 Redis 底层逻辑讲明白的人。

1. 先想明白:高可用到底要防住什么

很多人一上来就搭 Sentinel、搭 Cluster,结果连“高可用防的是什么”都没想明白。高可用不是“数据不丢”,也不是“速度更快”,它的核心目标只有一个:当某个节点不可用时,系统仍然能够对外提供服务。这里面的关键词是“对外连续可用”,至于数据会不会短暂不可见、会不会丢失少量数据,那是另一个维度的问题。搞清楚这一点,后面所有的方案选型才有判断依据。

1.1 单机 Redis 的三个核心瓶颈

单机部署看着简单,实际上把三个风险全部压在了你身上。第一个是单点故障,进程一挂、机器一断电、内存故障,整个服务直接不可用,如果业务链路里所有请求都打到这一个节点,那 Redis 一挂就是全站性的雪崩。第二个是容量上限,内存是有物理上限的,单机 64GB 已经不少,但你的数据量涨到 200GB 呢?加内存是可以,但单机内存在性能和成本上都有天花板。第三个是并发瓶颈,Redis 虽然号称单机 10 万 QPS,但这是有前提的,一旦遇到大量耗时命令(比如带KEYS的模糊匹配、大 key 的删除、复杂的 Lua 脚本),单线程的事件循环就会被阻塞,QPS 直接掉一个数量级。

这三个瓶颈决定了,单机 Redis 只适合两种场景:要么是数据量、并发量都极小的内部工具系统,要么是作为开发环境的缓存实例。但凡进入生产环境,往高可用架构走就是迟早的事,区别只在于你是主动升级,还是等故障逼着你升级。

1.2 主从复制:数据冗余是第一级防线

主从复制是 Redis 高可用的地基。一个主节点负责处理写请求,多个从节点同步主节点的数据,读请求可以分流到从节点上,而且一旦主节点挂了,从节点可以提升为新的主节点,这个过程虽然还需要人工介入,但至少数据有了副本。

它的核心工作机制分为两块:全量同步和增量同步。从节点第一次连接主节点,或者断开太久导致复制偏移量不在主节点的复制积压缓冲区范围内时,会触发全量同步。主节点执行BGSAVE生成 RDB 快照发给你从节点,同时把生成快照期间产生的新写命令记录在缓冲区里,跟随快照一起发过去。从节点加载完 RDB 后,再执行这些增量命令,就追上了主节点的数据状态。增量同步则是主节点把每个写命令同步写入复制积压缓冲区(repl_backlog),然后异步发给从节点。只要从节点的偏移量还落在这个缓冲区里,重连之后就能通过PSYNC做部分重同步,不必再来一次全量。

这里有一个值得深挖的细节:Redis 的主从复制是异步的。主节点执行完一条写命令后,会立刻返回给客户端,同时才把命令发给从节点。也就是说,从节点永远比主节点慢一拍。正常情况下这一个拍子只有几毫秒,但要是网络抖动、从节点负载过高,延迟会迅速拉大。生产环境里,我建议把repl-backlog-size从默认的 1MB 调大到 32MB 以上,这样在网络抖动导致从节点断连重连时,能更多命中增量同步,避免反复全量同步给主节点带来额外压力。

1.3 异步复制带来的数据丢失窗口

异步复制最大的代价就是:主节点一旦宕机,还没来得及同步到从节点的写命令就会丢失。这个窗口有多小?正常情况下只有几毫秒,但你不能因为“小”就无视它。比如双十一的秒杀页面,几毫秒内就有几千个请求,这里的库存扣减数据一旦丢了,对账的时候就是大事故。

Redis 官方其实给了两个降低丢失概率的参数:min-replicas-to-write和min-replicas-max-lag。意思就是,主节点写入时至少要保证 N 个从节点延迟不超过 M 秒,否则主节点拒绝写入。设置成min-replicas-to-write 1、min-replicas-max-lag 10,可以理解为“至少有一个从节点的延迟不超过 10 秒,才能执行写入”。这套配置本质上是拿可用性换一致性:从节点全部掉线时,主节点直接拒绝写操作,不再接收数据。代价也很明显,如果两个从节点都挂了,你的 Redis 就把自己锁死了,业务整个写不进去,这时候你必须在“短暂不可写”和“静默丢数据”之间做选择。

我在实际项目中比较务实:普通的用户会话缓存、读多写少的业务数据,容忍几毫秒的丢失问题不大;但库存、余额这类资金和资产相关的数据,绝不能只依赖 Redis 做主存储,要么用事务型数据库兜底,要么采用下文会讲的原子性方案并从架构上规避丢失窗口。这属于完全不同的设计决策。

2. 哨兵与集群:把故障转移变成“自动挡”

主从复制只是解决了数据冗余,节点挂了还是得人工介入。Sentinel(哨兵)系统的目标,就是把“主节点挂了、从节点顶上”这个过程自动化。Cluster(集群)则更进一步,把数据分片到多个主节点上,同时自带故障转移能力。这两者是两套独立的方案,解决的问题不同,部署形态也不同,别混用。

2.1 Sentinel 如何仲裁故障

Sentinel 是一个独立的 Redis 进程,它不存储业务数据,专职监控主从节点的状态。一个高可用架构里,最少要部署三个 Sentinel 实例,奇数个节点可以保证仲裁投票时能形成多数派。每个 Sentinel 以每秒一次的频率向主节点和从节点发送 PING,如果主节点在down-after-milliseconds时间内没有响应,这个 Sentinel 会把它标记为主观下线(sdown,subjective down)。注意,单个哨兵说主节点挂了不算数,它需要和其他哨兵沟通,当达到quorum数量时,才将主节点标记为客观下线(odown,objective down),这时才会触发一次故障转移流程。

故障转移的第一步,是哨兵们内部先选出一个 leader 来执行切换,这个过程用的是 Raft 共识算法,可以保证在多个哨兵同时发现故障时不至于各干各的。选出的 leader 哨兵会在从节点里挑选一个提升为新主节点,挑选顺序是:slave-priority配置优先,其次看复制偏移量(数据最接近失效主节点的优先),最后看 runid 的字典序。之后剩余从节点会重新指向新主节点,开始新的主从复制,客户端也会通过哨兵的发布订阅机制感知到主节点地址的变化。

我在生产环境里部署了一套 3 节点 Sentinel + 1 主 2 从的架构,但第一次演练切换时发现一个问题:down-after-milliseconds设置的是 5000ms,加上哨兵之间的仲裁和故障转移流程,整个自动切换过程要花掉 10~15 秒的时间。这意味着主节点发生故障后,业务端会有最多 15 秒的不可用窗口。如果你的业务只能接受 5 秒内的故障转移,就要把这个参数调小到 1000ms 左右。但调小也会带来另一个风险:网络瞬时抖动就可能触发误判和频繁切换,反而更不稳定。这个参数没有标准答案,需要根据网络环境来压测调优,别拿配置模板直接上生产。

2.2 Cluster 集群的分片与扩容逻辑

Cluter 模式解决的是容量和并发规模的问题。它的核心机制是槽位(slot):整个集群固定有 16384 个哈希槽,每个 key 通过CRC16(key) % 16384计算属于哪个槽,然后集群管理员把不同的槽范围分配给不同的主节点。客户端访问任意节点,如果 key 不在这个节点上,会返回MOVED重定向指令,客户端根据这个重定向去正确的节点访问。这个分片方案有几个好处:数据分布均匀、扩容时迁移单位固定、不用像一致性哈希那样靠虚拟节点去平衡分布,可控性强。

之前有人问过我为什么 Redis 是 16384 个槽而不是更多。因为集群节点之间每隔一段时间要交换状态信息,用的是 Gossip 协议,如果槽位数量太多,心跳包携带的位图信息会变得很大,网络带宽的浪费就比较明显。16384 这个数在“数据分布精度”和“网络通信开销”之间取到了一个平衡点,是官方压测权衡后的选择。

Cluster 的在线扩容我实际做过一次,流程上并没有想象中复杂:新节点加入集群,然后从现有节点迁移一部分槽位过去。迁移是逐槽进行的,每个槽的数据会以 slot 为单位用MIGRATE命令搬走。但迁移期间有个坑,如果某个槽的数据还在迁移中,客户端访问时可能会收到ASK重定向,客户端需要先发ASKING命令再访问,这个过程对封装不完整的客户端库来说会表现为偶发超时。所以在扩容之前,建议先把自动迁移的并发度调低,比如redis-cli --cluster resize时可以指定--cluster-pipeline和--cluster-replace,避免迁移流量挤占正常请求带宽。

2.3 部署高可用集群的实操心得

第一次搭 Sentinel 或 Cluster 时,建议先在小网络环境里完整演练一遍,再上生产。我总结了几条心得:

  • Sentinel 节点要放在独立的基础设施上,别跟 Redis 业务实例混部署在同一台物理机。否则机器一挂,Redis 和哨兵一起没了,高可用就变成了空话。
  • Cluster 至少部署 3 主 3 从,6 个节点起步。这样任意一个主节点挂掉,都能有从节点顶上;否则只有 3 主没有从,主节点挂一台,集群就会因为槽位不完整而部分不可用。
  • 客户端必须启用集群模式的连接池。如果你用的是 Jedis,要用JedisCluster;如果用 Lettuce,需要设置setValidateClusterNodeMembership(false)等参数来适配节点变动。普通JedisPool连集群是不认识的,会频繁报MOVED。
  • Cluster 模式下 multi-key 操作变难了。MGET、MSET、事务、Lua 脚本里如果涉及多个 key,这些 key 必须存在同一个槽里,否则直接报CROSSSLOT错误。官方给的方案是用哈希标签(hash tag),比如{product}:1001和{product}:1002都按大括号里的内容算槽位,可以保证落到同一个节点。
  • 一定要配置持久化,即使有从节点。主从架构里如果两边都配置save ""完全不开持久化,主节点一挂,从节点接管的副本数据也可能因为重启丢失。建议至少开启 AOF,并设置appendfsync everysec,在性能与安全性之间取一个合理的平衡。

3. 原子性从哪里来:单线程模型与原子命令

聊完整性,再聊聊原子性。很多人把 Redis 的原子性等同于编程语言里的“synchronized”或者数据库里的“事务”,这是个常见的误区。Redis 的原子性本质上来自它的单线程命令执行模型:同一时刻只有一个命令在真正执行,不会出现两个命令同时修改同一个 key 的情况。但这里要特别注意,单个命令是原子的,多个命令拼接起来就不是了。比如“先 GET 一个值,在本地加 1,再 SET 回去”这三步,中间完全可以插入其他客户端的命令,最终结果就可能是错的。所有原子性保障手段,本质上都在解决“如何把多个操作合并成一个不可分割的执行单元”这个问题。

3.1 Redis 的原子性本质

先澄清一点,Redis 6.x 之后引入了多线程 I/O,很多人就说“Redis 不再单线程了”,这话对但不全对。多线程化的只是网络读写和协议解析部分,命令本身的执行还是在单线程的事件循环里完成。也就是说,一条命令从开始执行到执行完,中间CPU不会被其他命令抢占。这个“不可中断”的特性,才是 Redis 一切原子性操作的底层来源。

这有个很直观的类比:就像单人食堂窗口,每次只有一个人能拿到菜,一份菜没打完之前,后面的人只能排队等着。这个模型让 Redis 的处理逻辑非常简单,不需要像数据库那样做繁重的锁管理和并发控制,所以在正常负载下能做到极高的吞吐量。

但代价也很明显:一条慢命令(比如KEYS *、对大 key 做求并集等操作)如果占用了事件循环,后面所有命令都得排队等着执行。所以在 Redis 生产环境里有两条铁律:一是官方文档极力劝你别在生产上用KEYS *,就是因为它会阻塞整个 Redis;二是对大 key 的删除(比如几千万元素的 hash 键),要用UNLINK命令将它放到后台线程异步删除,而不是DEL一把梭。

3.2 INCR 这类原子命令的底层逻辑

理解了单线程模型,再看INCR这类命令就很顺了。INCR key的语义是“把 key 的值加 1 并返回新值”,在关系型数据库里需要经历“读取-加1-写回”三步,但 Redis 把这三步封装在了一个命令里,单线程执行下它就是不可分割的。同理还有INCRBY、DECR、HINCRBY、GETSET等。

这类原子命令在实战里最有代表性的场景就是计数器:点赞数、PV 统计、接口调用次数、库存扣减。拿库存举例,你用DECR stock_key一个命令就完成了扣减,天然不会超卖,比起“GET当前库存 -> if库存>0 -> SET新库存”这套组合拳,既快又安全。我见过太多人在这上面踩坑,写了 10 几行 Java 代码去“懒加载地”扣库存,结果高并发下一测就超卖,最后把逻辑改成一条DECR就全好了。

不过使用DECR/INCR做库存扣减时,要额外处理“库存不能为负”的约束。如果你扣到负数才发现库存不足,那就晚了。常见做法是先用DECR扣减,发现返回值为负数时,再INCR加回来并提示库存不足。这一步虽然分了两个命令,但都是在自己业务代码里串行执行的,并发量大的时候会出现“明明扣到负数了,还让很多人等”的隐性偏差。更稳妥的做法是把判断逻辑写进 Lua 脚本,保证判断和扣减作为一个整体原子执行。

3.3 RedisTemplate increment() 报错排查实录

和原子命令直接相关的一个高频报错,就是 Spring Boot 里用RedisTemplate调increment()时抛出的ERR value is not an integer or out of range。很多人在这个问题上卡了很久,因为检查 Redis 里存的 value,明明就是一个数字字符串“100”,可increment()就是报错。这个问题大概率是你踩中了 RedisTemplate 的默认序列化机制。

默认情况下,RedisTemplate使用的是JdkSerializationRedisSerializer,key 和 value 都会经过 Java 对象序列化后写入 Redis。值真正落到 Redis 里的时候,前面会带上一堆类描述信息的二进制头,看起来根本不是纯数字字符串。INCR命令要求 value 是整型字符串,拿到一串乱码自然就报“not an integer or out of range”了。解决办法有三种:

  • 最彻底的办法是直接改用StringRedisTemplate,它对所有 key 和 value 都使用StringRedisSerializer,天然适配INCR这种需要数字字符串的命令。
  • 如果项目中必须用RedisTemplate来做 hash 等复杂结构就得换 HashOperations 时,可以单独给 value 设置GenericJackson2JsonRedisSerializer或Jackson2JsonRedisSerializer,但要保证这个 key 的 value 全程只使用统一序列化器,避免读出来类型不对。
  • 还有一种隐藏很深的坑,就是同一个 key 被两种方式写入过,比如线上调试时用命令行set key 100,业务代码里却用RedisTemplate写入,两边序列化方式不一致,导致数据虽然看着“对”,实际存储的格式却对不上。

这个报错想表达的“坑”其实是序列化和底层存储格式之间的概念错位,绝不是INCR命令本身的问题。排查的时候先确认数据的真实二进制格式,通常用可视化客户端看一眼,或者用redis-cli执行type key、get key看看能否拿到正常结果,思路就清晰了。

3.4 Lua 脚本:把多步操作捆绑成一步

如果原子命令解决不了你的问题——比如你的操作逻辑是“先判断余额是否充足,再扣减,最后记录流水”,这时候一条命令就搞不定了。Redis 从 2.6 版本开始提供 Lua 脚本支持,核心接口是EVAL。脚本在服务端执行的过程中,不会被其他命令打断,整个脚本就是一个不可分割的原子单元,这就相当于 Redis 的“存储过程”,可以把多个读写操作封装成事务块。

使用 Lua 脚本有几个要点要提醒:

  • 脚本里要访问的 key 必须通过KEYS[]数组传入,其他参数通过ARGV[]传入。这个设计不光是规范问题,更重要的是,在 Cluster 模式下脚本涉及的所有 key 必须落在同一个槽位内。如果你把 key 写在脚本字符串里,那 Redis 是不会帮你计算槽位的,跨槽执行的脚本直接报CROSSSLOT错误。
  • Lua 脚本执行过程中出错,不会回滚已经执行的写操作。比如脚本先SET了一个 key,再INCR一个不存在的 key 时报错,前一条SET是不会撤销的。所以写脚本时要特别留意逻辑顺序和前置条件判断,别指望“事务回滚”这种数据库概念。
  • 脚本不是越快越好。如果脚本逻辑里做了耗时的计算,比如在一个大数据量的集合里循环判断,它阻塞的就不只是一条命令,而是后面所有客户端的所有命令。

下面就是一个我很常用的“余额扣减并校验”脚本,可以当作模板参考:

-- KEYS[1]: 余额 key -- ARGV[1]: 扣减金额(字符串形式的数字) local balance = tonumber(redis.call('GET', KEYS[1]) or '0') local amount = tonumber(ARGV[1]) if balance < amount then return -1 end redis.call('DECRBY', KEYS[1], amount) return balance - amount

在 Java 里调用时,注意DefaultRedisScript的返回值类型要声明清楚,如果返回 Long,但脚本里某个分支返回了字符串,反序列化时很容易报类型转换异常,这个我在实战中遇到过,排查起来略费时间。

4. 分布式锁:集群世界里的原子性终极考验

分布式锁是“Redis 原子性”这个主题里最有实战价值、也最容易写出问题的一个场景。实现一个可用的分布式锁,本质上就是“在多个进程之间,利用 Redis 的某一个原子操作,保证同一时刻只有一个客户端能持有锁”。很多人以为SETNX一把锁就完事了,实际上分布式锁的坑远超想象。

4.1 SET NX EX 的正确姿势

最基础的实现逻辑是:多个客户端争抢同一个 key,谁用SET key value NX成功,谁就拿到了锁;释放时删除这个 key 就完事。但古老的写法是用SETNX加锁,再单独用EXPIRE设置过期时间。这两条命令是分开执行的,一旦在SETNX成功之后、执行EXPIRE之前,客户端进程崩了或者网络断了,这个 key 就永远没有过期时间,锁就变成了“僵尸锁”,后续所有客户端都拿不到锁。

所以 2.6.12 之后官方推荐的方式,是把加锁和过期时间合并成一条命令:

SET lock_key unique_token NX EX 30

这里有几个关键点要注意:

  • value必须是一个全局唯一的随机串(比如 UUID),不能所有客户端都用同一个值。这个值是用来安全释放锁的身份凭证,防止你释放了别人的锁。
  • EX 30表示锁的自动过期时间是 30 秒,防止持有锁的线程崩溃后锁永不释放。
  • NX 是只在 key 不存在时才写入,保证同一时刻只有一个客户端能成功。

这里的过期时间是一个“保底手段”,不是让你业务正好 30 秒执行完。如果业务逻辑执行时间超过了锁的过期时间,锁就自动释放了,下一个请求就能拿到锁,这时候前面那个还在跑的线程后面释放锁时,就会面临“删错锁”的风险。

4.2 释放锁为什么必须用 Lua 脚本

释放锁最简单的方式是直接DEL key,但这是极其危险的写法。考虑一个时间线:线程 A 拿到锁,执行了比较久的业务,锁超时自动过期;线程 B 拿到同一把锁,开始执行业务;线程 A 这时候终于干完活了,执行DEL key,会把线程 B 的锁给删了。接着线程 C 也能拿到锁,和 B 并行执行,锁的意义就消失了。

正确的做法是“先校验身份、再删除”,先 GET 锁的值,如果是自己当初设置的唯一串,才允许 DEL。但 GET 和 DEL 是两个命令,它们之间仍然可能插入其他命令。唯一的解法还是用 Lua 脚本,让校验和删除合成一个原子操作:

if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

这个脚本我建议你直接沉淀到公共组件里,所有业务团队复用一份,不要每个人自己写。写错分布式锁释放逻辑的成本不是运行时报错,而是并发正确性问题,线上排查极难复现,等出了事往往已经造成数据损失了。

还有一个进阶参数值得讲讲:锁续期,也叫 watch dog 看门狗机制。有些分布式锁框架(比如 Redisson)在申请锁的时候,会起一个后台线程,默认给锁设置 30 秒过期时间,然后每 10 秒检测一次:只要当前线程还持有锁,就自动把过期时间延长到 30 秒。这样即使业务执行时间超过 30 秒,锁也不会提前失效。但要注意,这个“看门狗”是客户端层面的逻辑,它只能在你进程还活着时工作,如果你的进程被强制 kill,锁最终还是会因为过期而释放,不会永远僵住。这个机制相当优雅,实际项目中我推荐使用 Redisson 的分布式锁封装,而不是自己造轮子,除非你有特殊需求。

4.3 Redlock 算法与它的争议

到了 Cluster 或哨兵架构下,单点 Redis 的分布式锁就有了新的问题:如果你只在一个主节点上加锁,而主节点挂了,从节点提升时如果锁的数据还没同步过去,新的主节点上锁就丢了,其他客户端可以重新加锁,这会导致“两个客户端同时持锁”的严重故障。Redis 作者 Antirez 提出了 Redlock 算法来解决跨节点加锁的问题。

Redlock 的核心思路是:在 N 个相互独立的 Redis 节点上(通常 5 个)尝试都加同一把锁,客户端要拿到锁,必须满足两个条件:一是超过半数(N/2+1)的节点加锁成功;二是加锁消耗的总时间小于锁的过期时间。这样做的好处是即使一两个节点发生故障,其他节点上的锁仍然是有效的,无法形成多数派的话就拿不到锁。

但这个算法发布后也引发了领域内一次著名的论战。反对方的核心观点是:Redlock 本质上仍然是“基于时间”的分布式系统,它假设各节点之间没有时钟跳跃、没有 GC 长时间停顿,可现实是客户端的 GC pause 一旦超过锁有效期,锁照样会失效,Redlock 并不能提供真正的互斥保证,而且它会引入额外的复杂度和延迟。Redis 作者则回应,Redlock 适合大多数实际场景,如果连 GC 暂停都会打乱你的锁语义,那这种情况应该用带 fencing token 的方案,而不是把锅甩给 Redlock。

我个人的态度是:Redlock 有它合理的适用场景,但它不是银弹。如果业务对数据一致性要求极其苛刻(比如资金转账、库存强一致扣减),我宁可在数据库层面做唯一约束,或者引入 ZooKeeper、etcd 这类基于强一致共识协议的锁服务,而不是在 Redis 上堆算法。如果业务只是防止重复提交、控制并发任务这类可以忍受极少情况下的误判,那 Redis 单点锁 + 看门狗已经能覆盖 95% 以上的场景,没必要非上 Redlock。

4.4 我的选型建议

这几年在不同项目里折腾过各种锁方案,最终沉淀下来一套比较务实的选型思路:

  • 单机 / 哨兵 Redis + Redisson 看门狗锁:适合大多数通用场景,比如防止用户重复下单、控制定时任务不重复执行、限流器中的临界区保护。
  • Cluster 架构下,用 Redisson 的 Redlock 或多节点写入增强锁可靠性:适合跨节点容灾有硬性要求的核心链路,但注意要在压测环境里模拟故障切换,验证线上真实延迟和超时配置是否吻合。
  • 资金、库存这类绝对不允许两个线程同时操作的场景:建议底层数据库加唯一索引或乐观锁兜底,Redis 锁只当第一道防线。毕竟再健壮的 Redis 锁也挡不住“客户端写了一行错误代码,把自己能拿到的锁提前释放”的业务逻辑 bug。

业务代码里还要养成一个习惯:给锁加一个最大可忍受等待时间。比如lock.tryLock(3, 30, TimeUnit.SECONDS),意思是拿锁最多等 3 秒,拿不到就直接失败返回,而不是无限阻塞等下去。无限等待看着“肯定能执行”,实际在高并发下会把线程池全部占满,导致整个服务假死。

5. 高可用与原子性的联合作战:缓存治理与踩坑速查

集群架构和原子性保障单独捋完了,但生产环境从来不是靠某一个点就能撑起来的。你在设计缓存方案时,高可用和原子性往往是同时出现的。比如你更新了一条用户信息,是先写数据库还是先删缓存?缓存过期时大量请求同时回源数据库怎么办?这章把这些联合作战场景讲透。

5.1 缓存一致性:先更新库还是先删缓存

关于缓存一致性,最经典的实践是 Cache Aside(旁路缓存)模式。读的时候先查缓存,没命中再查数据库,然后回写缓存;写的时候先更新数据库,然后删除缓存。之所以是“删除缓存”而不是“更新缓存”,是因为删除操作成本低,而且下次读取时自然会把最新数据重新放入缓存,能避免并发写多次导致缓存值覆盖错乱的问题。

顺序上为什么“先更新数据库,再删缓存”?反过来“先删缓存,再更新数据库”会有一个典型的竞态:线程 A 删了缓存,正准备写数据库;线程 B 这时来读,发现缓存空了就去读数据库,读到了还没被更新完的旧值,把旧值回写进缓存;紧接着线程 A 才更新完数据库。最后缓存里是旧值,数据库是新值,两边不一致。而“先更新数据库,再删缓存”虽然也存在极端情况下删除失败的隐患,但至少这个时间窗更短,而且大多数情况下数据库更新完成后再删缓存,失败概率要低得多。

删除缓存失败怎么补偿?我在实践中会配合两个手段:一是延迟双删——先更新数据库,删缓存,隔几百毫秒(具体时间根据业务容忍度设置)再删一次缓存,把并发期间可能被回写的旧值再清一遍;二是消息队列兜底——如果删缓存动作失败,把 key 发到延时队列,由消费端重试删除。第二个方案更工程化,适合对一致性要求严格的项目。

5.2 穿透、击穿、雪崩的立体防御

缓存场景里三座大山是面试高频题,也是生产事故的常客。它们原因不同,解法也完全不同,我放到一张对比表里:

问题触发场景危害典型解法
缓存穿透查询一个根本不存在的 key,缓存和数据库中都没有每次请求都打到数据库,可能拖垮数据库缓存空值并设置短过期时间;布隆过滤器前置拦截
缓存击穿某个热点 key 在过期的一瞬间,大量请求同时涌入该热点 key 回源压力巨大,数据库瞬间被打满互斥锁只允许一个线程回源重建;逻辑过期或热点 key 永不过期
缓存雪崩大批 key 在同一时间过期,或 Redis 节点整体宕机大量请求同时回源,数据库失守过期时间加随机飘移;多级缓存;限流降级和熔断

三个问题里,缓存穿透最常见也最好解决。布隆过滤器就是用一个很小的位数组表示一个较大的集合,用多个哈希函数把元素映射到位数组里的几个位置。查询 key 时,只要判断这几个位置是否都为 1,如果有一个为 0,那这个 key 一定不存在,直接返回空,数据库就免遭轰炸。但要注意布隆过滤器有误判率,它说“可能存在”不代表一定存在,所以拦截的只是完全不存在的 key,查得到但没被过滤的请求还是要走下面一层空值缓存。布隆过滤器还有一个特点是不能删除元素,如果要频繁增删元素的集合,建议周期性地重建过滤器。

缓存击穿的互斥锁方案值得展开说一句:回源重建缓存时,先尝试获取分布式锁,拿到锁的线程去查数据库并重建缓存,没拿到锁的线程可以短暂 sleep 后重试读缓存。这样同一时刻只有一个请求会穿透到数据库,其他请求等待后直接命中缓存。这个流程实现起来不复杂,但务必做好超时时间控制,别让应用线程一直等一个太久不释放的锁。

5.3 常见问题速查表

最后把整个建设过程中经常遇到的高频问题整理成一份速查表,每一行都是我或我的团队在线上实际踩过的坑,含金量比文档里写的高不少:

问题现象根因分析排查思路与解决
increment()报 not integer or out of rangeRedisTemplate 默认 JDK 序列化,value 带序列化头改用 StringRedisTemplate;统一 key/value 的序列化器;检查同一 key 是否被不同序列化方式写入
Redis 主从切换后写数据丢失主从复制是异步的,部分数据未同步调大 repl-backlog-size;配置 min-replicas-to-write 限制延迟窗口;核心数据不要只存 Redis
Sentinel 自动切换期间业务不可用down-after-milliseconds 过长,加上故障转移耗时可达到几十秒按业务容忍度压测调整 down 阈值;预演切换流程;客户端开启自动重连
集群扩容后客户端报 MOVED 频繁客户端缓存了旧的槽位映射,扩容迁移时大量重定向确保使用支持集群协议的客户端;升级到带路由缓存的连接池版本
分布式锁偶尔失效导致并发执行业务执行时间超过锁过期时间,锁自动释放引入锁续期看门狗;或者主动在业务代码中把锁的有效期改成更长的值(同时做好安全释放)
Lua 脚本在 Cluster 下报 CROSSSLOT脚本/事务中多个 key 不在同一槽使用 hash tag;脚本里要用 KEYS 数组传入所有 key
KEYS *导致线上 Redis 阻塞数十秒单线程事件循环被长耗时命令阻塞改用 SCAN 命令分批遍历;对大 key 删除用 UNLINK;禁止在生产上用 KEYS
延迟双删仍然偶发不一致第二次删除的延迟小于并发写入窗口,或者删除动作被跳过加入消息队列确认删除;对缓存设置较短的天然过期时间兜底

最后再分享一点经验

前几天还有一个同事问我,说 Redis 学到底到底在学什么?我当时的回答是:学的不是命令,是“单线程模型下的确定性”。Redis 之所以能在极简的架构上支撑极高并发,关键在于它用单线程规避了传统分布式系统里最难搞的并发竞争问题,一切不确定性都被收拢到了“命令执行”这个最小的原子单元里。正因为如此,你的高可用架构再复杂,最终服务端执行的还是一个个原子命令和 Lua 脚本,只要这一层你想透了,上面能搭的结构、能玩的方案,会一下子通透很多。

回到实践层面,我认为真正的“终极指南”不是某一份最佳实践模板,而是持续在故障、压测、复盘、调优这条路上反复打磨。我第一次搭哨兵集群时也在切换窗口上栽过跟头,第一次写分布式锁也遇到过超时误删,但这些坑踩过去之后,方案才真正变成了自己的东西。后面你如果在这个基础上继续深入,可以把 Redis 7.0 的 Multi-Part AOF、函数功能、Sharded Pub/Sub 这些新特性也一并研究研究,整个知识体系会更完整。

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

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

立即咨询