
本文首發于 欄軒·閣歡迎訪問閱讀原文獲取更好的閱讀體驗。為什么需要讀寫分離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