fix(gateway): 修复 RouteChangeBroadcaster 的 Redis 注入歧义(线上启动崩溃)
build / build-push (push) Successful in 1m1s
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:
@@ -4,10 +4,10 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.redis.connection.Message;
|
||||
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.listener.ChannelTopic;
|
||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||
@@ -41,19 +41,26 @@ public class RouteChangeBroadcaster implements MessageListener, DisposableBean {
|
||||
@Autowired
|
||||
private RouteRegistry routeRegistry;
|
||||
|
||||
/**
|
||||
* 广播通道走<b>应用主库</b>模板(bean 名 {@code stringRedisTemplate}),而非 appauth starter 的
|
||||
* 密钥专用 {@code signKeyStringRedisTemplate}(那个直连 db0 专供 {@code sign:app:*})。
|
||||
* 两者同类型,不显式 {@code @Qualifier} 会因注入歧义启动失败;选错则发布与订阅落在不同 db,
|
||||
* 广播静默失效(比启动失败更糟——看不出问题)。
|
||||
*/
|
||||
@Autowired
|
||||
@Qualifier("stringRedisTemplate")
|
||||
private StringRedisTemplate redisTemplate;
|
||||
|
||||
@Autowired
|
||||
private RedisConnectionFactory connectionFactory;
|
||||
|
||||
private RedisMessageListenerContainer container;
|
||||
|
||||
@PostConstruct
|
||||
void subscribe() {
|
||||
try {
|
||||
RedisMessageListenerContainer c = new RedisMessageListenerContainer();
|
||||
c.setConnectionFactory(connectionFactory);
|
||||
// 订阅连接直接取用上面 template 的连接工厂,保证「发布」与「订阅」必然同库:
|
||||
// 另注一个 RedisConnectionFactory 会引入同类型多 bean 歧义,且可能被解析到
|
||||
// appauth 的 db0 工厂上与主库模板不匹配。
|
||||
c.setConnectionFactory(redisTemplate.getConnectionFactory());
|
||||
c.addMessageListener(this, new ChannelTopic(CHANNEL));
|
||||
c.afterPropertiesSet(); // 连接不可用时异步重连,不阻塞启动
|
||||
container = c;
|
||||
|
||||
+102
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user