docs: 架构设计文档补充多租户架构和Head日志追踪设计

This commit is contained in:
zhangjf 2026-02-14 00:34:29 +08:00
parent 15d1cb698a
commit 8029ac31da

View File

@ -18,18 +18,794 @@
| **可扩展性** | 微服务架构,支持水平扩展 |
| **安全性** | 数据加密传输,完善的权限控制 |
| **可维护性** | 模块化设计,代码结构清晰 |
| **多租户** | 支持一库多租户和一库一租户两种模式 |
| **可观测性** | 全链路日志跟踪,支持 Head 日志追踪 |
### 1.2 架构风格
采用 **微服务架构** + **前后端分离** 模式:
采用 **微服务架构** + **前后端分离** + **多租户架构** 模式:
- 后端Spring Cloud Alibaba 微服务框架
- 前端Vue 3 + UniApp 多端应用
- 数据层MySQL + Redis 缓存
- 数据层MySQL + Redis 缓存(支持多租户隔离)
- 基础设施Nacos 服务治理、Nginx 负载均衡
- 可观测性Head 日志追踪 + 全链路监控
---
## 二、系统整体架构
## 二、多租户架构设计
### 2.1 多租户架构概述
系统支持两种多租户模式,可根据业务需求灵活选择:
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| **一库多租户** | 所有租户共享一个数据库,通过 `tenant_id` 字段隔离 | 中小租户、数据量较小、成本敏感 |
| **一库一租户** | 每个租户独立数据库,物理隔离 | 大客户、数据量大、安全要求高 |
### 2.2 一库多租户模式
#### 2.2.1 架构设计
```
┌─────────────────────────────────────────────────────────────┐
│ 应用服务层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ fund-sys │ │ fund-cust │ │ fund-proj │ │
│ │ (多租户) │ │ (多租户) │ │ (多租户) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
└─────────┼────────────────┼────────────────┼─────────────────┘
│ │ │
└────────────────┴────────────────┘
┌───────────────┴───────────────┐
│ TenantContextHolder │
│ (线程级租户上下文存储) │
└───────────────┬───────────────┘
┌─────────────────────────┼─────────────────────────────────────┐
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ MySQL 数据库 │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ fund_platform 库 │ │ │
│ │ │ ┌───────────┬───────────┬───────────┬─────────┐ │ │ │
│ │ │ │ tenant_id │ user_id │ username │ ... │ │ │ │
│ │ │ ├───────────┼───────────┼───────────┼─────────┤ │ │ │
│ │ │ │ 1 │ 1 │ admin │ ... │ │ │ │
│ │ │ │ 1 │ 2 │ user1 │ ... │ │ │ │
│ │ │ │ 2 │ 1 │ admin │ ... │ │ │ │
│ │ │ └───────────┴───────────┴───────────┴─────────┘ │ │ │
│ │ │ 所有表包含 tenant_id 字段用于数据隔离 │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
```
#### 2.2.2 技术实现
**1. 租户识别**
```java
/**
* 租户上下文持有者
*/
public class TenantContextHolder {
private static final ThreadLocal<Long> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(Long tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static Long getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
```
**2. 租户拦截器**
```java
/**
* 租户拦截器 - 从请求头或JWT中提取租户ID
*/
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 从请求头获取租户ID
String tenantId = request.getHeader("X-Tenant-Id");
// 或从JWT Token中解析
if (StringUtils.isEmpty(tenantId)) {
String token = request.getHeader("Authorization");
tenantId = JwtUtil.getTenantId(token);
}
if (StringUtils.isNotEmpty(tenantId)) {
TenantContextHolder.setTenantId(Long.valueOf(tenantId));
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
TenantContextHolder.clear();
}
}
```
**3. MyBatis-Plus 租户插件**
```java
/**
* 多租户 SQL 拦截器
*/
@Component
public class TenantLineInnerInterceptor implements InnerInterceptor {
@Override
public void beforeQuery(Executor executor,
MappedStatement ms,
Object parameter,
RowBounds rowBounds,
ResultHandler resultHandler,
BoundSql boundSql) {
// 获取当前租户ID
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
return;
}
// 获取SQL
String originalSql = boundSql.getSql();
// 忽略特定表(如租户表本身)
if (isIgnoreTable(ms.getId())) {
return;
}
// 添加租户条件
String tenantSql = addTenantCondition(originalSql, tenantId);
// 重写SQL
reflectSetField(boundSql, "sql", tenantSql);
}
private String addTenantCondition(String sql, Long tenantId) {
// 使用 JSqlParser 解析并修改 SQL
try {
Statement statement = CCJSqlParserUtil.parse(sql);
if (statement instanceof Select) {
Select select = (Select) statement;
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
// 添加 WHERE 条件
EqualsTo equalsTo = new EqualsTo();
equalsTo.setLeftExpression(new Column("tenant_id"));
equalsTo.setRightExpression(new LongValue(tenantId));
Expression where = plainSelect.getWhere();
if (where == null) {
plainSelect.setWhere(equalsTo);
} else {
AndExpression and = new AndExpression(where, equalsTo);
plainSelect.setWhere(and);
}
return select.toString();
}
} catch (JSQLParserException e) {
log.error("SQL解析失败", e);
}
return sql;
}
}
```
**4. 实体基类**
```java
/**
* 多租户实体基类
*/
@Data
public abstract class TenantBaseEntity extends BaseEntity {
@TableField(fill = FieldFill.INSERT)
private Long tenantId;
}
```
#### 2.2.3 数据隔离策略
| 隔离级别 | 实现方式 | 说明 |
|----------|----------|------|
| **行级隔离** | tenant_id 字段过滤 | 默认方式,所有业务表包含 tenant_id |
| **schema隔离** | 不同租户使用不同schema | 可选,适用于数据量大的租户 |
| **缓存隔离** | Redis Key 添加租户前缀 | `tenant:{id}:user:info` |
| **文件隔离** | COS 目录按租户划分 | `/tenant/{id}/files/` |
### 2.3 一库一租户模式
#### 2.3.1 架构设计
```
┌─────────────────────────────────────────────────────────────┐
│ 应用服务层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ fund-sys │ │ fund-cust │ │ fund-proj │ │
│ │ (多租户) │ │ (多租户) │ │ (多租户) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
└─────────┼────────────────┼────────────────┼─────────────────┘
│ │ │
└────────────────┴────────────────┘
┌───────────────┴───────────────┐
│ DynamicDataSource │
│ (动态数据源路由) │
└───────────────┬───────────────┘
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ tenant_1_db │ │ tenant_2_db │ │ tenant_3_db │
│ (租户1数据库) │ │ (租户2数据库) │ │ (租户3数据库) │
│ │ │ │ │ │
│ • sys_user │ │ • sys_user │ │ • sys_user │
│ • customer │ │ • customer │ │ • customer │
│ • project │ │ • project │ │ • project │
│ • expense │ │ • expense │ │ • expense │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
#### 2.3.2 技术实现
**1. 动态数据源配置**
```java
/**
* 动态数据源上下文
*/
public class DynamicDataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
public static void setDataSourceKey(String key) {
CONTEXT_HOLDER.set(key);
}
public static String getDataSourceKey() {
return CONTEXT_HOLDER.get();
}
public static void clear() {
CONTEXT_HOLDER.remove();
}
}
/**
* 动态数据源
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceContextHolder.getDataSourceKey();
}
}
```
**2. 数据源管理器**
```java
/**
* 租户数据源管理器
*/
@Component
public class TenantDataSourceManager {
@Autowired
private DynamicDataSource dynamicDataSource;
@Autowired
private TenantDataSourceConfigRepository configRepository;
/**
* 添加租户数据源
*/
public void addDataSource(Long tenantId) {
TenantDataSourceConfig config = configRepository.findByTenantId(tenantId);
DruidDataSource dataSource = new DruidDataSource();
dataSource.setDriverClassName(config.getDriverClassName());
dataSource.setUrl(config.getJdbcUrl());
dataSource.setUsername(config.getUsername());
dataSource.setPassword(config.getPassword());
dataSource.setInitialSize(5);
dataSource.setMaxActive(20);
// 添加到动态数据源
dynamicDataSource.addDataSource("tenant_" + tenantId, dataSource);
}
/**
* 切换租户数据源
*/
public void switchDataSource(Long tenantId) {
String key = "tenant_" + tenantId;
// 检查数据源是否存在
if (!dynamicDataSource.hasDataSource(key)) {
addDataSource(tenantId);
}
DynamicDataSourceContextHolder.setDataSourceKey(key);
}
}
```
**3. 租户数据源拦截器**
```java
/**
* 租户数据源切换拦截器
*/
@Component
public class TenantDataSourceInterceptor implements HandlerInterceptor {
@Autowired
private TenantDataSourceManager dataSourceManager;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 获取租户ID
String tenantId = request.getHeader("X-Tenant-Id");
if (StringUtils.isNotEmpty(tenantId)) {
// 切换数据源
dataSourceManager.switchDataSource(Long.valueOf(tenantId));
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
DynamicDataSourceContextHolder.clear();
}
}
```
### 2.4 租户管理
#### 2.4.1 租户表设计
```sql
CREATE TABLE sys_tenant (
tenant_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '租户ID',
tenant_code VARCHAR(50) NOT NULL UNIQUE COMMENT '租户编码',
tenant_name VARCHAR(100) NOT NULL COMMENT '租户名称',
tenant_type TINYINT DEFAULT 1 COMMENT '租户类型1-一库多租户2-一库一租户',
db_config JSON COMMENT '数据库配置(一库一租户模式使用)',
status TINYINT DEFAULT 1 COMMENT '状态0-禁用1-启用',
expire_time DATETIME COMMENT '过期时间',
created_time DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_code (tenant_code),
INDEX idx_status (status)
) COMMENT='租户表';
```
#### 2.4.2 租户创建流程
```
┌─────────┐ 创建租户 ┌─────────┐
│ 管理员 │ ───────────────> │ 系统 │
└─────────┘ └────┬────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 保存租户 │ │ 初始化数据 │ │ 创建数据源 │
│ 基本信息 │ │ (菜单/角色)│ │ (一库一租户)│
└───────────┘ └───────────┘ └───────────┘
```
### 2.5 两种模式对比
| 特性 | 一库多租户 | 一库一租户 |
|------|-----------|-----------|
| **数据隔离** | 逻辑隔离tenant_id | 物理隔离(独立数据库) |
| **部署成本** | 低(共享资源) | 高(独立资源) |
| **运维复杂度** | 低 | 高 |
| **扩展性** | 垂直扩展为主 | 水平扩展 |
| **安全性** | 中 | 高 |
| **适用场景** | 中小客户、SaaS | 大客户、私有化部署 |
| **数据迁移** | 复杂(需筛选数据) | 简单(整库迁移) |
---
## 三、Head 日志追踪设计
### 3.1 概述
Head 日志追踪Header-based Logging是一种基于请求头的全链路日志追踪方案通过在每个请求中传递唯一的 Trace ID实现跨服务的调用链追踪。
### 3.2 架构设计
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Head 日志追踪架构 │
│ │
│ ┌─────────┐ │
│ │ 客户端 │ X-Trace-Id: abc123 │
│ └────┬────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Gateway (网关) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ TraceIdFilter │ │ │
│ │ │ • 生成/接收 TraceId │ │ │
│ │ │ • 写入 MDC (Mapped Diagnostic Context) │ │ │
│ │ │ • 传递 TraceId 到下游服务 │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │fund-sys │ │fund-cust│ │fund-proj│ │
│ │ │ │ │ │ │ │
│ │• 接收 │ │• 接收 │ │• 接收 │ │
│ │ TraceId│ │ TraceId│ │ TraceId│ │
│ │• 写入 │ │• 写入 │ │• 写入 │ │
│ │ MDC │ │ MDC │ │ MDC │ │
│ │• 记录 │ │• 记录 │ │• 记录 │ │
│ │ 日志 │ │ 日志 │ │ 日志 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └─────────────────────────┼─────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ELK / Loki 日志平台 │ │
│ │ • 日志收集 → 解析 → 存储 → 检索 → 可视化 │ │
│ │ • 支持 TraceId 全链路查询 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 3.3 技术实现
#### 3.3.1 TraceId 生成与传递
```java
/**
* TraceId 工具类
*/
public class TraceIdUtil {
public static final String TRACE_ID_HEADER = "X-Trace-Id";
public static final String TRACE_ID_MDC_KEY = "traceId";
public static final String SPAN_ID_MDC_KEY = "spanId";
/**
* 生成 TraceId
*/
public static String generateTraceId() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
/**
* 生成 SpanId
*/
public static String generateSpanId() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
/**
* 获取当前 TraceId
*/
public static String getCurrentTraceId() {
return MDC.get(TRACE_ID_MDC_KEY);
}
/**
* 设置 TraceId 到 MDC
*/
public static void setTraceId(String traceId) {
MDC.put(TRACE_ID_MDC_KEY, traceId);
}
/**
* 清除 MDC
*/
public static void clear() {
MDC.clear();
}
}
```
#### 3.3.2 Gateway TraceId 过滤器
```java
/**
* Gateway TraceId 过滤器
*/
@Component
@Order(-100)
public class TraceIdGatewayFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
HttpHeaders headers = request.getHeaders();
// 获取或生成 TraceId
String traceId = headers.getFirst(TraceIdUtil.TRACE_ID_HEADER);
if (StringUtils.isEmpty(traceId)) {
traceId = TraceIdUtil.generateTraceId();
}
// 生成 SpanId
String spanId = TraceIdUtil.generateSpanId();
// 添加到 MDC
TraceIdUtil.setTraceId(traceId);
MDC.put(TraceIdUtil.SPAN_ID_MDC_KEY, spanId);
// 添加到请求头,传递给下游服务
ServerHttpRequest mutatedRequest = request.mutate()
.header(TraceIdUtil.TRACE_ID_HEADER, traceId)
.header("X-Span-Id", spanId)
.header("X-Parent-Span-Id", MDC.get(TraceIdUtil.SPAN_ID_MDC_KEY))
.build();
// 记录请求日志
log.info("[Gateway] Request: {} {}, TraceId: {}",
request.getMethod(),
request.getURI(),
traceId);
final String finalTraceId = traceId;
return chain.filter(exchange.mutate().request(mutatedRequest).build())
.doFinally(signalType -> {
// 记录响应日志
log.info("[Gateway] Response: {}, TraceId: {}",
exchange.getResponse().getStatusCode(),
finalTraceId);
TraceIdUtil.clear();
});
}
}
```
#### 3.3.3 服务间 TraceId 传递
```java
/**
* Feign 请求拦截器 - 传递 TraceId
*/
@Component
public class FeignTraceIdInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String traceId = TraceIdUtil.getCurrentTraceId();
if (StringUtils.isNotEmpty(traceId)) {
template.header(TraceIdUtil.TRACE_ID_HEADER, traceId);
}
}
}
/**
* 服务端 TraceId 过滤器
*/
@Component
public class TraceIdFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 从请求头获取 TraceId
String traceId = httpRequest.getHeader(TraceIdUtil.TRACE_ID_HEADER);
if (StringUtils.isEmpty(traceId)) {
traceId = TraceIdUtil.generateTraceId();
}
// 设置到 MDC
TraceIdUtil.setTraceId(traceId);
MDC.put(TraceIdUtil.SPAN_ID_MDC_KEY, TraceIdUtil.generateSpanId());
try {
chain.doFilter(request, response);
} finally {
TraceIdUtil.clear();
}
}
}
```
#### 3.3.4 日志配置
```xml
<!-- logback-spring.xml -->
<configuration>
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] [%X{spanId}] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 文件输出JSON格式便于ELK收集 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/application.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxHistory>30</maxHistory>
<maxFileSize>100MB</maxFileSize>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeContext>true</includeContext>
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<includeMdcKeyName>tenantId</includeMdcKeyName>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
</configuration>
```
### 3.4 日志格式规范
#### 3.4.1 标准日志格式
```
[时间] [线程] [TraceId] [SpanId] [租户ID] [级别] [类名] - [消息]
示例:
2026-02-13 14:30:25.123 [http-nio-8100-exec-1] [abc123def456] [span789] [tenant_1] INFO c.f.s.c.UserController - 用户登录成功: admin
```
#### 3.4.2 JSON 日志格式
```json
{
"@timestamp": "2026-02-13T14:30:25.123+08:00",
"level": "INFO",
"logger_name": "com.fundplatform.sys.controller.UserController",
"message": "用户登录成功: admin",
"thread_name": "http-nio-8100-exec-1",
"traceId": "abc123def456",
"spanId": "span789",
"tenantId": "tenant_1",
"service": "fund-sys",
"host": "192.168.1.100"
}
```
### 3.5 日志收集与分析
#### 3.5.1 ELK Stack 架构
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 应用服务 │────>│ Filebeat │────>│ Logstash │────>│ Elasticsearch│
│ (日志文件) │ │ (日志收集) │ │ (日志处理) │ │ (日志存储) │
└─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘
┌─────────────┐
│ Kibana │
│ (日志查询) │
└─────────────┘
```
#### 3.5.2 Kibana 查询示例
```
# 根据 TraceId 查询全链路日志
traceId: "abc123def456"
# 根据租户ID查询日志
tenantId: "tenant_1"
# 查询特定服务的错误日志
service: "fund-sys" AND level: "ERROR"
# 查询特定用户的操作日志
message: "admin" AND tenantId: "tenant_1"
```
### 3.6 调用链追踪增强
#### 3.6.1 与 SkyWalking 集成
```java
/**
* SkyWalking 插件配置
*/
@Component
public class SkyWalkingConfig {
/**
* 自定义 SkyWalking 标签
*/
@TraceContext
public void addCustomTags() {
// 添加租户ID标签
String tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
Tags.ofKey("tenant.id").set(tenantId);
}
// 添加用户ID标签
String userId = SecurityUtils.getCurrentUserId();
if (userId != null) {
Tags.ofKey("user.id").set(userId);
}
}
}
```
#### 3.6.2 性能指标采集
```java
/**
* 接口性能监控
*/
@Aspect
@Component
public class PerformanceAspect {
private static final Logger perfLog = LoggerFactory.getLogger("PERFORMANCE");
@Around("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
public Object around(ProceedingJoinPoint point) throws Throwable {
long start = System.currentTimeMillis();
String method = point.getSignature().toShortString();
String traceId = TraceIdUtil.getCurrentTraceId();
try {
return point.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
perfLog.info("[Performance] Method: {}, Cost: {}ms, TraceId: {}",
method, cost, traceId);
}
}
}
```
---
## 四、系统整体架构
### 2.1 架构全景图
@ -504,6 +1280,7 @@ fund-sys/
| 版本 | 修订日期 | 修订内容 | 修订人 |
|------|----------|----------|--------|
| v1.0 | 2026-02-13 | 初始版本 | zhangjf |
| v1.1 | 2026-02-13 | 补充多租户架构(一库多租户/一库一租户)和 Head 日志追踪设计 | zhangjf |
---