Redis学习笔记:Cluster 集群读写分离实战,Lettuce + Spring Boot 从配置到验证 📅 2026/8/9 2:32:42 本文首发于 栏轩·阁欢迎访问阅读原文获取更好的阅读体验。为什么需要读写分离Redis Cluster 采用分片架构将 16384 个 hash slot 均分到各个 master 节点。默认情况下所有读写请求都路由到 slot 对应的 master 节点。这意味着你的 replica 节点只在 master 宕机时被动接管平时完全空转——而你却为它们支付着同等的内存和 CPU 成本。现实的流量特征绝大多数业务场景的读写比都非常悬殊场景读写比说明商品详情页20:1 ~ 100:1商品频繁被浏览极少修改用户 Session50:1每次请求都读 Session登录/登出才写内容/资讯站100:1文章发布后极少改动被大量读取秒杀/抢购1:1 ~ 5:1读写都高但读仍是瓶颈以一个日活 1000 万的商品详情页为例假设每秒 10 万次请求其中 9.5 万次是读、5 千次是写。如果所有请求都压在 master 上你需要为读流量准备大量的 master 副本和内存。而如果你已经有了 3 个 replica 在闲置为什么不让它们分担那 9.5 万次读请求不做读写分离的成本假设一个 6 节点集群3 master 3 replica默认路由所有请求走 master master-1: 100% 读写负载 ← 瓶颈 master-2: 100% 读写负载 ← 瓶颈 master-3: 100% 读写负载 ← 瓶颈 replica-1: 0%空闲中 replica-2: 0%空闲中 replica-3: 0%空闲中 读写分离后 master-1: 仅写负载 master-2: 仅写负载 master-3: 仅写负载 replica-1: 分担读负载 replica-2: 分担读负载 replica-3: 分担读负载读吞吐提升 200%读写分离让三台 replica 从灾备资源变成了读容量在不增加服务器的情况下将集群读吞吐提升 2 倍。Cluster 为什么默认不做读写分离你可能会想这是一个如此常见的需求为什么 Redis Cluster 不内置支持原因在于 Cluster 的分片路由机制。客户端执行GET key时流程是这样的1. 对 key 计算 CRC16 得到 hash slot 2. 根据本地缓存的 slot→node 映射表找到负责该 slot 的节点 3. 如果该节点是 master → 直接执行命令 4. 如果该节点不是 master → 返回 MOVED 重定向在步骤 2 中Cluster 的 slot 映射表只记录了哪个 master 负责哪些 slot。replica 不在映射表中所以客户端默认不会把读请求发给 replica。要让 replica 参与读取客户端必须主动发送READONLY命令告诉 replica“我知道你不是 master但我还是想从你这里读。” replica 收到READONLY后针对该连接不再返回 MOVED 重定向而是直接执行读命令。一个常见的误解Redis 能不能在 redis.conf 里配一个全局参数一劳永逸地开启所有从节点的读取不能。这也是上面那段技术原理的必然结果——如果服务端能全局开启那所有 replica 的 slot 映射就必须对客户端可见但 Cluster 的设计原则是 slot 路由以 master 为准。READONLY命令必须由客户端连接主动发起服务端不会自动给所有连接开启这个权限。每个连接到 replica 的客户端都需要显式声明“我要从你这里读取”。客户端的选择不同的 Redis 客户端对读写分离的支持方式不同客户端支持方式易用性LettuceSpring Boot 默认ReadFrom.REPLICA_PREFERRED一行配置⭐⭐⭐⭐⭐Jedis需要手动向 replica 发送READONLY 自行选择 replica⭐⭐RedissonreadModeSLAVE配置⭐⭐⭐⭐Lettuce 是其中封装最完善的——你只需要告诉它优先读 replica它会自动管理 READONLY 握手、连接池和节点选择。技术方案本项目的技术栈选型如下组件版本说明Redis7.2.4稳定的 Cluster 支持兼容cluster-announce-ipSpring Boot4.1.02026 年 6 月发布的最新稳定版Lettuce内置Spring Data Redis 默认客户端取代了 JedisJDK21Spring Boot 4.x 最低要求为什么用 Lettuce 而不是 JedisSpring Data Redis 从 2.0 开始就把 Lettuce 作为默认客户端。原因有三响应式支持Lettuce 基于 Netty 的异步非阻塞架构Jedis 是同步阻塞连接复用一个连接可并发处理多个请求多路复用Jedis 每个操作独占连接集群友好Lettuce 原生支持 Cluster 拓扑刷新、MOVED 重定向自动处理Jedis 需要手动处理为什么用 Spring Boot 4.1.0这是 2026 年 6 月发布的最新稳定版。与 3.x 相比一个关键变化是 Redis 配置前缀从spring.redis.*迁移到了spring.data.redis.*如果你还在用 3.x 的配置方式启动时会收到弃用警告。核心原理ReadFromLettuce 通过ReadFrom策略控制读请求的路由目标。配置在连接工厂上影响该工厂创建的所有连接。五种策略策略路由目标适用场景MASTER只读 master默认行为读写分离关MASTER_PREFERRED优先 master不可用时读 replica高可用优先可接受少量 replica 读REPLICA_PREFERRED优先 replica不可用时 fallback 到 master读写分离 高可用 ← 最常用REPLICA只读 replica没有就报错极端读场景必须从 replica 读NEAREST从延迟最低的节点读跨机房部署追求最低延迟底层做了什么当你配置ReadFrom.REPLICA_PREFERRED后Lettuce 在执行读命令时的完整流程命令: GET product:1001 │ ├─ 计算 slot: CRC16(product:1001) 8893 │ ├─ 查找节点: slot 8893 → master-1端口 6379 │ ├─ 是否读命令是 │ ↓ ├─ ReadFrom.REPLICA_PREFERRED │ ↓ ├─ master-1 有没有 replica有 → replica-1端口 6382 │ ↓ ├─ 向 replica-1 发送 READONLY每个连接只需发送一次 │ ↓ └─ replica-1 执行 GET product:1001 → 返回结果关键细节READONLY 命令每个连接只发一次。Lettuce 在第一次向 replica 发送读请求前自动发送 READONLY后续所有读操作复用该连接不再重复发送写命令不受影响。Lettuce 内部识别命令类型——SET、DEL、EXPIRE 等写命令直接路由到 master不看 ReadFromreplica 不可用时自动 fallback。如果所有 replica 都宕机了REPLICA_PREFERRED会自动切到 master 读不会报错读流量分配策略当一个 slot 有多个 replica 时例如--cluster-replicas 2Lettuce 如何选择哪个 replica 来读ReadFrom.REPLICA_PREFERRED 的选择逻辑 1. 找到负责该 slot 的 master 2. 找到该 master 的所有 replica 列表 3. 从 replica 列表中随机选一个简单轮询 4. 如果该 replica 不可用换下一个 5. 如果所有 replica 都不可用 → 读 master这种设计天然实现了 replica 间的负载均衡。项目结构redis-cluster-demo/ ├── pom.xml Spring Boot 4.1.0 └── src/main/java/com/example/rediscluster/ ├── RedisClusterDemoApplication.java 启动类 ├── config/ │ └── RedisClusterConfig.java 双模板配置 ├── service/ │ └── RedisReadWriteService.java 读写封装 └── controller/ └── RedisController.java REST API配置实现双 RedisTemplate核心就在一行——ReadFrom.REPLICA_PREFERREDConfigurationpublicclassRedisClusterConfig{// 默认连接工厂 —— 读写分离BeanPrimarypublicLettuceConnectionFactorylettuceConnectionFactory(){returnbuildFactory(ReadFrom.REPLICA_PREFERRED);}// 强制读 master —— 用于对比验证Bean(lettuceConnectionFactoryMaster)publicLettuceConnectionFactorylettuceConnectionFactoryMaster(){returnbuildFactory(ReadFrom.MASTER);}privateLettuceConnectionFactorybuildFactory(ReadFromreadFrom){RedisClusterConfigurationclusterConfignewRedisClusterConfiguration();for(intport6379;port6384;port){clusterConfig.addClusterNode(newRedisNode(localhost,port));}LettuceClientConfigurationclientConfigLettuceClientConfiguration.builder().readFrom(readFrom)// ← 读写分离就这一行.build();returnnewLettuceConnectionFactory(clusterConfig,clientConfig);}}定义两个 template 是为了对比验证——你可以直接比较读 replica 和读 master 的行为差异。快速替代YAML 一行配置如果不需要双模板对比可以直接在application.yml中全局配置spring:data:redis:cluster:nodes:-localhost:6379-localhost:6380-localhost:6381-localhost:6382-localhost:6383-localhost:6384lettuce:cluster:read-from:REPLICA_PREFERRED# ← 读写分离就这一行pool:max-active:16max-idle:8min-idle:4相比 Java 配置类YAML 方式更简洁但局限性在于只有一个全局策略——所有读操作都走 replica无法在强一致场景下切回 master。如果业务中有支付、库存等需要强制读 master 的场景建议还是用双模板方案。注意LettuceConnectionFactory 必须作为 Bean 托管如果你在Configuration中 private 方法里 new 出LettuceConnectionFactorySpring 不会管理它的生命周期会导致LettuceConnectionFactory has been CREATED. Use start() to initialize it错误。解决方案将连接工厂暴露为BeanSpring 自动调用afterPropertiesSet()和start()。实战坑点Docker 网络与 CLUSTER SLOTS问题现象连接后一直超时日志反复出现Unable to connect to [172.23.0.2:6379]: connection timed out根因分析当你配置了localhost:6379作为种子节点Lettuce 连上去后第一件事就是发送CLUSTER SLOTS获取集群拓扑。Redis 节点如实返回了自己的真实地址——对于 Docker 容器来说就是内网 IP172.23.0.x。Lettuce 拿到拓扑后会用这些内网 IP覆盖你配置的 localhost 地址。但从宿主机Windows访问这些 Docker 内网 IP 是不可能的。解决方案让 Redis 节点向客户端宣告127.0.0.1而不是内网 IPredis-server\--cluster-announce-ip127.0.0.1\--cluster-announce-port6379对于 Docker Compose 环境最干净的方案是单容器多实例——6 个 Redis 实例跑在同一个容器里所有节点通过127.0.0.1通信services:redis-cluster:image:redis:7.2.4container_name:redis-clusterports:-6379-6384:6379-6384volumes:-./init-cluster.sh:/init-cluster.shcommand:sh /init-cluster.shinit-cluster.sh启动脚本会依次启动 6 个 Redis 实例端口 6379~6384等待节点就绪执行redis-cli --cluster create组建集群通过tail -f保持容器存活完整脚本及参数说明#!/bin/shset-e# # Redis Cluster 启动脚本# 在单个容器中启动 6 个 Redis 实例3 master 3 replica# 并自动组建集群# # 数据持久化目录挂载的卷APPDIR/data/redis-clusterecho 正在启动 6 个 Redis 实例 # 循环启动 6 个实例端口从 6379 到 6384forportin637963806381638263836384;do# 为每个实例创建独立的数据目录mkdir-p$APPDIR/$port# 启动 Redis 实例redis-server\--port$port\# 监听端口--cluster-enabledyes\# 开启集群模式--cluster-config-file$APPDIR/$port/nodes.conf\# 集群配置持久化文件--cluster-node-timeout5000\# 节点超时时间毫秒--appendonlyyes\# 开启 AOF 持久化--appendfilenameappendonly.aof\# AOF 文件名--dir$APPDIR/$port\# 数据存储目录--cluster-announce-ip127.0.0.1\# ★ 对外宣告的 IP解决 Docker 网络问题--cluster-announce-port$port\# ★ 对外宣告的端口必须与映射端口一致--daemonizeyes\# 后台运行单容器多实例必须加--logfile$APPDIR/$port/redis.log# 日志文件# 检查启动是否成功if[$?-eq0];thenecho [OK] 实例$port启动成功elseecho [FAIL] 实例$port启动失败exit1fidone#参数作用关键程度①--port实例监听端口范围 6379~6384必选②--cluster-enabled yes开启集群模式必选③--cluster-config-file集群配置持久化文件重启后恢复集群状态必选④--cluster-node-timeout节点超时时间毫秒超时判定节点宕机推荐⑤--appendonly yes开启 AOF 持久化推荐⑥--cluster-announce-ip节点对外宣告的 IP。设为 127.0.0.1 让客户端通过 localhost 连接核心⑦--cluster-announce-port节点对外宣告的端口与映射端口一致核心⑧--daemonize yes后台运行单容器多实例必须否则只能起一个必选注意--daemonize yes单容器多实例必须加这个参数否则第一个 redis-server 会占据前台进程后面的实例起不来。脚本最后用tail -f保持容器不退出。# 检查集群是否已经创建过重启时 nodes.conf 还在就跳过if[-f$APPDIR/6379/nodes.conf]grep-qslots$APPDIR/6379/nodes.conf2/dev/null;thenecho 集群已存在跳过创建步骤 elseecho 正在创建集群每组分片 1 master 1 replica # 组建集群前 3 个节点自动成为 master后 3 个成为 replica# echo yes | 用于跳过交互确认echoyes|redis-cli--clustercreate\127.0.0.1:6379127.0.0.1:6380127.0.0.1:6381\127.0.0.1:6382127.0.0.1:6383127.0.0.1:6384\--cluster-replicas1# 每个 master 配 1 个 replicafiredis-cli --cluster create参数参数作用127.0.0.1:6379 ... 127.0.0.1:63846 个节点地址前 3 个自动成为 master--cluster-replicas 1每个 master 配 1 个 replica3 master 3 replicaecho yes |跳过交互确认否则会停在Can I set the above configuration?启动后docker logs redis-cluster可以看到[OK] Instance 6379 started [OK] Instance 6380 started ... Creating cluster (1 master 1 replica per shard)... Performing hash slots allocation on 6 nodes... Master[0] - Slots 0 - 5460 Master[1] - Slots 5461 - 10922 Master[2] - Slots 10923 - 16383 ... [OK] All 16384 slots covered.为什么不能用 6 个独立容器有人会想那我用 6 个独立的容器分别映射端口 6379~6384 不就行了我试过不行。问题出在一个两难境地设置客户端宿主机视角节点间通信容器视角不加 announce-ip❌ CLUSTER SLOTS 返回 172.23.0.xLettuce 连不上✅ 容器间通过 Docker 内网 IP 正常通信announce-ip 设为 127.0.0.1✅ 客户端通过 localhost 正常连接❌节点间通信断了—— 容器 1 试图连127.0.0.1:6380等于连自己不是容器 2announce-ip 设为 WSL2 宿主机 IP⚠️ 取决于网络配置且 IP 会变化✅ 能工作但不稳定核心矛盾--cluster-announce-ip同时影响客户端和节点间通信但 Docker 的端口映射让内外视角不一致。实际操作中发生了什么我尝试用 6 个独立容器 --cluster-announce-ip 127.0.0.1创建集群# 从 host 执行通过 docker execredis-cli--clustercreate127.0.0.1:6379127.0.0.1:6380... --cluster-replicas1容器 1 收到你的同级节点在127.0.0.1:6380然后尝试连接127.0.0.1:6380——但在容器 1 内部127.0.0.1就是容器 1 自己它的 6380 端口并没有进程在监听连接失败。集群根本创建不起来。这验证了一条原则--cluster-announce-ip不能设为127.0.0.1除非所有节点真在同一个 127.0.0.1 上。生产环境呢环境推荐方案原因本地开发WSL2 Docker单容器多实例最简单稳定节点间通过 127.0.0.1 通信生产物理机/云服务器独立容器 host 网络 或 设 announce-ip 为固定主机 IP内外网络一致没有 NAT 问题单容器方案把 6 个节点放在同一个网络命名空间里所有节点通过127.0.0.1通信绕开了 Docker 端口映射带来的内外视角不一致问题。对于本地开发和测试这就是最优解。为什么不推荐代码层面禁用拓扑刷新在遇到 Docker 内网 IP 问题时我在网上查到了另一种思路通过客户端配置来绕过。具体做法是组合使用以下几个参数ClusterTopologyRefreshOptions.builder().dynamicRefreshSources(false)// 不从拓扑节点获取更新源.enablePeriodicRefresh(false)// 关闭定期刷新.build();// 加上clusterClientOptions.validateClusterNodeMembership(false);它的思路是“既然拓扑返回的 IP 是错的那我不用拓扑刷新只认我配置的 localhost 地址。”但这条路走不通。原因很简单初始 CLUSTER SLOTS 无法避免。Lettuce 一连接上种子节点第一件事就是发 CLUSTER SLOTS 获取 slot 分布。这个请求无论如何都会执行返回值里自带 Docker 内网 IP路由信息一旦被污染就无法恢复。Lettuce 拿到拓扑后会把 172.23.0.x 写入内部的 slot→node 映射表。禁用刷新只是阻止了后续更新第一次的错误映射已经存在了validateClusterNodeMembership(false) 只管连接验证。它只是说不检查连上的是不是已知节点但它不能阻止路由表使用内网 IP验证结果即使加了这些配置Lettuce 仍然会尝试连接 172.23.0.x:6379日志依然超时。这是我在实战中踩过的坑。结论客户端侧的 hack 治标不治本。最干净的方案就是改集群宣告地址。真实验证读操作到底走了 replica光配了REPLICA_PREFERRED不够你怎么知道读请求真的发到了 replica验证方法利用 Redis 的CLIENT LIST命令检查 replica 节点上是否有处于READONLY 模式的客户端连接。当 Lettuce 向 replica 发送READONLY指令后该连接会被标记flagsr。// 使用 replica template 执行一次读取service.getFromReplica(test:key);// 遍历所有 replica 节点检查 CLIENT LISTfor(RedisClusterNodenode:replicaNodes){varclientsclusterConn.getClientList(node);for(varclient:clients){if(client.getFlags().contains(r)){System.out.println(READONLY client on node.getPort(): client.getAddressPort() lastCmdclient.getLastCommand());}}}验证结果[OK] 默认 template → ReadFrom.REPLICA_PREFERRED [OK] master template → ReadFrom.MASTER [READONLY] 127.0.0.1:6382 → client 172.24.0.1:53900 flagsr lastCmdget关键证据解读flagsrRedis 的 readonly 标记证明该连接向 replica 声明了 READONLYlastCmdget最后执行的是读命令客户端在 replica 6382 上不在 master三行输出从配置到行为完整证明了读写分离生效。为什么不用 replication delay 验证有些人建议通过制造主从延迟来验证——写 master 后立刻读 replica如果读到旧值说明走了 replica。这种方法不推荐主从延迟不可控测试结果不稳定需要在集群上做额外操作暂停复制CLIENT LIST直接给出了确定性证据读写分离的局限性核心问题最终一致性Redis 的主从复制是异步的。当 master 写入一个 key 后复制日志Replication Backlog需要时间同步到 replica。这个时间通常很短毫秒级但在高负载或网络抖动时可能延长到秒级。时间轴 T0: 客户端写入 master → SET stock:1001 5 T1: master 返回 OK复制日志发出但 replica 还没收到 T2: 客户端从 replica 读取 → GET stock:1001 T3: replica 返回 3旧值复制还没追上 T4: replica 收到复制日志更新为 5晚了如果你的业务逻辑是读取当前库存→判断是否充足→扣减在 T2 时刻读到旧值就会导致库存超卖。场景适配表场景是否适合走 replica原因商品详情、列表页✅ 适合显示旧数据几秒不影响用户体验用户 Session 读取✅ 适合Session 一旦写入很少修改绝大部分是读取新闻/内容站✅ 适合内容发布后极少变更读多写少排行榜/计数器✅ 适合少量偏差可接受支付/库存扣减❌不适合强一致性要求读到的必须是最新值写入后立即回读Read Your Writes⚠️ 小心用户刚提交修改立刻刷新页面可能看到旧数据分布式锁❌不适合锁状态必须强一致最佳实践按场景分流解决方案不是全要或者全不要而是分层ServicepublicclassOrderService{// 双 template 注入privatefinalRedisTemplateString,StringredisTemplate;// 默认读 replicaprivatefinalRedisTemplateString,StringmasterTemplate;// 强制读 masterpublicOrderService(RedisTemplateString,StringredisTemplate,Qualifier(redisTemplateMaster)RedisTemplateString,StringmasterTemplate){this.redisTemplateredisTemplate;this.masterTemplatemasterTemplate;}// 读多写少可接受最终一致性 /** 商品详情 —— 走 replica */publicProductVOgetProduct(StringskuId){StringjsonredisTemplate.opsForValue().get(product:skuId);returnJSON.parse(json,ProductVO.class);}/** 用户信息 —— 走 replica */publicUserInfogetUserInfo(LonguserId){StringjsonredisTemplate.opsForValue().get(user:userId);returnJSON.parse(json,UserInfo.class);}// 强一致性要求必须读 master /** 商品库存 —— 强制读 master */publicintgetStock(StringskuId){StringvalmasterTemplate.opsForValue().get(stock:skuId);returnval!null?Integer.parseInt(val):0;}/** 订单支付状态 —— 强制读 master */publicStringgetOrderStatus(StringorderId){returnmasterTemplate.opsForValue().get(order:orderId:status);}/** 写入后立即回读 —— 强制读 master */publicStringsaveAndReadBack(Stringkey,Stringvalue){redisTemplate.opsForValue().set(key,value);// 写走 masterreturnmasterTemplate.opsForValue().get(key);// 读强制 master避免复制延迟}}三层读写策略模型在大型项目中往往会定义三层的读取策略层级策略对应 ReadFrom场景L1 - 最终一致读 replicaREPLICA_PREFERRED详情页、列表、非核心数据L2 - 写后读一致先记时间戳短时间内读 master动态切换用户刚修改的数据L3 - 强一致强制读 masterMASTER支付、库存、锁L2 的精髓是写入时记录时间戳后续的读操作如果距离写入时间在 N 毫秒内就切到 master 读超过 N 毫秒后恢复 replica 读。这样在用户刚改完的数据和其他用户的数据之间取得了平衡。但实现起来需要 AOP 或者注解支持大多数中小项目用 L1 L3 两层就够了——默认走 replica关键操作强制走 master。总结Redis Cluster 本身不支持读写分离需要客户端通过READONLY命令开启Lettuce 的ReadFrom.REPLICA_PREFERRED一行配置即可实现读写分离Docker 环境下的 CLUSTER SLOTS 地址问题通过--cluster-announce-ip 127.0.0.1解决真实验证通过CLIENT LIST的flagsr标记确认读操作确实走了 replica不是所有场景都适合最终一致性场景走 replica强一致性场景必须读 master