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

473 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 多租户架构实现指南
## 一、架构设计概述
资金服务平台采用**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`
```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-Uid``X-Uname`
- 传递 TraceId 用于链路追踪
**示例**
```java
@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`
**配置项**
```yaml
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. 轮询选择一个实例发起调用
```
**服务实例元数据**
```yaml
spring:
cloud:
nacos:
discovery:
metadata:
tenant-id: "1"
tenant-group: TENANT_1
```
---
## 三、使用方式
### 3.1 一库多租户模式(推荐 SaaS
**特点**:所有租户共享服务实例,通过 tenant_id 区分数据
**配置步骤**
#### 1. 服务提供方配置
```yaml
# application.yml
spring:
cloud:
nacos:
discovery:
server-addr: localhost:8848
username: nacos
password: nacos
# 不需要特殊配置,所有租户共用实例
```
```java
// 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. 服务消费方配置
```yaml
# application.yml
tenant:
routing:
enabled: false # 一库多租户模式不需要启用租户路由
```
### 3.2 一库一租户模式(推荐私有化)
**特点**:每个租户有独立的服务实例和数据库
**配置步骤**
#### 1. 启用租户路由
```yaml
# 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. 服务实例带租户标签注册
```yaml
# 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. 启动多个租户实例
```bash
# 租户 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 全局过滤器
```java
@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 动态路由配置
```yaml
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 设置规范
```java
// ✅ 正确:在 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 租户数据隔离
```java
// 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 日志记录租户信息
```yaml
# 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: 租户专属实例如何水平扩展?
**答**:启动多个相同配置的实例即可:
```bash
# 租户 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
```
---
## 八、下一步计划
### 已完成 ✅
- [x] TenantContextHolder租户上下文
- [x] FeignChainInterceptorFeign 租户信息透传)
- [x] TenantRoutingProperties租户路由配置
- [x] TenantAwareLoadBalancer租户感知负载均衡器
### 待实现 🚧
- [ ] Gateway 租户全局过滤器
- [ ] MyBatis Plus 租户自动注入
- [ ] 租户服务实例管理界面
- [ ] 租户级别的监控指标
---
**文档版本**: v1.0
**最后更新**: 2026-02-18
**维护者**: 架构团队