在系统中引入动态配置中心是为了实现智能化的资源分配和系统韧性提升,尤其是在高并发、资源紧张或异常场景下保障核心功能的稳定性。
首先说明一下为什么在这样一个英语学习系统中引入动态配置中心,主要原因还是在我们故事生成的功能中我们是通过调用人工智能大模型api来进行故事的生成,本质上还是依赖于第三方大模型api,这样就会面临一系列的问题,比如API限流或超额调用、高延迟(响应时间波动影响用户体验)、服务不可用(供应商故障或网络问题)。还有一个原因就是用户集中在高峰期使用系统的单词学习和复习功能,这样会对服务器造成压力,此时为了缓解压力我们可以通过动态配置让故事生成通过本地部署的大模型来生成,降低服务器的压力(大模型API调用消耗CPU/带宽)。
而这些问题无疑是对我们的功能是致命的,也会对用户的使用造成非常糟糕的体验。所以有了DCC动态配置中心,我们可以在第三方出现问题时,不通过重启系统而是直接修改配置来达到降级的目的。
经过一系列调研我决定通过redis的(pub/sub)机制来实现动态配置,其次redis支持数据持久化,配置不会因为 Redis 重启而丢失,服务实例重启时可以从 Redis 加载最新配置。最后就是redis的高性能,redis 的读写操作都是内存级别的,性能极高,适合频繁读取的场景,如降级开关的状态检查。
引入redis
在项目依赖中引入redis
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.26.0</version>
</dependency>
为了后续成员便于配置环境,我选择在docker中创建一个redis容器,于是编写了docker配置文件。
services:
# Redis
redis:
container_name: redis2
restart: always
hostname: redis2
privileged: true
ports:
- 16378:6379
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf
command: redis-server /usr/local/etc/redis/redis.conf
networks:
- my-network2
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 3
# RedisAdmin https://github.com/joeferner/redis-commander
redis-admin:
container_name: redis-admin2
hostname: redis-commander2
restart: always
ports:
- 8082:8081
environment:
- REDIS_HOSTS=local:redis2:6379
- HTTP_USER=admin
- HTTP_PASSWORD=admin
- LANG=C.UTF-8
- LANGUAGE=C.UTF-8
- LC_ALL=C.UTF-8
networks:
- my-network2
depends_on:
redis:
condition: service_healthy
networks:
my-network2:
driver: bridge
在docker中创建容器,结果如图:
随后在项目中配置redis连接配置。
spring.redis.host=localhost
spring.redis.port=16378
spring.redis.pool.max-active=10
spring.redis.pool.min-idle=5
spring.redis.pool.max-wait=30000
spring.redis.timeout=5000
spring.redis.retry.max-attempts=3
spring.redis.retry.interval=1000
spring.redis.ping.interval=60000
spring.redis.keepalive=true
随后编写redis的配置文件
package com.javaee.wordtree.config;
/**
* @Description
* @Author liuyang2004
* @Date 2025/5/17 14:05
*/
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.ByteBufInputStream;
import io.netty.buffer.ByteBufOutputStream;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.BaseCodec;
import org.redisson.client.protocol.Decoder;
import org.redisson.client.protocol.Encoder;
import org.redisson.codec.JsonJacksonCodec;
import org.redisson.config.Config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
@Configuration
@EnableConfigurationProperties(RedisClientConfigProperties.class)
public class RedisClientConfig {
@Bean("redissonClient")
public RedissonClient redissonClient(ConfigurableApplicationContext applicationContext, RedisClientConfigProperties properties) {
Config config = new Config();
config.setCodec(JsonJacksonCodec.INSTANCE);
config.useSingleServer()
.setAddress("redis://" + properties.getHost() + ":" + properties.getPort())
.setConnectionPoolSize(properties.getPoolSize())
.setConnectionMinimumIdleSize(properties.getMinIdleSize())
.setIdleConnectionTimeout(properties.getIdleTimeout())
.setConnectTimeout(properties.getConnectTimeout())
.setRetryAttempts(properties.getRetryAttempts())
.setRetryInterval(properties.getRetryInterval())
.setPingConnectionInterval(properties.getPingInterval())
.setKeepAlive(properties.isKeepAlive())
;
return Redisson.create(config);
}
static class RedisCodec extends BaseCodec {
private final Encoder encoder = in -> {
ByteBuf out = ByteBufAllocator.DEFAULT.buffer();
try {
ByteBufOutputStream os = new ByteBufOutputStream(out);
JSON.writeJSONString(os, in, SerializerFeature.WriteClassName);
return os.buffer();
} catch (IOException e) {
out.release();
throw e;
} catch (Exception e) {
out.release();
throw new IOException(e);
}
};
private final Decoder<Object> decoder = (buf, state) -> JSON.parseObject(new ByteBufInputStream(buf), Object.class);
@Override
public Decoder<Object> getValueDecoder() {
return decoder;
}
@Override
public Encoder getValueEncoder() {
return encoder;
}
}
}
package com.javaee.wordtree.config;
/**
* @Description
* @Author liuyang2004
* @Date 2025/5/17 14:16
*/
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "spring.redis", ignoreInvalidFields = true)
public class RedisClientConfigProperties {
/** host:ip */
private String host;
/** 端口 */
private int port;
/** 账密 */
private String password;
/** 设置连接池的大小,默认为64 */
private int poolSize = 64;
/** 设置连接池的最小空闲连接数,默认为10 */
private int minIdleSize = 10;
/** 设置连接的最大空闲时间(单位:毫秒),超过该时间的空闲连接将被关闭,默认为10000 */
private int idleTimeout = 10000;
/** 设置连接超时时间(单位:毫秒),默认为10000 */
private int connectTimeout = 10000;
/** 设置连接重试次数,默认为3 */
private int retryAttempts = 3;
/** 设置连接重试的间隔时间(单位:毫秒),默认为1000 */
private int retryInterval = 1000;
/** 设置定期检查连接是否可用的时间间隔(单位:毫秒),默认为0,表示不进行定期检查 */
private int pingInterval = 0;
/** 设置是否保持长连接,默认为true */
private boolean keepAlive = true;
}
这样就可以使用redis了。
实现DCC
首先在项目目录下面创建文件夹annotations,用于存放自定义的注解。创建DCC注解。
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
@Documented
public @interface DCCValue {
String value() default "";
}
随后编写DCCSeervice文件,使用DCC注解。
import com.javaee.wordtree.anntoation.DCCValue;
import lombok.Data;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
/**
* @Date 2025/5/17 15:49
* @Author liuyang2004
* @Description
*/
@Service
public class DCCService {
/**
* 降级开关 0关闭、1开启
*/
@DCCValue("ifLearn:0")
private String ifLearn;
@DCCValue("ifReview:0")
private String ifReview;
@DCCValue("ifToday:0")
private String ifToday;
public boolean ifLearn() {
return "1".equals(ifLearn);
}
public boolean ifReview() {
return "1".equals(ifReview);
}
public boolean ifToday() {
return "1".equals(ifToday);
}
}
编写DCCValueBeanFactory,通过监听器收到变更消息的时候,找到对应的 Bean 和字段,利用Java的反射机制 更新字段值;并且实现在 Bean 初始化后从 Redis 加载配置值(如果不存在则使用默认值)。
package com.javaee.wordtree.config;
import com.javaee.wordtree.anntoation.DCCValue;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.redisson.api.RBucket;
import org.redisson.api.RTopic;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
/**
* @Date 2025/5/17 15:38
* @Author liuyang2004
* @Description
*/
@Slf4j
@Configuration
public class DCCValueBeanFactory implements BeanPostProcessor {
private static final String BASE_CONFIG_PATH = "word_dcc_";
private final RedissonClient redissonClient;
private final Map<String, Object> dccObjGroup = new HashMap<>();
public DCCValueBeanFactory(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@Bean("dccTopic")
public RTopic dccRedisTopicListener(RedissonClient redissonClient) {
RTopic topic = redissonClient.getTopic("word_dcc");
topic.addListener(String.class, (charSequence, s) -> {
String[] split = s.split(",");
// 获取值
String attribute = split[0];
String key = BASE_CONFIG_PATH + attribute;
String value = split[1];
// 设置值
RBucket<String> bucket = redissonClient.getBucket(key);
boolean exists = bucket.isExists();
if (!exists) return;
bucket.set(value);
Object objBean = dccObjGroup.get(key);
if (null == objBean) return;
Class<?> objBeanClass = objBean.getClass();
// 检查 objBean 是否是代理对象
if (AopUtils.isAopProxy(objBean)) {
// 获取代理对象的目标对象
objBeanClass = AopUtils.getTargetClass(objBean);
}
try {
Field field = objBeanClass.getDeclaredField(attribute);
field.setAccessible(true);
field.set(objBean, value);
field.setAccessible(false);
log.info("DCC 节点监听,动态设置值 {} {}", key, value);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
return topic;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
Class<?> targetBeanClass = bean.getClass();
Object targetBeanObject = bean;
if (AopUtils.isAopProxy(bean)) {
targetBeanClass = AopUtils.getTargetClass(bean);
targetBeanObject = AopProxyUtils.getSingletonTarget(bean);
}
Field[] fields = targetBeanClass.getDeclaredFields();
for (Field field : fields) {
if (!field.isAnnotationPresent(DCCValue.class)) {
continue;
}
DCCValue dccValue = field.getAnnotation(DCCValue.class);
String value = dccValue.value();
if (StringUtils.isBlank(value)) {
throw new RuntimeException(field.getName() + " @DCCValue is not config value config case 「isSwitch/isSwitch:1」");
}
String[] splits = value.split(":");
String key = BASE_CONFIG_PATH.concat(splits[0]);
String defaultValue = splits.length == 2 ? splits[1] : null;
// 设置值
String setValue = defaultValue;
try {
// 如果为空则抛出异常
if (StringUtils.isBlank(defaultValue)) {
throw new RuntimeException("dcc config error " + key + " is not null - 请配置默认值!");
}
// Redis 操作,判断配置Key是否存在,不存在则创建,存在则获取最新值
RBucket<String> bucket = redissonClient.getBucket(key);
boolean exists = bucket.isExists();
if (!exists) {
bucket.set(defaultValue);
} else {
setValue = bucket.get();
}
field.setAccessible(true);
field.set(targetBeanObject, setValue);
field.setAccessible(false);
} catch (Exception e) {
throw new RuntimeException(e);
}
dccObjGroup.put(key, targetBeanObject);
}
return bean;
}
}
最后编写DCCController,提供配置更新的 HTTP 接口,接收配置更新请求。
import com.javaee.wordtree.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RTopic;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
/**
* @Date 2025/5/17 15:42
* @Author liuyang2004
* @Description
*/
@Slf4j
@RestController()
@CrossOrigin("*")
@RequestMapping("/dcc/")
public class DCCController {
@Resource
private RTopic dccTopic;
/**
* 动态值变更
* <p>
* curl http://127.0.0.1:3030/dcc/update_config?key=ifToday&value=1
*/
@RequestMapping(value = "update_config", method = RequestMethod.GET)
public Result<Boolean> updateConfig(@RequestParam String key, @RequestParam String value) {
try {
log.info("DCC 动态配置值变更 key:{} value:{}", key, value);
dccTopic.publish(key + "," + value);
return Result.success();
} catch (Exception e) {
log.error("DCC 动态配置值变更失败 key:{} value:{}", key, value, e);
return Result.error(String.valueOf(201),"动态配置失败");
}
}
}
在其他需要的地方引入DCCService即可使用。
@Resource
DCCService dccService;
/**
* 获取用户今天背的单词接口
* @param userID
* @return
*/
@GetMapping("/todayLearnedWords")
public Result<?> getTodayLearnedWords(@RequestParam String userID){
//进行降级判断
if(dccService.ifToday()){
return Result.error("202","该服务暂不可用");
}
List<Word> learnedWords = memoryService.getTodayLearnedWords(userID);
return Result.success(learnedWords);
}
实现效果
现在是可以进行查询的。
发送请求,进行动态配置
再对刚才的功能进行请求,被拒绝了。
感悟
Redis 不仅提供了高性能的键值存储,还通过发布订阅机制实现了配置的实时更新,这使得配置的动态调整变得非常便捷。在实现过程中,我通过 @DCCValue 注解将配置项与 Redis 中的键值绑定,确保了配置的集中管理和实时生效。
在 Spring 框架的支持下,我利用 BeanPostProcessor 接口在 Bean 初始化时自动注入配置值,并通过 Redis 的 RTopic 监听配置变更消息。这种设计不仅降低了代码的耦合度,还提高了系统的可扩展性和可维护性。