做过多租户后台管理系统的人大概都有这种体会:第一次听到"多租户"三个字,脑子里浮现的是"多加个字段而已",真正上手之后才发现,这是一场从数据库到前端路由、从权限模型到部署方式的全链路重构。我这几年从零搭过两套面向 SaaS 场景的后台管理系统,也接手过别人做了一半的多租户改造,踩过的坑足够写一篇长文了。这篇就把我对多租户后台管理系统的理解、方案取舍、实操细节和排查经验完整摊开来说,无论你是刚入行的后端、正在选型的技术负责人,还是准备把单租户系统改造成多租户的老项目维护者,应该都能从里面挑到能直接用的东西。
1. 多租户后台管理系统到底在解决什么问题
1.1 从一个真实的业务场景说起
先别急着谈技术,我们先把场景摆出来。假设你接了一个汽车 4S 店积分小程序的活,客户是某个汽车品牌方,他们旗下有几十家授权门店,每家门店都要有自己的会员、自己的积分规则、自己的活动、自己的核销人员。品牌方希望一套系统搞定所有门店,而不是给每家门店单独部署一套。这个时候系统的形态就清楚了:一套代码、一套部署、多个"租户",每个租户看到的数据互相隔离,运营人员登录之后只能看到自己门店的会员和订单。
这就是多租户后台管理系统最典型的模样。它和传统的单租户后台最大的区别在于,系统里所有的业务数据都必须带一个"归属"的概念,而且这个归属要贯穿从数据库到接口到前端的每一层。我见过太多项目在这一步偷懒,只在用户表上加了tenant_id,结果订单表、积分流水表全都没有,上线第一个月就出现了 A 门店能看到 B 门店会员的情况。
多租户后台管理系统,本质上是把"数据隔离"和"资源复用"这两件互相矛盾的事同时做到位。资源复用是为了省钱、省运维、省迭代成本;数据隔离是为了让每个租户用得放心。这两件事的平衡点在哪里,就是架构设计要回答的核心问题。
1.2 多租户和权限到底有什么区别
这是被问得最多的问题之一,我在技术群里至少回答过几十遍。简单说,权限解决的是"同一个租户内,不同角色能看到什么",多租户解决的是"不同租户之间,数据能不能互相看到"。一个是横向切分,一个是纵向切分。
举个具体的例子。某 4S 店租户内部,有店长、销售顾问、库管三个角色。店长能看全店数据,销售顾问只能看自己名下的客户,库管只看配件。这是权限要干的事,通过 RBAC(基于角色的访问控制)模型就能搞定。但是不管你是店长还是销售,你都绝对看不到隔壁门店的任何数据,这是多租户要干的事。
很多新手会把这两件事混在一起,设计出一张user_role表,然后用角色去控制数据可见范围,最后发现角色一多就失控了。正确的做法是分层:租户层负责数据边界,权限层负责行为边界。租户标识在请求进来的第一刻就被解析出来,落到线程上下文里,后面所有的数据访问都基于这个上下文去加过滤条件;权限则是在这个已经隔离好的范围内,再做一层细粒度的控制。
这里还有第三种东西容易被忽略,叫"数据权限",也就是数据行级别的可见范围。它介于多租户和功能权限之间。比如同样是销售顾问角色,A 只能看自己负责的客户,B 是销售主管能看整个小组的客户。这个用数据权限的范围标识(自己、本组、本部门、全部)来配置,不要和租户混为一谈。
1.3 三类隔离方案的取舍逻辑
真正落地的时候,数据隔离有三条主流路线,每一条都有自己的适用边界。我把它们整理成一张表,方便你对照自己的项目做判断。
| 隔离方案 | 数据存放方式 | 隔离强度 | 成本 | 适用场景 |
|---|---|---|---|---|
| 共享库共享表 | 一张表加 tenant_id 字段 | 弱,靠代码约束 | 最低 | 租户数量多、单租户数据量小、SaaS 中小客户 |
| 共享库独立 Schema | 每个租户一个 schema | 中,靠数据库约束 | 中 | 租户数量中等、需要一定隔离、DBA 有一定能力 |
| 独立库 | 每个租户一套库 | 强,物理隔离 | 最高 | 大客户、强合规要求、数据敏感行业 |
我个人的经验是,除非客户在合同里明确要求物理隔离,否则九成以上的项目都应该从共享库共享表起步。原因很现实:独立库的方案在租户数量超过几十个之后,数据库连接池、备份、升级、迁移全都会变成噩梦。一个字段变更要跑几十次 DDL,想想就头皮发麻。而共享表方案只要把租户字段和索引设计对,性能完全撑得住。
不过共享表方案有一个前提,就是你的代码必须在框架层就把租户过滤做掉,不能指望每个开发人员在写 SQL 的时候都记得加where tenant_id = ?。这一点我在第 2 章会展开讲。
2. 数据隔离方案的落地细节
2.1 共享库共享表:租户字段怎么加才不出事
先讲最容易踩坑的地方:租户字段的类型和位置。我见过有人用int自增做主键式的tenant_id,也见过用 UUID 的。我的建议是统一用定长字符串(比如varchar(32))或者雪花 ID,不要用自增数字。为什么?因为自增数字会暴露租户规模,而且分库分表的时候不好做水平拆分。更重要的是,很多甲方在验收的时候会看你的租户 ID,如果是 1、2、3 这种,会显得系统很"小作坊"。
字段的位置也有讲究。tenant_id要尽量放在联合索引的第一列。比如订单表经常按"租户 + 创建时间"查询,那索引就应该是(tenant_id, create_time),而不是(create_time, tenant_id)。这个顺序决定了查询能不能走到索引,别小看这一列的位置,数据量上来之后性能差距是数量级的。
还有一点是唯一索引的处理。假设会员手机号在单个租户内唯一,那唯一索引就必须是(tenant_id, phone)的联合唯一索引,绝对不能只给phone加唯一约束,否则 B 门店的会员手机号和 A 门店撞了,系统直接报错。这个坑我在第一个项目里就踩过,当时测试环境只有两三个租户,一直没复现,上线后某个门店的客户和另一个门店的客户用了同一个手机号(可能是夫妻共号),直接插入失败,排查了半天才定位到唯一索引上。
注意:做多租户改造时,把全库所有表的唯一索引都过一遍,凡是业务唯一约束,都要在前面补上 tenant_id。这一步没有捷径,只能靠人工 review 加上脚本辅助。
2.2 独立 Schema 与独立库到底什么时候该选
再说独立 Schema 和独立库。独立 Schema 的做法是同一个数据库实例下,每个租户一个 schema。好处是隔离性比共享表强,代码层面不需要到处加租户字段,连接的时候切 schema 就行。缺点是租户数量一多,数据库里的 schema 数量爆炸,很多运维工具会卡,而且跨租户的统计报表基本没法做。
独立库隔离最强,适合金融、医疗这类对数据物理隔离有硬性要求的场景。但它的代价是运维复杂度直线上升。我参与过一个独立库的项目,客户要求每个租户数据单独备份、单独恢复。结果有一次某个租户误删了数据,要单独恢复,整个流程走了三个小时,因为备份脚本、恢复脚本、连接配置全都是按租户维度配的。所以选独立库之前,一定要确认你的运维团队扛得住。
我的实际做法是"混合模式":默认共享表,给 VIP 大客户单独开实例。在租户表上加一个isolation_level字段,取值是shared和dedicated。路由层根据这个字段决定数据源。这样既能低成本服务小客户,又能满足大客户的隔离诉求,算是一个比较务实的平衡方案。
2.3 租户上下文在整个请求链路上的传递
这一节是实战重点。租户上下文怎么从 HTTP 请求一路传到 DAO 层,是整个多租户系统能不能稳住的关键。
链路大概是这样的:请求进来,先过一个租户解析过滤器,从域名、请求头或者用户 token 里解析出租户标识,存到一个 ThreadLocal 里。然后在数据访问层,用 MyBatis 的拦截器或者 JPA 的过滤器,自动往 SQL 里拼接租户条件。请求结束,清理 ThreadLocal。
这里有个必须注意的点:ThreadLocal 在异步场景下会丢。如果你的代码里用了线程池、CompletableFuture、或者消息队列,租户上下文不会自动传过去,必须手动传递。我推荐用阿里开源的 TransmittableThreadLocal,它能在线程池提交任务的时候自动拷贝上下文,比手动传参省事得多。
public class TenantContext { private static final ThreadLocal<String> CURRENT = new TransmittableThreadLocal<>(); public static void set(String tenantId) { CURRENT.set(tenantId); } public static String get() { return CURRENT.get(); } public static void clear() { CURRENT.remove(); } }另外还要防"漏网之鱼"。有些查询是系统级的,比如定时任务、后台管理员的全局统计,这些查询不应该被加上租户过滤。我的做法是在注解上做文章,定义一个@IgnoreTenant注解,MyBatis 拦截器发现当前方法带有这个注解,就跳过租户条件拼接。这样既能保证默认安全,又能给确实需要的场景留口子。
> 实操心得:租户拦截器上线之后,务必加一个"全表扫描告警"。只要发现某条 SQL 没有命中租户字段的索引,就打印警告日志。这个告警帮我在项目里揪出过好几个忘了加注解的定时任务。3. 权限模型怎么和多租户维度叠加
3.1 RBAC 在多租户后台里的常见变体
标准的 RBAC 是三张表:用户、角色、权限。放到多租户场景里,每张表都要加租户字段,而且角色一般是租户私有的。也就是说,A 门店能创建自己的"店长"角色,B 门店也能创建自己的"店长"角色,这两个角色虽然名字一样,但是完全独立。这个设计很重要,因为不同门店的岗位职责可能完全不同,你不能用一套全局角色去套所有人。
但也不是所有东西都要租户私有。菜单和功能权限点(比如"会员管理"、"订单导出")一般是系统预置的,全局共享。租户能做的只是决定自己启用哪些菜单。所以这里有个划分:功能权限点是平台级的,角色和角色权限关联是租户级的。
表结构大概是这样:
CREATE TABLE `sys_permission` ( `id` bigint NOT NULL COMMENT '权限点ID', `code` varchar(64) NOT NULL COMMENT '权限编码,如 member:list', `name` varchar(64) NOT NULL COMMENT '权限名称', `type` tinyint NOT NULL COMMENT '1菜单 2按钮 3接口', `parent_id` bigint DEFAULT 0, PRIMARY KEY (`id`) ) COMMENT='平台级权限点,不带租户字段'; CREATE TABLE `sys_role` ( `id` bigint NOT NULL, `tenant_id` varchar(32) NOT NULL, `name` varchar(64) NOT NULL, `data_scope` tinyint DEFAULT 1 COMMENT '1本人 2本组 3本部门 4全部', PRIMARY KEY (`id`), KEY `idx_tenant` (`tenant_id`) ) COMMENT='租户级角色'; CREATE TABLE `sys_role_permission` ( `role_id` bigint NOT NULL, `permission_id` bigint NOT NULL, PRIMARY KEY (`role_id`, `permission_id`) ) COMMENT='角色权限关联';注意sys_permission这张表我是故意不加租户字段的。因为权限点本身是描述系统能力的,属于平台资产。而角色是租户自己定义的,所以要加租户字段。这个划分一开始想清楚,后面就少很多麻烦。
3.2 数据权限范围怎么设计才不失控
数据权限这一层,我在前面提过,是行级别的可见范围控制。常见的有四种范围:本人、本组、本部门、全部。实现方式是在查询的时候动态拼接条件。
比如一个销售顾问只能看自己名下的会员,SQL 就变成where owner_id = 当前用户ID。销售主管能看整个小组的,就变成where owner_id in (小组成员列表)。这些条件要和租户条件叠加在一起,等于 SQL 的 where 子句里同时有tenant_id和owner_id两类约束。
设计上我建议把数据范围配置在角色上,而不是在用户上。因为用户是会变动的,但岗位职责相对稳定。用户换岗的时候只要换角色,数据范围自动跟着变。另外再给用户留一个"个人数据范围覆盖"字段,用于特殊场景,比如某个销售被临时授权看全店数据。这样优先级就是:个人覆盖 > 角色配置。
注意:数据权限的拼接一定要放在租户过滤之后,而且要用 AND 连接,绝对不能用 OR。我曾经见过一个同事把范围条件用 OR 拼上去,结果直接绕过了租户隔离,用户能看到别的门店的数据。这类错误非常隐蔽,测试的时候不容易发现,上线后是数据安全事故。
3.3 菜单权限与前端路由怎么联动
后端的权限配好了,前端要跟着动。Vue3 后台管理系统里,最常见的做法是登录之后拉取用户菜单树,然后动态生成路由。
流程是这样的:用户登录成功后,调一个/getRouters接口,后端根据用户的角色计算出这个租户下这个用户能访问的菜单,返回一棵树。前端拿到树之后,用router.addRoute()动态挂载。这样不同租户、不同角色的用户看到的后台界面是不一样的。
我的经验是,菜单树上给每个节点都带上component路径,前端维护一个组件映射表,用import.meta.glob批量导入页面组件。这样后端加菜单的时候不需要前端改代码,只要组件已经放在约定目录里就行。
// 动态路由注册 const modules = import.meta.glob('./views/**/*.vue') function loadComponent(componentPath) { return modules[`./views/${componentPath}.vue`] } function buildRoutes(menuTree) { return menuTree.map(item => ({ path: item.path, name: item.name, component: loadComponent(item.component), meta: { title: item.title, icon: item.icon }, children: item.children ? buildRoutes(item.children) : [] })) }这里有个坑要提醒:Vue3 的动态路由如果处理不好,刷新页面会白屏。因为路由是运行时挂载的,刷新之后还没挂载完,路由匹配就发生了。标准的解法是在路由守卫里先判断有没有拿到用户信息,没有就先拉信息再挂路由,挂完再next({ ...to, replace: true })重新走一遍。这个套路几乎每个后台项目都要写一遍。
4. Vue3 后台管理系统的工程化组织
4.1 项目结构怎么划分才适配多租户
一个能撑住多租户场景的 Vue3 后台,目录结构必须清晰。我一般这么分:
src/api:所有接口请求,按业务模块分文件src/store:Pinia 状态,租户信息、用户信息、权限菜单都在这里src/router:静态路由 + 动态路由生成逻辑src/views:页面组件,按业务模块分目录src/components:通用业务组件,比如租户切换器、数据权限选择器src/utils:请求封装、租户工具函数、权限指令src/directives:自定义指令,比如v-hasPerm控制按钮显隐
重点是store里的租户状态。用户登录之后,租户信息(租户 ID、租户名称、租户 logo、租户主题色)全都要存下来。因为很多地方要用,比如请求头、页面标题、顶部导航栏。放到 Pinia 里,配合持久化插件存到 localStorage,刷新之后不用重新登录。
页面标题也值得一提。多租户系统里,浏览器的标题应该是"租户名称 - 当前页面名称",而不是统一写死一个系统名。这个细节能显著提升用户体验,让每个门店的运营人员觉得"这是我们自己的系统"。
4.2 请求层怎么自动带上租户标识
请求层是多租户前端最容易被忽略的地方。很多人写完登录接口就忘了在请求拦截器里加租户标识,结果所有请求都是无租户上下文,后端要么报错要么查到全部数据。
我的做法是双重保险。第一层,在请求拦截器里从 store 里取租户 ID,塞到请求头X-Tenant-Id里。第二层,后端的租户解析过滤器优先从请求头取,取不到再从前端域名解析。这样即使前端某次忘了带,域名也能兜底。
service.interceptors.request.use(config => { const tenantStore = useTenantStore() if (tenantStore.tenantId) { config.headers['X-Tenant-Id'] = tenantStore.tenantId } const token = getToken() if (token) { config.headers['Authorization'] = `Bearer ${token}` } return config })关于租户 ID 到底放在请求头还是 token 里,我的看法是:如果用户只属于一个租户,放 token 里最省事;如果用户可能属于多个租户,需要切换,那必须放请求头,因为 token 是切换之后才刷新的,请求头可以每个请求单独指定。SaaS 平台上运营人员跨租户切换的场景很常见,所以我一般默认放请求头。
4.3 一套代码怎么支撑多租户的差异化配置
多租户系统经常遇到客户提个性化需求,比如 A 门店想要侧边栏是深色的,B 门店要浅色的,C 门店的 logo 要放在左上角。如果每个需求都改代码分支,那就完蛋了。正确的做法是把这些差异配置化。
具体来说,租户表里存一份配置 JSON,包含主题色、logo 地址、首页路径、是否开启某个功能开关。前端启动的时候拉取这份配置,动态生成 CSS 变量注入到根节点上。Vue3 配合 CSS 变量做主题切换非常方便,--primary-color一改,所有用到它的组件自动变色。
function applyTenantTheme(theme) { const root = document.documentElement root.style.setProperty('--primary-color', theme.primaryColor) root.style.setProperty('--sidebar-bg', theme.sidebarBg) root.style.setProperty('--header-bg', theme.headerBg) }功能开关也是同样的思路。比如积分商城功能,A 门店开了 B 门店没开。前端在路由生成的时候,从租户配置里读功能开关,没开的功能对应的菜单直接不挂载,用户看不到入口。后端也要同步校验,防止有人直接调接口绕过。
实操心得:租户配置一定要做缓存,而且要有版本号。前端每次启动比对版本号,版本没变就用本地缓存,变了才去拉全量。否则每次刷新页面都多一个接口请求,页面加载会变慢。
5. 以 4S 店积分小程序后台为例的完整落地
5.1 业务模型怎么拆
前面讲的都是通用打法,这一节我用一个具体项目把它串起来。场景是汽车 4S 店的积分小程序加后台管理系统,品牌方下面有几十家门店,每家门店独立运营自己的会员和积分。
核心业务实体有这些:门店(租户)、会员、积分账户、积分流水、积分商品、兑换订单、核销记录。其中门店就是租户,其余所有实体都要带tenant_id。
会员和门店的关系要特别说明。会员一般是手机号注册,一个手机号理论上可以注册多个门店的会员,因为 A 店买过车不代表不能在 B 店保养。所以会员表的主键不能用手机号,要用独立 ID,手机号 + 租户 ID 做联合唯一约束。这个设计我一开始没做对,用了手机号做主键,后来发现用户跨店注册会冲突,只能重构,代价很大。
积分账户和积分流水要分开。账户存当前余额,流水存每一次变动。余额更新要用乐观锁或者数据库的行锁,绝对不能用"先查再改"的方式,高并发下必然出现余额错误。我吃过这个亏,做活动的时候积分发放并发高,余额对不上,最后是靠补流水对账才修好的。
5.2 关键表结构设计
给出几张核心表的设计,重点是租户字段和索引的处理。
CREATE TABLE `t_member` ( `id` bigint NOT NULL COMMENT '会员ID', `tenant_id` varchar(32) NOT NULL COMMENT '门店ID', `phone` varchar(20) NOT NULL COMMENT '手机号', `nickname` varchar(64) DEFAULT NULL, `level_id` int DEFAULT 1 COMMENT '会员等级', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_tenant_phone` (`tenant_id`, `phone`), KEY `idx_tenant_create` (`tenant_id`, `create_time`) ) COMMENT='会员表'; CREATE TABLE `t_point_account` ( `id` bigint NOT NULL, `tenant_id` varchar(32) NOT NULL, `member_id` bigint NOT NULL, `balance` int NOT NULL DEFAULT 0 COMMENT '当前积分余额', `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY (`id`), UNIQUE KEY `uk_tenant_member` (`tenant_id`, `member_id`) ) COMMENT='积分账户'; CREATE TABLE `t_point_flow` ( `id` bigint NOT NULL, `tenant_id` varchar(32) NOT NULL, `member_id` bigint NOT NULL, `change_amount` int NOT NULL COMMENT '变动值,正为增负为减', `balance_after` int NOT NULL COMMENT '变动后余额', `biz_type` varchar(32) NOT NULL COMMENT '业务类型', `biz_id` varchar(64) DEFAULT NULL COMMENT '业务单据ID', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_tenant_member_time` (`tenant_id`, `member_id`, `create_time`) ) COMMENT='积分流水';积分账户的更新用乐观锁:
UPDATE t_point_account SET balance = balance + #{amount}, version = version + 1 WHERE id = #{id} AND version = #{version} AND tenant_id = #{tenantId}注意最后那个tenant_id条件,即使前面拦截器已经拼过一次,关键写操作我建议再显式写一遍。多一层保险,出事的概率就低一分。
5.3 核心接口的实现思路
积分发放接口是最核心的,它要同时做几件事:校验租户、加锁更新账户、写流水、发消息通知。我的实现顺序是这样的:
- 从租户上下文取出 tenant_id,校验会员是否属于该租户
- 开事务,用乐观锁更新积分账户,失败则重试(最多三次)
- 写积分流水,记录变动前后的余额
- 事务提交后,发送 MQ 消息,触发等级重算和消息推送
为什么要先更新账户再写流水?因为流水里有balance_after字段,必须先知道更新后的余额才能写流水。而且整个操作必须在同一个事务里,否则账户更新了流水没写,对账就会出现黑洞。
重试机制也很关键。乐观锁在高并发下失败率不低,如果直接抛异常给用户,体验很差。我的做法是在 Service 层包一层重试,每次重试重新查一次最新版本号。三次还失败就返回"系统繁忙请重试",这种情况在实际业务里极少。
跨租户的接口一定要禁止。比如全局统计接口,只能平台超管访问,普通租户管理员调的时候直接返回 403。我一般会在网关层做一个租户校验,普通租户的请求不允许访问/admin/global/**路径。这条规则简单粗暴,但非常有效。
6. 踩过的坑和排查速查表
6.1 几个印象最深的典型问题
第一个坑是唯一索引冲突,前面提过,这里再强调一次。多租户改造的时候,全库唯一索引都要 review。我的做法是写一个脚本,扫描information_schema里的所有唯一索引,列出没有包含tenant_id的,人工确认。这个脚本帮我发现了七八个遗漏的索引。
第二个坑是缓存 key 冲突。多租户系统里,缓存 key 必须带租户前缀。我见过有人把会员信息缓存成member:{memberId},结果不同租户的 memberId 如果用的是同一个序列,就会互相覆盖。正确的是member:{tenantId}:{memberId}。这个问题特别隐蔽,因为单租户测试永远发现不了,只有多个租户并行跑的时候才暴露。
第三个坑是异步任务的租户上下文丢失。定时任务扫描待发放的积分,如果在主线程里设置了租户上下文,然后提交到线程池,线程池里的线程是拿不到上下文的。结果就是扫描到了数据但是更新的时候租户条件为空,要么报错要么更糟——更新了别的租户的数据。用 TransmittableThreadLocal 能解决大部分场景,但如果任务本身是跨租户的,那就得在任务内部手动为每条数据处理时切换上下文。
第四个坑是前端菜单缓存。用户在一个租户下登录,菜单被缓存了,切换到另一个租户之后菜单没更新,点进去全是 403。解法是在租户切换的时候强制清空路由和菜单缓存,重新拉取。
6.2 问题排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 能看到其他租户数据 | 拦截器未生效或 SQL 绕过拦截 | 检查 SQL 是否走了 MyBatis 拦截器,是否有手写 JDBC |
| 数据插入报唯一约束错误 | 唯一索引未包含 tenant_id | 查 information_schema 中的唯一索引定义 |
| 查询变慢 | tenant_id 未放在联合索引首位 | 用 explain 看执行计划,调整索引列顺序 |
| 刷新页面白屏 | 动态路由未挂载完就渲染 | 检查路由守卫逻辑,确认先拉菜单再放行 |
| 异步任务更新错数据 | ThreadLocal 未传递 | 检查线程池类型,改用可传递的上下文容器 |
| 切换租户后接口 403 | 菜单缓存未清除 | 切换时清空路由与权限缓存并重新拉取 |
| 缓存数据串租户 | 缓存 key 未带租户前缀 | 统一缓存 key 生成规则,强制加租户标识 |
这张表我贴在项目 wiki 上,新人入职第一周就要看一遍。里面每一条都是真金白银换来的教训,能省下大量排查时间。
6.3 关于上线前必做的三件事
在我负责的项目里,多租户功能上线前一定会做三件事,缺一不可。
第一件是租户数据隔离测试。准备两个租户,各造一批数据,然后用租户 A 的账号去遍历所有接口,看能不能查到租户 B 的数据。这个测试要覆盖增删改查所有操作,尤其是导出、报表、批量操作这些容易漏掉的地方。我一般会写一个自动化脚本跑,人工测覆盖不全。
第二件是并发测试。用 JMeter 或者 k6 压积分发放接口,看余额是否一致。并发不高的时候问题不明显,一旦到了活动日,流量翻十倍,事务和锁的问题全出来了。压测能提前暴露。
第三件是数据初始化脚本。多租户系统里,新租户创建的时候需要初始化一批默认数据,比如默认角色、默认菜单、默认积分规则。这个初始化逻辑一定要做成可重复执行的脚本,而且要有事务保护。我见过初始化一半失败导致租户建了但没菜单的情况,用户登录进去一片空白,只能手动补数据。
最后分享一个我自己的小习惯。每次做完多租户相关的改动,我都会在本地起两个租户的账号,用两个浏览器(一个正常窗口一个无痕窗口)同时登录,交叉点击一遍核心流程。这个动作看起来笨,但抓出过好几次上下文串租户的问题,比看代码靠谱多了。多租户这东西,代码审查再仔细也难免有漏,只有真刀真枪跑一遍才放心。