fix(gateway): 修复 RouteChangeBroadcaster 的 Redis 注入歧义(线上启动崩溃)
build / build-push (push) Successful in 1m1s

线上 f456c857 启动失败、容器无限重启,根因为 StringRedisTemplate 注入歧义:

- Field redisTemplate in RouteChangeBroadcaster required a single bean, but 2 were found:
  stringRedisTemplate (RedisAutoConfiguration) 与
  signKeyStringRedisTemplate (yh-appauth SignKeyRedisAutoConfiguration)

修复:

- 显式 @Qualifier("stringRedisTemplate") 绑定应用主库模板。必须选主库:网关
  SPRING_REDIS_DATABASE=7,而 appauth 密钥模板直连 db0——选错会造成发布/订阅异库、
  广播静默失效,比启动失败更难排查。
- 订阅容器改用 redisTemplate.getConnectionFactory(),结构上保证发布与订阅同库,
  同时消除 RedisConnectionFactory 同类型多 bean 的隐患。

回归测试 RouteChangeBroadcasterWiringTest(3 例):

- 手工驱动 AutowiredAnnotationBeanPostProcessor 构造「stringRedisTemplate 与
  signKeyStringRedisTemplate 共存」的最小上下文(复刻 appauth 在场的现场)
- 断言装配成功;断言绑定的是主库模板而非密钥专用模板
- 反向断言:裸 @Autowired 在双 bean 下必然抛 UnsatisfiedDependencyException
  —— 钉死本次事故的成因,防止回退

既有测试为何漏掉:application-test.yml 的 redis-broadcast-enabled=false 把该 bean
整个关掉,装配分支从未被覆盖。

mvn -o test: 104/104 通过(新增 3 例)。
This commit is contained in:
chenxing
2026-09-24 10:57:42 +08:00
parent f456c85702
commit 48625d0f11
2 changed files with 114 additions and 5 deletions
@@ -4,10 +4,10 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener; import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic; import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.listener.RedisMessageListenerContainer;
@@ -41,19 +41,26 @@ public class RouteChangeBroadcaster implements MessageListener, DisposableBean {
@Autowired @Autowired
private RouteRegistry routeRegistry; private RouteRegistry routeRegistry;
/**
* 广播通道走<b>应用主库</b>模板(bean 名 {@code stringRedisTemplate}),而非 appauth starter 的
* 密钥专用 {@code signKeyStringRedisTemplate}(那个直连 db0 专供 {@code sign:app:*})。
* 两者同类型,不显式 {@code @Qualifier} 会因注入歧义启动失败;选错则发布与订阅落在不同 db,
* 广播静默失效(比启动失败更糟——看不出问题)。
*/
@Autowired @Autowired
@Qualifier("stringRedisTemplate")
private StringRedisTemplate redisTemplate; private StringRedisTemplate redisTemplate;
@Autowired
private RedisConnectionFactory connectionFactory;
private RedisMessageListenerContainer container; private RedisMessageListenerContainer container;
@PostConstruct @PostConstruct
void subscribe() { void subscribe() {
try { try {
RedisMessageListenerContainer c = new RedisMessageListenerContainer(); RedisMessageListenerContainer c = new RedisMessageListenerContainer();
c.setConnectionFactory(connectionFactory); // 订阅连接直接取用上面 template 的连接工厂,保证「发布」与「订阅」必然同库:
// 另注一个 RedisConnectionFactory 会引入同类型多 bean 歧义,且可能被解析到
// appauth 的 db0 工厂上与主库模板不匹配。
c.setConnectionFactory(redisTemplate.getConnectionFactory());
c.addMessageListener(this, new ChannelTopic(CHANNEL)); c.addMessageListener(this, new ChannelTopic(CHANNEL));
c.afterPropertiesSet(); // 连接不可用时异步重连,不阻塞启动 c.afterPropertiesSet(); // 连接不可用时异步重连,不阻塞启动
container = c; container = c;
@@ -0,0 +1,102 @@
package com.yunhai.platform.gateway.route;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor;
import org.springframework.beans.factory.annotation.QualifierAnnotationAutowireCandidateResolver;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/**
* 回归测试(2026-09-24 线上事故):{@link RouteChangeBroadcaster} 的 Redis 注入必须显式选主库模板,
* 且必须能容忍「两个同类型 StringRedisTemplate 共存」的上下文。
* <p>
* <b>事故</b>:{@code yh-appauth} 的 {@code SignKeyRedisAutoConfiguration} 注册了同名类型的
* {@code signKeyStringRedisTemplate}(直连 db0,专供 {@code sign:app:*})。本类原先裸
* {@code @Autowired StringRedisTemplate} → {@code NoUniqueBeanDefinitionException} →
* 网关启动即失败、容器无限重启(RestartCount=11)。
* <p>
* <b>既有测试为何漏掉</b>:测试 profile 一律 {@code yh.gateway.redis-broadcast-enabled=false}
* 把本类整个关掉,装配分支从未被触碰。
* <p>
* <b>为什么不用 @SpringBootTest</b>:需要一个<i>恰好只有这两个模板</i>的最小 bean 集合来复现歧义,
* 全量上下文会被组件扫描、数据源等噪声干扰。这里手工驱动
* {@link AutowiredAnnotationBeanPostProcessor}——它与生产环境用的是同一套注入逻辑,
* 因此能真实复现「裸 @Autowired 会炸」这一行为。
*/
class RouteChangeBroadcasterWiringTest {
/** appauth starter 密钥模板的 bean 名(与 SignKeyRedisAutoConfiguration.SIGN_TEMPLATE_BEAN 一致) */
private static final String SIGN_TEMPLATE_BEAN = "signKeyStringRedisTemplate";
private static final String MAIN_TEMPLATE_BEAN = "stringRedisTemplate";
/** 复现环境:注册两个同类型模板 + 一个 RouteRegistry */
private DefaultListableBeanFactory twoTemplatesFactory() {
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.setAutowireCandidateResolver(new QualifierAnnotationAutowireCandidateResolver());
factory.registerSingleton(MAIN_TEMPLATE_BEAN,
new StringRedisTemplate(mock(RedisConnectionFactory.class)));
factory.registerSingleton(SIGN_TEMPLATE_BEAN,
new StringRedisTemplate(mock(RedisConnectionFactory.class)));
factory.registerSingleton("routeRegistry", mock(RouteRegistry.class));
return factory;
}
/** 用生产同款注入逻辑装配一个 RouteChangeBroadcaster 实例 */
private RouteChangeBroadcaster build(DefaultListableBeanFactory factory) {
RootBeanDefinition def = new RootBeanDefinition(RouteChangeBroadcaster.class);
factory.registerBeanDefinition("routeChangeBroadcaster", def);
AutowiredAnnotationBeanPostProcessor processor = new AutowiredAnnotationBeanPostProcessor();
processor.setBeanFactory(factory);
RouteChangeBroadcaster bean = new RouteChangeBroadcaster();
processor.postProcessProperties(null, bean, "routeChangeBroadcaster");
return bean;
}
@Test
void 两个同类型模板共存时_广播器仍可装配() {
RouteChangeBroadcaster bean = build(twoTemplatesFactory());
assertThat(bean)
.as("stringRedisTemplate 与 signKeyStringRedisTemplate 共存时广播器必须装配成功")
.isNotNull();
}
@Test
void 广播器绑定主库模板而非密钥专用模板() {
DefaultListableBeanFactory factory = twoTemplatesFactory();
RouteChangeBroadcaster bean = build(factory);
StringRedisTemplate bound = (StringRedisTemplate) org.springframework.test.util.ReflectionTestUtils
.getField(bean, "redisTemplate");
assertThat(bound)
.as("必须绑定应用主库的 stringRedisTemplate(选错 = 发布订阅异库、广播静默失效)")
.isSameAs(factory.getBean(MAIN_TEMPLATE_BEAN, StringRedisTemplate.class));
}
/**
* 反向保护:钉死「裸 @Autowired 确实会失败」这一事实,防止有人误以为本测试是多余的。
* 若哪天歧义被 Spring 默认策略自动消解(例如某方加了 @Primary),本用例会失败并提醒复核。
*/
@Test
void 无Qualifier时同类型双Bean必然注入歧义() {
DefaultListableBeanFactory factory = twoTemplatesFactory();
factory.registerBeanDefinition("bareBroadcaster", new RootBeanDefinition(BareInjectProbe.class));
AutowiredAnnotationBeanPostProcessor processor = new AutowiredAnnotationBeanPostProcessor();
processor.setBeanFactory(factory);
BareInjectProbe probe = new BareInjectProbe();
assertThatThrownBy(() -> processor.postProcessProperties(null, probe, "bareBroadcaster"))
.as("两个同类型模板下裸 @Autowired 必须失败——这正是事故成因")
.isInstanceOf(org.springframework.beans.factory.UnsatisfiedDependencyException.class);
}
/** 与 RouteChangeBroadcaster 改前同形的裸注入探针,仅用于反向断言 */
static class BareInjectProbe {
@org.springframework.beans.factory.annotation.Autowired
private StringRedisTemplate redisTemplate;
}
}