ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

开源SaaS多租户云平台架构:基于SpringCloud2023与OAuth2.1的落地实践

开源SaaS多租户云平台架构:基于SpringCloud2023与OAuth2.1的落地实践 简介这是一套面向中高级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 ThreadLocalString 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 MonoVoid 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.ftlController暴露 REST 接口serviceImpl.java.ftlService 实现业务逻辑骨架mapper.xml.ftlMapper XML自定义 SQLresource.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 MapString, String columnMapping new HashMap(); private ListString 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 的表确认查不到第三步跑一遍全局表的查询确认没被加租户条件。这三步走完基本能覆盖租户隔离的主要路径。从那以后我每次接多租户项目都会先把租户字段的配置化做掉再开始写业务。硬编码字段名看着省事等表一多、字段名一乱返工的成本远高于一开始多写这几十行配置。希望帮到你。本文还有配套的精品资源点击获取
返回列表