
简介这是一套面向中高级Java开发者的开源SaaS多租户云平台架构源码基于SpringCloud2023、Spring Cloud Alibaba2022、Oauth2.1、Mybatis-Plus与MySQL构建适合需要搭建企业级多租户系统、研究微服务权限认证与租户隔离方案的团队参考。压缩包共708个文件约10.22MB以581个Java源码为核心辅以46个XML配置、13个properties与7个yml环境文件、4个SQL脚本另有18张界面截图、10个FreeMarker模板及Vue前端资源覆盖后端服务、数据库与代码生成器各层。其中entity、controller、serviceImpl、mapper等模板配合crud、api、index等前端模板构成完整的低代码生成链路便于快速扩展业务模块。目前已有656人学习下载作者承诺BUG第一时间修复。读者可从中获取多租户数据隔离、OAuth2.1认证授权、微服务拆分与代码生成器的落地思路并借助SQL脚本与配置文件快速还原可运行环境。1. 开源 SAAS 多租户云平台架构从单租户到多租户中间隔着多少坑很多团队一开始做的是单租户系统每个客户一套独立部署数据库独立、代码独立、服务器独立。客户少的时候没问题客户一多运维成本直接爆炸——升级一次要跑几十台机器改一个 bug 要同步几十个环境。这时候就会想能不能做一套开源 SAAS 多租户云平台架构让所有客户共用一套基础设施但数据互相隔离这个方向能解决的核心问题是用一套代码、一套数据库或分库、一套运维体系支撑多个租户同时使用且租户之间数据不可见、配置可定制、资源可计量。适合谁适合正在从项目制交付转向产品化 SAAS 的团队或者想基于开源方案快速搭建多租户能力的平台开发者。最近 dify 社区版 1.10 多租户的讨论很热说明连 AI 应用平台都在往多租户方向走这个架构不是可选项是必选项。但多租户不是加个 tenant_id 字段就完事。数据隔离级别怎么选、租户上下文怎么透传、连接池怎么按租户路由、计费怎么按租户聚合——每一个点都能让系统在生产环境翻车。下面按实际落地路径拆开讲。2. 多租户数据隔离的三种模式选错了后期改不动2.1 独立数据库、共享数据库独立 Schema、共享 Schema 带 tenant_id多租户架构最底层的决策是数据隔离模式。常见做法有三种隔离模式隔离级别成本适用场景典型开源实现独立数据库最高最高金融、医疗等强合规每租户一个 DB 实例共享数据库独立 Schema中中中等规模 SAASPostgreSQL Schema共享 Schema 带 tenant_id最低最低大规模轻量 SAAS行级隔离选哪种取决于你的租户规模和合规要求。我一般会建议早期用共享 Schema 带 tenant_id快速验证租户超过 500 或出现合规需求时迁移到独立 Schema只有金融级客户才上独立数据库。注意不要一开始就上独立数据库运维复杂度会让你在租户不到 50 个的时候就崩溃。2.2 共享 Schema 模式下 tenant_id 的强制注入共享 Schema 最大的风险是某条 SQL 忘了带 tenant_id导致租户 A 看到租户 B 的数据。靠开发者自觉写 WHERE tenant_id ? 是不可靠的血泪经验告诉我一定会有漏网之鱼。可靠做法是在 ORM 层或数据库代理层强制注入。以 MyBatis-Plus 为例// MyBatis-Plus 多租户插件配置 Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 多租户插件自动在 SQL 中追加 tenant_id 条件 TenantLineInnerInterceptor tenantInterceptor new TenantLineInnerInterceptor(); tenantInterceptor.setTenantLineHandler(new TenantLineHandler() { Override public Expression getTenantId() { // 从 ThreadLocal 中获取当前请求的租户 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_dict, sys_config) .contains(tableName); } }); interceptor.addInnerInterceptor(tenantInterceptor); return interceptor; } }逻辑说明这个拦截器会在所有 SELECT / UPDATE / DELETE 语句中自动追加tenant_id xxx条件INSERT 时自动填充 tenant_id 字段。TenantContextHolder是一个 ThreadLocal 容器在请求入口处从 JWT 或 Header 中解析租户 ID 并存入。参数说明getTenantIdColumn()返回的字段名必须和所有业务表一致建议统一用tenant_id。ignoreTable()里列出的表不会被拦截适合全局字典、系统配置等。2.3 独立 Schema 模式的动态数据源路由当租户规模上来后共享 Schema 的查询性能会下降因为每张表的数据量是所有租户之和。这时候需要切到独立 Schema 模式每个租户一个 Schema通过动态数据源路由。// Spring 动态数据源路由基于 AbstractRoutingDataSource public class TenantRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { // 从上下文中获取当前租户对应的数据源 key return TenantContextHolder.getDataSourceKey(); } } // 租户数据源注册启动时或租户创建时动态加载 Component public class TenantDataSourceRegistrar { Autowired private DataSource defaultDataSource; private final MapObject, Object targetDataSources new ConcurrentHashMap(); public void addTenant(String tenantId, String jdbcUrl, String username, String password) { HikariDataSource ds new HikariDataSource(); ds.setJdbcUrl(jdbcUrl); ds.setUsername(username); ds.setPassword(password); ds.setMaximumPoolSize(10); // 每租户连接池上限 targetDataSources.put(tenantId, ds); TenantRoutingDataSource routing new TenantRoutingDataSource(); routing.setTargetDataSources(targetDataSources); routing.setDefaultTargetDataSource(defaultDataSource); routing.afterPropertiesSet(); } }逻辑说明AbstractRoutingDataSource是 Spring 提供的路由抽象每次获取连接时调用determineCurrentLookupKey()决定用哪个数据源。租户创建时动态注册新的 HikariCP 连接池。参数说明setMaximumPoolSize(10)需要根据租户数量和数据库最大连接数反推。假设数据库最大连接 500预留 50 给系统450 / 10 最多 45 个租户。超过后需要引入连接池代理或分库。3. 租户上下文透传从 HTTP 请求到异步线程的完整链路3.1 请求入口解析租户标识的三种方式租户标识从哪里来常见三种方式域名tenant1.example.com、请求头X-Tenant-Id、JWT 声明。生产环境一般组合使用域名用于前端路由JWT 用于后端鉴权。// 过滤器从请求中解析租户 ID 并存入 ThreadLocal public class TenantContextFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; String tenantId null; // 优先级 1从 JWT 中解析 String token req.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { tenantId JwtUtils.getTenantId(token.substring(7)); } // 优先级 2从请求头获取内部服务调用场景 if (tenantId null) { tenantId req.getHeader(X-Tenant-Id); } // 优先级 3从域名解析 if (tenantId null) { String host req.getServerName(); tenantId host.split(\\.)[0]; // tenant1.example.com - tenant1 } if (tenantId null) { throw new BizException(无法识别租户身份); } try { TenantContextHolder.setTenantId(tenantId); chain.doFilter(request, response); } finally { TenantContextHolder.clear(); // 必须清理防止线程复用污染 } } }逻辑说明按优先级依次尝试从 JWT、请求头、域名中提取租户 ID。JWT 最可靠因为经过签名验证域名方式适合前端路由但容易被伪造。参数说明TenantContextHolder.clear()放在 finally 里是必须的。Tomcat 线程池会复用线程如果不清理下一个请求可能拿到上一个租户的 ID这是最隐蔽的生产事故之一。3.2 异步线程和线程池中的租户上下文丢失问题ThreadLocal 在异步场景下会丢失。比如你用Async或者CompletableFuture处理异步任务子线程拿不到父线程的租户 ID。// 方案一TransmittableThreadLocal阿里 TTL 方案 // 替换普通 ThreadLocal支持线程池场景下的上下文传递 public class TenantContextHolder { private static final TransmittableThreadLocalString TENANT_ID new TransmittableThreadLocal(); public static void setTenantId(String id) { TENANT_ID.set(id); } public static String getTenantId() { return TENANT_ID.get(); } public static void clear() { TENANT_ID.remove(); } } // 使用 TTL 包装线程池 ExecutorService executor TtlExecutors.getTtlExecutorService( new ThreadPoolExecutor(4, 8, 60, TimeUnit.SECONDS, new LinkedBlockingQueue(100)) ); // 方案二手动传递在提交任务时捕获上下文 String tenantId TenantContextHolder.getTenantId(); CompletableFuture.runAsync(() - { TenantContextHolder.setTenantId(tenantId); try { // 业务逻辑 } finally { TenantContextHolder.clear(); } }, executor);逻辑说明TransmittableThreadLocal是阿里开源的 TTL 库它在线程池提交任务时会自动捕获父线程的 ThreadLocal 值并在子线程执行时恢复。方案二是手动传递适合不想引入额外依赖的场景。参数说明TTL 需要配合TtlExecutors包装线程池才生效直接 new ThreadPoolExecutor 是不行的。另外注意TTL 会增加一定的性能开销在高频短任务场景下需要压测验证。3.3 跨服务调用时租户 ID 的透传微服务架构下A 服务调用 B 服务租户 ID 必须通过 RPC 上下文传递。以 Spring Cloud OpenFeign 为例// Feign 拦截器自动将当前租户 ID 放入请求头 Configuration public class FeignTenantInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { String tenantId TenantContextHolder.getTenantId(); if (tenantId ! null) { template.header(X-Tenant-Id, tenantId); } } }逻辑说明Feign 拦截器在每次发起 HTTP 调用前执行从当前线程的 TenantContextHolder 中取出租户 ID放入请求头。下游服务的 TenantContextFilter 会自动解析这个头。参数说明如果使用 Dubbo 或 gRPC原理相同通过 Attachment 或 Metadata 传递。关键是上下游的 key 要统一建议定义为常量TenantConstants.TENANT_HEADER。4. 多租户下的资源计量与计费怎么按租户算清楚账4.1 计量维度设计API 调用、存储、计算资源SAAS 平台要收费就得能算清楚每个租户用了多少资源。常见计量维度计量维度采集方式采集频率存储方案API 调用次数网关拦截计数实时Redis 原子递增存储用量定时扫描统计每小时时序数据库计算资源容器监控指标每分钟Prometheus并发连接数连接池监控实时内存 定期落库API 调用计数是最基础的一般在网关层做。用 Redis 的 INCR 命令按tenant:{id}:api:{date}为 key 计数每天凌晨归档到数据库。// 网关层 API 调用计数 Component public class ApiMeterFilter implements GlobalFilter, Ordered { Autowired private StringRedisTemplate redisTemplate; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String tenantId exchange.getRequest().getHeaders() .getFirst(X-Tenant-Id); if (tenantId ! null) { String key meter: tenantId :api: LocalDate.now().format(DateTimeFormatter.ISO_DATE); // 原子递增设置 48 小时过期 redisTemplate.opsForValue().increment(key); redisTemplate.expire(key, 48, TimeUnit.HOURS); } return chain.filter(exchange); } Override public int getOrder() { return -100; } // 优先级最高 }逻辑说明在网关过滤器中拦截所有请求按租户 ID 和日期维度计数。Redis 的 INCR 是原子操作不用担心并发问题。参数说明过期时间设 48 小时是为了留出归档窗口。如果 Redis 挂了计数会丢失所以关键计费场景建议用 Redis Kafka 双写Kafka 做持久化补偿。4.2 配额限制与限流防止单租户拖垮整个平台多租户平台最怕的一件事某个租户突然流量暴涨把数据库连接池占满其他租户全部不可用。所以必须有租户级别的限流和配额。// 基于 Redis 的租户级滑动窗口限流 public class TenantRateLimiter { Autowired private StringRedisTemplate redisTemplate; /** * param tenantId 租户 ID * param maxRequests 窗口内最大请求数 * param windowSeconds 窗口大小秒 * return true 表示允许通过 */ public boolean tryAcquire(String tenantId, int maxRequests, int windowSeconds) { String key rate: tenantId; long now System.currentTimeMillis(); long windowStart now - windowSeconds * 1000L; // 移除窗口外的记录 redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart); // 当前窗口内请求数 Long count redisTemplate.opsForZSet().zCard(key); if (count ! null count maxRequests) { return false; // 超限 } // 添加当前请求 redisTemplate.opsForZSet().add(key, String.valueOf(now), now); redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS); return true; } }逻辑说明滑动窗口算法用 Redis 的 ZSet 存储请求时间戳。每次请求先清理过期记录再判断当前窗口内数量是否超限。参数说明maxRequests和windowSeconds应该做成租户可配置的不同套餐不同配额。免费版可能 100 次/分钟企业版 10000 次/分钟。超限后返回 429 状态码并在响应头中带上X-RateLimit-Remaining和X-RateLimit-Reset。4.3 计费数据聚合与账单生成计量数据采集后需要按周期聚合生成账单。常见做法是每天凌晨跑一个定时任务把前一天的计量数据汇总到账单表。-- 账单聚合 SQL按租户按天汇总 INSERT INTO tenant_billing (tenant_id, billing_date, api_calls, storage_mb, amount) SELECT tenant_id, DATE(created_at) AS billing_date, COUNT(*) AS api_calls, SUM(request_size) / 1048576 AS storage_mb, COUNT(*) * 0.001 SUM(request_size) / 1048576 * 0.01 AS amount FROM api_access_log WHERE created_at CURRENT_DATE - INTERVAL 1 day AND created_at CURRENT_DATE GROUP BY tenant_id, DATE(created_at) ON CONFLICT (tenant_id, billing_date) DO UPDATE SET api_calls EXCLUDED.api_calls, storage_mb EXCLUDED.storage_mb, amount EXCLUDED.amount;逻辑说明从访问日志表中按租户和日期聚合计算调用次数和存储用量按单价算出金额。ON CONFLICT DO UPDATE保证重复执行不会产生重复账单。参数说明单价0.001和0.01是示例值实际应该从租户的套餐配置中读取。账单生成后需要有一个审核状态不能直接对用户可见防止计量异常导致错误扣费。5. 多租户架构避坑指南5 个生产环境真实翻车记录5.1 坑一缓存 key 没带租户前缀租户 A 看到租户 B 的数据现象某租户反馈看到了其他公司的订单列表排查发现是 Redis 缓存 key 冲突。原因缓存 key 设计为order:list:{page}没有加租户前缀。租户 A 请求后缓存了数据租户 B 请求相同分页时直接命中缓存。解决所有缓存 key 强制加租户前缀格式统一为{tenantId}:{module}:{key}。在 RedisTemplate 层面做一层封装自动拼接租户前缀禁止业务代码直接操作原始 key。5.2 坑二异步任务丢失租户上下文数据写到了默认租户现象定时任务生成的报表全部归属到了default租户其他租户看不到自己的报表。原因定时任务通过Scheduled触发运行在独立线程中ThreadLocal 中没有租户 ID代码取了默认值。解决定时任务不要依赖 ThreadLocal 获取租户 ID而是显式遍历所有租户逐个设置上下文后执行。或者用 TTL 包装定时任务线程池但更推荐显式遍历因为定时任务本身就需要按租户维度处理。5.3 坑三数据库连接池按租户分配租户多了连接耗尽现象平台运行三个月后新增租户时频繁报Connection timeout。原因每个租户独立 Schema 独立连接池每个池最少 5 个连接。租户到 80 个时80 × 5 400 个连接加上系统预留数据库最大连接数 500 被打满。解决改用共享连接池 Schema 切换方案或者引入 PgBouncer 做连接池代理。另一个思路是设置连接池的minimumIdle0按需创建连接但会增加首次请求延迟。5.4 坑四租户删除后数据没清理存储成本持续上涨现象财务发现数据库存储费用每月递增但活跃租户数没变。原因租户注销后只标记了状态为deleted实际数据和文件都没删除。日积月累废弃数据占了 40% 存储。解决实现租户数据生命周期管理注销后进入 30 天冷静期到期后异步清理所有相关数据。清理任务要记录日志支持审计。文件存储用租户 ID 做目录隔离删除时直接删目录。5.5 坑五跨租户查询没加权限校验越权访问现象安全审计发现通过修改请求中的租户 ID可以查询到其他租户的敏感数据。原因部分管理接口只校验了用户登录态没有校验用户是否属于目标租户。比如/api/admin/tenant/{tenantId}/users接口任何登录用户都能访问。解决在权限拦截器中增加租户归属校验确保当前用户的 tenantId 与路径参数中的 tenantId 一致除非用户是平台超级管理员。这个校验要放在框架层统一处理不能靠每个接口自己写。6. 多租户架构的进阶技巧租户级灰度发布与数据迁移6.1 按租户维度的灰度发布平台升级时不可能一次性全量发布。按租户灰度是更安全的做法先让内部测试租户用新版本再逐步放量到 10%、50%、100% 的租户。实现思路是在网关层做路由根据租户 ID 的哈希值决定走新版本还是旧版本的服务实例。Kubernetes 环境下可以用 Istio 的 VirtualService 做流量切分但更轻量的做法是在应用层做。// 网关灰度路由根据租户 ID 决定转发到新版本还是旧版本 Component public class GrayReleaseFilter implements GlobalFilter, Ordered { // 灰度租户白名单实际应从配置中心动态获取 Autowired private GrayReleaseConfig grayConfig; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String tenantId exchange.getRequest().getHeaders() .getFirst(X-Tenant-Id); if (tenantId ! null grayConfig.isGrayTenant(tenantId)) { // 灰度租户添加版本标记路由到新版本实例 exchange.getRequest().mutate() .header(X-Service-Version, v2); } return chain.filter(exchange); } Override public int getOrder() { return -50; } }逻辑说明网关根据租户 ID 判断是否在灰度名单中如果是则添加版本标记头后续的服务发现组件根据这个头路由到对应版本。参数说明灰度名单应该存在配置中心如 Nacos、Apollo支持动态修改不需要重启网关。灰度比例可以从 1% 开始观察错误率和延迟指标后再逐步扩大。6.2 租户数据迁移从共享 Schema 到独立 Schema当租户规模增长后大租户需要从共享 Schema 迁移到独立 Schema。这个过程不能停机需要在线迁移。迁移步骤创建目标 Schema建好表结构开启双写新数据同时写入源 Schema 和目标 Schema全量迁移历史数据分批将源 Schema 中该租户的数据复制到目标 Schema数据校验对比源和目标的数据量和关键字段切换读流量将读请求切到目标 Schema关闭双写清理源数据# 使用 pg_dump 按租户导出数据PostgreSQL 示例 pg_dump -h source_host -U user -d shared_db \ --tableorders --wheretenant_idtenant_001 \ --data-only --formatcsv tenant_001_orders.csv # 导入到独立 Schema psql -h target_host -U user -d tenant_001_db \ -c \COPY orders FROM tenant_001_orders.csv CSV HEADER逻辑说明先用pg_dump按租户条件导出数据为 CSV再导入到目标库。实际生产中建议用 CDC 工具如 Debezium做实时同步避免全量导出时的数据不一致。参数说明--where条件必须精确到租户--data-only表示只导数据不导表结构。迁移过程中要监控源库和目标库的数据量差异差异为 0 时才能切换。6.3 一个我踩过的坑迁移时忘了序列和索引第一次做租户迁移时数据导过去了但自增序列没同步导致新插入的数据主键冲突。索引也没重建查询性能下降了 10 倍。后来我的习惯是迁移脚本里必须包含序列重置和索引重建。序列用setval重置到当前最大值索引在数据导入后统一创建比导入时逐条维护索引快得多。-- 迁移后重置序列 SELECT setval(orders_id_seq, (SELECT MAX(id) FROM orders)); -- 数据导入完成后再建索引 CREATE INDEX CONCURRENTLY idx_orders_tenant_created ON orders (tenant_id, created_at DESC);CONCURRENTLY关键字让索引创建不锁表适合在线迁移场景。但注意它不能在事务中执行需要单独提交。这套架构从选型到落地最深的体会是多租户的复杂度不在功能实现而在边界情况的处理。租户上下文丢失、缓存串数据、连接池耗尽——这些问题在测试环境几乎不会出现只有生产环境跑到一定规模才会暴露。所以我的习惯是每加一个租户相关的功能先问自己三个问题上下文会不会丢数据会不会串资源会不会被单租户打满想清楚这三个能避开大部分坑。希望帮到你。本文还有配套的精品资源点击获取