欢迎来到窥视未来的博客

https://github.com/lwx57280 https://gitee.com/li_VillageHead

网关路由改完要重启?我写了个动态路由组件,配置秒生效,再也不怕半夜发版了

一、深夜发版的痛,你们懂的

兄弟们,你们有没有经历过这种场景:产品经理跑过来说“这个接口需要加个路由转发,帮我在网关里配一下”——你打开application.yml,加上一段路由配置,然后执行mvn clean package,再docker build,再kubectl rollout restart,整个过程下来少说20分钟。更惨的是,如果配置写错了,又要再来一遍。

我当时就想:能不能像Nacos改配置一样,改完路由立马生效,不用重启?

后来我翻遍了Spring Cloud Gateway的源码,发现它本身支持动态路由,只是需要自己实现路由数据源。于是我们封装了一个mate-starter-gateway组件,用Redis做路由数据源,配合发布订阅实现运行时热更新。今天就把这套方案从零到一复盘出来,同时把组件封装的设计思路也讲清楚。

二、痛点分析:为什么需要动态路由?

Spring Cloud Gateway原生支持从配置文件(application.yml)加载路由,但这种方式有几个硬伤:

 
痛点影响
改配置要重启 发版窗口受限,紧急配置无法快速生效
配置分散 多个网关实例的配置文件需要同步,容易不一致
无法运行时查看 当前生效的路由规则不透明,排查问题困难
回滚麻烦 配置错了要重新打包发布

我们需要的是一套配置集中管理、修改实时生效、无需重启网关的动态路由方案。

核心诉求管理端(mate-admin)改路由 → 网关端(mate-gateway)秒级生效,中间不用重启、不用打包、不用发版。

三、整体架构:管理端 + Redis + 网关端

3.1 三个角色各司其职

这套动态路由方案由三个角

image

 

 

三个角色的职责划分:

 
是什么谁读写
DB sys_route_conf 路由配置的权威源(含 isDel、排序、完整字段) 管理端 CRUD、列表展示
Redis Hash gateway:route:key 网关的运行时路由数据源 管理端写、网关读
DynamicRouteInitRunner DB → Redis 的同步桥(预热/恢复/重载) 启动、订阅重载 topic 时执行

Redis 这份数据是写穿式数据桥(write-through),而不是读加速缓存(cache-aside)。核心作用有三:

  1. 运行时热更新:管理端变更路由 → 写 Redis + publish,网关订阅后清内存缓存、重读 Redis,无需重启网关

  2. 容灾/重建:网关重启或 Redis 被 flush 后,由 DynamicRouteInitRunner 从 DB 全量把路由灌回 Redis,路由不丢

  3. 解耦:网关只认 Redis 这一份运行时视图,DB 只是配置持久化层,两个进程互不直接依赖

四、核心数据流:一条路由的“旅行”

以新增一条路由为例,完整的数据流如下:

mermaid-1787719353659

 

关键设计点:

  1. 管理端不依赖网关运行时:管理端只负责写 Redis + 发消息,不需要知道网关是否存活

  2. 网关端被动刷新:网关通过 Redis 订阅收到消息后主动拉取最新路由,而不是被动接收

  3. 内存缓存 + Redis 双层:网关内存中维护路由缓存,收到刷新消息后先清缓存,再重新从 Redis 加载

五、表结构设计

路由配置的核心是 sys_route_conf 表:

-- ----------------------------
-- Table structure for sys_route_conf
-- ----------------------------
DROP TABLE IF EXISTS `sys_route_conf`;
CREATE TABLE `sys_route_conf`
(
    `id`          bigint                                                        NOT NULL AUTO_INCREMENT COMMENT '主键id',
    `route_id`    varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL     DEFAULT NULL COMMENT '路由id',
    `route_name`  varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL     DEFAULT NULL COMMENT '服务名称',
    `predicates`  json                                                          NULL COMMENT '断言',
    `filters`     json                                                          NULL COMMENT '过滤器',
    `uri`         varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NULL     DEFAULT NULL,
    `sort`        int                                                           NULL     DEFAULT 0 COMMENT '排序',
    `metadata`    json                                                          NULL COMMENT '元数据',
    `create_by`   varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NULL     DEFAULT NULL COMMENT '创建人',
    `update_by`   varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NULL     DEFAULT NULL COMMENT '更新人',
    `create_time` datetime                                                      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time` datetime                                                      NULL     DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `is_del`      int                                                           NULL     DEFAULT 0 COMMENT '是否已删除',
    `tenant_id`   int                                                           NULL     DEFAULT NULL COMMENT '租户ID',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB
  AUTO_INCREMENT = 2
  CHARACTER SET = utf8mb4
  COLLATE = utf8mb4_general_ci COMMENT = '系统路由'
  ROW_FORMAT = DYNAMIC;

关键字段说明:

 
字段类型说明
predicates JSON 断言列表,如 [{"name":"Path","args":{"pattern":"/api/**"}}]
filters JSON 过滤器列表,如 [{"name":"StripPrefix","args":{"parts":"1"}}]
uri VARCHAR 转发地址,支持 lb://service-name 负载均衡
metadata JSON 元数据,可存放自定义扩展信息

六、核心代码实现

6.1 管理端:Service 层

SysRouteConfServiceImpl 中的 refreshRoute 方法是整个动态路由的核心入口

package com.mate.cloud.admin.service.impl;

import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.mate.cloud.admin.api.dto.SysRouteConfDTO;
import com.mate.cloud.admin.api.entity.SysRouteConf;
import com.mate.cloud.admin.mapper.SysRouteConfMapper;
import com.mate.cloud.admin.service.SysRouteConfService;
import com.mate.cloud.common.core.constant.CommonConstants;
import com.mate.cloud.gateway.constant.RouteCacheConstants;
import com.mate.cloud.gateway.vo.RouteDefinitionVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.FilterDefinition;
import org.springframework.cloud.gateway.handler.predicate.PredicateDefinition;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.net.URI;
import java.util.List;

/**
 * 系统路由
 * <p>
 * 管理端职责:把路由定义写入 Redis(网关 {@code RedisRouteDefinitionWriter} 读取),
 * 并通过 Redis 发布订阅通知网关重载,本模块不依赖 Spring Cloud Gateway 运行时。
 *
 * @author mate
 */
@Slf4j
@RequiredArgsConstructor
@Service("sysRouteConfService")
public class SysRouteConfServiceImpl extends ServiceImpl<SysRouteConfMapper, SysRouteConf> implements SysRouteConfService {

	private final StringRedisTemplate stringRedisTemplate;

	/**
	 * 获取全部路由
	 *
	 * @return 未删除的路由列表
	 */
	@Override
	public List<SysRouteConf> routes() {
		SysRouteConf condition = new SysRouteConf();
		condition.setIsDel(CommonConstants.STATUS_NORMAL);
		return baseMapper.selectList(new QueryWrapper<>(condition));
	}

	/**
	 * 通过路由 id 删除路由:数据库 + Redis + 通知网关。
	 */
	@Override
	@Transactional(rollbackFor = Exception.class)
	public boolean removeByRouteId(String routeId) {
		// 1. 删除数据库记录(按业务路由 id)
		boolean removed = this.remove(Wrappers.<SysRouteConf>lambdaQuery().eq(SysRouteConf::getRouteId, routeId));

		if (removed) {
			// 2. 删除 Redis 中的路由定义
			stringRedisTemplate.opsForHash().delete(RouteCacheConstants.GATEWAY_ROUTE_KEY, routeId);

			// 3. 通知网关重载路由
			stringRedisTemplate.convertAndSend(RouteCacheConstants.ROUTE_JVM_RELOAD_TOPIC, "route-refresh");
		}
		log.info("删除路由 {} 结果:{}", routeId, removed);
		return removed;
	}

	/**
	 * 新增/修改单条路由:写 Redis + 落库 + 通知网关(独立事务)
	 */
	@Override
	@Transactional(rollbackFor = Exception.class)
	public boolean refreshRoute(SysRouteConfDTO dto) {
		log.info("更新路由 ->{}", dto);

		// DTO -> 实体
		SysRouteConf routeConf = toEntity(dto);

		// 落库:存在则更新,不存在则新增(只更新非 null 字段,避免覆盖历史数据)
		String routeId = routeConf.getRouteId();
		long existCount = this.count(Wrappers.<SysRouteConf>lambdaQuery().eq(SysRouteConf::getRouteId, routeId));

		boolean saved;
		if (existCount > 0) {
			LambdaUpdateWrapper<SysRouteConf> updateWrapper = Wrappers.lambdaUpdate();
			updateWrapper.eq(SysRouteConf::getRouteId, routeId);
			saved = this.update(routeConf, updateWrapper);
		} else {
			saved = this.save(routeConf);
		}
		if (saved) {
			// 组装网关路由定义 JSON(字段与 Spring Cloud Gateway RouteDefinition 对齐),
			// 序列化后写入 Redis,网关 RedisRouteDefinitionWriter 直接反序列化使用。
			//2.数据库成功之后,组装VO写入Redis
			RouteDefinitionVO vo = buildRouteDefinitionVO(routeConf);
			String routeJson = JSONUtil.toJsonStr(vo);

			// 写 Redis:field=routeId,value=RouteDefinition JSON(供网关读取)
			stringRedisTemplate.opsForHash()
					.put(RouteCacheConstants.GATEWAY_ROUTE_KEY, routeConf.getRouteId(), routeJson);
		}
		// 通知网关重载路由(跨进程:Redis 发布订阅)
		stringRedisTemplate.convertAndSend(RouteCacheConstants.ROUTE_JVM_RELOAD_TOPIC, "route-refresh");
		log.debug("更新网关路由结束,已通知网关重载");
		return saved;
	}


	/**
	 * DTO -> 实体(断言/过滤器/元数据 由结构化对象序列化为 JSON 字符串)
	 */
	private SysRouteConf toEntity(SysRouteConfDTO dto) {
		SysRouteConf routeConf = new SysRouteConf();
		routeConf.setRouteId(dto.getRouteId());
		routeConf.setRouteName(dto.getRouteName());
		routeConf.setPredicates(JSONUtil.toJsonStr(dto.getPredicates()));
		routeConf.setFilters(JSONUtil.toJsonStr(dto.getFilters()));
		routeConf.setMetadata(JSONUtil.toJsonStr(dto.getMetadata()));
		routeConf.setUri(dto.getUri());
		routeConf.setSort(dto.getOrder());
		// 新路由默认未删除
		routeConf.setIsDel(CommonConstants.STATUS_NORMAL);
		return routeConf;
	}

	/**
	 * 实体 -> 路由定义 VO(断言 / 过滤器 / 元数据 由 JSON 字符串反序列化为网关原生对象)
	 * <p>
	 * 供 {@link #refreshRoute(SysRouteConfDTO)} 与 {@code DynamicRouteInitRunner#loadAllRouteToRedis()} 复用,
	 * 组装结果序列化后写入 Redis,与网关 {@code RedisRouteDefinitionWriter} 反序列化保持一致。
	 *
	 * @param routeConf 数据库路由实体
	 * @return 网关路由定义 VO
	 */
	@Override
	public RouteDefinitionVO buildRouteDefinitionVO(SysRouteConf routeConf) {
		RouteDefinitionVO vo = new RouteDefinitionVO();
		vo.setId(routeConf.getRouteId());
		vo.setRouteName(routeConf.getRouteName());
		vo.setOrder(routeConf.getSort());

		if (routeConf.getUri() != null) {
			vo.setUri(URI.create(routeConf.getUri()));
		}
		if (StrUtil.isNotBlank(routeConf.getFilters())) {
			JSONArray filterObj = JSONUtil.parseArray(routeConf.getFilters());
			vo.setFilters(filterObj.toList(FilterDefinition.class));
		}
		if (StrUtil.isNotBlank(routeConf.getPredicates())) {
			JSONArray predicateObj = JSONUtil.parseArray(routeConf.getPredicates());
			vo.setPredicates(predicateObj.toList(PredicateDefinition.class));
		}
		String metadataJson = routeConf.getMetadata();
		if (StrUtil.isNotBlank(metadataJson) && JSONUtil.isTypeJSONObject(metadataJson)) {
			vo.setMetadata(JSONUtil.parseObj(metadataJson));
		}
		return vo;
	}
}

三个操作的顺序很重要先落库 → 再写 Redis → 最后发消息。如果先发消息再写 Redis,网关可能读到旧数据或空数据。

6.2 管理端:Controller 接口

package com.mate.cloud.admin.controller;


import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.mate.cloud.admin.api.dto.SysRouteConfDTO;
import com.mate.cloud.admin.api.entity.SysRouteConf;
import com.mate.cloud.admin.api.vo.SysRouteConfVO;
import com.mate.cloud.admin.service.SysRouteConfService;
import com.mate.cloud.common.core.util.BeanMapper;
import com.mate.cloud.common.core.util.R;
import com.mate.cloud.log.annotation.SysLog;
import com.mate.cloud.security.annotation.HasPermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 系统路由
 *
 * @author mate
 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/route")
@Tag(description = "route", name = "系统路由管理")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SysRouteConfController {

	private final SysRouteConfService sysRouteService;

	/**
	 * 路由列表
	 *
	 * @param sysRoute 系统路由
	 * @return
	 */
	@Operation(summary = "路由列表", description = "路由列表")
	@GetMapping("/list")
	public R<List<SysRouteConfVO>> list() {
		List<SysRouteConf> sysRouteConfs = sysRouteService.routes();
		List<SysRouteConfVO> routeConfVOS = BeanMapper.convert(sysRouteConfs, SysRouteConfVO.class);
		return R.ok(routeConfVOS);
	}


	/**
	 * 根据路由 id 查询路由配置
	 *
	 * @param routeId 路由 id
	 * @return R 路由配置
	 */
	@GetMapping
	@HasPermission("sys_route_view")
	public R<SysRouteConfVO> getByRouteId(@RequestParam String routeId) {
		SysRouteConf sysRouteConf = sysRouteService.getOne(
				Wrappers.<SysRouteConf>lambdaQuery().eq(SysRouteConf::getRouteId, routeId), false);
		SysRouteConfVO sysRouteConfVO = BeanMapper.convert(sysRouteConf, SysRouteConfVO.class);
		return R.ok(sysRouteConfVO);
	}

	/**
	 * 通过条件查询系统路由
	 *
	 * @param sysRoute 查询条件
	 * @return R  对象列表
	 */
	@GetMapping("/details")
	@HasPermission("sys_route_view")
	public R getDetails(@ParameterObject SysRouteConfVO sysRouteConfVO) {
		SysRouteConf sysRoute = BeanMapper.convert(sysRouteConfVO, SysRouteConf.class);
		return R.ok(sysRouteService.list(Wrappers.query(sysRoute)));
	}

	/**
	 * 新增系统路由
	 *
	 * @param sysRoute 系统路由
	 * @return R
	 */
	@SysLog("新增系统路由")
	@PostMapping
	@HasPermission("sys_route_add")
	public R save(@RequestBody SysRouteConfDTO sysRouteConfDTO) {
		return R.ok(sysRouteService.refreshRoute(sysRouteConfDTO));
	}

	/**
	 * 修改系统路由
	 *
	 * @param sysRoute 系统路由
	 * @return R
	 */
	@SysLog("修改系统路由")
	@PutMapping
	@HasPermission("sys_route_edit")
	public R<Boolean> updateById(@RequestBody SysRouteConfDTO dto) {
		return R.ok(sysRouteService.refreshRoute(dto));
	}

	/**
	 * 通过路由 id 删除系统路由
	 *
	 * @param routeId 路由业务 id
	 * @return R
	 */
	@SysLog("通过路由id删除系统路由")
	@DeleteMapping("/{routeId}")
	@HasPermission("sys_route_del")
	public R<Boolean> removeById(@PathVariable String routeId) {
		return R.ok(sysRouteService.removeByRouteId(routeId));
	}

}

七、网关端组件封装(mate-starter-gateway)

以上是管理端的代码,但真正让这套方案变得“开箱即用”的,是 mate-starter-gateway 组件。它把“从Redis读路由、监听刷新消息、管理路由缓存、启动时恢复路由”这些能力封装成了一个可插拔的JAR包。

7.1 组件职责

mate-starter-gateway 做了四件事:

  1. 路由数据源切换把路由数据源从 application.yml 切换到了 Redis Hash,网关启动或运行时都从这里读路由配置

  2. 热更新机制通过 Redis Pub/Sub 监听路由变更消息,收到消息后自动刷新内存路由缓存

  3. 容灾恢复提供 DynamicRouteInitRunner,网关重启后自动从 DB 全量恢复路由到 Redis

  4. 配置开关一行配置控制整个动态路由功能启用/关闭

7.2 组件核心类

 
类名职责
RedisRouteDefinitionWriter 实现 Spring Cloud Gateway 的 RouteDefinitionRepository,从 Redis Hash 读取路由定义
RedisRouteRefreshListener 订阅 Redis 刷新消息,收到消息后清空内存缓存并触发路由刷新事件
RouteCacheHolder 管理网关内存中的路由缓存,提供清空方法供刷新时调用
DynamicRouteInitRunner 网关启动时从 DB 全量加载路由到 Redis,保证路由数据不丢失
DynamicRouteAutoConfiguration 条件装配所有动态路由 Bean,通过配置项控制开关

7.3 路由数据源:RedisRouteDefinitionWriter

作用实现Spring Cloud Gateway的RouteDefinitionRepository接口,让网关从Redis Hash读取路由定义。

package com.mate.cloud.gateway.support;

import cn.hutool.core.collection.CollUtil;
import com.alibaba.fastjson2.JSON;
import com.google.common.collect.Lists;
import com.mate.cloud.gateway.vo.RouteDefinitionVO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.gateway.route.RouteDefinitionRepository;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.List;

import static com.mate.cloud.gateway.constant.RouteCacheConstants.GATEWAY_ROUTE_KEY;
import static com.mate.cloud.gateway.constant.RouteCacheConstants.ROUTE_JVM_RELOAD_TOPIC;

/**
 * @description: 保存路由信息
 * <p>
 * https://blog.51cto.com/abcd/4541618
 * </p>
 * <p>
 * redis 保存路由信息,优先级比配置文件高
 * </p>
 * @author: MI
 * @createDate: 2026/5/18 9:10
 * @updateUser: MI
 * @updateDate: 2026/5/18 9:10
 * @updateRemark: 修改内容
 * @version: v1.0
 */
@Slf4j
@Component
public class RedisRouteDefinitionWriter implements RouteDefinitionRepository {

	@Resource
	private StringRedisTemplate stringRedisTemplate;

	/**
	 * 动态路由入口
	 * <p>
	 * 1. 先从内存中获取 2. 为空加载Redis中数据 3. 更新内存
	 * <p/>
	 * @return
	 */
	@Override
	public Flux<RouteDefinition> getRouteDefinitions() {
		List<RouteDefinitionVO> routeList = RouteCacheHolder.getRouteList();
		if (CollUtil.isNotEmpty(routeList)) {
			log.debug("内存 中路由定义条数: {}, {}", routeList.size(), routeList);
			return Flux.fromIterable(routeList);
		}

		// 管理端使用 StringRedisTemplate 以 JSON 字符串写入,此处 value 即为 JSON 字符串,直接解析即可。
		List<RouteDefinitionVO> routeDefinitions = Lists.newArrayList();
		stringRedisTemplate.opsForHash()
			.values(GATEWAY_ROUTE_KEY)
			.stream()
			.forEach(route -> routeDefinitions.add(JSON.parseObject(route.toString(), RouteDefinitionVO.class)));
		log.debug("redis 中路由定义条数: {}, {}", routeDefinitions.size(), routeDefinitions);

		RouteCacheHolder.refreshRouteList(routeDefinitions);
		// 返回路由
		return Flux.fromIterable(routeDefinitions);
	}

	@Override
	public Mono<Void> save(Mono<RouteDefinition> route) {
		return route.flatMap(routeDefinition -> {
			RouteDefinitionVO vo = new RouteDefinitionVO();
			BeanUtils.copyProperties(routeDefinition, vo);
			log.info("保存路由信息{}", routeDefinition);
			stringRedisTemplate.opsForHash().put(GATEWAY_ROUTE_KEY, routeDefinition.getId(), JSON.toJSONString(vo));
			stringRedisTemplate.convertAndSend(ROUTE_JVM_RELOAD_TOPIC, "新增路由信息,网关缓存更新");
			return Mono.empty();
		});
	}

	@Override
	public Mono<Void> delete(Mono<String> routeId) {
		return routeId.flatMap(id -> {
			if (Boolean.TRUE.equals(stringRedisTemplate.opsForHash().hasKey(GATEWAY_ROUTE_KEY, id))) {
				log.info("删除路由信息{}", id);
				stringRedisTemplate.opsForHash().delete(GATEWAY_ROUTE_KEY, id);
				RouteCacheHolder.removeRouteList();
				return Mono.empty();
			}
			return Mono.defer(() -> Mono.error(new RuntimeException("RouteDefinition not found, " + id + "")));
		});
	}

}

7.4 刷新监听器:RedisRouteRefreshMessageListener

作用订阅Redis的gateway:jvm:route:reload:topic频道,收到消息后清空内存缓存并触发路由刷新

package com.mate.cloud.gateway.listener;

import com.mate.cloud.gateway.event.RouteEventPublisher;
import com.mate.cloud.gateway.support.RouteCacheHolder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;

/**
 * Redis路由变更消息监听器
 * <p>
 * 监听Redis发布订阅消息,当后台管理系统修改网关路由配置后, 通过Redis channel推送变更通知,网关收到消息后清空内存路由缓存并发布路由刷新事件,
 * 触发网关重新加载全部路由定义,实现多网关实例路由动态刷新。
 * </p>
 *
 * @author: MI
 * @createTime: 2026/8/22 19:43
 * @updateUser: MI
 * @updateTime: 2026/8/22 19:43
 * @updateRemark: 修改内容
 * @version: 1.0
 */
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
public class RedisRouteRefreshMessageListener implements MessageListener {

	private final RouteEventPublisher routeEventPublisher;

	/**
	 * 接收Redis发布‑订阅通道消息
	 * @param message redis消息体
	 * @param pattern 匹配的channel模式
	 */
	@Override
	public void onMessage(Message message, byte[] pattern) {
		String routeId = new String(message.getBody());
		log.info("收到路由变更通知,routeId: {}", routeId);
		// 先清空内存缓存,再发布刷新事件,避免命中旧路由
		RouteCacheHolder.removeRouteList();
		routeEventPublisher.refreshRoutes();
	}

}

关键点:

  • 使用 @ConditionalOnWebApplication(type = REACTIVE),只在 Reactive 网关应用(非Servlet管理端)生效

  • 管理端即使引入依赖也不会被装配

 

7.5 RouteCacheHolder —— 路由缓存管理

作用管理网关内存中的路由缓存,提供清空方法供刷新时调用。

package com.mate.cloud.gateway.support;

import cn.hutool.cache.Cache;
import cn.hutool.cache.CacheUtil;
import com.google.common.collect.Lists;
import com.mate.cloud.gateway.vo.RouteDefinitionVO;
import lombok.experimental.UtilityClass;

import java.util.List;

/**
 * @description: 路由缓存工具类
 * @createDate: 2026/8/18 9:43
 * @updateDate: 2026/8/18 9:43
 * @updateRemark: 修改内容
 * @version: v1.0
 */
@UtilityClass
public class RouteCacheHolder {

	private Cache<String, RouteDefinitionVO> cache = CacheUtil.newLFUCache(50);

	/**
	 * 获取缓存的全部对象
	 * @return routeList
	 */
	public List<RouteDefinitionVO> getRouteList() {
		List<RouteDefinitionVO> routes = Lists.newArrayList();
		cache.forEach(route -> routes.add(route));
		return routes;
	}

	/**
	 * 更新路由缓存
	 * @param routeDefinitionVoList
	 */
	public void refreshRouteList(List<RouteDefinitionVO> routeDefinitionVoList) {
		routeDefinitionVoList.forEach(route -> cache.put(route.getId(), route));
	}

	/**
	 * 清空路由缓存
	 */
	public void removeRouteList() {
		cache.clear();
	}

}

  

7.6 网关路由事件发布器

package com.mate.cloud.gateway.event;

import lombok.RequiredArgsConstructor;
import org.springframework.cloud.gateway.event.RefreshRoutesEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

/**
 * 网关路由事件发布器
 * <p>
 * 对外提供方法,手动发布{@link RefreshRoutesEvent}事件, 触发Spring Cloud Gateway重新加载路由定义,用于本地直接触发路由刷新。
 * </p>
 *
 * @author: MI
 * @createTime: 2026/8/22 19:47
 * @updateUser: MI
 * @updateTime: 2026/8/22 19:47
 * @updateRemark: 修改内容
 * @version: 1.0
 */
@Component
@RequiredArgsConstructor
public class RouteEventPublisher {

	private final ApplicationEventPublisher publisher;

	/**
	 * 发布路由刷新事件,网关重新加载全部路由
	 */
	public void refreshRoutes() {
		// 发布刷新事件,网关会自动监听并重新加载路由
		publisher.publishEvent(new RefreshRoutesEvent(this));
	}

}

 

7.7 动态路由配置类

package com.mate.cloud.gateway.configuration;

import com.mate.cloud.gateway.constant.RouteCacheConstants;
import com.mate.cloud.gateway.listener.RedisRouteRefreshMessageListener;
import io.lettuce.core.ReadFrom;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.ClusterTopologyRefreshOptions;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties;
import org.springframework.cloud.gateway.config.GatewayProperties;
import org.springframework.cloud.gateway.config.PropertiesRouteDefinitionLocator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

import java.time.Duration;

/**
 * 动态路由配置类
 *
 * @link <a href=
 * "https://github.com/spring-cloud/spring-cloud-gateway/blob/main/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java">spring-cloud-gateway</a>
 * @author: MI
 * @createDate: 2026/5/18 8:39
 * @updateUser: MI
 * @updateDate: 2026/5/18 8:39
 * @updateRemark: 修改内容
 * @version: v1.0
 */
@Configuration
@ComponentScan({ "com.mate.cloud.gateway" })
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnProperty(name = "gateway.dynamic-route.enabled", havingValue = "true", matchIfMissing = true)
public class DynamicRouteAutoConfiguration {

	/**
	 * 配置文件设置为空 redis 加载为准
	 * <p>
	 * 加 {@link ConditionalOnMissingBean} 避免与 Spring Cloud Gateway 内置的同名 Bean 冲突:
	 * 网关自动装配已提供时,使用内置实现。
	 * @return
	 */
	@Bean
	@ConditionalOnMissingBean(PropertiesRouteDefinitionLocator.class)
	public PropertiesRouteDefinitionLocator propertiesRouteDefinitionLocator() {
		return new PropertiesRouteDefinitionLocator(new GatewayProperties());
	}

	/**
	 * redis监听器配置
	 * <p>
	 * 监听管理端发布的路由刷新频道,收到通知后交由 {@link RedisRouteRefreshMessageListener} 处理: 清空内存缓存并发布路由刷新事件,由
	 * Spring Cloud Gateway 内置的 RouteRefreshListener 重置路由定位器,实现动态重载。
	 * @param redisConnectionFactory redis配置
	 * @param redisRouteMessageListener 路由变更消息监听器
	 * @return
	 */
	@Bean
	public RedisMessageListenerContainer redisContainer(RedisConnectionFactory redisConnectionFactory,
			RedisRouteRefreshMessageListener redisRouteMessageListener) {
		RedisMessageListenerContainer container = new RedisMessageListenerContainer();
		container.setConnectionFactory(redisConnectionFactory);
		container.addMessageListener(redisRouteMessageListener,
				new ChannelTopic(RouteCacheConstants.ROUTE_JVM_RELOAD_TOPIC));
		return container;
	}

	@Bean
	@ConditionalOnProperty(value = "spring.redis.cluster.enable", havingValue = "true")
	public LettuceConnectionFactory redisConnectionFactory(DataRedisProperties redisProperties) {
		RedisClusterConfiguration redisClusterConfiguration = new RedisClusterConfiguration(
				redisProperties.getCluster().getNodes());

		// https://github.com/lettuce-io/lettuce-core/wiki/Redis-Cluster#user-content-refreshing-the-cluster-topology-view
		ClusterTopologyRefreshOptions clusterTopologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
			.enablePeriodicRefresh()
			.enableAllAdaptiveRefreshTriggers()
			.refreshPeriod(Duration.ofSeconds(5))
			.build();

		ClusterClientOptions clusterClientOptions = ClusterClientOptions.builder()
			.topologyRefreshOptions(clusterTopologyRefreshOptions)
			.build();

		// https://github.com/lettuce-io/lettuce-core/wiki/ReadFrom-Settings
		LettuceClientConfiguration lettuceClientConfiguration = LettuceClientConfiguration.builder()
			.readFrom(ReadFrom.REPLICA_PREFERRED)
			.clientOptions(clusterClientOptions)
			.build();

		return new LettuceConnectionFactory(redisClusterConfiguration, lettuceClientConfiguration);
	}

}

  

7.8 启动初始化器:DynamicRouteInitRunner

作用管理端启动时,从DB全量加载路由配置并写入Redis,保证路由数据不丢失。

package com.mate.cloud.admin.config;

import cn.hutool.json.JSONUtil;
import com.mate.cloud.admin.api.entity.SysRouteConf;
import com.mate.cloud.admin.service.SysRouteConfService;
import com.mate.cloud.gateway.constant.RouteCacheConstants;
import com.mate.cloud.gateway.vo.RouteDefinitionVO;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.List;
import java.util.Set;


/**
 * 容器启动后保存配置文件里面的路由信息到Redis
 * @author:         MI
 * @email:          448341911@qq.com
 * @createTime:     2026/8/24 22:36
 * @updateUser:     MI
 * @updateTime:     2026/8/24 22:36
 * @updateRemark:   修改内容
 * @version:        1.0
 */
@Slf4j
@Configuration
@AllArgsConstructor
public class DynamicRouteInitRunner implements ApplicationRunner {

	private final StringRedisTemplate stringRedisTemplate;

	private final SysRouteConfService sysRouteService;

	/**
	 * 项目启动完成回调:加载数据库路由写入Redis
	 */
	@Override
	public void run(@NotNull ApplicationArguments args) {
		log.info("项目启动,同步加载路由到redis,保证就绪后再对外服务");
		loadAllRouteToRedis();
	}


	/**
	 * 全量从数据库加载路由写入Redis Hash(Pipeline流水线 + 差异更新,不删除整个hash)
	 * 启动调用 / Redis订阅消息触发调用
	 */
	public void loadAllRouteToRedis() {
		try {
			List<SysRouteConf> routeList = sysRouteService.routes();
			final byte[] hashKeyBytes = RouteCacheConstants.GATEWAY_ROUTE_KEY.getBytes(StandardCharsets.UTF_8);

			Set<String> dbRouteIdSet = new HashSet<>();
			for (SysRouteConf route : routeList) {
				dbRouteIdSet.add(route.getRouteId());
			}

			// 获取Redis中全部field,计算需要删除的残留路由
			Set<Object> redisRouteIdObjs = stringRedisTemplate.opsForHash().keys(RouteCacheConstants.GATEWAY_ROUTE_KEY);
			Set<byte[]> needDeleteFields = new HashSet<>();
			if (redisRouteIdObjs != null) {
				for (Object obj : redisRouteIdObjs) {
					String routeId = (String) obj;
					if (!dbRouteIdSet.contains(routeId)) {
						needDeleteFields.add(routeId.getBytes(StandardCharsets.UTF_8));
					}
				}
			}

			// pipeline批量hset写入 + hdel删除残留field
			stringRedisTemplate.executePipelined((RedisCallback<Void>) connection -> {
				for (SysRouteConf route : routeList) {
					String routeId = route.getRouteId();
					RouteDefinitionVO vo = sysRouteService.buildRouteDefinitionVO(route);
					String jsonStr = JSONUtil.toJsonStr(vo);

					byte[] fieldBytes = routeId.getBytes(StandardCharsets.UTF_8);
					byte[] valueBytes = jsonStr.getBytes(StandardCharsets.UTF_8);
					connection.hSet(hashKeyBytes, fieldBytes, valueBytes);
					log.info("加载路由ID:{}", routeId);
				}

				if (!needDeleteFields.isEmpty()) {
					connection.hDel(hashKeyBytes, needDeleteFields.toArray(new byte[0][]));
					log.info("pipeline清除Redis残留路由field数量:{}", needDeleteFields.size());
				}
				return null;
			});

			log.debug("初始化网关路由结束,本次加载路由数量:{}", routeList.size());
		} catch (Exception e) {
			log.error("全量加载网关路由到Redis失败", e);
		}
	}

	/**
	 * redis 监听配置,监听 gateway_redis_route_reload_topic,重新加载Redis
	 *
	 * @param redisConnectionFactory redis 配置
	 * @return RedisMessageListenerContainer
	 */
	@Bean
	public RedisMessageListenerContainer redisContainer(RedisConnectionFactory redisConnectionFactory) {
		RedisMessageListenerContainer container = new RedisMessageListenerContainer();
		container.setConnectionFactory(redisConnectionFactory);
		container.addMessageListener((message, bytes) -> {
			log.warn("接收到Redis重新加载路由事件");
			loadAllRouteToRedis();
		}, new ChannelTopic(RouteCacheConstants.ROUTE_REDIS_RELOAD_TOPIC));
		return container;
	}
}

7.8 配置开关

gateway:
  dynamic-route:
    enabled: true   # true=开启动态路由(默认),false=关闭

 

@Configuration
@ConditionalOnProperty(
    prefix = "gateway.dynamic-route",
    name = "enabled",
    havingValue = "true",
    matchIfMissing = true  // 缺省开启
)
public class DynamicRouteAutoConfiguration {
    // 自动装配所有动态路由相关Bean
}

引入依赖后,无需在启动类加任何注解,组件通过配置项开关控制装配(缺省即开启)。

八、管理端如何使用组件

管理端(mate-admin-biz引入 mate-starter-gateway 依赖后,通过 SysRouteConfService 操作路由:

<!-- 管理端 pom.xml -->
<dependency>
    <groupId>com.mate</groupId>
    <artifactId>mate-starter-gateway</artifactId>
</dependency>

管理端的 refreshRoute 方法调用组件提供的 RedisTemplate 完成写入和发布,字段常量也统一从组件中获取:

redisTemplate.opsForHash().put(routeKey, routeId, routeValue);
redisTemplate.convertAndSend(topic, routeId);

九、列表查询为什么直查 DB?

这是一个容易被误解的设计点。GET /route/list 直查 DB 而非 Redis,是刻意设计:

原因说明
Redis 存的是运行时视图 Redis 里存的是 RouteDefinitionVO,天然只有「未删除」路由,缺少 isDel 字段
字段映射有缺口 Redis 存的字段与配置视图有映射缺口(routeIdidsortOrderorder
管理端需要展示配置视图 列表要展示的是 SysRouteConf(删除态、排序、原始 JSON 字段)
低频操作,无需缓存 列表是低频配置管理操作,直查 DB 没有性能压力,无需为此引入缓存一致性复杂度

三层职责清晰划分:

DB sys_route_conf              → 配置权威源(管理端 CRUD、列表展示)
Redis Hash gateway:route:key   → 网关运行时数据源(管理端写、网关读)
DynamicRouteInitRunner         → DB → Redis 的同步桥(启动/重载时执行)

十、配置方式

10.1 开启 Gateway Endpoint(查看当前路由)

management:
  endpoints:
    web:
      exposure:
        include: gateway
  endpoint:
    gateway:
      enabled: true

开启后可通过 /actuator/gateway/routes 查看当前生效的所有路由,方便排查问题。

10.2 动态路由开关

gateway:
  dynamic-route:
    enabled: true   # true=开启动态路由(默认),false=关闭

十一、最佳实践与避坑指南

11.1 先落库再写 Redis

顺序不能乱:先落库 → 再写 Redis → 最后发消息。如果网关收到消息时 Redis 还没写入,会读到旧数据或空数据。

11.2 管理端不依赖网关存活

管理端只负责写 Redis + 发消息,不需要知道网关是否存活。网关重启后,会通过 DynamicRouteInitRunner 从 DB 全量恢复路由。

11.3 网关端内存缓存 + Redis 双层

网关收到刷新消息后,先清内存缓存,再重新从 Redis 加载。这样可以保证路由更新的原子性。

11.4 配置项缺省开启

gateway.dynamic-route.enabled 缺省为 true,无需额外配置即可使用。

11.5 列表查询不走 Redis

管理端的路由列表查询直查 DB,不查 Redis。这是为了避免缓存一致性问题,且列表是低频操作。

十二、总结

 
核心要点说明
数据源 Redis Hash 作为运行时路由数据源,DB 作为配置权威源
热更新机制 管理端写 Redis + PUBLISH,网关端 SUBSCRIBE 后清缓存、重载
容灾能力 网关重启后通过 DynamicRouteInitRunner 从 DB 全量恢复
解耦设计 管理端不依赖网关存活,网关只认 Redis
组件封装 mate-common-gateway 将路由数据源、刷新监听、缓存管理、启动恢复全部封装成可插拔 JAR
配置开关 gateway.dynamic-route.enabled 一行配置控制开关
适用场景 需要频繁调整路由规则、多环境路由统一管理、金丝雀发布

 

一句话总结mate-starter-gateway 把“从Redis读路由、监听刷新消息、管理路由缓存、启动时恢复路由”这些能力封装成了一个可插拔组件,管理端只需要引入依赖、调用 refreshRoute 方法即可。网关端的路由热更新逻辑对业务开发完全透明,从此告别改路由要重启的噩梦。

兄弟们,你们生产环境的路由是怎么管理的?还在用配置文件硬编码吗?

 

posted on 2026-08-26 13:07  k8s-Mango  阅读(8)  评论(0)    收藏  举报

导航