## 新增功能 ### 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: 负载均衡器单元测试 - 混合模式集成测试脚本
473 lines
12 KiB
Markdown
473 lines
12 KiB
Markdown
# 多租户架构实现指南
|
||
|
||
## 一、架构设计概述
|
||
|
||
资金服务平台采用**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.yml(Gateway 或调用方服务)
|
||
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] FeignChainInterceptor(Feign 租户信息透传)
|
||
- [x] TenantRoutingProperties(租户路由配置)
|
||
- [x] TenantAwareLoadBalancer(租户感知负载均衡器)
|
||
|
||
### 待实现 🚧
|
||
- [ ] Gateway 租户全局过滤器
|
||
- [ ] MyBatis Plus 租户自动注入
|
||
- [ ] 租户服务实例管理界面
|
||
- [ ] 租户级别的监控指标
|
||
|
||
---
|
||
|
||
**文档版本**: v1.0
|
||
**最后更新**: 2026-02-18
|
||
**维护者**: 架构团队
|