简介这是一套面向中高级Java开发者与架构师的开源SaaS多租户云平台工程源码基于SpringCloud2023、Spring Cloud Alibaba2022、Oauth2.1、Mybatis-Plus与MySQL构建可用于学习多租户隔离、微服务拆分、统一认证授权等企业级架构设计也适合作为二次开发与项目脚手架。压缩包共708个文件约10.22MB其中581个Java源码构成核心业务与框架模块46个XML与7个YML负责依赖及服务配置13个properties与4个SQL脚本支撑运行参数和建表初始化另有10个ftl模板与前端资源文件覆盖代码生成与页面渲染链路。资源已有656人学习下载作者承诺BUG第一时间修复社区反馈相对及时。读者可获得一套结构完整、可直接运行的多租户SaaS后端骨架理解Oauth2.1认证流程、微服务注册调用与Mybatis-Plus数据层封装并借助代码生成模板快速产出CRUD模块适合在真实项目中参考落地或作为毕业设计、技术选型的实践样本。1. 开源 SAAS 撞上多租户为什么你的云平台一上量就崩很多团队做开源 SAAS 的第一天脑子里想的是功能列表用户注册、套餐订阅、后台管理。等到第二个企业客户进来才发现数据库里全是tenant_id满天飞一个慢查询拖垮整个平台客户 A 的报表任务把客户 B 的接口响应时间从 80ms 拉到 3 秒。这不是代码写得烂是多租户云平台架构这件事从数据隔离模型选型那一刻就埋下了分水岭。我见过太多项目在单租户模式下跑得飞快一旦切到多租户问题集中爆发连接池被某个租户的长事务占满、缓存 key 忘了带租户前缀导致数据串号、定时任务扫全表把 IO 打满。这些坑不是靠加机器能解决的得从架构层面把租户当成一等公民来设计。这篇笔记面向正在做或准备做开源 SAAS 的工程师从隔离模型选型、元数据设计、请求链路透传、到资源配额和避坑排查把一套能落地的多租户云平台架构拆开讲。读完你能判断自己的业务该选哪种隔离级别能照着代码把租户上下文跑通也能在出问题时知道先看哪里。2. 多租户隔离模型选型三种方案的成本与边界2.1 独立数据库、共享数据库独立 Schema、共享表带租户字段多租户架构绕不开的第一个决策就是数据怎么放。业界主流三种模型没有绝对优劣只有匹配度。独立数据库每个租户一个物理库。隔离性最强备份恢复按租户粒度操作某个租户的慢查询不会影响别人。代价是连接数和运维成本随租户数线性增长几百个租户就是几百个库迁移和 DDL 变更变成噩梦。适合租户数量少、客单价高、合规要求严的场景比如金融或医疗 SAAS。共享数据库独立 Schema一个库实例下每个租户一个 Schema。隔离性中等DDL 可以按 Schema 批量执行连接数比独立库好控制。但跨租户统计需要聚合多个 Schema而且部分数据库对 Schema 数量有上限。适合租户数在几十到几百、需要一定隔离但不想运维爆炸的场景。共享表带租户字段所有租户数据在一张表里用tenant_id区分。资源利用率最高运维最简单扩容方便。风险是隔离性最弱一个漏写WHERE tenant_id ?的查询就可能泄露数据而且大租户的数据量会拖慢整张表。适合租户数量多、单租户数据量小、追求快速迭代的场景。维度独立数据库独立 Schema共享表隔离强度高中低单租户成本高中低运维复杂度高中低跨租户统计难较难易适合租户规模少中多我一般的做法是早期用共享表快速验证当出现第一个对隔离有硬性要求的大客户时把那个租户迁到独立库形成混合模式。不要一上来就追求完美隔离那会让你在还没找到 PMF 之前就被运维压垮。2.2 用租户上下文把隔离逻辑从业务代码里抽出来选完模型接下来最关键的是别让每个业务方法都手动传tenant_id。常见做法是用 ThreadLocal 或请求作用域存租户上下文在入口处解析并注入在数据访问层自动拼接条件。下面是一个基于 Spring Boot 拦截器的租户上下文实现核心思路是从请求头或 JWT 中提取租户标识存入 ThreadLocal请求结束时清理。public class TenantContext { private static final ThreadLocalString CURRENT_TENANT new ThreadLocal(); public static void setTenantId(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getTenantId() { return CURRENT_TENANT.get(); } public static void clear() { CURRENT_TENANT.remove(); } } public class TenantInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 从 JWT 或请求头中解析租户标识常见做法是放在 X-Tenant-Id String tenantId request.getHeader(X-Tenant-Id); if (tenantId null || tenantId.isEmpty()) { throw new IllegalArgumentException(缺少租户标识); } TenantContext.setTenantId(tenantId); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 必须清理否则线程池复用时会串租户 TenantContext.clear(); } }逻辑说明拦截器在请求进入时解析租户标识并绑定到当前线程业务代码通过TenantContext.getTenantId()获取不需要层层传参。afterCompletion里的清理是必须的Tomcat 线程池会复用线程不清理就会导致下一个请求读到上一个租户的上下文这是最隐蔽的串号 bug 之一。参数说明X-Tenant-Id这个头名称可以换成你系统里的实际字段关键是保证网关层已经校验过该租户的合法性不能让客户端随便传一个租户 ID 就能访问别人数据。生产环境建议把租户 ID 编码进 JWT 的 claim 里由网关验签后透传。2.3 MyBatis 拦截器自动拼接租户条件有了上下文下一步是让 SQL 自动带上租户过滤。手写WHERE tenant_id ?迟早会漏用 MyBatis 拦截器在 SQL 解析阶段自动注入是更可靠的做法。Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class TenantSqlInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler (StatementHandler) invocation.getTarget(); BoundSql boundSql handler.getBoundSql(); String originalSql boundSql.getSql(); String tenantId TenantContext.getTenantId(); // 没有租户上下文时放行比如健康检查或全局配置查询 if (tenantId null || originalSql.contains(tenant_id)) { return invocation.proceed(); } // 简单示例在 WHERE 后追加条件生产环境建议用 JSqlParser 解析 String newSql originalSql.replaceFirst( (?i)where, WHERE tenant_id tenantId AND ); // 没有 WHERE 的语句在末尾追加 if (!originalSql.toLowerCase().contains(where)) { newSql originalSql WHERE tenant_id tenantId ; } // 通过反射改写 SQL Field field boundSql.getClass().getDeclaredField(sql); field.setAccessible(true); field.set(boundSql, newSql); return invocation.proceed(); } }逻辑说明拦截器在 SQL 执行前拿到原始语句检查是否已有租户条件没有就注入。这里用字符串替换只是演示生产环境必须用 JSqlParser 这类 SQL 解析器处理子查询、JOIN、UNION 等复杂情况否则很容易拼出语法错误的 SQL。参数说明originalSql.contains(tenant_id)这个判断是为了避免重复注入但要注意如果业务 SQL 里恰好有同名字段会误判。更稳妥的方式是维护一张需要租户隔离的表清单只对清单内的表注入条件。另外租户 ID 直接拼进 SQL 有注入风险实际应该用参数绑定这里为了演示简化了。3. 租户元数据与请求链路从注册到路由的完整闭环3.1 租户注册时该存哪些元数据多租户平台需要一个租户注册中心记录每个租户的基本信息、隔离模式、资源配额和状态。这张表是整个平台的枢纽设计好坏直接影响后续扩展。常见字段包括租户 ID全局唯一建议用雪花算法生成、租户名称、隔离模式独立库/独立 Schema/共享表、数据库连接信息独立库时使用、Schema 名称、套餐等级、资源配额最大用户数、最大存储、QPS 上限、状态激活/暂停/欠费、创建时间。CREATE TABLE tenant_registry ( tenant_id VARCHAR(64) PRIMARY KEY, tenant_name VARCHAR(128) NOT NULL, isolation_mode VARCHAR(16) NOT NULL DEFAULT SHARED, db_url VARCHAR(512), schema_name VARCHAR(64), plan_level VARCHAR(32) NOT NULL DEFAULT BASIC, max_users INT DEFAULT 10, max_storage_mb INT DEFAULT 1024, qps_limit INT DEFAULT 100, status VARCHAR(16) NOT NULL DEFAULT ACTIVE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );逻辑说明isolation_mode决定后续数据源路由策略db_url和schema_name只在非共享模式下使用。qps_limit和max_storage_mb是资源配额的基础配合限流组件使用。参数说明tenant_id用 VARCHAR 而不是自增 INT是为了支持分布式生成和避免租户数量泄露。status字段用于控制租户生命周期暂停状态的租户应该在网关层就被拦截不要等到数据层才报错。3.2 动态数据源路由让请求找到正确的库当平台同时存在共享表和独立库两种模式的租户时需要在数据访问层做动态路由。Spring 的AbstractRoutingDataSource是常见选择。public class TenantRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { String tenantId TenantContext.getTenantId(); if (tenantId null) { return default; } // 从租户注册中心查询隔离模式决定路由到哪个数据源 TenantInfo info TenantRegistryCache.get(tenantId); if (DEDICATED.equals(info.getIsolationMode())) { return tenant_ tenantId; } return shared; } }逻辑说明determineCurrentLookupKey返回的 key 对应targetDataSources里配置的数据源。共享租户走shared数据源独立库租户走各自的tenant_xxx数据源。租户信息用本地缓存避免每次请求都查库。参数说明TenantRegistryCache建议用 Caffeine 做本地缓存设置合理的过期时间租户变更时主动失效。数据源切换只对当前线程生效所以必须在请求线程内完成异步任务需要手动传递租户上下文。3.3 异步任务和消息队列里的租户透传请求链路里的租户上下文好办麻烦的是异步场景。线程池执行任务时 ThreadLocal 不会自动传递消息队列消费时更是完全脱离原始请求。常见做法是封装一个TenantAwareTaskDecorator在任务提交时捕获当前租户执行前恢复执行后清理。public class TenantAwareTaskDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { String tenantId TenantContext.getTenantId(); return () - { try { TenantContext.setTenantId(tenantId); runnable.run(); } finally { TenantContext.clear(); } }; } } // 配置线程池时挂上装饰器 Bean(tenantAwareExecutor) public Executor tenantAwareExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(32); executor.setQueueCapacity(256); executor.setTaskDecorator(new TenantAwareTaskDecorator()); executor.initialize(); return executor; }逻辑说明decorate方法在任务提交时执行此时还在原始请求线程里能拿到正确的租户上下文。返回的 Runnable 在目标线程执行时先设置租户执行完清理保证线程池复用时不串号。参数说明线程池参数按实际负载调整关键是setTaskDecorator这一行不能漏。消息队列场景需要在消息体里显式带上tenantId消费端解析后手动设置上下文不能依赖 ThreadLocal 自动传递。4. 资源配额与限流别让一个大租户拖垮整个平台4.1 按租户维度做 QPS 和并发限制共享模式下一个租户的突发流量可能占满整个平台的连接池和线程资源。必须在网关或应用层做租户级限流。常见做法是用 Redis 加滑动窗口或令牌桶key 带上租户 ID。下面是一个基于 Redis 的简单令牌桶实现思路。public boolean tryAcquire(String tenantId, int qpsLimit) { String key rate_limit: tenantId : System.currentTimeMillis() / 1000; Long current redisTemplate.opsForValue().increment(key); if (current 1) { // 第一次访问设置过期时间窗口为 1 秒 redisTemplate.expire(key, 1, TimeUnit.SECONDS); } return current qpsLimit; }逻辑说明用秒级时间戳做 key 后缀每个窗口独立计数increment保证原子性。超过配额直接拒绝返回 429。参数说明qpsLimit从租户注册中心的qps_limit字段读取可以按套餐等级差异化配置。这个实现是固定窗口临界点会有两倍突刺对精度要求高的场景换成 Lua 脚本实现的滑动窗口或令牌桶。Redis 本身要做高可用限流组件挂了不能影响主流程建议降级为放行并告警。4.2 存储配额和慢查询隔离除了 QPS存储用量也需要按租户统计和限制。常见做法是在文件上传和数据库写入前检查配额超限时拒绝并提示升级套餐。慢查询隔离更棘手。共享表模式下一个大租户的复杂查询会消耗大量 IO 和 CPU。除了加索引和优化 SQL还可以给租户设置查询超时超时后自动 kill。MySQL 可以通过max_execution_time提示或代理层实现PostgreSQL 用statement_timeout。-- MySQL 8.0 在会话级别设置查询超时 SET SESSION max_execution_time 5000; -- PostgreSQL 设置语句超时 SET statement_timeout 5s;逻辑说明在获取数据库连接后、执行 SQL 前设置超时避免单个慢查询长时间占用资源。连接归还时重置防止影响后续请求。参数说明超时时间按业务类型区分报表类可以放宽到 30 秒API 类控制在 3 到 5 秒。超时后要有降级策略比如返回缓存数据或提示稍后重试不能直接抛异常给用户。4.3 租户级别的监控指标没有监控的多租户平台就是黑匣子。至少需要采集每个租户的请求量、错误率、P95 延迟、数据库连接数、存储用量。这些指标按租户维度打标签出问题时能快速定位是哪个租户在捣乱。常见做法是在拦截器里记录请求耗时用 Micrometer 或 Prometheus 客户端上报标签带上tenant_id。告警规则按租户配置比如某租户错误率超过 5% 持续 1 分钟就通知。5. 多租户架构避坑与排查那些让你半夜起来修数据的坑5.1 缓存 key 漏带租户前缀导致数据串号现象客户 A 登录后看到了客户 B 的列表数据刷新后又恢复正常偶发且难以复现。原因缓存 key 设计时只用了业务 ID比如user:profile:123没有加租户前缀。当两个租户恰好有相同业务 ID 时后写入的覆盖先写入的读取时拿到别人的数据。解决所有缓存 key 强制加租户前缀封装统一的CacheKeyBuilder禁止业务代码手拼 key。代码审查时把缓存 key 作为检查项发现裸 key 直接打回。5.2 线程池复用导致租户上下文串号现象异步任务执行时操作了错误租户的数据日志里租户 ID 和预期不符。原因ThreadLocal 没有在任务执行后清理或者线程池提交任务时没有传递上下文。Tomcat 线程池和自定义线程池都会复用线程上一个任务的租户 ID 残留到下一个任务。解决所有 ThreadLocal 操作必须在finally里清理。异步任务用TaskDecorator显式传递上下文。消息队列消费端从消息体解析租户 ID不依赖 ThreadLocal。5.3 数据库连接池被单租户长事务占满现象平台整体响应变慢大量请求超时日志显示获取数据库连接超时。原因某个租户触发了长事务或慢查询占用了连接池里大量连接其他租户的请求拿不到连接。解决设置查询超时超时自动 kill。连接池按租户设置最大连接数上限防止单租户占满。监控连接池活跃连接数超过阈值告警。大租户考虑迁到独立库。5.4 定时任务扫全表忽略租户维度现象凌晨定时任务执行后部分租户数据被错误更新或统计报表数字对不上。原因定时任务在共享表上执行UPDATE或SELECT时没有带租户条件影响了所有租户的数据。解决定时任务必须遍历租户列表逐个租户执行每次执行前设置租户上下文。禁止在共享表上执行无租户条件的批量操作。任务代码里加断言检测到无租户上下文时直接抛异常。5.5 租户删除时数据残留和资源泄漏现象租户注销后数据库里还有该租户的数据缓存没清理文件存储还在计费。原因删除逻辑只标记了租户状态没有级联清理数据、缓存和文件。独立库模式下数据库实例没有释放。解决设计租户生命周期管理删除时按顺序清理先停服务再清缓存再删数据或归档最后释放数据库和存储资源。删除操作要幂等支持重试。保留操作日志便于审计和恢复。6. 从共享表到混合隔离一个租户迁移的实操技巧当平台跑了一段时间总会遇到第一个要求独立部署的大客户。这时候不可能把整个平台重构常见做法是把该租户从共享表平滑迁移到独立库形成混合隔离模式。这个过程有几个关键点。第一步是数据导出。从共享表里按tenant_id导出该租户的所有数据注意外键依赖顺序先导主表再导从表。用mysqldump加--where参数可以按条件导出。mysqldump -h shared-db -u root -p \ --wheretenant_idT10086 \ --no-create-info \ saas_db orders order_items users \ tenant_T10086_data.sql逻辑说明--where指定租户条件--no-create-info只导数据不导表结构因为独立库的表结构从模板库初始化。多个表按依赖顺序列出避免导入时外键报错。参数说明T10086替换为实际租户 ID。大表导出时加--single-transaction避免锁表。导出后校验行数和校验和确保数据完整。第二步是在独立库初始化表结构导入数据然后切换路由。切换时先把租户状态改为MIGRATING网关层对该租户返回维护提示等数据同步完成后改为DEDICATED路由自动指向新库。第三步是验证。对比迁移前后关键业务接口的返回结果跑一遍该租户的核心流程。确认无误后清理共享表里的旧数据但建议保留一个备份周期再删。这个方案我做过几次最深的教训是迁移前一定要在预发环境完整演练一遍包括回滚流程。生产环境的数据量和依赖关系往往比预想复杂直接上手容易翻车。另外迁移窗口尽量选在业务低峰期提前通知客户别让业务方在迁移过程中写入数据。希望帮到你。本文还有配套的精品资源点击获取