尧图精选

Redis事务与主从复制:原子性到最终一致性全解析

🕒 发布时间:2026/9/8 12:47:41 📁 来源:尧图网络
1. 先聊一个容易被忽略的背景事务和复制的交集在哪里我最近在梳理 Redis 知识点的时候发现一个很有意思的现象几乎每份面试题题库里事务和主从复制都是独立成章的但在真实项目中它们往往是同一个系统设计里被同时思考的两个支点。你比如最常见的“库存扣减”场景业务上要对商品库存做原子性扣减这是事务问题但为了扛住高并发读你又不得不把商品数据同步到多个从节点这就是主从复制问题。再比如订单超时关单这种异步任务你要保证 Redis 里的状态变更不丢、不被并发覆盖既要靠事务的原子性语义也要靠主从架构下的数据一致性策略来兜底。说白了Redis 事务解决的是“多条命令打包执行”的原子性问题主从复制解决的是“一份数据在多个节点上的最终一致性问题”。这两个主题放在一起聊不是因为它们语法上有多少关联而是因为它们共同构成了 Redis 在高可用、高并发场景下的地基。这篇文章我会从原理出发把MULTI/EXEC的执行链路、乐观锁的实现机制、Lua脚本的补偿方案再到主从复制的全量同步、增量同步、常见坑位全部过一遍。适合正在准备面试的开发者、刚接手 Redis 集群维护的运维同学以及写业务代码时老感觉“Redis 用起来不太顺手”的朋友。2. Redis事务深入ACID四个字在Redis里分别成立吗2.1 从一条命令的执行链路说起在讲MULTI/EXEC之前得先理解 Redis 的单线程事件循环模型。Redis 核心处理逻辑是单线程的所有命令在内存里都是顺序执行的。这个特性是理解 Redis 事务一切行为的基础。当你通过客户端发送命令时命令并不是直接执行的而是进入了一个输入缓冲区然后由主事件循环逐个读取、解析、执行。单线程意味着在执行某条命令的过程中不可能插入其他客户端的命令。所以 Redis 事务能做到的“原子性”本质上就是“把这批命令一次性连续执行完”。用一句话概括Redis 事务的本质就是把一组命令放进队列然后一口气执行完执行过程中不会有别的命令插队。2.2 MULTI到EXEC入队和执行为什么要分两步MULTI命令的作用是开启一个事务会话。在这个会话里你后续发送的所有命令都不会立即执行而是进入一个事务队列。127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET order:1001 status paid QUEUED 127.0.0.1:6379 SET order:1001 stock 0 QUEUED 127.0.0.1:6379 LPUSH payment:log 1001 paid at 10:00 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) OK 3) (integer) 1这里有个关键细节命令入队时Redis 返回的是QUEUED而不是真正的执行结果。它只是把命令存到了事务队列里并没有做任何数据变更。入队完成后有两种结束方式EXEC提交执行或者DISCARD丢弃队列。127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET order:1002 status paid QUEUED 127.0.0.1:6379 DISCARD OK执行DISCARD后刚才入队的命令全部被丢弃事务会话关闭。这个机制有点像一个“待办清单”你把要干的事先逐条写在清单上最后确认动手开干或者一把撕掉清单。还有一个需要注意的是入队阶段如果命令语法有误Redis 会立即返回错误。比如127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET key value QUEUED 127.0.0.1:6379 GETNOEXIST key (error) ERR unknown command GETNOEXIST注意这个错误是在入队阶段报的。此时如果你执行EXECRedis 会直接拒绝执行整个事务返回EXECABORT因为语法错误意味着命令根本无法执行与其执行到一半失败不如直接不执行。但如果是运行时错误——比如对一个字符串类型的 key 执行LPUSH——Redis 在入队阶段是检查不出来的只有真正执行时才会报错。这时候就出现一个非常经典的问题如果事务队列里第二条命令执行失败第一条命令的结果会不会回滚答案是不会。Redis 事务不会回滚。127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET order:1003 status paid QUEUED 127.0.0.1:6379 LPUSH order:1003 stock 10 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value看到没有第一条SET已经生效了第二条LPUSH因为 key 类型不对报错但 Redis 并没有把第一条命令的结果回滚掉。这是 Redis 事务和关系数据库事务一个非常大的差异。2.3 Redis事务的原子性为什么是有争议的如果你去翻官方文档Redis 官方对事务的定位是MULTI/EXEC保证的是“批处理中的命令序列不被打断”即具备事务隔离性隔离性具体表现我们后面说但在“原子性”上官方文档措辞相当谨慎。严格意义上的原子性通常指的是“要么全部成功要么全部失败”。Redis 事务在两个维度上都达不到这个标准运行时错误不回滚前面成功的命令继续生效在执行过程中如果发生服务器宕机、进程崩溃已经执行了一半的命令不会自动恢复。但如果你换一个角度理解Redis 事务保证的是“执行期间不会被其他命令插入”那么这批命令作为一个整体对外表现出的效果是“连续执行完”的。这种保证叫all-or-nothing吗不是应该叫sequential integrity顺序完整性或者叫“原子提交”更准确一点——命令序列作为一个整体被提交而非整体被回滚。所以你在面试时如果有面试官问“Redis 事务到底是不是原子的”你可以这样回答Redis 事务保证的是命令序列的连续执行不被中断但因为不支持回滚所以不具备传统数据库意义上的原子性。如果你需要真正的原子性应该使用 Lua 脚本。2.4 隔离性Redis天然具备的最高级隔离事务的隔离性解决的是“多个事务并发执行时彼此之间会不会互相干扰”的问题。数据库领域有四种隔离级别读未提交、读已提交、可重复读、串行化。Redis 因为单线程模型事务在执行期间天然就是“串行化”的——同一时刻只有一个事务在执行事务与事务之间根本不可能并发。这就解释了一个普遍困惑为什么隔离级别在 Redis 这里根本不是一个需要讨论的问题。因为它已经是最高的隔离级别了不需要任何锁机制、不需要 MVCC单线程就是最好的并发控制。但要注意这里的“事务与事务不并发”是指执行阶段。两个客户端都可以各自开启MULTI也都可以往自己的事务队列里加命令但一旦第一个客户端执行EXEC第二个客户端的事务就必须等它全部执行完才开始。这个语义是非常清晰的。2.5 持久性Redis事务的一个明显短板持久性这个维度Redis 给出的答案比较直接事务的持久性取决于持久化配置。如果在事务执行后、RDB 快照或 AOF 落盘之前Redis 发生崩溃那么事务中的部分命令可能已经丢失。这是文件系统层面的现实问题Redis 本身也无法保证。如果是appendfsync always模式每条命令执行后都会同步刷盘持久性最好但性能损耗也最明显。如果用的是everysec最多丢 1 秒的数据。如果你连 AOF 都没开那就谈不上持久性了。所以Redis 事务的定位从设计之初就不是“强一致、强持久的关系型事务”而是“在内存操作层面把一组命令编排成一个连续的执行单元”。理解了这个定位你就能明白为什么它有这么多和传统事务不一样的边界行为。3. WATCH的乐观锁机制从底层数据结构看版本号变化3.1 一个真实的并发覆盖问题上一章讲的MULTI/EXEC解决的是“命令连续执行”的问题但并没有解决“执行之前的数据被修改”的问题。什么意思呢假设你要实现一个“扣余额”的逻辑# 伪代码 balance GET user:balance # 第1步 new_balance balance - 100 # 第2步业务计算 SET user:balance new_balance # 第3步两个客户端同时执行这段逻辑都先GET到余额是 500。然后 A 扣了 100 设置成 400B 也扣了 100 设置成 400。最终余额是 400而正确结果应该是 300。如果只把第1步到第3步包在MULTI/EXEC里问题依然存在因为GET和SET之间并不是原子的——MULTI只是把命令排队但你在入队之前进行的业务计算步骤2是发生在客户端进程里的Redis 根本管不到。WATCH命令就是为了解决这个问题的。它的全称叫乐观锁核心思想是在执行事务之前先监控一个或多个 key 的变化如果在事务执行前这些 key 被其他客户端修改了那么事务直接拒绝执行。3.2 WATCH是怎么实现CAS的WATCH的实现原理本质上是 CASCompare And Swap比较并交换思想在 Redis 里的落地。当你执行WATCH key时Redis 会在当前客户端的watched_keys字典里记录这个 key。同时每个 key 内部都有一个version计数器每次对这个 key 执行写操作时计数器都会自增。在执行EXEC时Redis 会遍历当前客户端监听的 key逐个检查它们的 version 是否发生变化。如果任何一个 key 的 version 和 WATCH 时的版本不一致说明这个 key 在事务执行前被别人改过了EXEC会直接返回空值nil拒绝执行整个事务队列。这个机制其实就是一个非常简洁的CAS操作概念关系型数据库Redis并发控制方式悲观锁/乐观锁乐观锁WATCH锁的粒度行/表key冲突检测时机执行时阻塞/提交时校验EXEC 提交时校验冲突处理等待或回滚直接拒绝执行客户端重试3.3 一个完整的WATCH事务示例我以一个最简单的“账户转账”场景为例。假设账户 A 要给账户 B 转 100 元127.0.0.1:6379 SET account:A 500 OK 127.0.0.1:6379 SET account:B 200 OK 127.0.0.1:6379 WATCH account:A OK 127.0.0.1:6379 MULTI OK 127.0.0.1:6379 DECRBY account:A 100 QUEUED 127.0.0.1:6379 INCRBY account:B 100 QUEUED 127.0.0.1:6379 EXEC 1) (integer) 400 2) (integer) 300一切顺利的时候结果一目了然。但如果在这个过程中另一个客户端先修改了account:A比如# 客户端1 127.0.0.1:6379 WATCH account:A OK 127.0.0.1:6379 MULTI OK # 客户端2在客户端1执行 EXEC 前先改了 key 127.0.0.1:6379 SET account:A 1000 OK # 客户端1执行 EXEC 127.0.0.1:6379 EXEC (nil)客户端1的整个事务被拒绝执行返回nil。此时客户端1需要重新GET最新值、重新计算、重新WATCHMULTIEXEC这就是典型的重试机制。所以在业务代码里你不能假设EXEC一定成功而是要做返回值检查# 伪代码 while (true) { redis.watch(account:A); balance redis.get(account:A); new_balance balance - 100; if (new_balance 0) { throw 余额不足; } pipe redis.pipeline(); pipe.multi(); pipe.set(account:A, new_balance); if (pipe.exec() ! null) { break; // 事务执行成功 } // exec返回null说明key被改了重试 }这个循环重试的写法非常重要你如果没有这个重试逻辑WATCH的作用就仅仅停留在“检测冲突”而不是“解决冲突”。“检测到冲突后怎么办”是客户端代码层面的功课。3.4 WATCH的几个注意点第一WATCH要在MULTI之前调用。如果你已经执行了MULTI再想WATCH就不行了——WATCH命令本身不能进入事务队列。第二UNWATCH可以取消所有监控。如果你在WATCH之后又不想执行事务了可以用UNWATCH清除监控状态。第三WATCH不能监控不存在的 key 吗可以监控机制一样有效。如果你WATCH了一个不存在的 key然后其他客户端创建了这个 keyEXEC也会被拒绝。这个特性有时候你会用到的。第四WATCH的失效机制有个特殊情况当 key 被EXPIRE设置了过期时间、到达过期时间自动删除时WATCH也会认为这个 key 被修改了从而拒绝事务。这一点在生产环境踩坑的朋友应该深有体会。4. 我为什么建议你用Lua脚本替代一部分事务场景4.1 当WATCH的重试逻辑变得难以维护时WATCHMULTIEXEC能解决并发覆盖问题但它的代价是“重试”。在高并发场景下冲突概率一高重试次数就会上升对 Redis 的请求量也会成倍增加。而且WATCH本质上是把“业务判断”放到了客户端代码里。上面转账的例子中你必须在客户端先GET余额、在读到的新值上做计算然后SET回去。整个业务逻辑散落在多个网络请求中链路越长出错的可能性越高。这时候你就会发现Redis 内置的 Lua 脚本是一个更好的选择。4.2 Lua脚本的原子性是怎么保证的Redis 从 2.6 版本开始支持在服务端执行 Lua 脚本通过EVAL命令。Lua 脚本在 Redis 里的执行方式非常特殊Redis 会直接用单线程去执行整个 Lua 脚本在脚本执行期间不会有任何其他命令被处理。这意味着一个 Lua 脚本内部的逻辑天然是原子的不需要MULTI包裹也不需要WATCH检查。逻辑就在脚本内部完成判断和执行整个过程对外部客户端来说是一个不可分割的整体。关键在于脚本里可以包含业务判断逻辑比如“如果余额够就扣款否则返回错误”。这在MULTI/EXEC里是做不到的因为MULTI/EXEC只能把命令按顺序排队执行不能根据前一条命令的结果做条件分支。一个完整的扣库存脚本长这样-- 扣减库存脚本返回1表示成功0表示库存不足 local key KEYS[1] local delta tonumber(ARGV[1]) local current tonumber(redis.call(GET, key)) if not current then return -1 -- key不存在 end if current delta 0 then return 0 -- 库存不足 end redis.call(DECRBY, key, delta) return 1调用方式127.0.0.1:6379 EVAL local key KEYS[1] ... 1 stock:1001 -10 (integer) 1在这段脚本中GET、判断、DECRBY三个步骤在 Redis 内部一气呵成中间不会有任何其他命令插进来。你不需要考虑 WATCH、不需要重试因为根本不存在“检查之后被别人修改”的时间窗口。4.3 Lua和事务怎么选我的判断标准这个问题的答案取决于你的核心诉求如果只是需要把几条命令打包连续执行不需要中间判断那MULTI/EXEC就够了简单直接。如果需要在“读-判断-写”之间保证原子性那优先选 Lua 脚本。因为如果用MULTI/EXEC你只能在客户端先GET然后把判断逻辑写在客户端代码里——判断和写入之间会有一个时间窗口要凑合的话只能靠 WATCH重试来弥补。如果逻辑比较长、判断分支比较多Lua 的优势更明显。所有逻辑放服务端执行客户端只发一次请求网络往返也少了。我给一个比较务实的建议日常业务里能用 Lua 解决的并发问题就不要再绕道去搞 WATCH 了。WATCH 可以作为你理解 Redis 并发控制机制的一个学习入口但生产环境为了稳定性和可维护性Lua 是更常见的解法。当然Lua 也有它自己的问题。脚本太复杂会导致 Redis 单线程被占用过久期间所有其他命令都会被阻塞。我曾经见过有人把一段循环 10 万次的 Lua 脚本丢到生产直接把 Redis 卡了几十秒。所以 Lua 脚本的原则是逻辑清晰、单次执行时间控制在毫秒级绝对不要在里面写死循环。4.4 顺带聊一句Redis 7的新变化Redis 7.0 引入了functions机制本质上是对 Lua 脚本的管理方式升级——从单个的EVAL命令演进为可持久化的函数库。如果你正在用的版本是 7.0 以上可以考虑把常用脚本封装为 function避免每次传递大段脚本字符串带来的网络开销。这个和事务本身关系不太大但在工程化落地上是加分项。5. 主从复制全链路拆解全量同步、增量同步与复制积压缓冲5.1 主从复制的价值不止是“多几个副本”讲完事务我们来说主从复制。很多人一开始接触 Redis 主从觉得就是“主节点写从节点读数据自动同步”就这么简单。但真到配置和维护的时候发现里面的门道比想象中多得多。主从复制在架构上的价值至少有三个层级第一读写分离把读流量分摊到从节点减轻主节点压力第二高可用当主节点宕机时可以提升一个从节点为新主配合哨兵或集群第三数据灾备相当于多了一份完整的副本防止单节点磁盘损坏后数据全丢。从这两个功能的定位来看事务是“写一致性”的保障主从复制是“数据可用性”的保障。前者管“写入时不打架”后者管“数据不丢、随时能读”。5.2 一次全量复制的完整流程当一个从节点第一次连接主节点时触发的是全量复制Full Resynchronization。流程大致如下从节点发送PSYNC命令给主节点带上自己的 replication ID 和 offset。因为是从节点第一次连接没有历史信息所以发送的是PSYNC ? -1。主节点收到PSYNC后发现无法增量同步回复FULLRESYNC replid offset同时开始生成 RDB 快照文件。主节点在后台执行BGSAVE生成 RDB 快照同时把新写入的命令写入复制积压缓冲区repl_backlog中。RDB 快照生成完毕后主节点通过网络把 RDB 文件发送给从节点。从节点接收完 RDB 文件后会先清理自己的旧数据然后加载 RDB 文件。主节点把复制积压缓冲区中的增量命令继续发送给从节点从节点执行这些命令。这个流程里面我挑几个容易被忽略的细节重点说第一RDB 生成期间主节点可以不阻塞。因为BGSAVE是 fork 子进程去做的主进程继续服务读写请求。但 fork 本身会消耗内存如果 Redis 占用的内存非常大fork 耗时可能会达到几百毫秒甚至更久。第二从节点加载 RDB 期间不响应请求。这个阶段从节点会阻塞直到加载完成。所以全量复制期间如果从节点的读流量很大会出现短暂延迟或连接中断。第三复制积压缓冲区的大小非常关键。如果从节点在主节点生成 RDB 期间以及网络传输期间的增量数据量超过了复制积压缓冲区的大小那么从节点后续就无法进行增量同步主节点会再次触发全量复制。这就是我们常听到的“全量复制风暴”的一个典型诱因。5.3 增量复制到底是怎么做到“断点续传”的增量复制的核心是复制积压缓冲区简称 backlog。这是一个大小固定的环形缓冲区默认配置是 1MB。主节点每执行一个写命令除了执行之外还会把命令写入 backlog。同时主节点记录自己当前的复制偏移量master_repl_offset。从节点每接收并应用一条命令也会更新自己的偏移量slave_repl_offset。当从节点断线重连后它会带上自己记录的偏移量再次发送PSYNC。主节点收到后先看看从节点的偏移量是否还在 backlog 的有效范围内如果在说明从节点缺失的数据都还在缓冲区里主节点直接回复CONTINUE然后从 backlog 中把缺失的命令发给从节点这就是增量复制。如果不在说明从节点落后的数据量太大已经超出缓冲区容量主节点只能回复FULLRESYNC重新全量复制。所以backlog 的大小直接决定了“从节点可以断线多久还能走增量复制”。我用一个简单的计算来帮大家理解假设你业务高峰期每秒写入 2000 个命令每个命令平均 50 字节那么每秒产生的复制数据大约是 100KB。如果 backlog 设成 64MB那么从节点断线时间只要不超过 640 秒大约 10 分钟就能走增量复制。如果断线超过这个时间backlog 里的数据就被覆盖了只能全量重来。默认的 1MB 在这种写入量下连 10 秒钟都撑不住。这就是很多人在生产环境遇到“从节点一断线就触发全量复制”的根本原因。建议根据实际写入量把repl-backlog-size调大一些比如 64MB 或 128MB具体看你的可用内存情况。5.4 主从复制的数据一致性问题最终一致不是强一致这里要想清楚一点Redis 主从复制是异步的。主节点执行完一个写命令会立刻返回结果给客户端然后才把命令异步发送给从节点。因此主节点和从节点之间必然存在一个短暂的时间窗口在这个窗口内主节点能查到最新数据但从节点查不到。这也意味着如果你的业务做了读写分离并且对数据一致性要求很高比如刚写入的用户信息立刻要能从列表里查到那就要小心了。可能出现的问题包括从节点读到旧数据刚写入的数据从从节点查不到。从节点读到过期数据主节点已经删除了 key从节点因为同步延迟还没删除依然能查到这就是经典的 Redis 主从数据不一致问题。针对这种情况常见的应对方案有强一致性场景不读写分离直接都走主节点。对数据一致性要求不太高的场景容忍秒级延迟。在代码层面做兜底从节点查询不到时再回源到主节点查一次这种方案会增加主节点读压力需谨慎评估。使用WAIT命令Redis 3.0 开始支持主动等待从节点确认复制完成。但要记住WAIT只能等待“已经连接上的”从节点确认不能保证从节点宕机时的强一致。我在实际项目里见过一个典型事故公司做了一个秒杀系统库存扣减在主节点完成但商品详情页是读从节点的。活动开始时用户疯狂刷详情页看到库存还是满的但实际已经卖完了大量投诉说“页面库存不对”。最后排查下来就是主从复制延迟叠加了“读多写多”的场景放大了不一致问题。后来把库存相关的读请求强制走主节点并在缓存 key 上加了版本号才算稳定下来。6. 亲手搭一套主从环境配置步骤和验证方法6.1 目录结构和基础配置规划空谈原理没有意义我直接带大家从零搭一套主从环境。这里以 Redis 7.0 版本为例用一台 Linux 服务器模拟两个实例一主一从端口分别是 6379 和 6380。建议的目录结构如下/opt/redis/ ├── redis-7.0.14/ # 源码目录或二进制目录 ├── data/ │ ├── 6379/ # 主节点数据目录 │ └── 6380/ # 从节点数据目录 ├── conf/ │ ├── redis-6379.conf │ └── redis-6380.conf └── logs/6.2 主节点配置主节点主要配置如下注释里写了每个参数的作用方便你理解而不是照抄# /opt/redis/conf/redis-6379.conf port 6379 daemonize yes dir /opt/redis/data/6379 logfile /opt/redis/logs/redis-6379.log pidfile /var/run/redis-6379.pid # 内存和持久化 maxmemory 512mb maxmemory-policy allkeys-lru appendonly yes appendfsync everysec # 复制相关 repl-backlog-size 64mb repl-backlog-ttl 3600要注意的是主节点并没有设置requirepass但生产环境建议开启。如果开启了密码从节点的配置里必须配套填上对应密码否则复制连接会被拒绝。6.3 从节点配置从节点只需要比主节点多两行关键配置一个是端口一个是replicaof# /opt/redis/conf/redis-6380.conf port 6380 daemonize yes dir /opt/redis/data/6380 logfile /opt/redis/logs/redis-6380.log pidfile /var/run/redis-6380.pid maxmemory 512mb maxmemory-policy allkeys-lru appendonly yes appendfsync everysec repl-backlog-size 64mb repl-backlog-ttl 3600 # 声明自己是从节点主节点在 127.0.0.1:6379 replicaof 127.0.0.1 6379从节点还有几个重要参数要提前讲replica-read-only yes这个参数默认就是 yes表示从节点只接受读请求。如果你不小心从节点执行了SET命令Redis 会直接报错。但也有例外如果你显式改成了 no从节点就能接受写操作。问题是从节点上的写操作不会被复制回主节点也不会同步给其他从节点会直接破坏主从一致性。所以这个参数强烈建议保持默认的 yes不要乱动。另外一个常见的配置是replica-priority 100这个参数是在哨兵或集群故障转移时决定“哪个从节点优先被提升为主节点”的权重数字越小优先级越高。默认 100一般不用改但你要知道它存在。6.4 启动和验证依次启动主从实例/opt/redis/redis-7.0.14/src/redis-server /opt/redis/conf/redis-6379.conf /opt/redis/redis-7.0.14/src/redis-server /opt/redis/conf/redis-6380.conf查看复制状态使用INFO replication/opt/redis/redis-7.0.14/src/redis-cli -p 6379 INFO replication主节点输出大概长这样# Replication role:master connected_slaves:1 slave0:ip127.0.0.1,port6380,stateonline,offset1234,lag0 master_replid:3f0e2a... master_repl_offset:1234 repl_backlog_active:1 repl_backlog_size:67108864 repl_backlog_first_byte_offset:1 repl_backlog_histlen:1234从节点上查看/opt/redis/redis-7.0.14/src/redis-cli -p 6380 INFO replication输出中role应该是slavemaster_link_status应该为up。如果状态是down说明复制链路没有建立起来多半是网络不通、密码不对或者端口没监听。验证读写# 主节点写入 /opt/redis/redis-7.0.14/src/redis-cli -p 6379 SET user:1 zhangsan # 从节点读取 /opt/redis/redis-7.0.14/src/redis-cli -p 6380 GET user:1能读出来说明复制正常。这里面有个小细节值得注意从节点的master_link_status变为up并不代表数据已经完整复制过来了它只表示 TCP 连接和复制协商成功了。如果你在一个大数据量的主节点上新挂一个从节点RDB 的数据加载和追平需要时间期间从节点上查不到主节点已有的数据是正常的。6.5 动态调整主从关系除了在配置文件里指定replicaof还支持运行时动态切换# 让 6380 节点成为某个新主的从节点 /opt/redis/redis-7.0.14/src/redis-cli -p 6380 REPLICAOF 127.0.0.1 6390 # 把 6380 节点从从节点变回主节点 /opt/redis/redis-7.0.14/src/redis-cli -p 6380 REPLICAOF NO ONE这个命令在故障转移和演练时非常好用。你需要理解当执行REPLICAOF NO ONE时当前节点会清空自己的从节点身份、开始接受写操作但它已有的数据还是会保留。这是哨兵故障转移时的核心操作之一。6.6 Docker方式搭建的快速参考很多人在本地环境用 Docker 搭 Redis 主从我顺手把配置也贴出来。两个容器启动命令如下# 主节点 docker run -d --name redis-master \ -p 6379:6379 \ -v /opt/redis/data/6379:/data \ redis:7.0 redis-server --appendonly yes --requirepass yourpassword # 从节点 docker run -d --name redis-slave \ -p 6380:6379 \ -v /opt/redis/data/6380:/data \ redis:7.0 redis-server \ --replicaof 172.17.0.1 6379 \ --masterauth yourpassword \ --appendonly yes注意从容器访问宿主机时主节点地址要写宿主机的 IP比如 Docker 的默认网桥 IP 172.17.0.1不能写 127.0.0.1。另外如果主节点开启了密码从节点必须配置--masterauth否则复制连接无法鉴权主节点日志里会疯狂刷MASTER aborted replication with error: NOAUTH Authentication required。7. 主从复制实战中的坑与排查链路7.1 坑一从节点一直报同步错误问题出在密码不匹配这个坑是我带新人时遇到最多的。现象非常典型主从配置看起来没问题端口也能通防火墙也关了但从节点INFO replication里master_link_status一直是down。查主节点日志# Master Log MASTER - REPLICA sync started: Partial resynchronization not possible (no cached master) MASTER aborted replication with error: NOAUTH Authentication required. Unexpected reply to PSYNC from slave: -NOAUTH Authentication required.问题基本就定位了主节点开了requirepass但从节点没配置masterauth或者配的密码不一致。这种问题排查的时候不要只盯从节点日志主节点日志的信息往往更有价值。7.2 坑二大 key 导致全量复制堵死主节点网络有一次生产环境新增从节点时发现从节点状态卡在SYNC状态很久主节点的网络出方向流量被拉满导致正常业务请求延迟飙升。查下来原因是一个 value 大小为近百 MB 的 hash key 存储在 Redis 里全量复制时 RDB 文件过大网络传输时间过长。期间主节点还要持续把增量命令写入 backlog如果 backlog 被写满这个新从节点基本就陷入“永远追不上”的死循环。应对方案有几种拆 key把大 hash 拆成多个小 hash从源头避免超大 RDB。选择业务低峰期增加从节点。暂时调大repl-backlog-size并观察。最重要的是写入侧要控制大 key 的产生。可以用redis-cli --bigkeys命令来扫描大 key定期清理。7.3 坑三主节点内存太高BGSAVE fork 直接卡住 Redis全量复制需要主节点执行BGSAVE。这个操作会 fork 一个子进程来生成 RDB。fork 本身要复制主进程的内存页表如果 Redis 占用内存达到几十 GBfork 耗时会非常长。在 fork 期间主进程是阻塞的所有请求都会排队。如果 fork 时间超过 1 秒业务上就能明显感知到抖动。排查思路是先看 Redis 慢日志和日志里的时间戳# 日志例子 10622:M 12 Nov 2024 10:00:01.123 * Background saving started by pid 10650如果发现 fork 耗时过长建议用INFO stats查看latest_fork_usec确认 fork 实际耗时。给 Redis 所在的机器预留足够内存避免系统内存不足时 fork 失败。考虑拆分大实例把几十 GB 的大实例拆成多个小实例分散。7.4 坑四读写分离下的延迟和一致性问题这个问题在面试和实战中都很高频。主从复制是异步的所以从节点读到的数据天然存在延迟。如果业务上对一致性要求极高却硬要做读写分离问题就会暴露得非常彻底。我建议在业务层做“写读一致性”区分刚写入的数据如果后续流程的读操作对数据一致性要求高把读请求转发到主节点。可以容忍秒级延迟的数据比如热门排行榜、商品详情里的非关键字段放心走从节点。还有一种技巧是“从节点追平再读”# 伪代码根据主从偏移量判断数据是否追上 master_offset redis_master.info()[master_repl_offset] slave_offset redis_slave.info()[slave_repl_offset] if slave_offset target_offset: return redis_slave.get(key) else: return redis_master.get(key)这种方案逻辑上没问题但每次读都要查两个节点的偏移量增加一次额外 IO吞吐要求高的场景不建议用。实际项目中更常见的做法是直接按业务分类决定请求路由。7.5 坑五从节点把主节点拖垮的隐形问题在replica-read-only yes的前提下从节点本身不会写数据但有些版本的 Redis 在从节点上执行KEYS命令、SMEMBERS等大 O 操作会直接卡住从节点的事件循环。从节点一旦阻塞复制数据就没办法及时处理复制积压堆积进而导致从节点与主节点的偏移量差距越来越大。极端情况下主节点发送数据到从节点的 TCP 连接也可能因为从节点长时间不消费而触发主节点上的写超时间接影响主节点性能。所以主从架构上线后你要主动监控从节点的master_link_status是否为 up。主从偏移量差距是否持续扩大。从节点的阻塞时间INFO commandstats中 blocked 相关的指标。8. 面试和设计中的延伸点分布式锁、最终一致性与事务边界8.1 分布式锁和事务是两码事但经常被混淆因为热搜词里出现了redis分布式锁这里我多说一句。很多人把 Redis 分布式锁和 Redis 事务混为一谈实际上它们解决的是完全不同的问题。事务解决的是“命令序列的原子执行”。分布式锁解决的是“不同进程/不同节点之间的互斥访问”。用分布式锁实现的典型场景是并发请求同时到达只有一个请求能获得锁其他请求要么等待、要么放弃。它解决的是跨进程的同步问题。可以这么理解事务管的是“一个人干活的时候动作连贯”分布式锁管的是“同一时间只有一个人能进门干活”。两者可以搭配使用比如先获取锁保证互斥再通过 Lua 脚本或事务保证一组操作的原子性。8.2 Redis事务在主从架构下的边界Redis 事务本身是单机概念。在开启了主从复制的架构下事务的执行只发生在主节点上。主节点执行完EXEC后如果把命令直接发送给从节点从节点上执行的效果和主节点是相同的。但这里有一个非常容易踩坑的知识点主从复制传播的是“命令”而不是“事务语义”。也就是说主节点会把事务队列里的每条命令单独复制给从节点从节点逐个执行这些命令。如果事务执行过程中出现了运行时错误主节点上已生效的命令照样会复制给从节点从节点执行时也可能报同样的错误。这意味着事务的“不回滚”特性会通过主从复制传播到从节点上。你如果指望从节点能智能跳过出错的事务那是做不到的。8.3 主从复制不会自动切换哨兵才是干这个的很多新手容易犯一个认知偏差以为配置了主从复制主节点挂了以后从节点会自动顶上。这是不对的。主从复制只做数据同步不做故障转移。主节点宕机后从节点会一直等待重连不会自己提升为主节点。自动故障转移需要额外的组件——哨兵Sentinel或者 Redis Cluster。哨兵会监控主节点的健康状态一旦发现主节点不可用会执行故障转移流程从从节点中选举一个新的主节点并通知其他从节点重新指向新主。这里要特别注意即使配了哨兵原主节点恢复后它也不会自动重新成为主节点。哨兵会把恢复后的旧主节点降级为新主节点的从节点。主从关系在这个过程中发生了反转如果你在代码或运维工具里硬编码了主节点地址故障转移后会全部失效。所以生产环境建议通过哨兵提供的地址来访问 Redis而不是直连某个具体节点。8.4 缓存一致性和主从复制的联动设计最后聊一个设计层面的点。主从复制在缓存架构里经常和缓存一致性问题绑定在一起——也就是“数据库更新了Redis 缓存怎么办”的问题。最常见的方案是 Cache Aside Pattern读的时候先读缓存缓存没有就去数据库读然后回填缓存写的时候先更新数据库再删除缓存或者更新缓存。这个方案的缺点是如果数据库更新成功后删除缓存失败缓存里就是旧数据了。要想降低这个风险可以引入消息队列或本地消息表删除缓存失败时重试。或者在缓存值里携带时间戳/版本号读取时校验。这些设计细节和 Redis 事务本身无关但它们共同构成了一个高可用系统的完整拼图。我自己的体会是Redis 的事务和主从复制单独拎出来任何一个都不复杂难的是把它们放到真实的业务链路里去组合。事务保证单机内的一致性复制保证多节点间的可用性再加上合理的数据失效策略和路由规则才能构建一个既快又稳的缓存体系。这也是我为什么会建议你不要只背 Redis 指令而要把这些机制放进一个典型的业务场景里去想问题。最后再分享一个我自己常用的排查套路遇到 Redis 相关的诡异问题先不要急着翻代码先执行INFO replication和INFO stats看一遍主从状态、命令统计和内存指标很多问题的线索都在这些基础数据里藏着。用熟了之后你会发现排查效率能提升不少。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →