简介:这份《SaaS+架构设计》PDF文档面向希望系统掌握SaaS架构原理与实践的开发者、架构师及技术学习者,围绕多租户系统从需求分析到性能优化的完整设计链路展开。内容涵盖SaaS成熟度模型四级分级、RUP“4+1”视图模式(场景、逻辑、开发、过程、物理视图)、MDA模型驱动架构,以及系统级与程序级安全性设计、多租户数据存储的三种方案(独立数据库、共享数据库隔离数据架构、共享数据库共享数据架构)等核心议题。文档还深入讲解数据库层索引优化与消除大表连接、应用层缓存与日志记录、数据加密算法,以及云计算网络性能测试的速率、并发数、吞吐量和响应时间等指标。资源包为1个PDF文件,大小约967KB,结构紧凑、便于随时查阅。目前已有197人学习,适合需要理解SaaS架构设计要点、构建高效可伸缩多租户系统的读者参考。
1. SaaS 架构设计:从单租户到多租户,到底改了什么
很多团队做 SaaS 的第一版,本质上就是把一套单体应用部署了 N 份,每个客户一套数据库、一套进程,靠运维脚本做隔离。客户少的时候没问题,客户一多,发布一次要动几十个实例,加一个字段要跑几十遍迁移脚本,这时候才意识到:SaaS 架构设计和普通 Web 架构设计,根本区别不在技术栈,而在租户模型。
这份笔记围绕 SaaS 架构设计展开,重点讲清楚三件事:多租户的数据隔离方案怎么选、租户上下文怎么在请求链路里透传、以及套餐计费和配额怎么落到架构层。适合正在把项目从单租户改造成多租户的工程师,也适合从零设计 SaaS 平台、需要判断隔离粒度和扩展边界的架构决策者。下面按「模型选型 → 落地实现 → 踩坑排查 → 进阶验证」的顺序推进,每一步都给出可复现的配置和代码。
2. 多租户数据隔离:三种模型怎么选、怎么落地
2.1 三种隔离模型的成本与边界对比
多租户架构设计里最先要拍板的就是数据隔离粒度。业界常见三种模型:独立数据库、共享数据库独立 Schema、共享数据库共享表带租户字段。没有绝对优劣,只有和业务阶段匹配不匹配。
| 模型 | 隔离强度 | 单租户成本 | 迁移/扩展难度 | 适用阶段 |
|---|---|---|---|---|
| 独立数据库 | 最高 | 最高 | 扩容需新建实例,跨租户统计难 | 大客户、强合规 |
| 共享库独立 Schema | 中高 | 中 | Schema 数量膨胀后连接池吃紧 | 中型客户、中等规模 |
| 共享表带 tenant_id | 最低 | 最低 | 单表数据量大,需分库分表 | 海量小客户、SaaS 标准版 |
选型判断的核心不是「哪个更安全」,而是租户规模分布。如果 80% 客户是几十人的小团队,用独立数据库会让你的运维成本随客户数线性上升,最后被拖垮。常见做法是混合:标准版走共享表,企业版走独立 Schema,旗舰版走独立库,用同一套代码通过配置切换。
2.2 共享表模式下租户字段的强制注入
共享表模式最大的风险是漏加tenant_id条件,导致跨租户数据泄露。靠人工 review 不可靠,要在框架层强制注入。下面是一个基于 MyBatis 拦截器的实现思路。
// TenantInterceptor.java // 拦截所有查询,自动在 WHERE 后追加 tenant_id 条件 @Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})}) public class TenantInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; Object param = invocation.getArgs()[1]; // 从 ThreadLocal 取当前请求的租户 ID String tenantId = TenantContext.getTenantId(); if (tenantId == null) { throw new IllegalStateException("租户上下文缺失,拒绝执行"); } // 只对 SELECT 做处理,INSERT/UPDATE 在业务层显式写入 if (ms.getSqlCommandType() == SqlCommandType.SELECT) { BoundSql boundSql = ms.getBoundSql(param); String sql = boundSql.getSql(); // 用 JSqlParser 解析后追加条件,避免字符串拼接出错 String newSql = SqlParserUtil.appendTenantCondition(sql, tenantId); // 通过反射替换 BoundSql 中的 SQL ReflectUtil.setFieldValue(boundSql, "sql", newSql); } return invocation.proceed(); } }逻辑说明:拦截器在 SQL 执行前介入,从ThreadLocal拿到当前租户 ID,用 SQL 解析器在原有查询上追加tenant_id = ?条件。参数说明:TenantContext是一个ThreadLocal容器,在请求进入时由过滤器写入;SqlParserUtil负责解析 SQL 并安全追加条件,避免直接字符串拼接导致注入或语法错误。注意:INSERT 和 UPDATE 不在这里处理,因为写入时需要显式指定租户,放在业务层更可控,但要在 DAO 基类里做强制校验。
2.3 独立 Schema 模式的动态数据源切换
如果选了独立 Schema,核心是请求进来时根据租户 ID 切换数据源。常见做法是用AbstractRoutingDataSource。
// DynamicDataSource.java public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 返回当前线程绑定的数据源 key,即租户对应的 schema return DataSourceContext.getDataSourceKey(); } } // 配置类中注册多个数据源 @Configuration public class DataSourceConfig { @Bean public DataSource dynamicDataSource() { Map<Object, Object> targetDataSources = new HashMap<>(); // tenant_a、tenant_b 对应不同 schema 的连接池 targetDataSources.put("tenant_a", buildDataSource("jdbc:mysql://host:3306/db_a")); targetDataSources.put("tenant_b", buildDataSource("jdbc:mysql://host:3306/db_b")); DynamicDataSource ds = new DynamicDataSource(); ds.setTargetDataSources(targetDataSources); ds.setDefaultTargetDataSource(targetDataSources.get("tenant_a")); return ds; } }逻辑说明:AbstractRoutingDataSource在每次获取连接时调用determineCurrentLookupKey(),返回的 key 决定用哪个数据源。参数说明:DataSourceContext同样是ThreadLocal,在请求过滤器中根据租户配置写入对应的 schema key。注意:连接池数量要按租户数预留,Schema 数量超过 50 个时建议改用分库中间件,否则连接池会成为瓶颈。
3. 租户上下文透传:从网关到线程池的完整链路
3.1 请求入口的租户识别与校验
租户上下文的第一站是网关或过滤器。识别方式常见三种:域名前缀(tenant-a.example.com)、请求头(X-Tenant-Id)、JWT 中的 claim。生产环境建议以 JWT claim 为准,域名和请求头作为辅助校验。
// TenantFilter.java public class TenantFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; // 优先从 JWT 解析,其次从请求头 String tenantId = JwtUtil.extractTenantId(request); if (tenantId == null) { tenantId = request.getHeader("X-Tenant-Id"); } if (tenantId == null || !TenantRegistry.exists(tenantId)) { ((HttpServletResponse) res).sendError(403, "无效租户"); return; } try { TenantContext.setTenantId(tenantId); chain.doFilter(req, res); } finally { // 必须清理,否则线程复用会串租户 TenantContext.clear(); } } }逻辑说明:过滤器在请求进入时解析租户 ID,校验合法性后写入ThreadLocal,请求结束时清理。参数说明:TenantRegistry是租户注册表,缓存了所有有效租户的配置;JwtUtil.extractTenantId从 token 的 claim 中读取租户标识。注意:finally里的clear()不能省,Tomcat 线程池会复用线程,不清理会导致下一个请求读到上一个租户的上下文,这是最隐蔽的串数据 bug。
3.2 异步任务与线程池的上下文传递
同步链路靠ThreadLocal没问题,但一旦用了@Async或自定义线程池,ThreadLocal就断了。解决方案是装饰线程池,在任务提交时捕获上下文,执行时恢复。
// TenantAwareTaskDecorator.java 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("tenantExecutor") public Executor tenantExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(32); executor.setQueueCapacity(200); executor.setTaskDecorator(new TenantAwareTaskDecorator()); executor.initialize(); return executor; }逻辑说明:TaskDecorator在任务提交时捕获当前线程的租户 ID,包装成新的Runnable,在执行时恢复。参数说明:corePoolSize和maxPoolSize按并发量调整,queueCapacity决定排队上限,超过后触发拒绝策略。注意:如果任务内部又提交了子任务,装饰器不会自动传递,需要在子任务提交处手动捕获,或者用TransmittableThreadLocal替代原生ThreadLocal。
3.3 跨服务调用时租户信息的透传
微服务拆分后,租户上下文要跨 HTTP 或 RPC 传递。常见做法是在 Feign 或 RestTemplate 的拦截器里把租户 ID 塞进请求头。
// FeignTenantInterceptor.java public class FeignTenantInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { String tenantId = TenantContext.getTenantId(); if (tenantId != null) { template.header("X-Tenant-Id", tenantId); } } }逻辑说明:Feign 发起调用前,拦截器从当前线程上下文取出租户 ID,写入请求头。参数说明:下游服务的过滤器会读取X-Tenant-Id并重建上下文,形成闭环。注意:内部服务间调用要校验请求头来源,避免外部请求伪造X-Tenant-Id绕过网关直接访问内部服务,常见做法是网关层剥离外部传入的该请求头,只允许内部链路携带。
4. 套餐计费与配额:架构层怎么支撑灵活策略
4.1 套餐模型的数据结构设计
SaaS 套餐的费用策略经常变,如果硬编码在代码里,每次调价都要发版。合理做法是把套餐、功能、配额抽象成配置数据。
-- 套餐表 CREATE TABLE plan ( id BIGINT PRIMARY KEY, name VARCHAR(64) NOT NULL, price_monthly DECIMAL(10,2), price_yearly DECIMAL(10,2) ); -- 功能项表,定义所有可售功能 CREATE TABLE feature ( id BIGINT PRIMARY KEY, code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(64) NOT NULL ); -- 套餐功能关联,记录每个套餐包含哪些功能及配额 CREATE TABLE plan_feature ( plan_id BIGINT, feature_id BIGINT, quota_limit INT, -- -1 表示不限 PRIMARY KEY (plan_id, feature_id) ); -- 租户订阅表 CREATE TABLE tenant_subscription ( tenant_id BIGINT PRIMARY KEY, plan_id BIGINT, start_at DATETIME, end_at DATETIME, status VARCHAR(16) -- active / expired / trial );逻辑说明:套餐和功能解耦,plan_feature记录配额上限,tenant_subscription记录租户当前订阅。参数说明:quota_limit用 -1 表示不限量,避免用 NULL 导致查询逻辑复杂。注意:调价时只改plan表,已订阅租户的权益不受影响,新订阅按新价格执行,这是 SaaS 计费的基本规则。
4.2 配额校验的拦截与降级
配额校验要放在业务执行前,超限时返回明确错误,而不是让请求跑完再报错。
// QuotaChecker.java public class QuotaChecker { public void check(String featureCode) { Long tenantId = TenantContext.getTenantId(); // 查缓存,避免每次请求都打数据库 QuotaConfig config = QuotaCache.get(tenantId, featureCode); if (config == null) { throw new BizException("当前套餐不包含该功能"); } if (config.getLimit() == -1) { return; // 不限量 } long used = QuotaCounter.getUsed(tenantId, featureCode); if (used >= config.getLimit()) { throw new BizException("配额已用完,请升级套餐"); } } }逻辑说明:校验前先从缓存取配额配置,再取已用量,比较后决定放行或拒绝。参数说明:QuotaCache用 Redis 缓存套餐配置,TTL 设 5 分钟,套餐变更时主动失效;QuotaCounter用 Redis 原子计数器记录用量。注意:计数器和业务操作不在同一事务里,极端情况下会超卖,对配额精度要求高的场景要用 Lua 脚本保证原子性,或者接受少量超卖、事后对账补偿。
5. 避坑与排查:多租户 SaaS 最容易翻车的五个点
5.1 现象:某租户能看到其他租户的数据
原因:共享表模式下某条 SQL 漏加tenant_id条件,常见于手写的复杂查询或报表 SQL。解决:开启数据库层的行级安全策略(如 PostgreSQL 的 RLS),或在拦截器里对所有 SELECT 强制注入,并加单元测试覆盖每个 DAO 方法。
5.2 现象:异步任务里租户上下文为 null
原因:用了原生ThreadLocal,线程池复用线程时上下文丢失。解决:给线程池加TaskDecorator,或改用TransmittableThreadLocal,并在任务执行入口做空值校验,缺失时直接失败而不是用默认租户兜底。
5.3 现象:套餐升级后配额没生效
原因:配额配置缓存在 Redis,套餐变更后没有主动失效,TTL 到期前一直读旧值。解决:套餐变更时发布事件,订阅方删除对应租户的缓存 key;或者把 TTL 缩短到 1 分钟,用短暂不一致换实现简单。
5.4 现象:独立 Schema 模式下连接池耗尽
原因:每个租户一个 Schema,连接池按租户数配置,租户增长后总连接数超过数据库上限。解决:改用共享连接池加动态 Schema 切换(USE schema_name),或者引入分库中间件统一管理;监控连接池活跃数,超过 70% 告警。
5.5 现象:跨服务调用时租户 ID 被外部伪造
原因:内部服务直接信任请求头里的X-Tenant-Id,外部请求可以伪造该头绕过网关。解决:网关层剥离外部传入的X-Tenant-Id,内部服务只信任来自网关的调用;或者在内部调用时加签名,下游验签后才采信。
6. 进阶验证:用混沌测试确认租户隔离真的可靠
架构设计做完,怎么验证隔离没漏洞?靠人工点测覆盖不全,我一般会写一组混沌测试,模拟并发场景下租户上下文串扰。核心思路是:同时发起多个租户的请求,每个请求携带可识别的标记数据,跑完后检查每个租户只能读到自己的数据。
# tenant_isolation_test.py import concurrent.futures import requests def write_and_read(tenant_id, marker): headers = {"X-Tenant-Id": tenant_id} # 写入带标记的数据 requests.post("http://localhost:8080/api/record", json={"content": marker}, headers=headers) # 读回列表,检查是否只包含自己的标记 resp = requests.get("http://localhost:8080/api/record/list", headers=headers) records = resp.json()["data"] for r in records: if marker not in r["content"] and r["content"] in all_markers: raise AssertionError(f"租户 {tenant_id} 读到了其他租户数据: {r}") all_markers = [f"marker-{i}" for i in range(20)] with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool: futures = [pool.submit(write_and_read, f"tenant-{i}", f"marker-{i}") for i in range(20)] for f in futures: f.result() # 任何异常都会在这里抛出 print("隔离测试通过")逻辑说明:20 个线程并发模拟 20 个租户,每个租户写入唯一标记后立即读回,检查返回列表中是否混入其他租户的标记。参数说明:max_workers要大于租户数,确保真正并发;all_markers用于判断读到的数据是否属于其他租户。注意:这个测试要跑在预发环境,且每次发版后都跑一遍,因为拦截器或过滤器的改动很容易引入串租户问题,靠 code review 很难发现。
除了隔离测试,配额校验也值得做边界验证:把某租户的配额调到刚好用完,再发一个请求,确认返回的是明确的配额错误而不是 500。我自己的习惯是,每次改完租户上下文相关的代码,先跑一遍这组混沌测试再提交,比事后排查线上串数据省事得多。希望帮到你。
本文还有配套的精品资源,点击获取