Spring Cloud 多租户实现(MySQL + MyBatis + MyBatis-Plus)
技术栈:Spring Cloud、Spring Boot 3.x、MySQL 8、MyBatis、MyBatis-Plus、Spring Cloud Gateway、OpenFeign
目标:在单套微服务中实现多租户数据/权限隔离,可从“字段级共享库”平滑演进到“库级独库”。
0. 隔离级别选型与路线
| 隔离级别 | 方案 | 说明 | 适用 | 成本 |
|---|---|---|---|---|
| 表字段级 | 每表增加 tenant_id + MyBatis-Plus 租户插件 | 单库多租户,改造小 | 中小体量,10^2~10^4 租户 | ★ |
| 库/Schema级 | 每租户独库/独 Schema + 动态数据源 | 隔离更强,性能可隔离 | 中/大体量,合规强隔离 | ★★★ |
| 实例级 | 独立数据库实例 | 最强隔离,运维成本高 | 金融/政企 | ★★★★★ |
建议:字段级快速落地 → 大租户或合规租户迁移到库级;业务代码尽量保持无感。
1. 统一约定
-
租户标识:
tenantId。来源优先级:二级域名xxx.example.com→ HeaderX-Tenant→ JWT ClaimtenantId。 -
跨服务传递:Gateway 统一解析并透传;Feign 拦截器统一添加 Header;消息(MQ)Header 也携带。
-
超管租户:
tenantId=0代表平台态,默认不开放跨租户查询。
2. 字段级方案:MyBatis-Plus 租户插件
2.1 数据库改造
-- 示例:对 user 表添加租户列与索引
ALTER TABLE `user` ADD COLUMN `tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID' AFTER `id`;
CREATE INDEX idx_user_tenant ON `user`(`tenant_id`);
-- 业务关键表统一添加 tenant_id 与组合索引(tenant_id, biz_key...)
2.2 上下文载体(线程本地)
// TenantContext.java
public class TenantContext {
private static final ThreadLocal<Long> TL = new ThreadLocal<>();
public static void set(Long tenantId) { TL.set(tenantId); }
public static Long get() { return TL.get(); }
public static void clear() { TL.remove(); }
}
2.3 Web 入口解析(Filter)
@Component
public class TenantContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
try {
String host = req.getServerName();
Long fromDomain = parseTenantFromDomain(host); // 自定义解析
String header = req.getHeader("X-Tenant");
Long tenant = fromDomain != null ? fromDomain : (header != null ? Long.valueOf(header) : parseFromJwt(req));
if (tenant != null) TenantContext.set(tenant);
chain.doFilter(req, res);
} finally { TenantContext.clear(); }
}
}
2.4 MyBatis-Plus 租户插件配置
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
Long tid = Optional.ofNullable(TenantContext.get()).orElse(0L);
return new LongValue(tid);
}
@Override
public String getTenantIdColumn() { return "tenant_id"; }
@Override
public boolean ignoreTable(String tableName) {
return Set.of("sys_dict", "tenant").contains(tableName); // 白名单:不参与租户隔离
}
}));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
2.5 自动填充与幂等
@Component
public class TenantMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
Long tid = Optional.ofNullable(TenantContext.get()).orElse(0L);
this.strictInsertFill(metaObject, "tenantId", Long.class, tid);
}
@Override
public void updateFill(MetaObject metaObject) { /* 可选 */ }
}
@Data
@TableName("order")
public class OrderEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId; // 与列 tenant_id 对应
private String orderNo;
private Integer status;
@Version private Integer version;
@TableLogic private Integer deleted;
}
幂等:写请求携带
requestId,使用去重表/Redis Set 以租户粒度去重:dedup:{tenantId}:{requestId}。
2.6 Feign & MQ 透传
@Configuration
public class FeignTenantInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
Long tid = TenantContext.get();
if (tid != null) template.header("X-Tenant", String.valueOf(tid));
}
}
MQ 发送时在 Header 附带
X-Tenant;消费端拿到后先TenantContext.set()再执行业务。
2.7 Gateway 统一透传(可选)
@Component
public class TenantGatewayFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest req = exchange.getRequest();
String host = req.getHeaders().getFirst("Host");
String tenant = resolveTenant(host, req.getHeaders().getFirst("X-Tenant"));
ServerHttpRequest mutated = req.mutate().header("X-Tenant", tenant).build();
return chain.filter(exchange.mutate().request(mutated).build());
}
@Override public int getOrder() { return -100; }
}
2.8 常见坑位
-
联表/子查询:MP 对主表和子查询可注入租户条件,复杂 SQL 需手动补
AND t.tenant_id = ?。 -
分页 count:某些复杂 count SQL 需手写,避免被插件改写造成性能问题。
-
缓存:Key 加租户前缀,如
cache:{tenantId}:user:{id}。 -
定时任务:按租户分片执行,或在任务体内循环各租户。
-
日志/追踪:将
tenantId放入 MDC 与 Trace,日志格式如%X{tenantId}。
MDC.put("tenantId", String.valueOf(Optional.ofNullable(TenantContext.get()).orElse(0L)));
3. 库级方案:动态数据源路由
当大租户需要更强隔离/独立扩容时,迁移到独库。
3.1 路由设计
-
方案 A:
AbstractRoutingDataSource自定义路由。 -
方案 B:引入
dynamic-datasource-spring-boot-starter,按租户动态注册/获取数据源。 -
路由 Key=tenantId;数据源配置可从 Nacos/DB 获取并做 懒加载。
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() { return TenantContext.get(); }
}
public DataSource createDs(TenantConfig cfg){
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(cfg.getUrl());
ds.setUsername(cfg.getUser());
ds.setPassword(cfg.getPwd());
ds.setMaximumPoolSize(20);
return ds;
}
3.2 与 MP 的关系
-
库级 与 字段级 二选一:迁移到独库后可移除租户插件;如存在共享库与独库并存,可保留插件(独库仍写入自身
tenant_id),以降低迁移成本。
3.3 Schema 演进
-
使用 Flyway/Liquibase 管理 DDL;按租户批量迁移,失败回滚可追踪。
4. 权限与安全
-
RBAC 分层:
tenant -> role -> user -> permission,授权时强校验tenantId。 -
越权防护:Controller 层切面校验用户的
tenantId与请求中的一致;平台态接口默认关闭。 -
导出与审计:导出任务与对象存储路径均加租户前缀;操作日志带
tenantId便于稽核。
5. 运维与 SRE
-
租户开通:生成
tenantId→ 分配域名 → 初始化库/Schema 或共享库记录 → 发放管理员。 -
监控:按租户维度统计 QPS、RT、错误率、慢 SQL、连接池水位。
-
限流:Gateway/Sentinel 按
tenantId维度限流与熔断。 -
备份/归档:共享库按
tenant_id过滤;独库按库备份;定期校验恢复演练。 -
关停/迁移:数据导出、对账、回收域名/数据源。
6. 验收清单(上线前自查)
-
所有业务表有
tenant_id(或已独库)。 -
Feign/MQ 透传
X-Tenant,端到端链路可观测。 -
复杂联表 SQL 已补充
tenant_id条件并有用例覆盖。 -
缓存 Key、定时任务、导出路径均带租户前缀/分片。
-
超管能力关闭或内网白名单;审计日志包含
tenantId。
7. 复盘/汇报要点
-
两步走:共享库字段级先跑通 → 大租户独库化,成本与收益可量化。
-
稳定性:日志/追踪/指标均可按租户切片定位问题。
-
合规性:跨租户串读串写有强校验,导出/备份可按租户审计。
1万+

被折叠的 条评论
为什么被折叠?



