Skip to content

SaaS 多租户的机制,之前拆开讲过不少:隔离策略、动态租户上下文、租户生命周期、套餐开关、计费模型——每个专题只管自己那一块。但真实做一个 SaaS 平台时,这些机制不是各管各的,而是在一条注册和使用的链路上互相咬合。

这篇就假想一个任务协作类 SaaS 产品(团队建任务、看板、统计报表),把这些机制放进一个完整系统里走一遍:一个租户怎么注册进来、请求进来怎么隔离、套餐怎么裁剪功能、到期那天发生什么。机制细节不展开,正文里随时链回对应的专题篇。

一、系统全景 ​

先看整体分层。一个多租户 SaaS 平台,和单体应用最大的区别是多了三个“横向切面”——租户上下文、套餐校验、数据隔离,它们不属于任何业务模块,却横切所有业务请求:

多租户 SaaS 平台分层架构

三个切面各司其职:

  • 租户上下文:每个请求进来,先弄清楚“当前是谁家的请求”,放进上下文供后续所有代码取用;
  • 套餐校验:功能接口按套餐裁剪,防止低套餐客户调到高套餐功能;
  • 数据隔离:所有 SQL 自动拼上租户条件,缓存按租户分开——这是不能靠程序员自觉的底线,必须框架层强制。

下面按“一个租户的一生”顺序展开:注册开通 → 日常请求 → 套餐升降级 → 到期处理。

二、租户开通:一条链路串起状态机、事件与幂等 ​

新团队注册,后端要做的事远不止“insert 一条租户记录”:

租户开通流程

几个关键设计:

① 注册即建租户,状态是“试用”。 租户生命周期是个状态机(试用 → 正式 → 冻结 → 注销),转换规则和每个状态的含义在《租户生命周期管理》里讲过,这里只放状态流转的骨架。先说清一点:这套状态机是本文的设计示意,不是哪个框架自带的——比如 RuoYi-Vue-Plus 原生的 sys_tenant.status 只有「正常 / 停用」两态,到期靠 expire_time 在登录时校验;业务要精细化管租户的一生,就得像下面这样自己引入状态机:

java
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,各初始化逻辑各自监听:

java
// 注册主流程:只做核心三步
@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 租户插件自动改写:

java
// 业务代码写的
taskMapper.selectList(new LambdaQueryWrapper<Task>()
    .eq(Task::getBoardId, boardId));
// 插件改写后实际执行的
// ... WHERE board_id = ? AND tenant_id = 't_8f3a2c'

隔离的不只是数据库。Redis 缓存 key 带租户前缀、定时任务以指定租户身份跑批(dynamic() 包裹)、异步线程显式传租户 ID——这些“上下文边界”的处理,两篇专题里都有对应章节,是这个系统最容易翻车的地方(第八节集中说)。

四、按套餐裁剪:功能开关与配额两道闸 ​

试用客户和付费客户、基础版和企业版,跑的是同一套代码,差别靠两道闸:

第一道闸:功能开关。 用注解声明接口需要什么套餐,拦截器统一校验——设计细节(套餐-功能矩阵、配置来源)见《SaaS 套餐功能开关设计》,本系统里的用法:

java
@RequiresPlan(feature = "advanced-report")   // 高级报表:企业版专属
@GetMapping("/reports/advanced")
public R<AdvancedReportVo> advancedReport() { ... }

第二道闸:配额。 功能开了,用量还要管——基础版 10 个成员、企业版不限人数;免费版 3 块看板。配额校验是个“先查后比”的逻辑:

java
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》):

java
// 每天 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 架构演进》的路线图走:

阶段客户规模部署形态关键动作
v10 ~ 几十家共享库 + tenant_id 字段隔离租户插件、上下文、套餐闸门全部就位
v2出现大客户,要求独立库独立 schema / 独立库路由层按租户定向数据源,上下文不变
v3规模化混合:小客户共享、大客户独立租户 → 数据源的路由表 + 配置中心管理

表格里「路由层按租户定向数据源,上下文不变」信息量很大,拆开讲——所谓「抽象留好」,具体就是两个接缝:

接缝一:租户上下文是唯一的租户入口。 任何代码想知道「当前是谁」,都只从上下文取(TenantHelper.getTenantId()),不自己传参、不自己查库。第三节整套隔离机制已经保证了这一点。

接缝二:数据源由路由层决定,业务代码不感知。 业务代码写 tenantMapper.selectById(...) 时,不知道也不需要知道连的是哪个库——用哪个数据源,由路由层按当前租户决定:

java
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,见动态租户原理第四节。

三条的共同根源是一样的:租户上下文是有边界的,所有“边界之外”的代码——异步、定时、系统级——都必须显式处理租户身份。

整套机制过完,真要做的时候,心里先有张图。

系列导航 ​