SaaS 多租户的机制,之前拆开讲过不少:隔离策略、动态租户上下文、租户生命周期、套餐开关、计费模型——每个专题只管自己那一块。但真实做一个 SaaS 平台时,这些机制不是各管各的,而是在一条注册和使用的链路上互相咬合。
这篇就假想一个任务协作类 SaaS 产品(团队建任务、看板、统计报表),把这些机制放进一个完整系统里走一遍:一个租户怎么注册进来、请求进来怎么隔离、套餐怎么裁剪功能、到期那天发生什么。机制细节不展开,正文里随时链回对应的专题篇。
一、系统全景
先看整体分层。一个多租户 SaaS 平台,和单体应用最大的区别是多了三个“横向切面”——租户上下文、套餐校验、数据隔离,它们不属于任何业务模块,却横切所有业务请求:
三个切面各司其职:
- 租户上下文:每个请求进来,先弄清楚“当前是谁家的请求”,放进上下文供后续所有代码取用;
- 套餐校验:功能接口按套餐裁剪,防止低套餐客户调到高套餐功能;
- 数据隔离:所有 SQL 自动拼上租户条件,缓存按租户分开——这是不能靠程序员自觉的底线,必须框架层强制。
下面按“一个租户的一生”顺序展开:注册开通 → 日常请求 → 套餐升降级 → 到期处理。
二、租户开通:一条链路串起状态机、事件与幂等
新团队注册,后端要做的事远不止“insert 一条租户记录”:
几个关键设计:
① 注册即建租户,状态是“试用”。 租户生命周期是个状态机(试用 → 正式 → 冻结 → 注销),转换规则和每个状态的含义在《租户生命周期管理》里讲过,这里只放状态流转的骨架。先说清一点:这套状态机是本文的设计示意,不是哪个框架自带的——比如 RuoYi-Vue-Plus 原生的 sys_tenant.status 只有「正常 / 停用」两态,到期靠 expire_time 在登录时校验;业务要精细化管租户的一生,就得像下面这样自己引入状态机:
public enum TenantStatus {
TRIAL, ACTIVE, FROZEN, TERMINATED;
// 合法转换表:状态机拒绝一切表外转换
private static final Map<TenantStatus, Set<TenantStatus>> ALLOWED = Map.of(
TRIAL, Set.of(ACTIVE, TERMINATED), // 试用转正 或 试用放弃
ACTIVE, Set.of(FROZEN, TERMINATED), // 欠费冻结 或 主动注销
FROZEN, Set.of(ACTIVE, TERMINATED), // 续费解冻 或 注销
TERMINATED, Set.of() // 终态,不可再转
);
public boolean canTransitTo(TenantStatus target) {
return ALLOWED.getOrDefault(this, Set.of()).contains(target);
}
}注销终态用 TERMINATED 而不是 CANCELLED——后者在业界语义里是“中途取消”(比如 Stripe 的订阅取消 canceled),租户注销是正式终止一段合作关系,TERMINATED 更贴切。
开通时 TRIAL 入库,转正、冻结都走这张表审批转换——状态转换永远走集中入口,不允许业务代码直接 update 状态字段。
② 初始化靠事件,不靠长事务。 租户记录落库后,要给它初始化默认角色、菜单、示例数据、配额记录——七八件事。写一个大事务全包是最直觉的做法,但任何一个步骤慢都会拖垮注册接口,而且以后加初始化项就得改注册代码。
更好的做法是发布一个 TenantCreatedEvent,各初始化逻辑各自监听:
// 注册主流程:只做核心三步
@Transactional
public TenantCreateResult register(RegisterCmd cmd) {
tenantDedupService.checkAndLock(cmd.getEmail()); // 防重复注册(幂等)
Tenant tenant = Tenant.create(cmd, TenantStatus.TRIAL); // 试用状态入库
tenantMapper.insert(tenant);
adminAccountService.createRoot(tenant.getId(), cmd); // 建管理员账号
eventPublisher.publishEvent(new TenantCreatedEvent(tenant.getId()));
return TenantCreateResult.of(tenant);
}
// 监听方各自初始化,失败不影响注册主流程(可补偿重跑)
// 注意 phase:必须是 AFTER_COMMIT。普通 @EventListener 是同步的、
// 在事务提交前执行——监听器抛异常会把注册事务整个带崩(实测如此)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void initDefaultRoles(TenantCreatedEvent event) {
try {
roleInitService.initDefaultRoles(event.getTenantId()); // 幂等:重跑不重复插
} catch (Exception e) {
// 此时注册已提交,失败只能记下来待补偿,不能向调用方抛
log.error("租户 {} 初始化失败,待补偿重跑", event.getTenantId(), e);
}
}这个坑值得单独说:上面监听器如果用直觉的 @EventListener 写,实跑结果是——监听器抛异常,注册事务一起回滚,租户记录根本没落库(我在一个最小 Spring Boot + H2 工程里实际跑过:@EventListener 场景下 tenant 表 0 条,AFTER_COMMIT 场景下 1 条且注册成功)。原因:@EventListener 同步执行,且发生在事务提交之前,等价于把初始化代码内联进了 register() 的事务里。要用事件解耦,就得 AFTER_COMMIT:注册事务先提交成功,初始化才开始跑。
这里还有个纪律:每个监听器的初始化必须幂等——事件可能被重放,初始化脚本可能被补偿重跑。“是否已初始化”的判断和写入要在同一个事务里,具体防重手段(唯一键、幂等表)见《接口幂等与防重提交》。
一个自然会冒出来的疑问:与其搞这套「提交后再初始化、失败靠补偿重跑」,不如让初始化失败把注册整个回滚,用户重新注册一遍,不是更简单?
如果所有初始化都只写一张库、能塞进同一个事务,确实这样更简单——全放 register() 里让 @Transactional 兜底就行。但真实的 SaaS 初始化往往塞不进一个事务,这才是选补偿的原因:
| 差异点 | 注册回滚重试 | 补偿重跑 |
|---|---|---|
| 事务外的资源 | @Transactional 管不了:Redis 键、对象存储桶、发给第三方的开通请求,数据库回滚后这些残影还在,甚至可能反过来挡住重注册(如唯一性校验判「邮箱已用」) | 外部资源天然在补偿的覆盖范围内,失败的那步重跑即可 |
| 失败的根因 | 多数初始化失败是瞬时故障(下游超时、网络抖动),根因不在注册数据上——让用户重新注册,大概率在同一个地方再摔一次 | 只重试失败的那一步,成功率随系统恢复而上升 |
| 重试成本 | 交互成本:用户选的邮箱、设的密码、填的团队信息全部重来,转化直接流失 | 系统成本:后台自动重跑或运维点一下重试,用户无感 |
所以判断标准是:初始化能不能全放进一个事务。能,就别上事件,事务回滚是最省事的;不能(有外部资源、要异步、跨服务),才用「先提交注册、失败补偿」这套。两种都是正解,错的是拿着补偿的复杂度去管一个事务内就能解决的事。
③ 开通完成,系统里还只是一个「能登录的账号」,没有任何带着租户身份的请求。 租户身份不是开通时生成的,而是第一次登录时才生成:管理员在登录页选好租户、输入账号密码,框架先对租户做三查(存在、未停用、未过期),不过就直接拦在门外;通过后把租户 ID 写进登录会话(token)。从这一刻起,他发出的每个请求都从 token 解析出租户身份,不用前端传、也不用查库——第三节讲的隔离机制,前提全在这一步。完整链路见《SaaS 多租户数据隔离》的「识别」一节。
三、日常请求:租户上下文是所有隔离的前提
一个管理员打开看板,前端带着 token 请求接口。框架要在业务代码运行之前完成两件事:
识别租户:从登录态解析出 tenant_id。没登录的场景(定时任务、系统内部调用)拿不到登录租户,需要动态指定——这套“登录租户 + 动态租户”的四级解析链,在《SaaS 多租户数据隔离》里画过图;上下文“存在哪、什么时候清”的细节(ThreadLocal → 请求缓存 → Redis 的三级存储、-1 哨兵、回调式清理)在《动态租户实现原理》里展开过,这里不重复。
业务代码全程不出现 tenant_id,查询由 MyBatis-Plus 租户插件自动改写:
// 业务代码写的
taskMapper.selectList(new LambdaQueryWrapper<Task>()
.eq(Task::getBoardId, boardId));
// 插件改写后实际执行的
// ... WHERE board_id = ? AND tenant_id = 't_8f3a2c'隔离的不只是数据库。Redis 缓存 key 带租户前缀、定时任务以指定租户身份跑批(dynamic() 包裹)、异步线程显式传租户 ID——这些“上下文边界”的处理,两篇专题里都有对应章节,是这个系统最容易翻车的地方(第八节集中说)。
四、按套餐裁剪:功能开关与配额两道闸
试用客户和付费客户、基础版和企业版,跑的是同一套代码,差别靠两道闸:
第一道闸:功能开关。 用注解声明接口需要什么套餐,拦截器统一校验——设计细节(套餐-功能矩阵、配置来源)见《SaaS 套餐功能开关设计》,本系统里的用法:
@RequiresPlan(feature = "advanced-report") // 高级报表:企业版专属
@GetMapping("/reports/advanced")
public R<AdvancedReportVo> advancedReport() { ... }第二道闸:配额。 功能开了,用量还要管——基础版 10 个成员、企业版不限人数;免费版 3 块看板。配额校验是个“先查后比”的逻辑:
public void assertMemberQuota(String tenantId) {
TenantQuota quota = quotaService.get(tenantId); // 配额:套餐决定
int used = memberService.countActive(tenantId); // 现用:业务查询
if (quota.memberLimit() >= 0 && used >= quota.memberLimit()) {
throw new QuotaExceedException("成员数已达套餐上限 " + quota.memberLimit());
}
}两道闸都过,请求才真正进业务逻辑。注意配额里 -1 表示“不限”,和动态租户的 -1 哨兵是两码事——一个是业务含义,一个是存储技巧,别混。
升级套餐后功能立刻可用、下周期生效的差额怎么算,是计费模型的事——订阅表设计、按比例结算(proration)、价格快照,全部在《SaaS 套餐与计费设计》里,本系统直接沿用那套四实体模型。
五、到期处理:定时任务以租户身份跑批
试用 14 天到期、订阅到期未续费——这些“时间到了要做的事”靠定时任务。两类 job:
到期扫描:每天凌晨扫一遍,试用到期 → 冻结;订阅到期 → 进宽限期 → 冻结。多实例部署要防重复执行,用分布式调度框架(选型与防重见《分布式任务调度:从 @Scheduled 到 SnailJob》):
// 每天 00:30 扫描到期租户(分布式调度保证单实例执行)
public void scanExpiredTenants() {
// 租户表本身是全局数据,不受租户插件隔离,全表扫描没有问题
List<String> expired = tenantMapper.selectExpiredBefore(LocalDate.now());
for (String tenantId : expired) {
// 定时任务线程没有登录态,必须显式进入该租户的上下文
TenantHelper.dynamic(tenantId, () -> {
tenantStatusService.transit(TenantStatus.FROZEN); // 该转哪个租户:从上下文取
notifyService.sendExpireNotice(); // 通知发给谁:查上下文租户的管理员
});
}
}注意 dynamic() 回调包裹——定时线程没有登录租户,不包就是查全表、发错通知,这是多租户系统定时任务的头号坑。
顺着可能还有个疑问:把 tenantId 作为参数传进每个方法不就行了,为什么非要上下文?因为租户插件改写 SQL 时认的是上下文,不是方法参数:transit 内部的 update 不会手写 where tenant_id = ?,条件是插件按当前上下文自动追加的;定时线程上下文为空,插件就拼不出条件——参数传得再准,数据层照样隔离失败。dynamic() 做的就是把上下文填上,让插件在无登录态的线程里也能拼对;方法里需要租户信息做业务判断时(转哪个租户的状态机、通知发给谁),从上下文取即可。传参表达业务语义,上下文供给数据隔离,谁也替代不了谁。
账单生成:每月 1 号 0 点对每个活跃租户出账——拉上月计量事件,按计费篇的公式汇总(升降级补差、按用量计费各一套),生成账单记录后触发扣费。跑批同样逐租户 dynamic() 包裹,一个租户出账失败不挡其余租户,失败的记下来重跑;幂等靠「租户 + 账期」唯一键,重跑不会出两张账单。计量与出账的完整链路见《SaaS 套餐与计费设计》。
状态机转换从来不是改一个字段:冻结要保数据、保留期内可解冻恢复、注销要按策略清理——每个状态背后都拖着一套数据动作,规则直接沿用生命周期篇,不在这里重复展开。
六、架构演进:一套代码怎么长成混合架构
客户从几家涨到几百家,架构不是一步到位的,按《SaaS 架构演进》的路线图走:
| 阶段 | 客户规模 | 部署形态 | 关键动作 |
|---|---|---|---|
| v1 | 0 ~ 几十家 | 共享库 + tenant_id 字段隔离 | 租户插件、上下文、套餐闸门全部就位 |
| v2 | 出现大客户,要求独立库 | 独立 schema / 独立库 | 路由层按租户定向数据源,上下文不变 |
| v3 | 规模化 | 混合:小客户共享、大客户独立 | 租户 → 数据源的路由表 + 配置中心管理 |
表格里「路由层按租户定向数据源,上下文不变」信息量很大,拆开讲——所谓「抽象留好」,具体就是两个接缝:
接缝一:租户上下文是唯一的租户入口。 任何代码想知道「当前是谁」,都只从上下文取(TenantHelper.getTenantId()),不自己传参、不自己查库。第三节整套隔离机制已经保证了这一点。
接缝二:数据源由路由层决定,业务代码不感知。 业务代码写 tenantMapper.selectById(...) 时,不知道也不需要知道连的是哪个库——用哪个数据源,由路由层按当前租户决定:
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 路由的输入和租户插件同源:都是当前租户上下文
String tenantId = TenantHelper.getTenantId();
return tenantId == null ? "shared" : routingTable.lookup(tenantId);
}
}这两个接缝在位,三个阶段的演进就只是换「路由表的内容」:
- v1:路由表里所有租户都指向同一个共享库,路由层存在但形同虚设,隔离全靠插件拼
tenant_id——就是第二、三节讲的架构; - v2:大客户要求独立库,新库建好、历史数据迁移,路由表里把该租户指向新库——上下文、套餐闸门、业务代码全部不动,变的只有路由表一行;配套要把插件对该租户的 SQL 改写关掉(独立库的表没有
tenant_id字段,这个条件是多余的); - v3:独立库多了,路由表从配置文件挪进配置中心或数据库,支持运行时变更和扩容,代码照样不改。
反过来看 v1 偷懒的代价:如果 SQL 里写死了 where tenant_id = 'xxx',到了 v2,独立库根本没有这个字段,几百条 SQL 要逐条人工翻修;而插件自动追加的写法,数据源一切、插件一关就干净了。演进成本在 v1 写代码那天就定下来了。
什么时候值得独立库、什么时候不值得,这笔成本账在《SaaS 架构演进》里有完整分析,这里只讲系统上要留好的两个接缝。
七、对照表:问题 → 机制 → 专题
| 问题 | 本系统的机制 | 展开的专题 |
|---|---|---|
| 隔离:A 租户看不到 B 租户数据 | 租户插件自动改写 SQL + 缓存前缀 | 多租户数据隔离 |
| “当前租户”从哪来、何时清 | 三级上下文 + 哨兵 + 回调式清理 | 动态租户实现原理 |
| 试用/冻结/注销怎么管 | 状态机 + 到期扫描 job | 租户生命周期管理 |
| 套餐决定功能可见性 | 注解 + 拦截器 + 配额闸 | 套餐功能开关设计 |
| 收多少钱、升降级怎么算 | 订阅四实体 + proration | 套餐与计费设计 |
| 架构怎么随客户规模长 | 混合部署 + 数据源路由抽象 | SaaS 架构演进 |
| 开通初始化不重复、不丢 | 事件 + 幂等监听 | 接口幂等与防重提交 |
| 到期任务防重、不串租户 | 分布式调度 + dynamic() 包裹 | 分布式任务调度 |
八、三个最容易翻车的点
① 异步丢租户上下文。 统计报表并行化,supplyAsync 里查库——租户插件拿不到 ThreadLocal 上下文,SQL 不拼 tenant_id,跨租户泄漏。修法:显式传参或 dynamic() 包裹,判断标准见动态租户原理第五节。
② 缓存没分租户。 “全局配置”这类看似租户无关的 key,两个租户写同名 key 互相覆盖。规则:默认全部带租户前缀,确属全局的 key 加 global: 前缀白名单显式豁免。
③ ignore 状态忘清理。 系统级维护代码用“忽略租户插件”查全表,异常路径没恢复,后续业务查询全裸奔。修法:可重入栈 + 回调式 API,见动态租户原理第四节。
三条的共同根源是一样的:租户上下文是有边界的,所有“边界之外”的代码——异步、定时、系统级——都必须显式处理租户身份。
整套机制过完,真要做的时候,心里先有张图。
系列导航
- 《SaaS 架构演进》——本文第六节的路线图展开版
- 《SaaS 多租户数据隔离》——第三节的隔离机制全貌
- 《动态租户实现原理》——上下文存储与清理的深挖
- 《租户生命周期管理》——第二节状态机、第五节到期处理的完整版
- 《SaaS 套餐功能开关设计》——第四节第一道闸的设计
- 《SaaS 套餐与计费设计》——订阅模型与差额结算
- 兄弟案例:《秒杀系统架构分析》——另一个维度的综合实战:瞬时洪峰怎么扛
