前阵子我们某个长期运行的核心服务做了一次大的缓存架构升级把底层的Redis从哨兵模式迁到了分片集群。当时以为这只是一个配置地址的改动代码层面几乎不需要动结果上线第一天就被告警呼了一脸CROSSSLOT报错、批量接口的响应时间从30毫秒涨到3秒、偶尔还冒出连接超时。后来一圈排查下来发现所有问题的根源几乎都集中在同一个点上——RedisTemplate访问分片集群的姿势不对。这篇文章就把我在这段升级过程中踩过的坑、排过的错、最后沉淀下来的方案从连接工厂到序列化再到集群操作边界完整梳理一遍。1. 分片集群的槽位路由客户端不是“连上一台机器”这么简单1.1 Redis Cluster到底怎么把数据分到多个节点单机Redis和哨兵模式下无论挂多少个从节点写入主节点的数据都是全量的。Redis Cluster不一样整个数据空间被切成16384个槽位每个主节点只负责一段区间。比如3主节点的集群通常是0-5460、5461-10922、10923-16383。写入某个key时客户端先对key做一次CRC16校验算出一个0到16383之间的值再找到负责这个槽位的节点把命令发过去。很多人说“集群模式就是多台Redis一起干活”这话没毛病但忽略了一个关键点数据落在哪一台不是客户端随便挑的而是由key的哈希值严格决定。这意味着客户端不再是一个简单的socket连接它必须维护一张“槽位到节点”的映射表也就是拓扑信息。拓扑一旦对不上节点就会返回重定向错误MOVED 3999 10.0.0.12:6379 ASK 14580 10.0.0.13:6379普通客户端不会把这两行报错当作业务异常处理它应该捕获后更新本地槽位缓存再把命令重发给目标节点。在Spring Data Redis里这套逻辑被封装在连接工厂和底层客户端中应用层看起来一切透明可真等拓扑刷新不及时问题就会以各种诡异的形态冒出来。1.2 为什么同样的代码在单机上正常、在集群上就翻车最典型的就是批量操作。比如MSET user:1001:name tom user:1002:name jerry单机Redis直接执行没有任何问题。到了分片集群客户端算出两个key大概率落在不同槽位于是直接拒绝执行返回CROSSSLOT Keys in request dont hash to the same slot再比如MULTI/EXEC事务、Lua脚本、以及跨节点的pipeline都有类似的槽位限制。Redis Cluster要求一个命令里涉及的所有key必须在同一槽位否则服务端宁可报错也不愿意“尽力执行”。这就是事务、批量操作在集群模式下最核心的约束。理解了这一点后面很多坑都能提前避开。1.3 拓扑信息会过时主从切换和扩缩容不是运维单方面的事Redis Cluster的节点不是一成不变的。主节点挂了会有从节点顶上扩容会把一部分槽位迁移到新节点。如果客户端还攒着旧拓扑把命令发给已经不做主的节点就会得到MOVED或ASK重定向甚至CLUSTERDOWN。这个细节在哨兵模式下几乎不存在——哨兵客户端只需要知道“当前主节点是谁”而集群客户端需要维护的是全量槽位映射表。所以拓扑刷新机制好不好直接决定线上稳定性。2. 连接工厂选型与初始化Jedis还是Lettuce配置怎么调2.1 两个客户端在集群场景下的真实差异Spring Boot默认集成的底层客户端是Lettuce。Lettuce基于Netty线程安全一个连接可以被多个线程复用集群模式下对槽位表的管理、重定向的自动跟随、拓扑刷新的支持都比较完整。Jedis则是老牌的同步客户端用法直白但线程不安全必须靠连接池兜着在集群模式下由JedisCluster按节点维护连接池管理粒度更细代码风格也偏传统。我见过不少团队坚持用Jedis理由往往是“Lettuce的共享连接不靠谱一个连接坏了全链路遭殃”。这个判断有一半对有一半过时。Lettuce确实存在单连接阻塞的问题但这不是“不能用”而是“要正确配置连接池”。Spring Boot对Lettuce连接池默认是关闭的很多人根本不知道有这个开关于是默认用着“单连接高并发”的模式直到线上开始超时。对比项JedisLettuce线程安全否必须配连接池是连接可跨线程集群节点管理JedisCluster按节点建池拓扑自动维护拓扑刷新依赖手动配置支持定时加自适应刷新Spring Boot默认不默认默认典型风险池配置不当容易节点连接爆炸单连接慢请求阻塞其他请求我的结论是新项目直接用Spring Boot默认的Lettuce但必须补上连接池依赖并显式开启。别被“Lettuce是连接复用”这句话迷惑复用的前提是每条命令都很快。一旦有慢查询、大value或网络抖动同一连接上的所有请求都会排队这个放大效应在集群场景下更明显。2.2 一份可以直接抄的初始化配置先加依赖。Spring Boot 2.x以后用spring-boot-starter-data-redis会自动带Lettuce但连接池需要单独引入commons-pool2dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency然后是配置文件这里用三个示例节点spring: data: redis: timeout: 3s connect-timeout: 2s password: ${REDIS_PASSWORD} cluster: nodes: - 10.0.0.11:6379 - 10.0.0.12:6379 - 10.0.0.13:6379 max-redirects: 3 lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 2s cluster: refresh: adaptive: true period: 30s字段含义值得说清楚。cluster.nodes不需要把每个节点都填进去客户端启动时会连上任意一个可用节点再通过CLUSTER SLOTS拿到全量拓扑。max-redirects是客户端在一次命令请求里最多跟随几次重定向设3够用。lettuce.pool开启连接池它是线上问题排查里最容易遗漏的配置项。timeout是命令执行超时connect-timeout是建立连接的超时两者别混为一谈。2.3 集群拓扑刷新的正确打开方式逻辑链是节点变化客户端感知刷新槽位映射后续命令走新路由。Lettuce支持两种刷新方式定期刷新和自适应刷新。上面yaml里的adaptive: true表示客户端碰到MOVED、ASK重定向或者连接异常时主动触发一次拓扑刷新这是应对主从切换最及时的手段。period: 30s是兜底策略就算没有异常信号也每隔30秒重新拉一次集群状态。如果不想写在yaml里也可以用编程方式定义Bean public LettuceConnectionFactory lettuceConnectionFactory() { RedisClusterConfiguration clusterConfig new RedisClusterConfiguration( List.of(10.0.0.11:6379, 10.0.0.12:6379, 10.0.0.13:6379)); ClusterTopologyRefreshOptions topologyRefreshOptions ClusterTopologyRefreshOptions.builder() .enablePeriodicRefresh(Duration.ofSeconds(30)) .enableAllAdaptiveRefreshTriggers() .build(); LettuceClientConfiguration clientConfig LettuceClientConfiguration.builder() .commandTimeout(Duration.ofSeconds(5)) .clusterTopologyRefreshOptions(topologyRefreshOptions) .build(); return new LettuceConnectionFactory(clusterConfig, clientConfig); }注意enableAllAdaptiveRefreshTriggers()会在各种异常场景下触发刷新包括重定向、未知节点、连接关闭等。这个设计一开始最好就开全不要等到线上出了问题再临时补。3. 序列化策略集群前最容易被忽略、集群后最容易翻车的配置3.1 默认序列化器的“三宗罪”很多项目的RedisTemplate直接用默认配置key和value序列化器都是JdkSerializationRedisSerializer。生产环境用默认配置Redis服务端看到的是一堆类似\xAC\xED\x00\x05t\x00...的二进制排查数据时基本靠猜。更麻烦的是JDK序列化会写入类的完整限定名一旦应用改了包名、类名旧缓存在反序列化时直接抛ClassCastException。这带来三个直接后果。第一可读性极差Redis客户端里看到的全是乱码。第二兼容性脆弱升级实体类就有可能让历史数据全部失效。第三放在集群场景下key被序列化成一长串不可读的字节你想通过Redis客户端观察槽位分布、判断是否存在数据倾斜都无从下手。3.2 推荐的序列化组合我现在的标准组合是key用StringRedisSerializervalue用GenericJackson2JsonRedisSerializer。如果你确定value本身就是JSON字符串那value也可以直接用StringRedisSerializer让业务层自己负责序列化这种方案最稳妥。Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(connectionFactory); StringRedisSerializer keySerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }GenericJackson2JsonRedisSerializer在序列化时会往JSON里写入一个class字段用来保存原始类型信息反序列化时才能还原成对象。这也带来一个约束序列化完成后如果改了类名或包路径反序列化就会失败。对需要长期缓存的业务对象建议预留一个version字段或者干脆回归“只存字符串自己维护JSON”的方案牺牲一点自动化便利换取绝对稳定。3.3 key可读性和槽位分析的关系集群模式下key的序列化结果直接影响两件事一是hash tag能不能正常写进key里二是排查数据倾斜时能不能一眼看出某些key大量落在同一个节点。如果key被JDK序列化成二进制这两件事都会变得很难做。所以我的建议是切分片集群前先把序列化配置统一改掉再谈其他优化。很多时候线上报CROSSSLOT不是代码逻辑错了而是key序列化后的实际字节和你预期不一样导致hash tag根本没有生效。我建议切集群前写个单元测试打印几个典型key序列化后的字节确认它们就是纯字符串再进行下一步。4. 集群约束下RedisTemplate哪些操作会直接报错、哪些可以绕行4.1 一次CROSSSLOT报错的完整复现先看发生在两个不同槽位的key MSET order:1001:status paid order:1002:status unpaid (error) CROSSSLOT Keys in request dont hash to the same slotCRC16(order:1001:status)和CRC16(order:1002:status)大概率落在不同槽位所以服务端直接拒绝。在Spring Data Redis的业务代码里最常见的触发方式是封装一个批量方法循环设置多个key最后又用pipeline或事务把命令合并发送。单机模式下没有任何感觉集群模式下兼容性问题立刻暴露。slot的计算规则对所有key一视同仁但花括号{}是例外。user:{1001}:name、user:{1001}:age、user:{1001}:address虽然看起来是三个不同的key客户端计算槽位时只取花括号里的1001所以三个key会落在同一个槽位。这就是hash tag的基础用法也是解决CROSSSLOT最直接的手段。4.2 事务和Lua脚本的边界MULTI/EXEC在Redis Cluster里并没有被彻底禁用但要求事务里所有命令操作的key必须落在同一个槽位。Lua脚本也一样脚本里访问的所有key必须由一个或多个hash tag钉在同一槽位。我接手的一个项目里就有过这样的案例一段库存扣减脚本单机时跑得很好切集群后偶发报错排查发现是脚本里同时访问了stock:1001和order:1001两个key彼此之间没有hash tag。把两个key改成{trade}:stock:1001和{trade}:order:1001后问题消失。如果你有一个业务动作必须操作多个不同业务的key并且被事务约束强烈建议在架构评审阶段就表明这个约束。切集群后再要求“把这段逻辑保持原子性”往往意味着业务建模本身需要调整远不是改一行配置能解决的。4.3 批量操作的三种替代方案集群模式下批量操作有三条路可以走。第一按槽位分组再批量发送。思路是先把需要操作的key按SlotHash.getSlot(key)分组然后把属于同一slot的命令合并成一次批量请求分别发给对应节点。这个方案保留了批量的性能优势代码量稍大但最通用。第二用hash tag把相关key钉进同一个槽位。这适合“必然要一起操作”的一组key比如同一用户的多个缓存字段。缺点很明显如果大量key都用同一个tag数据就会积压到一个槽位造成数据倾斜。tag值要有足够的区分度优先用用户ID、订单ID别用固定的业务名。第三直接拆成单命令多线程并行。比如RedisTemplate的executePipelined在集群跨节点场景下不可用那就换个思路把一个批量请求拆成多个单SET用线程池并行发送最后统一收集结果。性能比纯串行高很多代码也简单。4.4 Scan在集群模式下不是扫一遍就完事单机模式下KEYS和SCAN都只是扫当前节点。到了集群一个节点的数据只占全量数据的一部分只对一个节点执行SCAN拿到的一定是不完整结果。Spring Data Redis的redisTemplate.scan默认连接的是当前路由节点这在集群下很容易让人误以为已经全量扫描了。正确姿势是获取所有主节点地址对每个节点单独执行SCAN再把结果合并去重。日常研发排查问题用scan还能接受生产环境里做全量缓存对账、清理过期key这种任务就得老老实实按节点遍历否则漏数据是必然的。5. 三个线上坑的完整排查链路从日志到根因5.1 案例一Lettuce单连接阻塞整个服务跟着超时【现象】 线上突然大面积出现org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis; io.lettuce.core.RedisConnectionException: Redis connection lostCPU和内存监控都正常只有Redis相关接口成功率掉到50%以下。【排查过程】 先看Redis服务端连接数info clients显示connected_clients很平稳没有明显暴涨。再到应用侧看线程堆栈发现大量业务线程都阻塞在同一个Lettuce连接的写队列上。原因是当时有个埋点逻辑往Redis写入一个大JSONsize接近1MB单次命令耗时超过2秒。而Lettuce默认共享同一个连接其他线程的读写请求只能排队全部超时。【修复方式】 开启lettuce.pool连接池配置8到50个连接同时把大key单独拆走禁止缓存超过200KB的value。开池之后慢命令只影响池中的个别连接不会再让全部请求排队。这个改动上线后超时问题基本消失。【教训】 Lettuce的“共享连接”是性能优势也是可用性风险。连接池不是Jedis时代的遗留物而是给Lettuce加一道隔离屏障。5.2 案例二主从切换后客户端还在朝旧节点发命令有一段时间我们频繁做故障演练手动切主从测试高可用。切完大概一分钟内应用日志里频繁出现MOVED、ASK重定向紧接着是CLUSTERDOWN。起初我以为是代码逻辑问题后来才发现是拓扑刷新没开。客户端在第一次连接时拿到了全量拓扑但主从切换后某个槽位的master从A变成了B客户端还认为槽位归A管自然反复收到重定向信号。【排查过程】 第一步用redis-cli -c -h xxx cluster nodes确认服务端拓扑正常。第二步发现应用侧连的还是旧master的IP。第三步翻Lettuce的刷新配置period和adaptive都没有开启。【修复方式】 按前面第二节的配置同时打开定期刷新和自适应刷新。实践下来adaptive对异常信号的响应最关键基本能做到秒级感知主从切换。5.3 案例三一个批量写入接口从30ms变成3s系统里有个对账模块批量任务会一次性写入几百个key单机时用pipeline一个RTT搞定响应30ms。切集群后同样的代码直接变慢接口耗时接近3秒。一开始代码里确实有pipeline但集群模式下跨槽位的pipeline在客户端层面直接退化了实际变成逐条发送只能串行执行几百次。【优化方案】 按槽位聚合把所有key按照SlotHash.getSlot(key)分组每组内部用pipeline批量提交组间并行。改造后的耗时对比大致如下方案写入500个key耗时单机pipeline约30ms集群逐条SET约1.8s集群按slot分组pipeline约55ms这个数字不绝对取决于网络和并发但量级差别是真实的。核心思路是集群模式下pipeline不是不能用而是要按槽位组织。6. 稳定运行半年后沉淀出来的调优心得6.1 连接池和超时参数的实测标定上面把参数讲了一遍这里给一组经过线上验证的推荐值参数推荐值说明max-active50按峰值线程数估算max-idle20保留的活跃连接数min-idle5防止流量尖峰时建连过慢max-wait2s拿不到连接时最多等多久commandTimeout3s到5s单条命令超时connectTimeout2s建连超时topology refresh period30s定期拉拓扑adaptive refreshtrue异常时主动刷新特别强调一点max-wait不要配成-1s那是无限等待连接池耗尽时线程会一直挂着接口直接假死。宁可早点报超时让调用方快速失败也不要无限排队。6.2 面向分片集群的Key设计规范这部分是我个人沉淀最久、收益最大的经验。Key务必带业务前缀和模块名比如mall:order:detail:{orderId}方便在Redis客户端里一眼认出归属方是谁的业务。需要合并操作的多key必须使用hash tagtag值优先用业务ID而不是固定词。比如{userId}:cart:list和{userId}:coupon:list属于同一用户的两类缓存放同一槽位才有条件做批量操作。hash tag本身不要设计太长两位到三位足够。过长的tag会降低可读性过短的公共tag则会造成倾斜。大value和热key必须单独治理。集群模式下一个节点物理上是独立进程热点集中在某个槽位等于把压力全压在一台机器上其他机器再闲也帮不了忙。不要指望靠RedisTemplate的抽象把客户端差异全部屏蔽。底层是Jedis还是Lettuce、拓扑刷新开没开、序列化器是谁这些配置决定了上层代码是否稳定。具体到Key的设计我的习惯是每接入一个新模块先问三个问题这批key单次操作涉及几个槽位有没有必须一起原子的多key场景缓存对象会不会超过200KB。三个问题都有了明确答案才动手写代码。6.3 一个提前规避很多问题的自检清单最后给一份切集群前的检查清单我每次接类似任务都会照着过一遍是否检查了RedisTemplate默认序列化器并改为String key方案是否启用了Lettuce连接池是否打开了拓扑定期刷新和自适应刷新是否梳理过业务代码里所有多key操作明确跨槽位场景的替代方案是否用SlotHash.getSlot(key)对重要key做过槽位分布统计是否限制过大value缓存是否对批量接口在分片集群下做过压测如果让我再做一次类似的升级我会把第一步从“改配置”换成“全量代码走查RedisTemplate使用点”。集群模式真正改变的不是连接地址而是一整套操作边界。宁可提前三天检查每个多key操作也不要上线之后靠告警帮你找问题这是这次升级换来的最大教训。