我把Redis双活塞进Spring,业务代码零改动

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

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

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

这一节先把账算清楚。双活到底在解决什么,为什么我挑了应用层方案。

双活不是主从复制,先说清我们到底在解决什么

很多人听见双活,第一反应是主从复制加个反向同步。这是把两件事混为一谈,我得先泼盆冷水。

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

java 复制代码
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() { return true; }
}

注意 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。

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

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

java 复制代码
public class DualWriteInvocationHandler implements InvocationHandler {

    private final StringRedisTemplate primary;
    private final StringRedisTemplate secondary;
    private final 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);
                return null;
            }
            throw e;
        }
        return null;
    }

    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 实例模拟双机房

bash 复制代码
redis-server --port 6379
redis-server --port 6380

✅ 验证:

bash 复制代码
redis-cli -p 6379 ping
# PONG
redis-cli -p 6380 ping
# PONG

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

第二步,引入依赖

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

✅ 验证:

bash 复制代码
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 线程,连接层够稳,做双活客户端底座没问题。

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

java 复制代码
public interface RedisDualClient {
    void set(String key, String value);
    void set(String key, String value, long ttlSeconds);
    String get(String key);
}

public class DualWriteConfig {
    // 主或备写入失败时是否降级而非抛错,双活优先保可用
    private boolean degradeOnPrimaryFail = true;
    private boolean degradeOnSecondaryFail = true;
    // getters / setters 省略
}

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

java 复制代码
@Configuration
public class DualRedisConfig {

    @Bean
    public RedisConnectionFactory primaryFactory() {
        return new LettuceConnectionFactory("127.0.0.1", 6379);
    }

    @Bean
    public RedisConnectionFactory secondaryFactory() {
        return new LettuceConnectionFactory("127.0.0.1", 6380);
    }

    @Bean
    public StringRedisTemplate primaryTemplate(RedisConnectionFactory primaryFactory) {
        return new StringRedisTemplate(primaryFactory);
    }

    @Bean
    public StringRedisTemplate secondaryTemplate(RedisConnectionFactory secondaryFactory) {
        return new 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 里下断点,冲突排查不用去翻同步工具日志。

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

java 复制代码
@Service
public class 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);
    }
}

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

bash 复制代码
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 产出的货是什么,注入时自然找不到。

参考资料

我的判断

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

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

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

相关推荐
风流 少年4 小时前
Spring AI 2.0:阿里云百炼平台(智能体应用)
redis·spring·阿里云
肠畔码农4 小时前
深度解密 Redis 分布式锁:从单机原子语义到集群架构博弈
redis·分布式·架构
宠友信息6 小时前
IM系统开发技术路线分析,即时通讯源码助力快速搭建聊天应用
java·spring boot·redis·websocket·mysql·uni-app·vue
肠畔码农6 小时前
Redis 主从复制内核深潜:从物理模型到全量增量同步的本质
网络·redis·php
小的~~9 小时前
面试被问分布式锁,我差点语塞…直到搞懂了Redis、ZooKeeper和etcd的“三国杀”
redis·分布式·面试
2602_9599609210 小时前
Java大厂面试实录:Spring Boot、Redis、Kafka、Elasticsearch、分布式事务与云原生高并发系统
java·jvm·spring boot·redis·面试题
肠畔码农10 小时前
高可用底座:Redis 哨兵机制(Sentinel)底层内核拆解
redis·bootstrap·sentinel
晚安code11 小时前
Redis 内存淘汰策略实战:什么时候淘汰、怎么选、怎么防缓存击穿
java·redis·缓存
Dovis(誓平步青云)12 小时前
从Redis指标采集到异常告警:redis_exporter + Prometheus 完整实战
服务器·数据库·人工智能·redis·架构·prometheus·vibe coding