首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Spring FactoryBean 配合 JDK 动态代理,在应用层实现 Redis 双活架构的客户端双写、读,业务零侵入

Spring FactoryBean 配合 JDK 动态代理,在应用层实现 Redis 双活架构的客户端双写、读,业务零侵入

作者头像
码哥字节
发布2026-07-21 14:56:49
发布2026-07-21 14:56:49
1610
举报
文章被收录于专栏:后端架构师后端架构师

你好,我是码哥

你有没有过这种经历。A 机房 Redis 主节点磁盘满了被摘除,流量切到 B 机房,用户购物车全空了,因为 B 机房的 Redis 里根本没有这几分钟的写入。主从复制救不了你,它天生就是单向异步的。

更要命的是,你翻遍社区,搜到的双活方案全是 Redis-Shake、CloudCanal、RedisSyncer 这类中间件,再不济也是一层 Proxy 双向同步。没有一篇告诉你,能不能在自己那个天天跑的 Spring 应用里,用几行代码把双活焊死在客户端上,业务代码一行都不用改。

我去年带一个下单系统做多机房容灾时就卡在这里。运维同学甩给我一份 RedisShake 双向同步配置,我看完一句实话,这套东西能跑,但它把双写逻辑藏到了应用之外,冲突排查时你只能去扒同步日志,等于把最该自己掌控的部分交了出去。

应用层双活方案 vs Proxy 中间件方案对比
应用层双活方案 vs Proxy 中间件方案对比

应用层双活方案 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 接口。业务代码按接口注入,完全不知道背后有双写和路由在发生。

代码语言:javascript
复制
public class DualWriteRedisFactoryBean implements FactoryBean<RedisDualClient> {

    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 名字前加 & 前缀,比如 &dualWriteRedisFactoryBean,Spring 才会把工厂交出来而非它的产品。这个 & 自指约定也是 FactoryBean 和 @Bean 的又一道分水岭,@Bean 根本没有这种能力,你只能拿到它直接 return 的那个对象。

JDK 动态代理怎么在 set/get 上动手脚

壳套好了,里面那层代理才是真正的戏肉。JDK 动态代理有个硬约束,它只能代理接口,目标类必须实现接口。这就是为什么双活客户端我选了自定义 RedisDualClient 接口,而不是去代理 StringRedisTemplate 这个类。要是你拿 Jedis 这种具体类去 Proxy.newProxyInstance,运行期直接 ClassCastException 教你做人,要么换接口,要么上 CGLIB。

双活 Redis 客户端整体架构
双活 Redis 客户端整体架构

双活 Redis 客户端整体架构

架构上就四块,FactoryBean 负责造壳,InvocationHandler 负责拦截,两个 RedisConnectionFactory(Lettuce 的实现)各自连一个机房,最上面业务 Service 只认 RedisDualClient 接口。Spring Data Redis 里 RedisConnectionFactory 本身就是接口,LettuceConnectionFactory 是它的实现,我们对接口编程,换客户端不伤筋动骨。

代理的核心是 InvocationHandler.invoke,它拦截每一次方法调用。set 走双写,get 走读路由,其他方法是少数派,先按主机房处理保持扩展能力。

代码语言:javascript
复制
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.2,Lettuce 6.3 由 spring-boot-starter-data-redis 3.3.2 传递管理,不用单独引。

第一步,起两个 Redis 实例模拟双机房

代码语言:javascript
复制
redis-server --port 6379
redis-server --port 6380

✅ 验证:

代码语言:javascript
复制
redis-cli -p 6379 ping
# PONG
redis-cli -p 6380 ping
# PONG

两个实例都回 PONG,说明两个机房就位。生产里它们该在不同的可用区,这里本地起端口区分。

第二步,引入依赖

代码语言:javascript
复制
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
  <version>3.3.2</version>
</dependency>

✅ 验证:

代码语言:javascript
复制
mvn dependency:tree | grep lettuce
# io.lettuce:lettuce-core:jar:6.3.2.RELEASE:compile

Spring Boot 3.x 默认 Redis 客户端就是 Lettuce,而且它实现 RedisConnectionFactory 接口,正好满足 JDK 代理「只代理接口」的硬约束。Redis 6.0 引入了 ACL 和多 IO 线程,连接层够稳,做双活客户端底座没问题。

第三步,定义客户端接口与降级配置

代码语言:javascript
复制
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 省略
}

第四步,配置类把零件组装起来

代码语言:javascript
复制
@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);
    }

    // 返回的是 FactoryBean,Spring 会自动调用它的 getObject() 拿到双活代理
    @Bean
    public DualWriteRedisFactoryBean dualWriteRedisFactoryBean(
            StringRedisTemplate primaryTemplate,
            StringRedisTemplate secondaryTemplate) {
        DualWriteRedisFactoryBean factory = new DualWriteRedisFactoryBean();
        factory.setPrimaryTemplate(primaryTemplate);
        factory.setSecondaryTemplate(secondaryTemplate);
        return factory;
    }
}
双写与读路由数据流
双写与读路由数据流

双写与读路由数据流

写路径是请求进 set,代理先落主再落备,任一机房挂掉按 config 决定降级还是抛错。读路径默认打主,主异常秒级切备。这条数据流的每一个分支你都能在 InvocationHandler 里下断点,冲突排查不用去翻同步工具日志。

第五步,业务代码零改动地使用

代码语言:javascript
复制
@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);
    }
}

✅ 验证,跑个测试往两个机房各查一次:

代码语言:javascript
复制
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.html
  • Redis 官方 Replication 文档 https://redis.io/docs/latest/operate/oss_and_enterprise/management/replication/
  • RedisShake GitHub https://github.com/tair-opensource/RedisShake

我的判断

双活客户端这事,我越来越觉得它不是技术炫技,而是把「数据该往哪写、该从哪读」这个本该属于业务语义的决策,从运维中间件手里拿回来。FactoryBean 加 JDK 动态代理这套组合,最大的好处不是少写几行,是你每次双写失败都能直接断点进代理方法,看清楚两个机房各自发生了什么,而不是半夜去扒同步日志猜真相。

往后看,云厂商的单元化方案和 Redis 自身的多线程演进会把一部分双活能力下沉到基础设施,但应用层这层壳不会过时,因为它贴着业务语义,冲突该用后写覆盖还是版本号,只有写业务的人最清楚。工具能帮你把数据搬平,替不了你决定哪一份才算数。

顺手把码哥跳动设为星标,下篇我接着这篇写冲突合并和异步双写线程池的实打实落地,漏了你就得翻历史记录。你身边要有人正在做多机房容灾选型,这篇可以直接甩给他,省得他明天也在半夜两点被购物车清空告警叫醒。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-19,如有侵权请联系 cloudcommunity@tencent.com 删除
目录
  • 双活不是主从复制,先说清我们到底在解决什么
  • FactoryBean 凭什么比 @Bean 更适合这道活儿
  • JDK 动态代理怎么在 set/get 上动手脚
  • 动手接入,FactoryBean 加代理跑起双活客户端
    • 第一步,起两个 Redis 实例模拟双机房
    • 第二步,引入依赖
    • 第三步,定义客户端接口与降级配置
    • 第四步,配置类把零件组装起来
    • 第五步,业务代码零改动地使用
    • 常见报错与解决
  • 机房挂了怎么办,故障降级与一致性取舍
  • 这套方案的坑与边界,什么时候别用
  • 常见问题
  • 参考资料
  • 我的判断
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档