diff --git a/doc/资金服务平台 FundPlatform 架构设计文档.md b/doc/资金服务平台 FundPlatform 架构设计文档.md index 79ce7f7..531c6ee 100644 --- a/doc/资金服务平台 FundPlatform 架构设计文档.md +++ b/doc/资金服务平台 FundPlatform 架构设计文档.md @@ -427,17 +427,547 @@ CREATE TABLE sys_tenant ( └───────────┘ └───────────┘ └───────────┘ ``` -### 2.5 两种模式对比 +### 2.5 一库一租户路由方案对比 -| 特性 | 一库多租户 | 一库一租户 | -|------|-----------|-----------| -| **数据隔离** | 逻辑隔离(tenant_id) | 物理隔离(独立数据库) | -| **部署成本** | 低(共享资源) | 高(独立资源) | -| **运维复杂度** | 低 | 高 | -| **扩展性** | 垂直扩展为主 | 水平扩展 | -| **安全性** | 中 | 高 | -| **适用场景** | 中小客户、SaaS | 大客户、私有化部署 | -| **数据迁移** | 复杂(需筛选数据) | 简单(整库迁移) | +#### 2.5.1 方案对比:DynamicDataSource vs Feign 动态路由 + +| 对比维度 | DynamicDataSource | Feign 动态路由 | +|----------|-------------------|----------------| +| **架构层级** | 数据层 | 服务层 | +| **隔离级别** | 数据源级别 | 服务实例级别 | +| **资源占用** | 高(连接池 × N租户) | 中(服务实例 × N租户) | +| **扩展性** | 差(单服务多数据源) | 优(独立扩容) | +| **故障隔离** | 差(单点故障影响多租户) | 优(租户间互不影响) | +| **跨租户查询** | 困难 | 可通过聚合服务实现 | +| **服务治理** | 简单 | 需要完善的服务发现 | +| **部署复杂度** | 低 | 中 | +| **私有化部署** | 困难 | 简单(整体迁移) | +| **运维成本** | 中 | 高 | + +#### 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 sharedServices = Arrays.asList("fund-gateway", "fund-report"); + + /** 租户服务配置 */ + private Map tenantConfigs = new HashMap<>(); +} + +/** + * 租户服务配置 + */ +@Data +public class TenantServiceConfig { + /** 租户ID */ + private String tenantId; + + /** 服务实例列表 */ + private Map 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 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 supplierProvider; + + public TenantAwareLoadBalancer(String serviceId, + ObjectProvider supplierProvider) { + this.serviceId = serviceId; + this.supplierProvider = supplierProvider; + } + + @Override + public Mono> 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 filterByTenantGroup( + List instances, String tenantGroup) { + + // 优先选择租户专属实例 + List 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 getInstanceResponse(List 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 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 CURRENT_TENANT = new ThreadLocal<>(); + private static final ThreadLocal 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 动态路由** | 可持续演进,租户自治 | ---