docs: 架构文档补充Feign动态路由方案详细设计,对比DynamicDataSource方案优劣

This commit is contained in:
zhangjf 2026-02-14 00:41:49 +08:00
parent 8029ac31da
commit 2088742543

View File

@ -427,17 +427,547 @@ CREATE TABLE sys_tenant (
└───────────┘ └───────────┘ └───────────┘ └───────────┘ └───────────┘ └───────────┘
``` ```
### 2.5 两种模式对比 ### 2.5 一库一租户路由方案对比
| 特性 | 一库多租户 | 一库一租户 | #### 2.5.1 方案对比DynamicDataSource vs Feign 动态路由
|------|-----------|-----------|
| **数据隔离** | 逻辑隔离tenant_id | 物理隔离(独立数据库) | | 对比维度 | DynamicDataSource | Feign 动态路由 |
| **部署成本** | 低(共享资源) | 高(独立资源) | |----------|-------------------|----------------|
| **运维复杂度** | 低 | 高 | | **架构层级** | 数据层 | 服务层 |
| **扩展性** | 垂直扩展为主 | 水平扩展 | | **隔离级别** | 数据源级别 | 服务实例级别 |
| **安全性** | 中 | 高 | | **资源占用** | 高(连接池 × N租户 | 中(服务实例 × N租户 |
| **适用场景** | 中小客户、SaaS | 大客户、私有化部署 | | **扩展性** | 差(单服务多数据源) | 优(独立扩容) |
| **数据迁移** | 复杂(需筛选数据) | 简单(整库迁移) | | **故障隔离** | 差(单点故障影响多租户) | 优(租户间互不影响) |
| **跨租户查询** | 困难 | 可通过聚合服务实现 |
| **服务治理** | 简单 | 需要完善的服务发现 |
| **部署复杂度** | 低 | 中 |
| **私有化部署** | 困难 | 简单(整体迁移) |
| **运维成本** | 中 | 高 |
#### 2.5.2 推荐方案Feign 动态路由
**推荐理由:**
1. **真正的租户隔离**:每个租户拥有独立的服务实例和数据库
2. **弹性扩容**:单个租户可独立水平扩展
3. **故障隔离**:单个租户故障不影响其他租户
4. **私有化友好**:便于大客户私有化部署
5. **符合云原生**:与 Kubernetes 等容器平台配合良好
---
### 2.6 Feign 动态路由详细设计
#### 2.6.1 架构设计
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Feign 动态路由架构 │
│ │
│ ┌─────────┐ │
│ │ 客户端 │ Header: X-Tenant-Id: tenant_001 │
│ └────┬────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Gateway (网关) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ TenantRoutingFilter │ │ │
│ │ │ • 解析 X-Tenant-Id │ │ │
│ │ │ • 根据租户ID路由到对应服务组 │ │ │
│ │ │ • 添加 X-Tenant-Route 标记 │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Nacos 服务注册中心 │ │
│ │ │ │
│ │ 服务分组设计: │ │
│ │ • fund-sys:DEFAULT (共享服务组) │ │
│ │ • fund-sys:TENANT_001 (租户001专属) │ │
│ │ • fund-sys:TENANT_002 (租户002专属) │ │
│ │ • fund-cust:TENANT_001 │ │
│ │ • ... │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 租户001服务组 │ │ 租户002服务组 │ │ 租户003服务组 │ │
│ │ │ │ │ │ │ │
│ │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │
│ │ │fund-sys │ │ │ │fund-sys │ │ │ │fund-sys │ │ │
│ │ │:8100 │ │ │ │:8101 │ │ │ │:8102 │ │ │
│ │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │ │
│ │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │
│ │ │fund-cust │ │ │ │fund-cust │ │ │ │fund-cust │ │ │
│ │ │:8110 │ │ │ │:8111 │ │ │ │:8112 │ │ │
│ │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │ │
│ │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │
│ │ │fund-proj │ │ │ │fund-proj │ │ │ │fund-proj │ │ │
│ │ │:8120 │ │ │ │:8121 │ │ │ │:8122 │ │ │
│ │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────────────────────────┼─────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │tenant_001│ │tenant_002│ │tenant_003│ │
│ │ _db │ │ _db │ │ _db │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
#### 2.6.2 核心组件实现
**1. 租户路由元数据管理**
```java
/**
* 租户路由元数据
*/
@Data
@Component
@ConfigurationProperties(prefix = "tenant.routing")
public class TenantRoutingProperties {
/** 是否启用租户路由 */
private boolean enabled = true;
/** 租户ID请求头 */
private String tenantHeader = "X-Tenant-Id";
/** 服务组分隔符 */
private String groupSeparator = "TENANT_";
/** 共享服务列表(不区分租户) */
private List<String> sharedServices = Arrays.asList("fund-gateway", "fund-report");
/** 租户服务配置 */
private Map<String, TenantServiceConfig> tenantConfigs = new HashMap<>();
}
/**
* 租户服务配置
*/
@Data
public class TenantServiceConfig {
/** 租户ID */
private String tenantId;
/** 服务实例列表 */
private Map<String, ServiceInstanceConfig> services = new HashMap<>();
/** 数据库配置 */
private DatabaseConfig database;
}
/**
* 服务实例配置
*/
@Data
public class ServiceInstanceConfig {
/** 服务名 */
private String serviceName;
/** 端口号 */
private int port;
/** 实例数 */
private int replicas = 1;
}
```
**2. Gateway 租户路由过滤器**
```java
/**
* Gateway 租户路由过滤器
* 根据 X-Tenant-Id 路由到对应租户的服务组
*/
@Component
@Order(-50)
public class TenantRoutingGatewayFilter implements GlobalFilter {
@Autowired
private TenantRoutingProperties routingProperties;
@Autowired
private NacosServiceManager nacosServiceManager;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String tenantId = request.getHeaders().getFirst(routingProperties.getTenantHeader());
// 未指定租户或共享服务,使用默认路由
if (StringUtils.isEmpty(tenantId) || isSharedService(exchange)) {
return chain.filter(exchange);
}
// 构建租户服务组名称
String tenantGroup = buildTenantGroup(tenantId);
// 将租户路由信息存入 Exchange 属性
exchange.getAttributes().put("tenantId", tenantId);
exchange.getAttributes().put("tenantGroup", tenantGroup);
// 添加到请求头,传递给下游服务
ServerHttpRequest mutatedRequest = request.mutate()
.header("X-Tenant-Group", tenantGroup)
.header("X-Tenant-Id", tenantId)
.build();
log.info("[TenantRouting] Route request to tenant group: {}, URI: {}",
tenantGroup, request.getURI());
return chain.filter(exchange.mutate().request(mutatedRequest).build());
}
/**
* 构建租户服务组名称
*/
private String buildTenantGroup(String tenantId) {
return routingProperties.getGroupSeparator() + tenantId.toUpperCase();
}
/**
* 判断是否为共享服务
*/
private boolean isSharedService(ServerWebExchange exchange) {
String path = exchange.getRequest().getURI().getPath();
return routingProperties.getSharedServices().stream()
.anyMatch(path::contains);
}
}
```
**3. 自定义负载均衡器(按租户路由)**
```java
/**
* 租户感知的负载均衡器
*/
public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private final String serviceId;
private final ObjectProvider<ServiceInstanceListSupplier> supplierProvider;
public TenantAwareLoadBalancer(String serviceId,
ObjectProvider<ServiceInstanceListSupplier> supplierProvider) {
this.serviceId = serviceId;
this.supplierProvider = supplierProvider;
}
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
// 从请求上下文获取租户ID
String tenantId = getTenantIdFromRequest(request);
String tenantGroup = buildTenantGroup(tenantId);
ServiceInstanceListSupplier supplier = supplierProvider.getIfAvailable();
return supplier.get().next()
.map(instances -> filterByTenantGroup(instances, tenantGroup))
.map(this::getInstanceResponse);
}
/**
* 根据租户组过滤服务实例
*/
private List<ServiceInstance> filterByTenantGroup(
List<ServiceInstance> instances, String tenantGroup) {
// 优先选择租户专属实例
List<ServiceInstance> tenantInstances = instances.stream()
.filter(inst -> tenantGroup.equals(inst.getMetadata().get("tenant-group")))
.collect(Collectors.toList());
if (!tenantInstances.isEmpty()) {
return tenantInstances;
}
// 回退到默认实例
return instances.stream()
.filter(inst -> !inst.getMetadata().containsKey("tenant-group"))
.collect(Collectors.toList());
}
private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> instances) {
if (instances.isEmpty()) {
return new EmptyResponse();
}
// 轮询选择
int index = ThreadLocalRandom.current().nextInt(instances.size());
return new DefaultResponse(instances.get(index));
}
}
/**
* 租户负载均衡配置
*/
@Configuration
public class TenantLoadBalancerConfig {
@Bean
public ReactorLoadBalancer<ServiceInstance> tenantAwareLoadBalancer(
Environment environment,
LoadBalancerClientFactory loadBalancerClientFactory) {
String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
return new TenantAwareLoadBalancer(name,
loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class));
}
}
```
**4. Feign 租户路由拦截器**
```java
/**
* Feign 租户路由拦截器
* 在服务间调用时保持租户上下文
*/
@Component
public class FeignTenantRoutingInterceptor implements RequestInterceptor {
@Autowired
private TenantRoutingProperties routingProperties;
@Override
public void apply(RequestTemplate template) {
// 从当前线程上下文获取租户ID
String tenantId = TenantContextHolder.getTenantId();
if (StringUtils.isNotEmpty(tenantId)) {
// 添加租户ID到请求头
template.header(routingProperties.getTenantHeader(), tenantId);
// 添加租户组到请求头(用于目标服务的路由)
String tenantGroup = buildTenantGroup(tenantId);
template.header("X-Tenant-Group", tenantGroup);
log.debug("[FeignTenantRouting] Add tenant header: {}, group: {}",
tenantId, tenantGroup);
}
}
private String buildTenantGroup(String tenantId) {
return routingProperties.getGroupSeparator() + tenantId.toUpperCase();
}
}
```
**5. Nacos 服务注册(带租户标记)**
```java
/**
* Nacos 租户服务注册配置
*/
@Configuration
public class NacosTenantRegistrationConfig {
@Value("${tenant.id:}")
private String tenantId;
@Bean
public NacosRegistrationCustomizer tenantNacosRegistrationCustomizer() {
return registration -> {
if (StringUtils.isNotEmpty(tenantId)) {
// 添加租户标记到服务元数据
registration.getMetadata().put("tenant-id", tenantId);
registration.getMetadata().put("tenant-group", "TENANT_" + tenantId.toUpperCase());
// 修改服务分组
registration.setGroup("TENANT_" + tenantId.toUpperCase());
log.info("[NacosRegistration] Register service with tenant: {}", tenantId);
}
};
}
}
```
**6. 租户上下文管理**
```java
/**
* 租户上下文持有者
*/
public class TenantContextHolder {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
private static final ThreadLocal<String> CURRENT_TENANT_GROUP = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getTenantId() {
return CURRENT_TENANT.get();
}
public static void setTenantGroup(String tenantGroup) {
CURRENT_TENANT_GROUP.set(tenantGroup);
}
public static String getTenantGroup() {
return CURRENT_TENANT_GROUP.get();
}
public static void clear() {
CURRENT_TENANT.remove();
CURRENT_TENANT_GROUP.remove();
}
}
/**
* 租户上下文过滤器
*/
@Component
public class TenantContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
try {
// 从请求头获取租户信息
String tenantId = httpRequest.getHeader("X-Tenant-Id");
String tenantGroup = httpRequest.getHeader("X-Tenant-Group");
if (StringUtils.isNotEmpty(tenantId)) {
TenantContextHolder.setTenantId(tenantId);
TenantContextHolder.setTenantGroup(tenantGroup);
// 设置到 MDC 用于日志
MDC.put("tenantId", tenantId);
}
chain.doFilter(request, response);
} finally {
TenantContextHolder.clear();
MDC.remove("tenantId");
}
}
}
```
#### 2.6.3 配置示例
```yaml
# application-tenant.yml
spring:
cloud:
nacos:
discovery:
# 动态服务组,由代码设置
group: ${TENANT_GROUP:DEFAULT}
metadata:
tenant-id: ${TENANT_ID:}
tenant-group: ${TENANT_GROUP:DEFAULT}
loadbalancer:
configurations: tenant-aware
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
group-separator: TENANT_
# 共享服务(所有租户共用)
shared-services:
- fund-gateway
- fund-report
- fund-file
# 租户配置
tenant-configs:
tenant_001:
tenant-id: tenant_001
services:
fund-sys:
service-name: fund-sys
port: 8100
replicas: 2
fund-cust:
service-name: fund-cust
port: 8110
replicas: 1
database:
url: jdbc:mysql://localhost:3306/tenant_001_db
username: tenant_001
password: xxx
tenant_002:
tenant-id: tenant_002
services:
fund-sys:
service-name: fund-sys
port: 8101
replicas: 1
fund-cust:
service-name: fund-cust
port: 8111
replicas: 1
database:
url: jdbc:mysql://localhost:3306/tenant_002_db
username: tenant_002
password: xxx
```
#### 2.6.4 部署架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Kubernetes 部署示例 │
│ │
│ apiVersion: apps/v1 │
│ kind: Deployment │
│ metadata: │
│ name: fund-sys-tenant-001 │
│ labels: │
│ app: fund-sys │
│ tenant: tenant-001 │
│ spec: │
│ replicas: 2 │
│ selector: │
│ matchLabels: │
│ app: fund-sys │
│ tenant: tenant-001 │
│ template: │
│ metadata: │
│ labels: │
│ app: fund-sys │
│ tenant: tenant-001 │
│ spec: │
│ containers: │
│ - name: fund-sys │
│ image: fundplatform/fund-sys:1.0.0 │
│ env: │
│ - name: TENANT_ID │
│ value: "tenant_001" │
│ - name: TENANT_GROUP │
│ value: "TENANT_001" │
│ - name: DB_URL │
│ value: "jdbc:mysql://mysql:3306/tenant_001_db" │
└─────────────────────────────────────────────────────────────────────────────┘
```
#### 2.6.5 两种方案最终对比
| 场景 | 推荐方案 | 原因 |
|------|----------|------|
| 中小租户 (< 100) | DynamicDataSource | 资源利用率高运维简单 |
| 大客户/私有化 | **Feign 动态路由** | 物理隔离,独立扩容 |
| 金融/政务行业 | **Feign 动态路由** | 安全合规要求高 |
| 快速原型/MVP | DynamicDataSource | 快速上线,成本低 |
| 长期运营 SaaS | **Feign 动态路由** | 可持续演进,租户自治 |
--- ---