Spring应用实现Redis双活:几行代码搞定跨机房容灾

发布时间:2026/7/25 2:17:57

Spring应用实现Redis双活:几行代码搞定跨机房容灾 你有没有过这种经历。A 机房 Redis 主节点磁盘满了被摘除流量切到 B 机房用户购物车全空了因为 B 机房的 Redis 里根本没有这几分钟的写入。主从复制救不了你它天生就是单向异步的。更要命的是你翻遍社区搜到的双活方案全是 Redis-Shake、CloudCanal、RedisSyncer 这类中间件再不济也是一层 Proxy 双向同步。没有一篇告诉你能不能在自己那个天天跑的 Spring 应用里用几行代码把双活焊死在客户端上业务代码一行都不用改。我去年带一个下单系统做多机房容灾时就卡在这里。运维同学甩给我一份 RedisShake 双向同步配置我看完一句实话这套东西能跑但它把双写逻辑藏到了应用之外冲突排查时你只能去扒同步日志等于把最该自己掌控的部分交了出去。应用层双活方案 vs Proxy 中间件方案对比这一节先把账算清楚。双活到底在解决什么为什么我挑了应用层方案。双活不是主从复制先说清我们到底在解决什么很多人听见双活第一反应是主从复制加个反向同步。这是把两件事混为一谈我得先泼盆冷水。Redis 主从复制的契约很简单从节点单向、异步地追主节点。它解决的是读写分离和高可用不是双向写入。一旦你把它当双活用两个机房同时写复制链路是单向的数据根本对不上。机房级故障切换时还没同步过去的那部分写操作直接丢这就是开头购物车清空的根因。“MySQL「那你用主从复制做双向同步不就行了配两条链路互相同步」你的建议很好下次不要再建议了。双向主从同步会制造复制回环A 写给 B、B 又写回 A同一个 key 在两头无限打转到头来谁都分不清哪个是真相。业界工具之所以要专门做双向同步模式就是为了解决这个回环和冲突检测而不是简单把主从反过来配一遍。所以双活真正要啃的三块硬骨头是数据双向同步、读请求就近路由、单机房故障自动降级。Redis-Shake、CloudCanal、RedisSyncer 这些工具从存储层解决了第一块代价是双写逻辑脱离应用、冲突排查靠外部日志、对业务的写入语义没有感知。应用层方案反过来想这个问题。拆开看双写就是一次 set 调用落两个机房读路由就是一次 get 去最近的机房取。这些动作发生在方法调用层而 Spring 恰好给了我们一个在 Bean 创建时动手脚的标准钩子这个钩子就是 FactoryBean。把写路由下沉到应用层比 Proxy 层双向同步更可控冲突发生时你能直接断点进代理方法看两个机房分别写了什么。这是我做了三个多机房项目后越来越确信的一件事。FactoryBean 凭什么比 Bean 更适合这道活儿先回答读者最常被卡住的一个问题。Bean 也能返回一个对象为什么双活客户端非得用 FactoryBean 不可。区别在于Bean 方法你写return new X()Spring 就把 X 的实例交出去你没机会在交付之前给这个实例套一层壳。FactoryBean 不一样它的约定是getObject()返回的最终是个被包装过的产品对象而getObjectType()告诉 Spring 这个产品到底是什么类型Bean 名字前加前缀才能拿到 FactoryBean 自己。这套约定真正的价值不是创建对象而是给对象套一层你想要的壳。双活客户端的壳就是 JDK 动态代理它包裹着两个机房的 Redis 连接对外仍是一个普通的RedisDualClient接口。业务代码按接口注入完全不知道背后有双写和路由在发生。public class DualWriteRedisFactoryBean implements FactoryBeanRedisDualClient { private StringRedisTemplate primaryTemplate; private StringRedisTemplate secondaryTemplate; private DualWriteConfig config new DualWriteConfig(); // 交给 Spring 注入两个机房的连接模板FactoryBean 自己只管组装 public void setPrimaryTemplate(StringRedisTemplate t) { this.primaryTemplate t; } public void setSecondaryTemplate(StringRedisTemplate t) { this.secondaryTemplate t; } public void setConfig(DualWriteConfig c) { this.config c; } Override public RedisDualClient getObject() throws Exception { // 关键在这里返回的不是裸客户端而是包了双写代理的壳 DualWriteInvocationHandler handler new DualWriteInvocationHandler(primaryTemplate, secondaryTemplate, config); return (RedisDualClient) Proxy.newProxyInstance( RedisDualClient.class.getClassLoader(), new Class?[]{RedisDualClient.class}, handler); } // 必须返回产品类型而非 FactoryBean 类型否则 Autowired RedisDualClient 会找不到 Bean Override public Class? getObjectType() { return RedisDualClient.class; } Override public boolean isSingleton() { returntrue; } }注意getObjectType()这一行它返回的是RedisDualClient.class而不是DualWriteRedisFactoryBean.class。这是新手最容易翻车的地方配错了 Spring 就认为这个 FactoryBean 产出的 Bean 类型是 FactoryBean 自身业务里Autowired RedisDualClient直接报NoSuchBeanDefinitionException。壳套得再好类型对不上Spring 容器连门都不让你进。顺带说一个面试常考的细节。哪天你想拿到的不是 FactoryBean 产出的客户端而是工厂自己得在 Bean 名字前加 前缀比如dualWriteRedisFactoryBeanSpring 才会把工厂交出来而非它的产品。这个 自指约定也是 FactoryBean 和 Bean 的又一道分水岭Bean 根本没有这种能力你只能拿到它直接 return 的那个对象。JDK 动态代理怎么在 set/get 上动手脚壳套好了里面那层代理才是真正的戏肉。JDK 动态代理有个硬约束它只能代理接口目标类必须实现接口。这就是为什么双活客户端我选了自定义RedisDualClient接口而不是去代理StringRedisTemplate这个类。要是你拿 Jedis 这种具体类去Proxy.newProxyInstance运行期直接ClassCastException教你做人要么换接口要么上 CGLIB。双活 Redis 客户端整体架构架构上就四块FactoryBean 负责造壳InvocationHandler 负责拦截两个RedisConnectionFactoryLettuce 的实现各自连一个机房最上面业务 Service 只认RedisDualClient接口。Spring Data Redis 里RedisConnectionFactory本身就是接口LettuceConnectionFactory是它的实现我们对接口编程换客户端不伤筋动骨。代理的核心是InvocationHandler.invoke它拦截每一次方法调用。set 走双写get 走读路由其他方法是少数派先按主机房处理保持扩展能力。public class DualWriteInvocationHandler implements InvocationHandler { privatefinal StringRedisTemplate primary; privatefinal StringRedisTemplate secondary; privatefinal DualWriteConfig config; public DualWriteInvocationHandler(StringRedisTemplate primary, StringRedisTemplate secondary, DualWriteConfig config) { this.primary primary; this.secondary secondary; this.config config; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String name method.getName(); if (set.equals(name)) return handleSet(args); if (get.equals(name)) return handleGet(args); // 非 set/get 的操作直接走主机房让接口可平滑扩展而不报错 return routeToPrimary(method, args); } private Object handleSet(Object[] args) { String key (String) args[0]; String value (String) args[1]; // 主先写备后写顺序保证主机房是权威源备永远落后一点点 try { opsSet(primary, key, value, args); } catch (Exception e) { if (!config.isDegradeOnPrimaryFail()) throw e; log.warn(主机房写入失败降级为仅写备机房, key{}, key, e); } try { opsSet(secondary, key, value, args); } catch (Exception e) { if (config.isDegradeOnSecondaryFail()) { log.warn(备机房写入失败两机房短暂不一致, key{}, key, e); returnnull; } throw e; } returnnull; } private Object handleGet(Object[] args) { String key (String) args[0]; // 读路由默认打主机房主挂了立刻切备业务无感知 try { return primary.opsForValue().get(key); } catch (Exception e) { log.warn(主机房读取失败路由到备机房, key{}, key, e); return secondary.opsForValue().get(key); } } private void opsSet(StringRedisTemplate tpl, String k, String v, Object[] args) { if (args.length 2 args[2] instanceof Long ttl) { tpl.opsForValue().set(k, v, ttl, TimeUnit.SECONDS); } else { tpl.opsForValue().set(k, v); } } private Object routeToPrimary(Method method, Object[] args) throws Throwable { return method.invoke(primary, args); } }看到没双写和读路由不是什么黑魔法就是在方法调用这一层插了一道闸。这也是对「动态代理只能做 AOP 日志和事务」这个误解最干净的反击它在任意 Redis 操作上都能动手脚。动态代理在这里不是装饰它是双活逻辑的承载层。set 进代理就被拆成两次写get 进代理就被翻译成一次路由业务侧完全没有感知。把双活焊死在方法调用这一层比在 Proxy 中间件里配规则直观得多断点一打就知道哪边写漏了、哪边读歪了排查冲突不用再翻同步工具的日志。动手接入FactoryBean 加代理跑起双活客户端光讲原理不过瘾这一节把它真正跑起来。环境是 macOS、JDK 17、Spring Boot 3.3.2、Redis 7.2Lettuce 6.3 由spring-boot-starter-data-redis3.3.2 传递管理不用单独引。第一步起两个 Redis 实例模拟双机房redis-server --port 6379 redis-server --port 6380✅ 验证redis-cli -p 6379 ping # PONG redis-cli -p 6380 ping # PONG两个实例都回 PONG说明两个机房就位。生产里它们该在不同的可用区这里本地起端口区分。第二步引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId version3.3.2/version /dependency✅ 验证mvn dependency:tree | grep lettuce # io.lettuce:lettuce-core:jar:6.3.2.RELEASE:compileSpring Boot 3.x 默认 Redis 客户端就是 Lettuce而且它实现RedisConnectionFactory接口正好满足 JDK 代理「只代理接口」的硬约束。Redis 6.0 引入了 ACL 和多 IO 线程连接层够稳做双活客户端底座没问题。第三步定义客户端接口与降级配置public interface RedisDualClient { void set(String key, String value); void set(String key, String value, long ttlSeconds); String get(String key); } publicclass DualWriteConfig { // 主或备写入失败时是否降级而非抛错双活优先保可用 privateboolean degradeOnPrimaryFail true; privateboolean degradeOnSecondaryFail true; // getters / setters 省略 }第四步配置类把零件组装起来Configuration publicclass DualRedisConfig { Bean public RedisConnectionFactory primaryFactory() { returnnew LettuceConnectionFactory(127.0.0.1, 6379); } Bean public RedisConnectionFactory secondaryFactory() { returnnew LettuceConnectionFactory(127.0.0.1, 6380); } Bean public StringRedisTemplate primaryTemplate(RedisConnectionFactory primaryFactory) { returnnew StringRedisTemplate(primaryFactory); } Bean public StringRedisTemplate secondaryTemplate(RedisConnectionFactory secondaryFactory) { returnnew StringRedisTemplate(secondaryFactory); } // 返回的是 FactoryBeanSpring 会自动调用它的 getObject() 拿到双活代理 Bean public DualWriteRedisFactoryBean dualWriteRedisFactoryBean( StringRedisTemplate primaryTemplate, StringRedisTemplate secondaryTemplate) { DualWriteRedisFactoryBean factory new DualWriteRedisFactoryBean(); factory.setPrimaryTemplate(primaryTemplate); factory.setSecondaryTemplate(secondaryTemplate); return factory; } }双写与读路由数据流写路径是请求进set代理先落主再落备任一机房挂掉按config决定降级还是抛错。读路径默认打主主异常秒级切备。这条数据流的每一个分支你都能在InvocationHandler里下断点冲突排查不用去翻同步工具日志。第五步业务代码零改动地使用Service publicclass OrderService { // 注入的就是 FactoryBean 产出的代理业务完全无感 Autowired private RedisDualClient redisDualClient; public void cacheOrder(String orderId, String payload) { redisDualClient.set(order: orderId, payload, 3600L); } public String loadOrder(String orderId) { return redisDualClient.get(order: orderId); } }✅ 验证跑个测试往两个机房各查一次redis-cli -p 6379 get order:1001 # {\sku\:\iphone16\,\qty\:1} redis-cli -p 6380 get order:1001 # {\sku\:\iphone16\,\qty\:1}两个端口都查到了同一个 key双写确实发生了而OrderService里没有任何双机房相关的代码。这就是 FactoryBean 加代理这套壳的价值业务方甚至不知道自己写了两个 Redis。常见报错与解决第一个坑ClassCastException: com.sun.proxy.$ProxyXX cannot be cast to org.springframework.data.redis.core.StringRedisTemplate。你试图代理一个类而不是接口JDK 动态代理不认。解法要么像本文定义RedisDualClient接口要么换 CGLIB 代理类。第二个坑NoSuchBeanDefinitionException: No qualifying bean of type RedisDualClient。getObjectType()返回错了类型Spring 不知道这个 FactoryBean 产出的是RedisDualClient。把getObjectType()改成返回产品接口类型即可。机房挂了怎么办故障降级与一致性取舍现在聊最扎心的部分。双写不保证原子性两个机房在故障窗口里可能短暂不一致这是物理定律不是你代码写得丑。我踩过最痛的一次A 机房网络分区写入降级成只写 B分区期间 B 机房正常服务。等 A 恢复B 有最新数据 A 没有得靠同步工具把 B 的增量补回 A。所以双活场景下写入必须幂等冲突策略要么后写覆盖要么上版本号写时带CAS或逻辑时钟谁新听谁的。“扔个投票双活写路由你更倾向哪种做法同步双写强一致优先性能让位异步双写性能优先接受短暂不一致单元化路由按 key 分片写固定机房跨机房只读这里我必须说句得罪人的实话双活不是银弹。你的业务能接受最终一致才上双写强一致场景比如账户余额硬上双写反而把本来能跑的系统拖垮。antirez 老哥设计 Redis 时从没承诺过跨机房强一致那是分布式系统的经典取舍没有两全其美的策略。降级策略我偏向保守主机房失败就降级到仅写备并打告警备机房也失败才真正抛错因为双活的第一目标是保可用。但读路径我坚持主失败才切备避免备机房落后数据被读出来造成业务错乱。这套方案的坑与边界什么时候别用讲完好处该泼第二盆冷水。什么场景这套方案不适合。第一超高写入吞吐且对延迟极敏感的系统别用同步双写。一次 set 变两次网络往返P99 直接翻倍。这种场景要么异步双写丢到线程池要么直接上单元化路由按 key 分片固定写某个机房根本不产生跨机房双写。第二需要跨数据结构、跨 key 事务一致性的别用。本文代理只覆盖了set/get这类单 key 操作你要是multi/exec、 Lua 脚本、 Hash 批量操作得在InvocationHandler里自己扩工作量不小。第三团队没有应用层掌控能力时别用。双活逻辑藏在你的代码里意味着它跟着你的发布节奏走也跟着你的 bug 走。如果团队更习惯基础设施统一管控Redis-Shake 双向同步模式反而更省心两套方案粒度不同甚至可以并存应用层做精细路由存储层做兜底同步。说到底FactoryBean 加 JDK 动态代理这套组合拳卖点不是性能多强而是把双活的控制权交回写业务的人手里。你能在方法调用层看到每一次双写、每一次路由切换这才是它和 Proxy 中间件最本质的区别。常见问题双写让延迟翻倍性能扛得住吗同步双写确实两次往返写入 P99 大致翻倍。扛不住就改成异步双写把备机房写入丢进独立线程池主返回即成功代价是故障窗口内可能丢备机房那次写配合幂等和同步工具兜底能接受。两个机房同时写同一个 key 冲突怎么解写入全部幂等冲突策略二选一后写覆盖简单但可能丢中间态或带版本号/逻辑时钟写时比较谁新听谁的。读路由永远优先主避免读到落后的备数据。用 Jedis 能替代 Lettuce 做代理吗JDK 动态代理只认接口Jedis 是具体类直接代理会ClassCastException。两条路要么像本文自定义接口让代理绕过具体客户端要么改用 CGLIB 代理类。Lettuce 实现RedisConnectionFactory接口天然契合 JDK 代理。这套和 Redis-Shake 双向同步怎么选不冲突粒度不同。应用层代理做精细的写路由和读路由对业务语义有感知冲突好排查。Redis-Shake 双向同步模式在存储层做兜底适合你不想改代码或跨异构系统的场景两者可并存。getObjectType 配错到底报什么错典型是UnsatisfiedDependencyException嵌套NoSuchBeanDefinitionException提示没有RedisDualClient类型的 Bean。根因是getObjectType()没返回产品接口类型Spring 容器不知道 FactoryBean 产出的货是什么注入时自然找不到。参考资料Spring 官方 FactoryBean 文档 https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/beans/factory/FactoryBean.htmlRedis 官方 Replication 文档 https://redis.io/docs/latest/operate/oss_and_enterprise/management/replication/RedisShake GitHub https://github.com/tair-opensource/RedisShake我的判断双活客户端这事我越来越觉得它不是技术炫技而是把「数据该往哪写、该从哪读」这个本该属于业务语义的决策从运维中间件手里拿回来。FactoryBean 加 JDK 动态代理这套组合最大的好处不是少写几行是你每次双写失败都能直接断点进代理方法看清楚两个机房各自发生了什么而不是半夜去扒同步日志猜真相。往后看云厂商的单元化方案和 Redis 自身的多线程演进会把一部分双活能力下沉到基础设施但应用层这层壳不会过时因为它贴着业务语义冲突该用后写覆盖还是版本号只有写业务的人最清楚。工具能帮你把数据搬平替不了你决定哪一份才算数。顺手把码哥跳动设为星标下篇我接着这篇写冲突合并和异步双写线程池的实打实落地漏了你就得翻历史记录。你身边要有人正在做多机房容灾选型这篇可以直接甩给他省得他明天也在半夜两点被购物车清空告警叫醒。

相关新闻