简介:这是一套面向中高级Java开发者与架构师的开源SaaS多租户云平台工程源码,基于SpringCloud2023、Spring Cloud Alibaba2022、Oauth2.1、Mybatis-Plus与MySQL构建,可用于学习多租户隔离、微服务拆分与统一认证授权等企业级场景,也适合作为二次开发脚手架。压缩包共708个文件,约10.22MB,以581个Java源码为主体,辅以46个XML配置、13个properties与7个yml环境文件、4个SQL初始化脚本,另有png界面截图、ftl代码模板、md说明文档及html、css等前端资源,覆盖后端服务、数据层与页面模板的完整结构。目前已有656人学习下载。读者可从中获取多租户SaaS的目录组织方式、Oauth2.1认证链路、Mybatis-Plus数据访问与代码生成模板等实践参考,并借助作者持续修复BUG的维护节奏,快速理解微服务云平台的落地思路与排错方向。
1. 一套能跑通的多租户脚手架,到底省掉了哪些脏活
如果你接过那种“从零搭一套 SaaS 后台”的活,大概率经历过这样的开局:先纠结租户字段怎么设计,再纠结数据隔离用共享表还是独立库,接着 OAuth2.1 的授权码流程调半天,最后前端 CRUD 页面还得一个个手写。这套开源 SAAS 多租户云平台架构,本质上是把这些脏活提前干完了——它基于 SpringCloud2023、Spring Cloud Alibaba2022、Mybatis-Plus、Oauth2.1 和 MySQL,把多租户隔离、认证授权、代码生成这几块最容易翻车的地方做成了可复用的骨架。它适合两类人:一类是要快速交付一套带租户体系的后台系统,不想在基础设施上耗时间的团队;另一类是正在研究多租户架构怎么落地,想拿一份能跑起来的参考实现对照着看的工程师。下面我按“它是什么、怎么跑起来、坑在哪、怎么改”的顺序拆一遍。
2. 多租户隔离的三种落法:为什么这套选了共享表加租户字段
2.1 三种隔离方案的成本对比
多租户系统最核心的决策就是数据怎么隔离。常见做法有三类:独立数据库、共享数据库独立 Schema、共享数据库共享表加租户字段。独立库隔离最彻底,但租户一多,连接池和运维成本直接爆炸;独立 Schema 居中,但跨租户统计和迁移都麻烦;共享表加租户字段是成本和隔离性折中后的主流选择,也是这套架构采用的方案。
| 方案 | 隔离级别 | 运维成本 | 跨租户统计 | 适用规模 |
|---|---|---|---|---|
| 独立数据库 | 最高 | 高 | 困难 | 大客户定制 |
| 独立 Schema | 中 | 中 | 较难 | 中型 SaaS |
| 共享表加租户字段 | 低 | 低 | 容易 | 中小型 SaaS |
选共享表方案,意味着每条业务数据都要带一个租户标识,所有查询都必须自动拼上这个条件。手写 SQL 时漏掉一次,就是一次数据越权。这套架构用 Mybatis-Plus 的租户插件把这个条件做成了自动注入,业务代码里基本不用关心租户过滤。
2.2 租户字段是怎么自动拼进 SQL 的
Mybatis-Plus 提供了TenantLineInnerInterceptor,配合一个TenantLineHandler实现类,就能在 SQL 解析阶段自动给查询、更新、删除语句加上租户条件。核心配置大概长这样:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 多租户插件,必须放在分页插件之前 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { @Override public Expression getTenantId() { // 从当前请求上下文取租户ID,通常由网关或过滤器写入 String tenantId = TenantContextHolder.getTenantId(); return new StringValue(tenantId); } @Override public String getTenantIdColumn() { return "tenant_id"; } @Override public boolean ignoreTable(String tableName) { // 租户表、字典表等全局表不参与租户过滤 return Arrays.asList("sys_tenant", "sys_dict").contains(tableName); } })); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }逻辑说明:getTenantId()返回当前请求的租户标识,这个值一般由网关解析 JWT 后透传,或者由过滤器从请求头取出写入ThreadLocal。getTenantIdColumn()指定数据库里的租户字段名,这里统一叫tenant_id。ignoreTable是关键,系统级的租户管理表、全局字典表不能加租户条件,否则租户自己都查不到自己的配置。
参数说明:TenantContextHolder需要自己实现,内部用ThreadLocal存租户 ID,请求结束时记得清理,否则线程池复用会导致租户串号。ignoreTable的名单要随业务扩展维护,新增全局表时忘了加进去,查询就会莫名少数据。
2.3 租户上下文怎么在请求链路里传递
租户 ID 从登录那一刻就确定了。OAuth2.1 的授权码流程走完后,令牌里会带上租户标识,网关校验令牌时把它取出来放进请求头,下游服务再用过滤器写入ThreadLocal。这套链路里最容易断的地方是异步调用和定时任务——ThreadLocal不会自动传递。
public class TenantContextHolder { private static final ThreadLocal<String> TENANT = new TransmittableThreadLocal<>(); public static void setTenantId(String tenantId) { TENANT.set(tenantId); } public static String getTenantId() { return TENANT.get(); } public static void clear() { TENANT.remove(); } }这里用TransmittableThreadLocal而不是普通ThreadLocal,是为了在线程池场景下能把租户上下文传给子线程。如果项目里用了@Async或者线程池做异步任务,普通ThreadLocal会丢租户,导致异步查询报租户为空或者查到全量数据。常见做法是在过滤器里set,在finally里clear,异步任务提交前手动捕获当前租户再传入。
3. OAuth2.1 认证授权链路:从登录到接口鉴权的完整走法
3.1 授权码模式在前后端分离下的落地
OAuth2.1 相比 2.0 最大的变化是废弃了隐式授权和密码模式,主推授权码加 PKCE。这套架构的前端是 Vue,后端是 Spring Cloud,登录流程走的是标准授权码模式。用户点登录,前端跳到认证服务的授权端点,认证服务返回授权码,前端拿授权码换令牌,之后所有业务请求带令牌访问网关,网关校验后转发。
spring: security: oauth2: authorizationserver: client: saas-client: registration: client-id: saas-web client-secret: "{noop}secret" client-authentication-methods: - client_secret_basic authorization-grant-types: - authorization_code - refresh_token redirect-uris: - "http://localhost:8080/login/oauth2/code/saas-web" scopes: - read - write逻辑说明:client-id和client-secret是前端应用的凭证,authorization-grant-types只开授权码和刷新令牌,符合 OAuth2.1 的推荐配置。redirect-uris必须和前端实际回调地址完全一致,多一个斜杠都会报invalid_redirect_uri。
参数说明:client-secret前面的{noop}表示不加密,生产环境要换成{bcrypt}并配置加密器。scopes按业务需要开,不要图省事给all,令牌权限过大一旦泄露影响面很广。
3.2 网关怎么做令牌校验和租户透传
网关是认证和业务的分界点。令牌校验通过后,网关要把用户信息和租户 ID 从令牌里解出来,塞进请求头传给下游。下游服务不再重复校验令牌,只信任网关传来的头。
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (token == null || !token.startsWith("Bearer ")) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 解析JWT,取出租户ID和用户ID Claims claims = JwtUtils.parse(token.substring(7)); ServerHttpRequest request = exchange.getRequest().mutate() .header("X-Tenant-Id", claims.get("tenantId", String.class)) .header("X-User-Id", claims.get("userId", String.class)) .build(); return chain.filter(exchange.mutate().request(request).build()); } @Override public int getOrder() { return -100; } }逻辑说明:过滤器从Authorization头取令牌,解析 JWT 拿到租户和用户信息,重新构造请求把信息放进自定义头。下游服务的过滤器读这些头写入ThreadLocal,业务代码就能直接取。
参数说明:getOrder()返回-100是为了让这个过滤器尽早执行,排在路由转发之前。X-Tenant-Id和X-User-Id是自定义头,要确保外部请求不能伪造——网关要先把外部传入的同名头清掉再写自己的,否则别人直接带个头就冒充租户了。
3.3 下游服务怎么接住租户信息
下游服务不需要再解析令牌,只需要一个过滤器把网关注入的头写进上下文。
@Component public class TenantFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; String tenantId = req.getHeader("X-Tenant-Id"); try { if (tenantId != null) { TenantContextHolder.setTenantId(tenantId); } chain.doFilter(request, response); } finally { // 必须清理,否则线程复用会串租户 TenantContextHolder.clear(); } } }逻辑说明:过滤器从请求头取租户 ID 写入上下文,请求结束后在finally里清理。这个clear是血泪经验,漏掉的话线程池里的线程会带着上一个租户的 ID 去处理下一个请求,数据直接串。
参数说明:过滤器注册时要确保对所有业务路径生效,/actuator之类的监控端点可以排除。如果服务间还有内部调用,内部调用也要带上租户头,否则下游拿不到租户。
4. 代码生成器怎么用:从建表到出前后端代码
4.1 模板文件对应哪些产物
项目正文里列出的那串文件,其实是代码生成器的模板清单。entity.java.ftl生成实体类,controller.java.ftl生成控制器,serviceImpl.java.ftl生成服务实现,mapper.xml.ftl生成 Mybatis 映射文件,resource.sql.ftl生成建表语句,crud.ts.ftl和api.ts.ftl生成前端接口层,index.vue.ftl生成列表页,style.css和signin.css是登录页样式。理解这套模板的对应关系,改代码生成规则时才知道动哪个文件。
| 模板文件 | 生成产物 | 作用 |
|---|---|---|
| entity.java.ftl | 实体类 | 映射数据库表字段 |
| controller.java.ftl | Controller | 暴露 REST 接口 |
| serviceImpl.java.ftl | Service 实现 | 业务逻辑骨架 |
| mapper.xml.ftl | Mapper XML | 自定义 SQL |
| resource.sql.ftl | 建表 SQL | 初始化表结构 |
| crud.ts.ftl | 前端 CRUD 逻辑 | 增删改查请求封装 |
| api.ts.ftl | 前端 API 层 | 接口地址定义 |
| index.vue.ftl | 列表页组件 | 表格和表单页面 |
4.2 建表时租户字段不能漏
代码生成器读的是数据库表结构,所以建表时就要把租户字段设计进去。如果表建好了才发现漏了tenant_id,生成出来的实体和 SQL 都不带租户,后面补起来很麻烦。
CREATE TABLE `biz_order` ( `id` bigint NOT NULL COMMENT '主键', `tenant_id` varchar(32) NOT NULL COMMENT '租户ID', `order_no` varchar(64) NOT NULL COMMENT '订单号', `amount` decimal(12,2) DEFAULT NULL COMMENT '金额', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_tenant` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';逻辑说明:tenant_id设成varchar(32)是为了兼容字符串形式的租户标识,如果租户 ID 是自增数字也可以改成bigint。idx_tenant索引必须建,所有查询都会带租户条件,没索引全表扫。
参数说明:tenant_id设NOT NULL,避免出现租户为空的脏数据。字符集统一utf8mb4,不然遇到特殊字符会插入失败。
4.3 生成后要手动补的三处
代码生成器出的是骨架,有三处必须手动补。第一处是租户字段的自动填充,实体里的tenantId不应该由前端传入,要在插入时自动从上下文取。第二处是权限注解,生成的 Controller 方法默认没有@PreAuthorize,需要按角色补上。第三处是前端租户切换,如果支持一个用户属于多个租户,前端要有租户选择器,切换后重新获取令牌。
@TableField(fill = FieldFill.INSERT) private String tenantId; @Component public class TenantMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { // 插入时自动填租户ID,前端传了也不认 this.strictInsertFill(metaObject, "tenantId", String.class, TenantContextHolder.getTenantId()); } @Override public void updateFill(MetaObject metaObject) { // 更新不填租户,租户字段不允许改 } }逻辑说明:@TableField(fill = FieldFill.INSERT)标记插入时自动填充,TenantMetaObjectHandler从上下文取租户 ID 写入。这样即使前端恶意传了别的租户 ID,也会被覆盖掉。
参数说明:strictInsertFill只在字段为空时填充,如果业务上允许手动指定租户(比如管理员代操作),要换成setFieldValByName并加判断。更新时不填租户,防止租户字段被改。
5. 避坑与排查:多租户系统最容易翻车的五个地方
5.1 租户串号:异步任务里查到了别人的数据
现象:某个租户的报表里出现了其他租户的订单,排查发现是异步导出任务查的数据。
原因:异步任务跑在独立线程池里,ThreadLocal里的租户 ID 没传过去,TenantLineHandler取到空值,SQL 没加租户条件,查了全量。
解决:用TransmittableThreadLocal替换ThreadLocal,或者在提交异步任务前手动捕获租户 ID 作为参数传入,任务内部再set回去。定时任务同理,每个租户循环处理时要显式设置租户上下文。
5.2 全局表被加了租户条件导致查不到数据
现象:租户登录后加载字典失败,日志显示 SQL 带了tenant_id = 'xxx',但字典表里没有这个字段。
原因:字典表是全局表,不该参与租户过滤,但ignoreTable名单里漏了它。
解决:把所有全局表列进ignoreTable,包括租户表、字典表、系统配置表、菜单表。新增全局表时同步维护这个名单,最好在代码里用常量集合管理,别散落在各处。
5.3 令牌校验通过但接口 403
现象:登录成功拿到令牌,调业务接口返回 403,网关日志显示令牌有效。
原因:网关校验了令牌,但下游服务的权限注解没配对应的角色或权限标识,Spring Security 拦截了。
解决:检查@PreAuthorize里的权限字符串和令牌里的authorities是否匹配。OAuth2.1 的 scope 和 Spring Security 的权限是两套东西,scope 控制客户端能访问什么,权限控制用户能做什么,别混。
5.4 代码生成后前端接口 404
现象:后端接口用 Postman 能通,前端调就是 404。
原因:api.ts.ftl生成的接口路径和后端@RequestMapping不一致,常见的是多了或少了一层前缀。
解决:对比生成的api.ts里的baseURL和 Controller 的类级路径,确认网关路由的Path断言和实际路径匹配。前端开发环境还要检查代理配置有没有把请求转发到网关。
5.5 租户切换后旧令牌还能用
现象:用户从租户 A 切到租户 B,旧令牌没失效,还能查到 A 的数据。
原因:令牌里绑定了租户 ID,切换租户后旧令牌的租户信息没变,服务端也没做失效处理。
解决:切换租户时让前端丢弃旧令牌重新走授权流程,服务端可以把令牌加入黑名单或者用短过期时间加刷新令牌。如果业务允许一个令牌访问多个租户,那租户 ID 就不能放在令牌里,要改成每次请求显式传,但这样安全性会下降,需要权衡。
6. 把租户字段做成可配置:一个减少返工的改法
前面几章里租户字段一直叫tenant_id,但实际项目里这个字段名经常有历史包袱,有的表叫tenant_code,有的叫org_id。硬编码在TenantLineHandler里,遇到不一致的表就得改代码。我一般会把它做成配置项,按表名映射不同的租户字段。
saas: tenant: default-column: tenant_id column-mapping: biz_order: tenant_code sys_user: org_id ignore-tables: - sys_tenant - sys_dict - sys_config然后在TenantLineHandler里读这份配置:
@ConfigurationProperties(prefix = "saas.tenant") @Component public class TenantProperties { private String defaultColumn = "tenant_id"; private Map<String, String> columnMapping = new HashMap<>(); private List<String> ignoreTables = new ArrayList<>(); // getter/setter 省略 } public class ConfigurableTenantHandler implements TenantLineHandler { private final TenantProperties props; public ConfigurableTenantHandler(TenantProperties props) { this.props = props; } @Override public Expression getTenantId() { return new StringValue(TenantContextHolder.getTenantId()); } @Override public String getTenantIdColumn() { // 按当前表名取对应字段,取不到用默认值 String tableName = TenantTableContext.getCurrentTable(); return props.getColumnMapping().getOrDefault(tableName, props.getDefaultColumn()); } @Override public boolean ignoreTable(String tableName) { return props.getIgnoreTables().contains(tableName); } }逻辑说明:getTenantIdColumn()不再返回固定值,而是根据当前解析的表名从配置里取。TenantTableContext需要在 SQL 解析时把表名存进上下文,Mybatis-Plus 的插件机制里可以通过TableNameParser拿到。这样新增表时只改配置不改代码。
参数说明:default-column兜底,没配映射的表用它。column-mapping的 key 是表名,value 是租户字段名。ignore-tables是全局表名单,和前面ignoreTable逻辑一致。
验证这个改法有没有生效,我习惯用三步:第一步,开 Mybatis-Plus 的 SQL 日志,看生成的 SQL 里租户条件是不是按表名取了不同字段;第二步,造两个租户的数据,用租户 A 的令牌查租户 B 的表,确认查不到;第三步,跑一遍全局表的查询,确认没被加租户条件。这三步走完基本能覆盖租户隔离的主要路径。
从那以后我每次接多租户项目,都会先把租户字段的配置化做掉,再开始写业务。硬编码字段名看着省事,等表一多、字段名一乱,返工的成本远高于一开始多写这几十行配置。希望帮到你。
本文还有配套的精品资源,点击获取