fundplatform/doc/多租户架构实现指南.md
zhangjf 10eca3fb35 feat: 实现多租户架构完整能力
## 新增功能

### 1. 多租户核心组件
- TenantRoutingProperties: 租户路由配置属性
- TenantAwareLoadBalancer: 租户感知负载均衡器
- TenantLineHandlerImpl: MyBatis Plus 租户插件
- TenantIgnoreHelper: 忽略租户过滤工具类
- NacosMetadataConfig: Nacos 元数据自动注册

### 2. Gateway 租户过滤器
- TenantGatewayFilter: 从 JWT 提取租户信息写入请求头
- 透传 X-Tenant-Id、X-Tenant-Group、X-User-Id、X-Username

### 3. 支持的部署模式
- 一库多租户(SaaS 模式): 通过 tenant_id 字段隔离
- 一库一租户(私有化): 独立服务实例和数据库
- 混合模式: VIP 租户专属实例 + 普通租户共享实例

### 4. Nacos 3.0 适配
- 所有业务模块添加 username/password 认证配置
- 服务实例自动注册租户标签

## 问题修复
- #8: FeignClient 硬编码 URL 导致 Nacos 服务发现失效
- #9: Nacos 3.0 客户端缺少 username/password 认证配置
- fund-exp expenseType 字段类型从 Integer 改为 Long

## 测试
- TenantAwareLoadBalancerTest: 负载均衡器单元测试
- 混合模式集成测试脚本
2026-02-19 18:10:16 +08:00

12 KiB
Raw Permalink Blame History

多租户架构实现指南

一、架构设计概述

资金服务平台采用Feign 动态路由方案实现多租户隔离,支持两种部署模式:

1.1 多租户部署模式对比

特性 一库多租户(逻辑隔离) 一库一租户(物理隔离)
数据隔离 tenant_id 字段区分 独立数据库
资源占用 低(共享服务实例) 中(可独立部署)
扩展性 一般 优秀(单租户可独立扩容)
安全性
运维成本
适用场景 中小租户/SaaS 大客户/私有化/金融政务

1.2 核心能力

租户上下文传递:通过 TenantContextHolder 在调用链中传递租户 ID
Feign 租户感知FeignChainInterceptor 自动透传租户信息
租户负载均衡TenantAwareLoadBalancer 根据租户路由到对应实例
Nacos 服务注册:服务实例带租户标签注册


二、已实现的核心组件

2.1 租户上下文管理

文件位置fund-common/src/main/java/com/fundplatform/common/context/TenantContextHolder.java

// 设置当前线程的租户 ID
TenantContextHolder.setTenantId(1L);

// 获取当前线程的租户 ID
Long tenantId = TenantContextHolder.getTenantId();

// 清理(必须在 finally 中执行)
TenantContextHolder.clear();

使用场景

  • Gateway 过滤器解析 Token 后设置
  • Feign 拦截器从请求头提取并设置
  • Service 层业务逻辑使用

2.2 Feign 租户信息透传

文件位置fund-common/src/main/java/com/fundplatform/common/feign/FeignChainInterceptor.java

功能

  • 自动将 X-Tenant-Id 传递到下游服务
  • 同时传递用户信息(X-UidX-Uname
  • 传递 TraceId 用于链路追踪

示例

@FeignClient(name = "fund-proj")
public interface ProjectFeignClient {
    @GetMapping("/api/v1/proj/project/{id}")
    Result<Project> getById(@PathVariable Long id);
}

// 调用时自动携带租户信息
projectFeignClient.getById(1L);
// ↓ 实际请求头包含X-Tenant-Id: 1

2.3 租户路由配置属性

文件位置fund-common/src/main/java/com/fundplatform/common/config/TenantRoutingProperties.java

配置项

tenant:
  routing:
    enabled: true                    # 是否启用租户路由
    tenant-header: X-Tenant-Id       # 租户 ID 请求头
    tenant-group-header: X-Tenant-Group  # 租户组请求头
    group-separator: TENANT_         # 租户组分隔符
    default-tenant-id: "1"           # 默认租户 ID
    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
        database:
          url: jdbc:mysql://localhost:3306/tenant_001_db
          username: tenant_001
          password: xxx

2.4 租户感知负载均衡器

文件位置fund-common/src/main/java/com/fundplatform/common/loadbalancer/TenantAwareLoadBalancer.java

工作原理

1. 从请求头获取 X-Tenant-Id
   ↓
2. 构建租户组名称TENANT_{tenantId}
   ↓
3. 过滤 Nacos 服务实例
   - 优先选择租户专属实例metadata.tenant-group = TENANT_XXX
   - 回退到共享实例(无 tenant-group 标签)
   ↓
4. 轮询选择一个实例发起调用

服务实例元数据

spring:
  cloud:
    nacos:
      discovery:
        metadata:
          tenant-id: "1"
          tenant-group: TENANT_1

三、使用方式

3.1 一库多租户模式(推荐 SaaS

特点:所有租户共享服务实例,通过 tenant_id 区分数据

配置步骤

1. 服务提供方配置

# application.yml
spring:
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
        username: nacos
        password: nacos
        # 不需要特殊配置,所有租户共用实例
// Service 层设置租户 ID 默认值
@Service
public class ProjectServiceImpl implements ProjectService {
    
    @Autowired
    private ProjectMapper projectMapper;
    
    @Override
    public void saveProject(Project project) {
        // 设置默认租户 ID如果未提供
        if (project.getTenantId() == null) {
            project.setTenantId(1L); // 或从 TenantContextHolder 获取
        }
        
        projectMapper.insert(project);
    }
    
    @Override
    public List<Project> listProjects() {
        // 自动添加租户过滤条件
        Long tenantId = TenantContextHolder.getTenantId();
        return projectMapper.selectList(new LambdaQueryWrapper<Project>()
            .eq(Project::getTenantId, tenantId));
    }
}

2. 服务消费方配置

# application.yml
tenant:
  routing:
    enabled: false  # 一库多租户模式不需要启用租户路由

3.2 一库一租户模式(推荐私有化)

特点:每个租户有独立的服务实例和数据库

配置步骤

1. 启用租户路由

# application.ymlGateway 或调用方服务)
tenant:
  routing:
    enabled: true
    group-separator: TENANT_
    default-tenant-id: "1"
    shared-services:
      - fund-gateway
      - fund-report
    tenant-configs:
      tenant_001:
        tenant-id: tenant_001
        services:
          fund-sys:
            service-name: fund-sys
            port: 8100
            replicas: 2
        database:
          url: jdbc:mysql://db-tenant001.com:3306/tenant_001_db
          username: t001
          password: xxx

2. 服务实例带租户标签注册

# application.yml租户专属服务实例
spring:
  application:
    name: fund-sys
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
        username: nacos
        password: nacos
        metadata:
          tenant-id: tenant_001
          tenant-group: TENANT_TENANT_001
server:
  port: 8100

3. 启动多个租户实例

# 租户 001 的系统服务
java -jar fund-sys.jar \
  --spring.application.name=fund-sys \
  --spring.cloud.nacos.discovery.metadata.tenant-id=tenant_001 \
  --spring.cloud.nacos.discovery.metadata.tenant-group=TENANT_TENANT_001 \
  --server.port=8100 \
  --spring.datasource.url=jdbc:mysql://db-tenant001.com:3306/tenant_001_db

# 租户 002 的系统服务
java -jar fund-sys.jar \
  --spring.application.name=fund-sys \
  --spring.cloud.nacos.discovery.metadata.tenant-id=tenant_002 \
  --spring.cloud.nacos.discovery.metadata.tenant-group=TENANT_TENANT_002 \
  --server.port=8101 \
  --spring.datasource.url=jdbc:mysql://db-tenant002.com:3306/tenant_002_db

四、Gateway 租户路由配置

4.1 Gateway 全局过滤器

@Component
public class TenantGatewayFilter implements GlobalFilter, Ordered {
    
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        
        // 1. 从 Token 中提取租户 ID
        String token = getToken(request);
        Claims claims = tokenProvider.parseToken(token);
        String tenantId = claims.get("tenantId", String.class);
        
        // 2. 将租户 ID 写入请求头
        ServerHttpRequest mutatedRequest = request.mutate()
            .header("X-Tenant-Id", tenantId)
            .header("X-Tenant-Group", "TENANT_" + tenantId.toUpperCase())
            .build();
        
        // 3. 继续执行
        return chain.filter(exchange.mutate().request(mutatedRequest).build());
    }
    
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 1; // 在 JWT 认证之后
    }
}

4.2 Gateway 动态路由配置

spring:
  cloud:
    gateway:
      routes:
        # 系统服务路由(租户感知)
        - id: fund-sys
          uri: lb://fund-sys  # 使用负载均衡
          predicates:
            - Path=/sys/**
          filters:
            - StripPrefix=1
            # 租户路由过滤器会自动添加 X-Tenant-Id 和 X-Tenant-Group 头
        
        # 报表服务路由(共享服务)
        - id: fund-report
          uri: lb://fund-report
          predicates:
            - Path=/report/**
          filters:
            - StripPrefix=1

五、最佳实践

5.1 租户 ID 设置规范

// ✅ 正确:在 Service 层设置默认值
@Service
public class UserServiceImpl implements UserService {
    
    @Override
    public void saveUser(User user) {
        if (user.getTenantId() == null) {
            // 从上下文获取,或设置默认值
            user.setTenantId(TenantContextHolder.getTenantId());
        }
        userMapper.insert(user);
    }
}

// ❌ 错误:依赖前端传递
@Override
public void saveUser(User user) {
    // 如果前端没传 tenantId会报 NOT NULL 错误
    userMapper.insert(user);
}

5.2 租户数据隔离

// MyBatis Plus 自动注入租户条件
@Bean
public ISqlInjector sqlInjector() {
    return new LogicSqlInjector();
}

// 或使用拦截器
@Intercepts({
    @Select(type = Executor.class, method = "query", args = {...})
})
public class TenantInterceptor implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 自动添加 WHERE tenant_id = ? OR tenant_id = 0
        Long tenantId = TenantContextHolder.getTenantId();
        // ... 修改 SQL
        return invocation.proceed();
    }
}

5.3 日志记录租户信息

# logback-spring.xml
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} 
  [TraceId:%X{traceId}, TenantId:%X{tenantId}] - %msg%n</pattern>

输出示例:

2026-02-18 10:30:00 [main] INFO  c.f.s.service.UserService 
  [TraceId:abc123, TenantId:1] - User login: admin

六、监控与运维

6.1 Nacos 控制台查看租户实例

访问:http://localhost:8048/

路径:服务管理 → 服务列表 → fund-sys → 实例列表

可以看到:

  • 实例 IP 和端口
  • 元数据tenant-id, tenant-group
  • 健康状态

6.2 查看租户调用链

通过 SkyWalking 或 ELK 查看:

GET /api/v1/proj/project/1
Headers:
  X-Tenant-Id: 1
  X-Trace-Id: abc123
  
日志输出:
[TraceId:abc123, TenantId:1] Gateway → fund-sys → fund-proj

七、常见问题

Q1: 什么时候需要启用租户路由?

  • 一库多租户:不需要启用(tenant.routing.enabled=false
  • 一库一租户:需要启用(tenant.routing.enabled=true

Q2: 如何判断服务是共享还是租户专属?

:通过 tenant.routing.shared-services 配置区分:

  • 列表中的服务:所有租户共用
  • 不在列表中的服务:可以是租户专属

Q3: 租户专属实例如何水平扩展?

:启动多个相同配置的实例即可:

# 租户 001 的 fund-sys 服务,启动 3 个实例
for i in {8100..8102}; do
  java -jar fund-sys.jar \
    --spring.cloud.nacos.discovery.metadata.tenant-id=tenant_001 \
    --spring.cloud.nacos.discovery.metadata.tenant-group=TENANT_TENANT_001 \
    --server.port=$i &
done

八、下一步计划

已完成

  • TenantContextHolder租户上下文
  • FeignChainInterceptorFeign 租户信息透传)
  • TenantRoutingProperties租户路由配置
  • TenantAwareLoadBalancer租户感知负载均衡器

待实现 🚧

  • Gateway 租户全局过滤器
  • MyBatis Plus 租户自动注入
  • 租户服务实例管理界面
  • 租户级别的监控指标

文档版本: v1.0
最后更新: 2026-02-18
维护者: 架构团队