Java SaaS多租户架构实战:JWT识别+MP自动隔离

发布时间:2026/10/9 8:09:48
Java SaaS多租户架构实战:JWT识别+MP自动隔离 简介本资源是一份面向中高级软件架构师、云原生开发者及SaaS平台技术负责人的专业架构设计指南系统梳理SaaS系统从理论建模到工程落地的核心方法论。文档完整覆盖SaaS成熟度四级模型定制开发→可配置→高性能多租户→可伸缩多租户、RUP“41”视图场景/逻辑/开发/过程/物理视图的架构表达规范、MDA模型驱动开发实践路径并深入剖析多租户数据隔离三大方案独立库/共享库隔离Schema/共享库共享Schema的选型依据与安全边界。同时详述系统级HTTPS/SSL/Token加密/备份与程序级权限控制/XSS/SQL注入防护/验证码双重安全设计要点以及数据库索引优化、缓存策略、日志分级行为/数据/安全与加密算法权衡等性能调优实操建议。资源为单个PDF文件大小967KB内容结构清晰、图文结合已获197人学习下载是理解SaaS架构本质、规避常见设计陷阱、构建高可用多租户系统的实用参考材料。1. SaaS 架构设计不是画张图就完事它决定你三年后还能不能快速上线新租户、扛住流量洪峰、不被数据库锁死“SaaS架构设计.pdf”这个标题背后藏着一堆正在深夜改库表、被租户数据隔离漏洞吓醒、在压测时发现连接池崩了才想起没做连接隔离的工程师。这不是一份讲微服务拆分或 Spring Boot 启动参数的文档——它是多租户系统落地前必须过的一道生死线你选的租户模型Shared DB / Shared Schema / Isolated DB直接决定后续 80% 的开发成本HTTPS 不只是加个证书而是影响租户域名绑定、API 网关路由、甚至审计日志字段是否可信Java 技术栈里 MyBatis-Plus 自动生成建表 SQL 的能力一旦没和租户上下文联动就会在上线第二天生成一堆同名但归属混乱的表。本文面向已用 Spring Boot MyBatis-Plus 搭出 MVP 的团队聚焦真实战场如何让一个 Java SaaS 系统从“能跑”走向“稳跑、快跑、安全跑”。不讲理论空话只拆三件事租户识别怎么嵌进请求链路、数据隔离怎么不靠人肉写 WHERE、HTTPS 下租户级域名与 API 路由怎么解耦。2. 租户识别从 URL 到 Header再到 JWT Payload哪条路最稳SaaS 系统第一道门是“你是谁家的租户”。常见做法有三种子域名tenant1.example.com、路径前缀example.com/tenant1/api、请求头X-Tenant-ID: tenant1。选错一种后期改造成本翻倍。我们团队踩过所有坑最终锁定JWT Payload 请求头兜底的组合方案——既兼容前端直连又规避子域名 SSL 证书管理地狱。2.1 为什么放弃子域名方案证书、DNS、CDN 全是雷区子域名看似直观saas-customer1.myapp.com但实际落地时每新增租户需申请独立 SSL 证书Let’s Encrypt 有速率限制商业证书要钱要流程DNS 解析需自动化脚本对接云厂商 API稍有延迟就导致租户访问失败CDN 缓存策略需按 Host 头区分否则 A 租户缓存可能被 B 租户命中提示若你已用 Nginx 做反向代理且租户数 50子域名仍可短期用但务必把证书续期脚本写进 CI/CD 流水线否则凌晨三点被告警叫醒是常态。2.2 路径前缀方案简单但破坏 RESTful 设计且网关层易漏处理路径前缀/t/{tenantId}/v1/users优点是 HTTPS 统一、证书零成本。但问题在于所有 Controller 接口必须显式声明RequestMapping(/t/{tenantId}/v1)MyBatis XML 中的 SQL 也得手动拼WHERE tenant_id #{tenantId}极易遗漏OpenAPI 文档无法自动生成租户上下文Swagger UI 测试时需人工填 tenantId网关如 Spring Cloud Gateway若未配置Path Route Predicate过滤恶意请求可绕过租户校验我们最终弃用此方案因它把租户逻辑散落在 Controller、Service、Mapper 三层违反单一职责。2.3 最终方案JWT Payload 内置 tenant_id X-Tenant-ID 头兜底我们在登录成功后签发 JWTPayload 固定包含{ sub: user123, tenant_id: tenant_abc_789, exp: 1735689600, iat: 1735603200 }Spring Security 配置全局OncePerRequestFilter提取并注入TenantContext// TenantContext.java public class TenantContext { private static final ThreadLocalString currentTenant new ThreadLocal(); public static void setTenantId(String tenantId) { currentTenant.set(tenantId); } public static String getTenantId() { return currentTenant.get(); } public static void clear() { currentTenant.remove(); } }// JwtTenantFilter.java Component public class JwtTenantFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null jwtUtil.validateToken(token)) { String tenantId jwtUtil.getTenantIdFromToken(token); // 从 JWT Payload 解析 TenantContext.setTenantId(tenantId); } else { // 兜底检查 X-Tenant-ID 头用于 Postman 测试、内部服务调用 String headerTenant request.getHeader(X-Tenant-ID); if (StringUtils.isNotBlank(headerTenant)) { TenantContext.setTenantId(headerTenant); } } try { filterChain.doFilter(request, response); } finally { TenantContext.clear(); // 必须清空避免线程复用污染 } } }关键点说明TenantContext.clear()是血泪经验Tomcat 默认用线程池复用线程若不清空A 用户请求残留的 tenant_id 可能被 B 用户继承X-Tenant-ID头不用于生产用户请求防止伪造仅用于测试和内部 RPC生产环境强制走 JWTJWT 签发时tenant_id必须来自数据库查出的合法租户记录不可信任前端传入值3. 数据隔离MyBatis-Plus 自动注入 tenant_id不改一行业务 SQL租户识别只是开始真正的硬仗是让每条 SQL 自动带上 tenant_id 条件且不侵入业务代码。我们拒绝在每个 Mapper XML 里手写AND tenant_id #{tenantId}也不接受 Service 层手动拼 WHERE——这等于把安全责任甩给每个开发者迟早翻车。3.1 MyBatis-Plus 多租户插件原理SQL 重写拦截器MyBatis-Plus 提供TenantLineInnerInterceptor其核心是在 SQL 解析阶段StatementHandler.prepare()之前自动注入WHERE tenant_id ?。它不修改 Mapper XML也不要求 DAO 方法加参数纯框架级拦截。启用方式Spring Boot 2.7Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 多租户拦截器必须放在最前面确保其他插件如分页看到的是已加租户条件的 SQL interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { Override public Expression getTenantId() { // 返回 tenant_id 字段的值这里从 TenantContext 获取 String tenantId TenantContext.getTenantId(); if (StringUtils.isBlank(tenantId)) { throw new RuntimeException(Tenant context is empty. Check JWT or X-Tenant-ID header.); } return new LongValue(Long.parseLong(tenantId.replace(tenant_, ))); } Override public Field getTenantIdColumn() { // 指定所有表中租户字段名统一为 tenant_id return new Column(tenant_id); } Override public boolean ignoreTable(String tableName) { // 白名单哪些表不加租户条件通常是 sys_user、sys_tenant 等系统表 return Arrays.asList(sys_tenant, sys_user, sys_dict).contains(tableName); } })); return interceptor; } }参数说明getTenantId()必须返回Expression类型这里用LongValue匹配BIGINT类型的tenant_id字段若你的 tenant_id 是字符串如 UUID改用StringValuegetTenantIdColumn()所有业务表必须有该字段命名必须一致否则拦截失效ignoreTable()系统表如租户元数据表必须忽略否则查租户列表时会误加WHERE tenant_id ?导致查不到数据3.2 关键验证INSERT/UPDATE/DELETE 也自动带 tenant_id 吗是的。TenantLineInnerInterceptor对INSERT、UPDATE、DELETE、SELECT全部生效。例如原始 Mapper 方法Select(SELECT * FROM user WHERE status 1) ListUser selectActiveUsers();拦截后实际执行 SQLSELECT * FROM user WHERE status 1 AND tenant_id 12345原始 INSERTInsert(INSERT INTO user(name, email) VALUES(#{name}, #{email})) int insertUser(User user);拦截后INSERT INTO user(name, email, tenant_id) VALUES(?, ?, ?)注意INSERT语句中tenant_id字段必须存在于实体类User中且TableField(fill FieldFill.INSERT)注解必须配置否则 MP 不会自动填充。这是唯一需要改实体类的地方。TableField(fill FieldFill.INSERT) private Long tenantId; // 实体类中必须有此字段3.3 为什么不用 ShardingSphere 的多租户它太重且和 MP 生态有冲突ShardingSphere 的TenantLineManager功能更全支持分库分表但引入后需额外部署 ShardingProxy 或集成到应用内增加运维复杂度与 MyBatis-Plus 的PaginationInnerInterceptor存在 SQL 解析冲突常出现分页失效租户字段类型必须严格匹配如VARCHARvsBIGINT而 MP 插件对类型宽容度更高我们团队实测纯租户隔离场景下MP 内置插件性能损耗 3%ShardingSphere 增加 GC 压力约 15%且调试链路变长。除非你明确需要分库分表否则别过早引入。4. HTTPS 与租户域名Nginx Spring Cloud Gateway 双层路由怎么配才不丢 tenant_idHTTPS 不是终点而是租户路由的起点。当客户要用自己的域名customer-a.com访问你的 SaaS且要求 HTTPS就必须解决SSL 终止在哪层租户标识如何从域名传递到业务层4.1 方案对比SSL 终止在 Nginx vs 在 Gateway维度SSL 终止在 NginxSSL 终止在 Spring Cloud Gateway性能✅ 更高Nginx 处理 TLS 更高效❌ Gateway JVM 处理 TLS 增加 CPU 和 GC 压力租户识别⚠️ 需通过proxy_set_header X-Tenant-Domain $host透传域名✅ Gateway 可直接读request.getServerName()证书管理✅ 单点管理支持 ACME 自动续期❌ 每个 Gateway 实例需单独配置证书集群同步难故障面✅ Nginx 故障只影响接入层❌ Gateway 故障导致整个服务不可用我们选择SSL 终止在 Nginx因为租户数增长后Gateway 的 TLS 处理瓶颈比想象中来得更快。4.2 Nginx 配置动态提取域名并透传租户 ID假设租户域名格式为{tenantCode}.saas-company.com如acme.saas-company.comNginx 配置如下# /etc/nginx/conf.d/saas.conf upstream saas_backend { server 10.0.1.10:8080; # Spring Boot 应用 server 10.0.1.11:8080; } server { listen 443 ssl http2; server_name ~^(?tenant.)\.saas-company\.com$; # 正则捕获 tenantCode ssl_certificate /etc/letsencrypt/live/saas-company.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/saas-company.com/privkey.pem; location / { proxy_pass http://saas_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 关键将正则捕获的 tenantCode 作为 X-Tenant-ID 透传 proxy_set_header X-Tenant-ID $tenant; # 若租户用根域名saas-company.com则 fallback 到默认租户 if ($tenant ) { set $tenant default; proxy_set_header X-Tenant-ID $tenant; } } }关键点说明server_name ~^(?tenant.)\.saas-company\.com$使用命名捕获组$tenant即为子域名部分proxy_set_header X-Tenant-ID $tenant将租户标识透传到后端供JwtTenantFilter兜底使用if ($tenant )处理根域名访问saas-company.com分配默认租户避免空指针4.3 Spring Cloud Gateway 路由配置不依赖域名专注 API 聚合Gateway 不再处理 SSL只做 API 聚合与限流# application.yml spring: cloud: gateway: routes: - id: saas-api uri: lb://saas-backend predicates: - Path/api/** filters: - StripPrefix1 - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 100 redis-rate-limiter.burstCapacity: 200此时X-Tenant-ID已由 Nginx 注入Gateway 无需解析域名大幅降低复杂度。我们曾试过让 Gateway 解析Host头并查库映射租户结果在 2000 QPS 下 CPU 暴涨至 95%原因就是每次请求都触发一次 Redis 查询。透传 header 是更轻量、更可靠的方案。5. 避坑指南那些让 SaaS 上线后集体翻车的 4 个隐蔽陷阱SaaS 架构设计中最危险的不是大问题而是那些看起来“应该没问题”的小细节。以下是我们在三个项目中踩过的真坑每一条都附带线上故障现象、根本原因和修复动作。5.1 现象租户 A 的数据偶尔出现在租户 B 的列表里原因TenantContext.setTenantId()被调用两次第二次覆盖第一次且clear()未执行排查过程日志发现同一请求中TenantContext.getTenantId()返回了两个不同值检查过滤器链发现自定义LoggingFilter也在doFilter中调用了TenantContext.setTenantId()且未clear()修复所有自定义 Filter 必须遵循try-finally { TenantContext.clear() }模式在TenantContext的setTenantId()中加断言if (currentTenant.get() ! null) throw new IllegalStateException(TenantContext already set);5.2 现象MyBatis-Plus 分页查询返回总数错误count(*) 不带 tenant_id原因PaginationInnerInterceptor插件顺序在TenantLineInnerInterceptor之后导致 count SQL 未加租户条件排查过程开启mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl观察生成的 count SQL发现SELECT COUNT(*) FROM user无WHERE tenant_id ?修复在MybatisPlusInterceptor.addInnerInterceptor()中必须先 addTenantLineInnerInterceptor再 addPaginationInnerInterceptor官方文档未强调顺序但源码中插件按添加顺序执行顺序错则 count 失效5.3 现象HTTPS 下 WebSocket 连接失败报错ERR_SSL_PROTOCOL_ERROR原因Nginx 未配置 WebSocket 升级头且proxy_pass指向 HTTP 地址而非 HTTPS排查过程Chrome DevTools Network 标签页显示 WebSocket 请求状态为(failed)查 Nginx error.log发现upstream sent no valid HTTP/1.0 or HTTP/1.1 header修复Nginx 配置中必须添加 WebSocket 支持头proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade;proxy_pass必须用http://非https://因 SSL 已在 Nginx 终止后端 Spring Boot 用 HTTP 即可5.4 现象租户切换后MyBatis-Plus 的二级缓存返回旧租户数据原因MP 默认二级缓存 key 未包含 tenant_id导致user:123缓存被多个租户共享排查过程租户 A 查询用户 123缓存命中租户 B 查询用户 123同 ID 但不同 tenant_id返回租户 A 的数据修复关闭全局二级缓存mybatis-plus.configuration.cache-enabledfalse或自定义 CacheKey继承CacheKey并在update()方法中加入TenantContext.getTenantId()我们选择前者因租户数据天然高隔离性共享缓存收益低、风险高6. 进阶技巧用 MyBatis-Plus 代码生成器一键生成带租户字段的建表 SQL 与实体类手工写建表 SQL 和实体类是 SaaS 项目初期最耗时的重复劳动。MyBatis-Plus 的AutoGenerator可以根据数据库表结构自动生成含tenant_id字段的实体、Mapper、Service且自动注入TableField(fill FieldFill.INSERT)彻底消灭手误。6.1 生成器核心配置强制所有表加 tenant_id 字段// CodeGenerator.java public class CodeGenerator { public static void main(String[] args) { AutoGenerator generator new AutoGenerator(); // 数据源配置 DataSourceConfig dataSourceConfig new DataSourceConfig() .setUrl(jdbc:mysql://localhost:3306/saas_db?useSSLfalseserverTimezoneGMT%2B8) .setUsername(root) .setPassword(123456) .setDriverName(com.mysql.cj.jdbc.Driver); generator.setDataSource(dataSourceConfig); // 全局配置 GlobalConfig globalConfig new GlobalConfig() .setOutputDir(System.getProperty(user.dir) /src/main/java) .setAuthor(dev-team) .setOpen(false) .setSwagger2(true); generator.setGlobalConfig(globalConfig); // 包配置 PackageConfig packageConfig new PackageConfig() .setParent(com.example.saas) .setModuleName(business); generator.setPackageInfo(packageConfig); // 策略配置 StrategyConfig strategyConfig new StrategyConfig() .setNaming(NamingStrategy.underline_to_camel) .setColumnNaming(NamingStrategy.underline_to_camel) .setEntityLombokModel(true) .setRestControllerStyle(true) // 关键指定所有表都添加 tenant_id 字段并自动填充 .setTableFillList(Arrays.asList( new TableFill(tenant_id, FieldFill.INSERT), new TableFill(create_time, FieldFill.INSERT), new TableFill(update_time, FieldFill.UPDATE) )) // 关键排除系统表不生成它们的代码 .setInclude(user, order, product) // 只生成业务表 .setExclude(sys_tenant, sys_user); // 系统表不生成 generator.setStrategy(strategyConfig); // 执行 generator.execute(); } }生成效果实体类User.java自动包含TableField(fill FieldFill.INSERT) private Long tenantId;Mapper XML 中insert标签自动包含tenant_id字段insert idinsert parameterTypecom.example.saas.business.entity.User INSERT INTO user (name, email, tenant_id, create_time) VALUES (#{name}, #{email}, #{tenantId}, #{createTime}) /insertService 层save()方法调用时tenantId由 MP 自动从TenantContext注入无需业务代码赋值6.2 如何让生成器适配多租户建表 SQLMyBatis-Plus 本身不生成建表 SQL但可结合SchemaExport或 Liquibase。我们采用Liquibase 自定义 ChangeLog方式创建changelog-master.xml?xml version1.0 encodingUTF-8? databaseChangeLog xmlnshttp://www.liquibase.org/xml/ns/dbchangelog xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.2.xsd changeSet id1 authordev createTable tableNameuser column nameid typeBIGINT autoIncrementtrue constraints primaryKeytrue nullablefalse/ /column column namename typeVARCHAR(50)/ column nameemail typeVARCHAR(100)/ !-- 关键所有表必须有 tenant_id -- column nametenant_id typeBIGINT constraints nullablefalse/ /column column namecreate_time typeDATETIME/ column nameupdate_time typeDATETIME/ /createTable /changeSet /databaseChangeLog启动时自动执行spring: liquibase: enabled: true change-log: classpath:db/changelog-master.xml好处所有租户共用同一套 DDL避免因手动建表导致字段缺失tenant_id字段在 DDL 层强制存在数据库级约束比应用层更可靠Liquibase 的diffChangeLog可对比环境差异确保测试/生产库结构一致我带过的三个 SaaS 项目上线前最耗时的环节从来不是功能开发而是租户数据隔离的验证。后来我们固化了一套 checklist每个接口用 Postman 换 3 个不同X-Tenant-ID跑一遍看数据是否完全隔离查看 MySQL general_log确认每条 SQL 都带tenant_id ?用 JMeter 模拟 100 并发监控TenantContext.getTenantId()是否有 null 值强制关闭二级缓存上线两周后再评估是否开启这些不是银弹但能让你少熬 3 个通宵。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询