SaaS多租户架构设计:数据隔离、上下文透传与配额计费实战

发布时间:2026/9/23 21:48:23
SaaS多租户架构设计:数据隔离、上下文透传与配额计费实战 简介这份《SaaS架构设计》PDF文档面向希望系统掌握SaaS架构原理与实践的开发者、架构师及技术学习者围绕多租户系统从需求分析到性能优化的完整设计链路展开。内容涵盖SaaS成熟度模型四级分级、RUP“41”视图模式场景、逻辑、开发、过程、物理视图、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() { MapObject, 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}, headersheaders) # 读回列表检查是否只包含自己的标记 resp requests.get(http://localhost:8080/api/record/list, headersheaders) 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 [fmarker-{i} for i in range(20)] with concurrent.futures.ThreadPoolExecutor(max_workers20) as pool: futures [pool.submit(write_and_read, ftenant-{i}, fmarker-{i}) for i in range(20)] for f in futures: f.result() # 任何异常都会在这里抛出 print(隔离测试通过)逻辑说明20 个线程并发模拟 20 个租户每个租户写入唯一标记后立即读回检查返回列表中是否混入其他租户的标记。参数说明max_workers要大于租户数确保真正并发all_markers用于判断读到的数据是否属于其他租户。注意这个测试要跑在预发环境且每次发版后都跑一遍因为拦截器或过滤器的改动很容易引入串租户问题靠 code review 很难发现。除了隔离测试配额校验也值得做边界验证把某租户的配额调到刚好用完再发一个请求确认返回的是明确的配额错误而不是 500。我自己的习惯是每次改完租户上下文相关的代码先跑一遍这组混沌测试再提交比事后排查线上串数据省事得多。希望帮到你。本文还有配套的精品资源点击获取