☰
开源SAAS多租户云平台架构实战:数据隔离、上下文透传与计费避坑指南
2026/10/1 11:37:03 网站建设 项目流程

简介:这是一套面向中高级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 Map<Object, 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 TransmittableThreadLocal<String> 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 Mono<Void> 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 做连接池代理。另一个思路是设置连接池的minimumIdle=0,按需创建连接,但会增加首次请求延迟。

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 Mono<Void> 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。这个过程不能停机,需要在线迁移。

迁移步骤:

  1. 创建目标 Schema,建好表结构
  2. 开启双写:新数据同时写入源 Schema 和目标 Schema
  3. 全量迁移历史数据:分批将源 Schema 中该租户的数据复制到目标 Schema
  4. 数据校验:对比源和目标的数据量和关键字段
  5. 切换读流量:将读请求切到目标 Schema
  6. 关闭双写,清理源数据
# 使用 pg_dump 按租户导出数据(PostgreSQL 示例) pg_dump -h source_host -U user -d shared_db \ --table=orders --where="tenant_id='tenant_001'" \ --data-only --format=csv > 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关键字让索引创建不锁表,适合在线迁移场景。但注意它不能在事务中执行,需要单独提交。

这套架构从选型到落地,最深的体会是:多租户的复杂度不在功能实现,而在边界情况的处理。租户上下文丢失、缓存串数据、连接池耗尽——这些问题在测试环境几乎不会出现,只有生产环境跑到一定规模才会暴露。所以我的习惯是,每加一个租户相关的功能,先问自己三个问题:上下文会不会丢?数据会不会串?资源会不会被单租户打满?想清楚这三个,能避开大部分坑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询