SaaS架构设计实战:多租户隔离、计费与迁移避坑指南
2026/9/23 16:01:21 网站建设 项目流程

简介:这份《SaaS+架构设计》PDF文档面向希望系统掌握SaaS架构原理与实践的开发者、架构师及技术学习者,围绕多租户系统从需求分析到性能优化的完整设计链路展开。内容涵盖SaaS成熟度模型四级分级、RUP“4+1”视图模式(场景、逻辑、开发、过程、物理视图)、MDA模型驱动架构,以及系统级与程序级安全性设计、独立数据库与共享数据库等三种多租户数据存储方案,并延伸至数据库索引优化、应用层缓存、日志记录与数据加密等性能与安全实践,最后给出速率、并发数、吞吐量、响应时间等云计算网络性能测试指标。资源包为1个PDF文件,大小约967KB,结构紧凑、便于随时查阅。目前已有197人学习,适合作为SaaS架构入门与方案设计的参考笔记。

1. 从一份 SaaS 架构设计文档说起:多租户到底难在哪

很多团队做 SaaS,第一版都能跑起来,第二版开始崩。崩的地方往往不是功能,而是租户隔离、计费口径、数据归属这三件事。你手里如果正拿着一份 SaaS 架构设计文档,或者正准备写一份,真正要回答的不是“用哪些中间件”,而是:同一套代码,怎么让一千个租户互不干扰,还能按套餐差异化限流、按用量出账、按租户导出数据。

SaaS 架构设计和传统 To B 项目最大的区别,是“共享”与“隔离”要同时成立。共享是为了摊薄成本,隔离是为了守住边界。这两者天然矛盾,所以架构设计文档里最该写清楚的,是隔离级别怎么选、租户上下文怎么透传、计费数据从哪来。这篇笔记按“先立住模型,再落到表结构和代码,最后讲踩坑”的顺序展开,适合正在做 SaaS 从 0 到 1 或从 1 到 10 的后端、架构和运维同学。

2. 多租户隔离模型:三种方案怎么选,别一上来就分库

2.1 三种隔离级别的成本与边界对比

SaaS 多租户隔离,业内常见三种做法:共享库共享表加租户字段、共享库独立表、独立库。它们不是谁替代谁,而是按租户规模和合规要求分层使用。

隔离级别数据存放隔离强度单租户成本适合场景
共享表 + tenant_id同库同表弱,靠代码约束极低中小客户、免费套餐
共享库独立表同库不同表中,靠表名路由中腰部客户、需要单独备份
独立库每租户一库强,物理隔离大客户、强合规、私有化过渡

选型逻辑很直接:先看合规,再看租户体量分布。如果 90% 租户是中小客户,全量独立库会让运维成本失控;如果头部客户要求数据物理隔离,共享表方案又过不了审计。常见做法是混合:默认共享表,大客户走独立库,用同一套租户路由层屏蔽差异。

这里有个容易被忽略的点:隔离级别一旦定下,迁移成本极高。所以架构设计文档里必须写明“升级路径”——共享表租户如何平滑迁到独立库。我一般会要求路由层从第一天就支持按租户配置数据源,哪怕初期所有租户都指向同一个库。

2.2 租户上下文透传:从网关到 SQL 的完整链路

隔离能不能守住,取决于租户上下文有没有在每一层都带上。典型链路是:网关解析租户标识,写入请求头;应用层拦截器提取并放入 ThreadLocal 或上下文对象;数据访问层从上下文取 tenant_id,拼进查询条件或路由到对应数据源。

// 租户上下文持有者,请求结束时必须清理,否则线程复用会串租户 public class TenantContext { private static final ThreadLocal<String> CURRENT = new ThreadLocal<>(); public static void set(String tenantId) { CURRENT.set(tenantId); } public static String get() { return CURRENT.get(); } // 关键:在 finally 中调用,避免线程池复用导致租户污染 public static void clear() { CURRENT.remove(); } }

逻辑说明:ThreadLocal 是透传租户标识最轻的方式,但它和线程池是天敌。如果请求结束不清理,下一个复用该线程的请求可能读到上一个租户的 ID,这就是典型的串租户事故。参数上,tenantId 建议用不可猜测的字符串而非自增数字,避免被遍历。

数据访问层再包一层,所有查询强制注入租户条件:

-- 共享表模式下,任何查询都必须带 tenant_id,禁止裸查 SELECT id, name, plan_code FROM saas_order WHERE tenant_id = #{tenantId} AND status = 'PAID';

提示:可以在 ORM 层做统一拦截,自动追加 tenant_id 条件,但一定要留“白名单”给平台级管理查询,否则运营后台查不到全量数据。

2.3 独立库路由:动态数据源的最小实现

当部分租户升级到独立库,路由层要能按 tenantId 切换数据源。常见做法是用 AbstractRoutingDataSource,在 determineCurrentLookupKey 里返回租户对应的数据源 key。

public class TenantRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 从上下文取租户,映射到数据源标识 String tenantId = TenantContext.get(); return DataSourceRegistry.lookup(tenantId); } }

逻辑说明:DataSourceRegistry 维护 tenantId 到数据源 key 的映射,key 对应 Spring 容器里注册的多个 DataSource。参数上要注意连接池数量——每个独立库一个连接池,租户多了会耗尽数据库连接,所以独立库租户数量要有上限,或者用连接池共享方案。

3. 套餐与计费:用量数据从哪来,账单怎么算准

3.1 套餐模型设计:功能、配额、计费项三张表

SaaS 套餐的费用策略,落到数据模型上就是三件事:这个套餐能用哪些功能、每个功能有多少配额、超出后怎么计费。常见做法是拆成三张表:plan(套餐)、plan_feature(功能开关)、plan_quota(配额与计费规则)。

CREATE TABLE plan ( id BIGINT PRIMARY KEY, plan_code VARCHAR(64) NOT NULL UNIQUE, plan_name VARCHAR(128) NOT NULL, billing_cycle VARCHAR(16) NOT NULL -- MONTHLY / YEARLY ); CREATE TABLE plan_quota ( id BIGINT PRIMARY KEY, plan_code VARCHAR(64) NOT NULL, metric_key VARCHAR(64) NOT NULL, -- 如 api_calls / storage_gb / seats included BIGINT NOT NULL, -- 套餐内包含量 unit_price DECIMAL(12,4) NOT NULL, -- 超出单价 hard_limit BIGINT -- 硬上限,NULL 表示不限 );

逻辑说明:metric_key 是计费项的抽象,所有用量都归一到这个键上。included 是套餐内免费额度,unit_price 是超出部分单价,hard_limit 用于防止单个租户把系统打爆。参数上,unit_price 建议用 DECIMAL 而非 FLOAT,避免累计误差。

3.2 用量采集:埋点、聚合、对账三步走

计费准不准,取决于用量数据可不可信。我一般分三步:采集、聚合、对账。采集层在业务动作发生时写一条用量事件,聚合层按小时或天汇总,对账层用聚合结果和原始事件做校验。

# 用量事件写入,要求幂等:同一 event_id 重复写入不产生双倍计费 def record_usage(tenant_id, metric_key, amount, event_id): sql = """ INSERT INTO usage_event (tenant_id, metric_key, amount, event_id, created_at) VALUES (%s, %s, %s, %s, NOW()) ON DUPLICATE KEY UPDATE amount = amount -- 幂等,重复事件不叠加 """ db.execute(sql, (tenant_id, metric_key, amount, event_id))

逻辑说明:event_id 是幂等键,通常由业务侧生成,比如订单号加动作类型。ON DUPLICATE KEY UPDATE 保证重复投递不重复计费。参数上,amount 要统一单位,比如存储统一用 GB、调用统一用次,避免聚合时单位混乱。

聚合任务按周期把 usage_event 汇总到 usage_summary,账单只读汇总表:

INSERT INTO usage_summary (tenant_id, metric_key, period, total_amount) SELECT tenant_id, metric_key, DATE_FORMAT(created_at, '%Y-%m'), SUM(amount) FROM usage_event WHERE created_at >= #{start} AND created_at < #{end} GROUP BY tenant_id, metric_key, DATE_FORMAT(created_at, '%Y-%m') ON DUPLICATE KEY UPDATE total_amount = VALUES(total_amount);

逻辑说明:按租户、计费项、账期三个维度汇总。ON DUPLICATE KEY UPDATE 让聚合任务可重跑,修正历史数据时不会产生重复行。参数上,账期格式要和账单周期对齐,月付用年月,年付用年份。

3.3 账单生成:把配额、单价、用量拼成一张账单

账单生成是把 usage_summary 和 plan_quota 做匹配计算。核心逻辑是:套餐内用量不计费,超出部分按单价乘超出量。

def calc_bill(tenant_id, period): quotas = load_quotas(tenant_id) # 当前套餐配额 usages = load_usage_summary(tenant_id, period) items = [] for metric, used in usages.items(): quota = quotas.get(metric) if not quota: continue over = max(0, used - quota.included) amount = over * quota.unit_price items.append({ "metric": metric, "used": used, "included": quota.included, "over": over, "amount": amount }) return items

逻辑说明:over 用 max(0, ...) 保证套餐内不产生负计费。参数上,unit_price 要区分阶梯价时,这里要换成阶梯计算函数。账单生成后建议冻结快照,后续套餐变更不影响已出账单。

4. 避坑与排查:多租户 SaaS 最容易翻车的五个地方

4.1 现象:某租户看到了别的租户数据

原因:租户上下文没清理,线程池复用导致串租户;或者某条 SQL 漏了 tenant_id 条件。解决:在拦截器的 finally 里强制 clear;ORM 层统一注入租户条件,并对裸 SQL 做代码扫描。血泪经验是,这类问题往往在压测时才暴露,因为低并发下线程复用不明显。

4.2 现象:计费金额和客户预期对不上

原因:用量事件重复投递导致双倍计费,或者聚合任务重跑时没做幂等。解决:所有用量事件带 event_id 并做唯一约束;聚合任务用 ON DUPLICATE KEY UPDATE 保证可重跑。对账环节要保留原始事件,账单争议时能回溯。

4.3 现象:独立库租户越来越多,数据库连接被打满

原因:每个独立库一个连接池,租户数量增长后连接数线性上升。解决:给独立库租户设上限,超出后引导升级到专属实例;或者用连接池代理做连接复用。参数上要监控每个数据源的活跃连接数,设告警阈值。

4.4 现象:套餐变更后,当月账单算错

原因:账单生成时读取的是当前套餐,而不是账期内的历史套餐。解决:套餐变更要记录生效时间,账单计算按账期匹配对应版本的套餐快照。常见做法是套餐变更时生成一条 plan_snapshot 记录,账单只读快照。

4.5 现象:大租户导出数据时拖垮整个库

原因:共享表模式下,大租户的全量导出会扫描大量数据,影响其他租户。解决:导出走只读副本;或者对大租户做限流,导出任务排队执行。架构设计文档里要明确“重操作隔离”策略,别让一个租户的操作影响全局。

5. 进阶:用租户分级和灰度迁移把架构演进成本压下来

SaaS 架构不是一次设计到位,而是随租户结构演进的。我一般会把租户分成三级:S 级走独立库、A 级走共享库独立表、B 级走共享表。分级不是拍脑袋,而是按营收贡献、合规要求、数据量三个维度打分。分级的好处是,架构升级时只动需要动的部分,不用全量重构。

灰度迁移是另一个关键技巧。当你要把一批租户从共享表迁到独立库,不要一次性切。做法是:双写一段时间,用影子表校验数据一致性,确认无误后切读,最后停写旧表。迁移期间租户无感知,出问题能快速回滚。

# 双写校验:新旧存储同时写,比对结果,不一致则告警 def dual_write(tenant_id, record): old_result = write_to_shared(tenant_id, record) new_result = write_to_dedicated(tenant_id, record) if not consistent(old_result, new_result): alert(f"tenant {tenant_id} dual-write mismatch") return new_result

逻辑说明:双写期间以新存储为准,旧存储作为回滚兜底。consistent 函数比对关键字段,不一致时告警但不阻断业务。参数上,双写持续时间取决于数据量和一致性要求,一般至少覆盖一个完整业务周期。

验证迁移是否成功,我习惯看三个指标:数据行数一致、关键字段校验和一致、业务查询延迟没有明显上升。这三个都过了,才停旧存储。

最后说个我自己的教训:早期做 SaaS 时,我总想把架构设计得“一步到位”,结果过度设计拖慢了交付。后来才明白,SaaS 架构的核心不是多先进,而是隔离边界清晰、计费口径可信、迁移路径可走。把这三件事写进文档、落到代码,比堆中间件有用得多。希望帮到你。

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

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

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

立即咨询