news 2026/10/4 13:17:07

开源SaaS多租户架构:数据隔离到动态路由与K8s部署实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
开源SaaS多租户架构:数据隔离到动态路由与K8s部署实践

简介:这是一份面向中高级Java开发者的开源SAAS多租户云平台源码,基于SpringCloud2023与Spring Cloud Alibaba2022构建,集成Mysql、Mybatis-Plus及Oauth2.1认证,适合需要快速搭建或学习多租户架构的团队。资源共708个文件,压缩包约10.22MB,其中以581个Java核心业务代码为主,辅以XML配置、YML环境配置、数据库SQL脚本及Vue/FreeMarker前端模板,并含项目说明文档与导入配置,目录结构清晰便于二次开发。目前已有656人学习下载,作者承诺BUG第一时间修复,降低了实际应用中的维护风险。通过这份资料可以掌握企业级SAAS平台的权限隔离、租户数据隔离和微服务认证等关键实现,同时获得可直接改造使用的后台管理及代码生成模板,适合作为架构设计参考或项目基础脚手架。

1. 开源SaaS多租户云平台:为什么一套代码服务多个客户反而更难

你接手过一个开源SaaS项目,代码能跑,但每次签约新客户都要改数据库连接、改配置文件、重启服务,时间长了连自己都忘了哪个实例对应哪个客户。这就是没做多租户隔离的典型症状。开源SaaS多租户云平台架构,核心不是“把软件租出去”,而是用同一套实例服务多个租户,同时让每个租户的数据互不可见、资源可计量、版本可独立演进。它适合正在选型SaaS平台的创业者、要把单体CRM改造成多租户的团队,以及想基于开源项目二次开发的架构师。一个反直觉的结论是:多租户改造最难的不是业务功能,而是数据隔离级别。共享库还是独立库,决定了后续运维成本、扩容方式和合规边界,选错一步,后面几年都在给它打工。

2. 多租户数据隔离选型:共享库共享 Schema 与独立库独立 Schema 怎么挑

我在做开源SaaS架构时,第一件事不是画架构图,而是先回答“租户的数据放在哪里”。因为SaaS平台所有功能都建立在这条决定上,后边改起来最伤筋动骨的就是它。

2.1 三种隔离级别的成本、安全性和扩容边界对比

常见的数据隔离方案有三档:共享库共享Schema、共享库独立Schema、独立库(或独立实例)。下面这张表可以帮你快速对比。

隔离方式实现成本数据隔离性扩容边界适合场景
共享库 + 共享Schema低低,靠应用层保证单库连接和容量试运行、租户量大的长尾业务
共享库 + 独立Schema中中,逻辑隔离单库容量上限中型租户、需要SQL级隔离
独立库高高,物理隔离数据库实例数量大客户、金融医疗合规要求

共享库共享Schema的意思是所有租户的表混在同一个库同一套表里,用tenant_id区分。这是绝大多数开源SaaS的默认做法,因为成本最低、开发最省事。但代价是“防线只有应用层”,任何一次漏传租户ID都可能变成一次数据泄露。共享库独立Schema是同一个库下面为每个租户开一个schema,表结构可以按租户初始化,隔离性比行级好,但连接数还是共享的。独立库看起来最安全,每个租户一个数据库,互不干扰,但备份、迁移、连接池管理都会随租户数量线性膨胀。

我一般建议从“共享库 + tenant_id”起步。理由很直接:在租户规模低于几百个、单表数据量还没过千万时,独立库带来的运维压力远超它带来的安全感。独立库更适合那些合同里写了“数据必须物理隔离”的客户,或者一个租户的数据量已经能占到全平台一半的场景。

2.2 共享库 + tenant_id:最小可行方案的建表语句与查询改造

选定了共享库,第一步就是把所有业务表统一加上tenant_id。以订单表为例:

CREATE TABLE `saas_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `tenant_id` VARCHAR(64) NOT NULL, `order_no` VARCHAR(128) NOT NULL, `customer_name` VARCHAR(128) DEFAULT '', `amount` DECIMAL(12,2) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_tenant_order` (`tenant_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字段说明:tenant_id是逻辑外键,创建后要放进联合索引的第一列,让订单查询先按租户过滤数据页。order_no不需要全局唯一,只需要租户内唯一,所以别给它建全局唯一索引,否则会挡住多租户插入。如果业务上必须显示唯一单号,可以做成“租户ID+订单号”拼接出的业务唯一键。

建完表后,所有SQL都要强制带租户条件。下面是对比:

-- 错误:漏了 tenant_id,查询会看到别的租户的订单 SELECT * FROM saas_order WHERE order_no = 'SO123'; -- 正确:强制加上租户范围 SELECT * FROM saas_order WHERE tenant_id = 'tenant-a' AND order_no = 'SO123';

只改查询还不够,INSERT、UPDATE、DELETE语句同样要带。我见过不止一次“查询加了tenant_id但UPDATE忘加了”导致一批数据被跨租户覆盖。如果团队规模小,建议用MyBatis拦截器在SQL执行前把tenant_id拼到条件里;但拦截器匹配SQL有玄学,后面避坑章节细说。初期宁可花点时间把每个Mapper的参数都传上tenant_id,让代码里显式可见,排查起来才不折磨人。

2.3 独立库或独立 Schema:什么时候值得切换

共享库方案不是终点。当租户量增长后,会碰到两类信号:一是某个大租户的单表到了千万行,统计查询经常拖垮其他租户;二是合同要求数据必须以独立库交付。这时候可以切换到独立库或独立Schema。

常见做法是做一个租户到数据源的映射表,路由在应用层完成。切换过程我建议分四步:先给新租户创建独立库并跑一遍初始化脚本;然后把共享库里的存量数据按tenant_id导出、导入到新库;接着开启双写,写操作同时落到共享库和新库,用对账任务比较两边数据;最后切流量到新库,保留一个回滚开关。四步里最容易翻车的是双写阶段,两边事务不在同一个数据库,要自己做补偿,不然会出现一边成功一边失败的脏数据。

这里有个参数需要提前决定:独立库模式下,连接池是每个租户固定一个池,还是按需创建。固定池配置简单,但租户超过几十个后,数据库连接数会被池撑爆。按需创建则要处理空转回收,一般用连接池的管理接口动态创建DataSource并设置空闲时间,比如30秒没有请求就关闭。这个阈值太短会造成频繁建连,太长又会占用连接资源,我一般先从60秒起步,根据CPU和连接数曲线再调。

3. 用 Spring Boot 搭建多租户认证与动态数据源路由

多租户微服务架构里,第二个关键点不只是“登录认证”,而是让服务在处理每个请求时都知道“我是哪个租户”。如果租户上下文传递断了,后续所有数据源路由和数据过滤都会跟着失效。

3.1 租户身份解析:从 JWT 到 ThreadLocal 的上下文传递

常见做法是从JWT里解析租户ID,也可以通过自定义请求头传入。我推荐在网关或入口过滤器统一解析,业务代码里不要到处取Header,而是放到TenantContext里。

public class TenantContext { private static final ThreadLocal<String> 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(); } }

这个类很薄,但要注意两点:clear()必须用remove而不是set(null),否则Tomcat线程池复用线程时ThreadLocal里的值可能残留;另外ThreadLocal不是银弹,异步线程、定时任务里不会自动传递,后面会说怎么补。

在过滤器里解析请求头并设置上下文:

@Component public class TenantFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String tenantId = httpRequest.getHeader("X-Tenant-Id"); if (tenantId == null || tenantId.isBlank()) { tenantId = extractFromJwt(httpRequest); } TenantContext.setTenantId(tenantId); try { chain.doFilter(request, response); } finally { TenantContext.clear(); } } }

这里X-Tenant-Id是我们约定的租户头,由调用方传入;如果是外部开放接口,则从JWT里解析。要注意finally里的clear是必须的,不然一次请求里某段代码抛了异常,上下文在这个线程里就残留了,下一个请求复用同一个线程时就会串租户。

3.2 AbstractRoutingDataSource:按租户切换数据库连接的实现

如果选了独立库模式,数据源层要跟着租户走。Spring的AbstractRoutingDataSource天生就是干这个的。

public class TenantRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return TenantContext.getTenantId(); } }

在配置类里初始化这个路由数据源,把租户ID和数据源映射传进去:

@Bean public DataSource dataSource() { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("tenant-a", buildDataSource("jdbc:mysql://db-host-a/saas")); targetDataSources.put("tenant-b", buildDataSource("jdbc:mysql://db-host-b/saas")); TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource(); routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(buildDataSource("jdbc:mysql://db-main/saas")); return routingDataSource; }

参数说明:setTargetDataSources接受的是Map,key对应determineCurrentLookupKey返回的值;setDefaultTargetDataSource是兜底数据源,租户ID不在映射里时不要直接报错,而是落到默认库。动态数据源这里有个坑:连接池是在数据源初始化时建立的,如果新租户上线时才往Map里put,要调用routingDataSource.afterPropertiesSet()重新刷缓存,否则老租户的连接池里读不到新配置。

3.3 定时任务与消息队列:补上租户上下文才不会串数据

动态数据源解决了同步请求的路由,但定时任务和消息队列经常被忽略。一个报表任务如果直接执行,TenantContext里是空的,路由数据源拿不到key就会落默认库。正确姿势是按租户循环执行。

@Scheduled(cron = "0 0 2 * * ?") public void generateDailyReports() { List<String> tenantIds = tenantService.getAllActiveTenantIds(); for (String tenantId : tenantIds) { TenantContext.setTenantId(tenantId); try { reportService.generateDailyReport(tenantId); } finally { TenantContext.clear(); } } }

这段代码的关键是循环体内每次都要setTenantId,而不是循环外set一次。因为TaskScheduler的线程池复用线程,上一次循环结束时如果没有clear,下一次可能还在上一次的租户上下文里跑。消息队列也一样,消费者从消息里解析出tenant_id后,要显式把它放进TenantContext,再调用业务方法。不能在消费者里依赖ThreadLocal,因为消息可能来自不同租户。

4. 开源SaaS云平台部署:Kubernetes 上的租户隔离与可观测性

数据库和应用层都做完后,云平台部署不是简单把镜像推上去就行。开源SaaS云平台部署时,租户隔离要覆盖到计算资源、接入层和监控三个维度,否则大租户一个高峰流量就能让全平台跟着抖动。

4.1 用 Namespace 划分租户资源边界

如果每个租户独立部署一套服务,Kubernetes的Namespace是最自然的资源隔离单元。一个租户一个Namespace,再用ResourceQuota限制它能用的CPU和内存。

apiVersion: v1 kind: Namespace metadata: name: tenant-a --- apiVersion: v1 kind: ResourceQuota metadata: name: tenant-a-quota namespace: tenant-a spec: hard: requests.cpu: "4" requests.memory: 8Gi limits.cpu: "8" limits.memory: 16Gi persistentvolumeclaims: "10"

注意:ResourceQuota里的requests和limits是两回事。requests是pod调度时申请的最小资源,limits是运行时上限。如果只配limits不配requests,调度器会忽略租户真实需求,可能把多个大Pod挤到一个节点上。我一般两个都配,并且requests.memory不要超过limits.memory,否则直接创建失败。

但Namespace隔离只解决资源争抢,不解决应用层的数据隔离。即使两个租户在各自的Namespace里,如果业务代码的tenant_id传递做漏了,数据照样串。所以Kubernetes层往往是兜底,不是主防线。

4.2 Ingress 按域名分流到租户接入层

当每个租户有独立域名时,Ingress用host规则把流量转发到对应Namespace的服务。以tenant-a为例:

apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: tenant-a-ingress namespace: tenant-a spec: rules: - host: tenant-a.example.com http: paths: - path: / pathType: Prefix backend: service: name: app-service port: number: 8080

这里的关键是host必须与租户域名完全匹配,别用泛域名把所有租户指到同一个Service,那样就把前面的隔离又打回原形。如果同一个域名下要按路径区分租户,比如example.com/a/和example.com/b/,pathType用Prefix,但要注意后端服务必须能从路径里解析租户ID,否则还是空谈。

Ingress的TLS配置也建议按租户独立,每个租户有自己的证书,避免把证书混用。证书过期时只影响一个租户,不至于全网断。

4.3 用标签维度把监控和日志切成租户视角

部署完成后,运维上最容易忽视的是多租户可观测性。你只监控集群总体的CPU和内存,是看不出某个租户在拖垮全平台的。Prometheus的标签体系天然适合做租户维度。接入层在生成监控指标时,把tenant_id作为标签打进去。

groups: - name: tenant-rules rules: - alert: TenantHighLatency expr: histogram_quantile(0.9, rate(http_request_duration_seconds_bucket{tenant_id="tenant-a"}[5m])) > 1 for: 5m labels: severity: warning

日志同理,应用输出日志时统一带tenant_id字段。Loki查询用标签过滤,例如{namespace="tenant-a"}或|= "tenant_id=tenant-a"。前者是资源维度,后者是应用维度。我一般两个都会配,遇到“租户a投诉慢”的问题时,先在监控面板切到租户a的视图,再跟着日志里的trace_id捞请求链。

日志里没打租户ID的,建议在日志框架的pattern里加一个%X{tenantId},用MDC透传。这样代码里不需要每行日志都手写租户ID,排查时又不会漏。这是我在多租户云平台上最后悔没早点做的事。

5. 多租户架构避坑:租户串数据与资源打满的 5 个常见问题排查

多租户架构翻车多数不发生在架构设计时,而是发生在你自信满满上线之后。下面5个问题是我们在开源SaaS项目里最常见的,按危害程度排个序,每个都是现象、原因、解决三件套,可以直接对号入座。

5.1 租户 ID 漏传导致串租户

现象:租户A的订单列表里出现了租户B的订单,或者租户A看到了租户B的报表数据。这是多租户系统最严重的事故,一般会在上线后第一时间暴露。

原因:链路上某个环节没有传递租户ID。常见漏点有三个:前端没有把租户ID放进请求头;网关解析JWT后没有把租户ID放进透传头;业务服务在调用另一个服务时没把租户ID带过去。还有一种隐蔽情况:Feign调用时自定义RequestInterceptor没配置,导致下游拿不到头。

解决:我用三步走。第一步,在所有Mapper层的SQL里排查,凡是没有tenant_id条件的UPDATE/DELETE全部修掉。第二步,在网关透传租户ID后,在链路入口做断言:如果请求头或JWT里都没有租户ID,直接拒绝,不要放到默认租户。第三步,写一个“串数据检查”的自动化用例,建两个最小租户tenant-a和tenant-b,各插入一条标记数据,然后互相查询对方资源,测试脚本里一旦查到对方数据就让CI失败。这个用例从第一天就放在回归集里,后面每次改动都不敢碰坏它。

5.2 一个租户打满连接池

现象:某个大租户发起大量并发请求后,其他租户的接口开始爬满超时,连接池报“Connection is not available”异常。

原因:共享连接池时,所有租户用的是同一个池。大租户的流量占满了池里的连接,小租户的请求就得排队。这本质上是资源隔离没做到位,而不是连接池参数不对。

解决:短期调整连接池的最大连接数,但不要只调大,调大后数据库端同样有连接数上限。更靠得住的方案是给重要租户单独隔离连接池,或者直接用第2章说的独立库模式。如果暂时不打算分库,就在数据库层对租户做限流,比如按租户ID配置最大并发数,超出的请求快速失败返回429。快速失败比排队更符合SaaS的运维预期——至少小租户不会因为大租户的流量而活活等死。

5.3 跨租户分布式事务黑匣子

现象:一次操作涉及订单和库存两个服务,订单创建成功,库存扣减失败,订单和库存对不上。等你想重试时,库存已经回滚或数据错乱。

原因:跨服务的事务用的是分布式事务,如果用了最终一致性方案,每个分支事务的补偿回调不一定成功;如果没有给每个分支事务带上租户ID,回滚时可能把别的租户的数据也回滚了。分布式事务本身就是个黑匣子,日志不全时很难定位是哪一步导致整体不一致。

解决:我的做法是,跨租户写操作绝不放在一个强一致事务里,全部走“本地消息表 + 消息队列 + 重试”。消息体里除了业务数据,把tenant_id作为必填字段,消费者校验租户ID后再处理。补偿逻辑要幂等,用业务唯一键防止重复执行。最后,给每条分布式事务增加一个对账任务,业务低峰期扫描两边数据的不一致,并生成差异报表。别依赖开发者的记性,要让系统自己兜底。

5.4 定时任务跑完所有租户

现象:每天凌晨的报表任务,某一天突然把所有租户的数据统计成了一团,报表数据全错了。

原因:定时任务没有按租户设置上下文。常见的错误写法是任务开头设置一次tenantId,然后在循环里调用租户A、B、C的处理逻辑。由于线程复用,第二次循环时TenantContext可能还是租户A的值,租户B的处理逻辑就用租户A的数据源跑完了。

解决:回到第3章那种写法:for循环内每次setTenantId并在finally里clear。同时,报表任务最好改成按租户维度分片调度,比如用xxl-job的租户参数,每个分片只处理一个租户。定时任务出现数据错乱时,先看日志里的租户ID是不是发生了跳跃,如果是,十有八九是上下文泄漏。这个排查方法我们已经在生产环境验证过很多次。

5.5 升级开源项目时自定义字段被覆盖

现象:基于开源SaaS项目二次开发后,升级上游版本发现自定义字段和表结构没有了,或者升级后启动报列不存在。

原因:很多开源项目用Flyway或Liquibase做数据库迁移,日常开发中改过表结构就在项目中加了一个自定义迁移脚本。但版本升级时,上游的迁移脚本会先执行,把数据库结构变更到上游版本,跟你的自定义字段冲突;或者你的自定义迁移脚本没有放到正确的位置,被新版本跳过。

解决:把自定义改动全部隔离到独立的迁移目录。比如Flyway配置里,让上游的迁移脚本用默认位置db/migration,你的自定义脚本放在db/migration/custom并配置location包含它。升级前先备份数据库,在测试环境跑一遍完整升级,比对表结构,再决定是否需要新增兼容脚本。这个习惯能省下大把后悔药,也是我在开源项目上二次开发最推荐的版本管理方式。

6. 验证多租户没做漏:用埋点和压测脚本给租户隔离上保险

多租户系统上线后,验证隔离是否有效不能只靠眼睛看。我习惯给所有应用接入OpenTelemetry,在span里打上tenant.id属性。这样在追踪系统里可以按租户ID过滤全部请求链路。Java里可以用注解或装饰器给Controller加一个统一切面。不过重点不是埋点实现,而是提醒:没有租户维度的数据,压测和排查都是盲人摸象。

压测工具我常用k6,它非常适合模拟多租户混合负载。下面这个脚本会从三个租户里随机选一个,带上自己的租户头和Token去请求接口。

import http from 'k6/http'; import { check } from 'k6'; const tenants = ['tenant-a', 'tenant-b', 'tenant-c']; export default function () { const tenantId = tenants[Math.floor(Math.random() * tenants.length)]; const params = { headers: { 'X-Tenant-Id': tenantId, 'Authorization': `Bearer ${__ENV.TOKEN}`, }, }; const res = http.get('https://saas.example.com/api/orders', params); check(res, { 'status is 200': (r) => r.status === 200 }); }

运行命令:k6 run --vus 50 --duration 30s tenant-load.js。vus是并发数,duration是持续时间。这个脚本能真实反映租户混合流量下的表现。执行后除了看错误率,还要刻意给租户A增加压力,观察租户B的响应时间有没有跟着恶化。如果B的P90也明显拉高,说明数据源或连接池没有按租户隔离,回到第5.2节去查。

我的习惯是,每个新租户上线前,先跑一遍串数据测试,再用k6单独压新租户10分钟,确认它不影响存量租户。这套流程已经救过我几次,希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 13:09:40

配电网韧性提升中的移动电源预配置:基于MILP的Matlab建模与实现

1. 别急着写代码&#xff1a;先把“韧性提升MPS预配置”这件事想清楚1.1 配电网韧性和移动电源到底解决什么问题先说一个很常见的场景&#xff1a;台风过境&#xff0c;或者冰灾压垮线路&#xff0c;配电网最容易出现的情况是“一条主馈线断掉&#xff0c;后面一串负荷全黑”。…

作者头像 李华
网站建设 2026/10/4 13:08:06

本地部署大模型+RAG:打造专属私人情感智能助手

最近我一直在琢磨一件事&#xff1a;把大模型真正拉到自己电脑里&#xff0c;再配上RAG&#xff08;检索增强生成&#xff09;&#xff0c;做一个属于我自己的“感情智能助手”。不是那种一问一答的聊天机器人&#xff0c;而是能记住我写过的东西、看懂情绪变化、在低落时翻出以…

作者头像 李华
网站建设 2026/10/4 13:07:31

Python人工智能课程案例代码包实战:从环境配置到模型训练

简介&#xff1a;这是一套Python人工智能经典案例合集&#xff0c;面向刚入门AI或希望快速上手机器学习实践的读者&#xff0c;涵盖数据处理、模型训练与结果评估等完整学习链路。压缩包共104个文件&#xff0c;大小仅2.61MB&#xff0c;以24个Python脚本为核心代码&#xff0c…

作者头像 李华
网站建设 2026/10/4 13:07:16

论文生成要多久 —— 从点下按钮到下载 Word

很多人第一次用汇写&#xff08;https://www.huixielunwen.com/tool/graduationThesis&#xff09;时最关心的问题是&#xff1a;到底要等多久&#xff1f;毕竟传统写论文要几周&#xff0c;AI 生成总得给个准信。实际用下来&#xff0c;整个流程比你想象的快得多。 前面几步是…

作者头像 李华
网站建设 2026/10/4 13:00:55

Quanto期权定价全解析:从测度变换到汇率风险修正

像很多刚接触衍生品定价的朋友一样&#xff0c;我第一次看到“Quanto option”这个名字的时候&#xff0c;第一反应是&#xff1a;这不就是一个带汇率折算的期权吗&#xff1f;直接用BS公式乘个汇率不就行了&#xff1f;后来在实盘里被真实场景教育了一次才明白&#xff0c;Qua…

作者头像 李华
网站建设 2026/10/4 13:00:31

企业知识库GEO优化实战:从RAG流水线到生成引擎引用提升

1. 企业知识库与GEO优化系统的底层逻辑拆解1.1 为什么企业知识库需要GEO优化很多团队在搭建知识库时&#xff0c;第一反应是“把文档丢进去、能搜到就行”。但真正跑过一段时间就会发现&#xff0c;知识库的访问量上不去&#xff0c;AI回答的引用率低&#xff0c;搜索引擎也几乎…

作者头像 李华