SaaS 模式的核心挑战,从来都不是功能多少,而是隔离。
一套系统承载几十上百个商家租户,共用服务器、共用数据库、共享同一套代码。但数据绝对不能串——A 租户看不到 B 租户的商品、订单、用户;功能绝对不能越——基础版租户用不了专业版的模块;资源必须受限——超套餐的商品数、订单数要能拦住。
任何一层做不到位,都是线上事故。
LikeShop 单商户 SaaS 版(Java 技术栈)在租户隔离上采用的是分层隔离思路:数据层分库 / 分表可选、框架层强制 tenant_id 注入、应用层配置级功能权限。在轻量部署和安全隔离之间找平衡,同时支持从小规模到大规模的平滑演进。
一、SaaS 租户隔离的两种主流方案
多租户数据隔离,行业内主流就两种:独立数据库(分库)和共享数据库 + tenant_id(分表)。没有绝对的好坏,只看租户规模、行业合规要求和运维成本。
1.1 两种方案对比
| 维度 | 共享库·分表方案 | 独立库·分库方案 |
|---|---|---|
| 隔离级别 | 逻辑隔离,靠代码保证 | 物理隔离,数据库层面天然隔离 |
| 租户数量上限 | 高,单库可承载上千租户 | 低,几十到几百租户 |
| 数据库成本 | 低,一个实例搞定 | 高,每个租户一个库或按组分配 |
| 性能开销 | 小,只是多一个查询条件 | 小,但连接池管理复杂 |
| 运维难度 | 低,统一备份、统一升级 | 高,每个库单独维护、升级、备份 |
| 合规性 | 一般,满足普通电商场景 | 高,适合强监管行业 |
| 适合场景 | 中小租户、标准 SaaS 服务 | 大客户定制、强合规要求、数据量大 |
1.2 LikeShop 的选型策略
LikeShop 单商户 SaaS 版默认采用共享库 + tenant_id 分表方案,这是综合了绝大多数中小 SaaS 场景的选择:
- 租户规模从几十到几百都能承载;
- 运维成本低,一套数据库统一维护;
- 系统升级简单,一次发布全量生效;
- 性能足够,只要索引设计合理,tenant_id 过滤后的数据量对 MySQL 完全不是压力。
同时架构上也支持分库模式:当平台出现超大租户、或者合规要求物理隔离时,可以按租户哈希分库、按套餐分库,平滑演进,不用推翻重构。
二、分表方案:全表 tenant_id + 框架层强制注入
分表方案最核心的原则只有一条:绝对不能靠开发者自觉加 tenant_id。
只要靠人,就一定会漏。漏一次,就是跨租户数据泄露。正确的做法是在框架层强制注入,业务开发者根本感知不到 tenant_id 的存在。
2.1 数据库表设计
所有业务表必须带 tenant_id:
-- 商品表
CREATE TABLE ls_goods (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL COMMENT '租户ID',
name VARCHAR(255) NOT NULL,
-- ... 其他字段
INDEX idx_tenant_id(tenant_id)
);
-- 订单表
CREATE TABLE ls_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
order_sn VARCHAR(32) NOT NULL,
-- ...
INDEX idx_tenant_id(tenant_id)
);
-- 用户表
CREATE TABLE ls_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
nickname VARCHAR(64),
-- ...
);
系统公用表不带 tenant_id:
- 套餐表
ls_plan - 租户表
ls_tenant - 字典表、配置表、行政区域表
- 定时任务调度表
2.2 框架层自动注入:MyBatis-Plus 租户插件
LikeShop Java 版用的是 MyBatis-Plus 内置的 TenantLineInnerInterceptor 租户线插件。所有 SQL 执行前,插件自动拼接 tenant_id = ? 条件。
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 租户线拦截器
TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor();
tenantInterceptor.setTenantLineHandler(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 从线程上下文取当前租户
Long tenantId = TenantContext.getTenantId();
return new LongValue(tenantId);
}
@Override
public boolean ignoreTable(String tableName) {
// 忽略系统表,不加 tenant_id
return TenantConfig.getIgnoreTables().contains(tableName);
}
});
interceptor.addInnerInterceptor(tenantInterceptor);
return interceptor;
}
}
这样一来,业务代码里写:
goodsMapper.selectList(queryWrapper);
实际执行的 SQL 会自动变成:
SELECT * FROM ls_goods WHERE tenant_id = 1001 AND ...
开发者完全不用管 tenant_id,想漏都漏不掉。
2.3 租户上下文:怎么识别当前是谁
租户 ID 不能每次请求都查库,要放在线程上下文里:
public class TenantContext {
private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();
public static void setTenantId(Long tenantId) {
TENANT_ID.set(tenantId);
}
public static Long getTenantId() {
Long id = TENANT_ID.get();
if (id == null) {
throw new IllegalStateException("租户上下文未初始化");
}
return id;
}
public static void clear() {
TENANT_ID.remove();
}
}
再用一个 Web 拦截器,在请求最开始识别租户:
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 方式1:按域名识别
String domain = request.getServerName();
Tenant tenant = tenantService.getByDomain(domain);
// 方式2:按请求头 tenant-id 识别(开放接口用)
// String tenantHeader = request.getHeader("X-Tenant-Id");
TenantContext.setTenantId(tenant.getId());
return true;
}
@Override
public void afterCompletion(...) {
TenantContext.clear();
}
}
从请求入口到数据库底层,全链路自动带上租户,业务代码零感知。
三、分库方案:物理隔离的进阶模式
当平台出现以下情况时,分表就不够了:
- 单个租户数据量超大(百万级订单);
- 大客户要求物理隔离;
- 行业合规要求数据必须独立存储。
这时候就要演进到分库模式。
3.1 实现思路:动态数据源 + 路由策略
// 动态数据源路由
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
Long tenantId = TenantContext.getTenantId();
// 按租户ID哈希分库:tenant_0 ~ tenant_7
return "tenant_" + (tenantId % 8);
}
}
分库策略有几种常见玩法:
- 按租户 ID 哈希分库:均匀分布,适合大量中小租户;
- 按套餐分库:免费版一个库、专业版一个库、大客户独立库;
- 按行业分库:零售一个库、餐饮一个库、服务类一个库。
3.2 平滑演进
LikeShop 的架构支持平滑过渡:
- 初期:单库分表,快速上线,成本低;
- 增长期:按套餐分库,把大客户迁出去;
- 成熟期:全量哈希分库,统一架构。
不需要一开始就上最复杂的方案,跟着业务规模长就行。
四、配置级权限:不只是数据隔离,功能也要隔离
SaaS 的隔离不只是数据。不同套餐的租户,能使用的功能也必须是隔离的——基础版不能用分销、免费版不能开 APP、入门版没有数据导出。
这就是配置级权限控制。
4.1 套餐功能配置
每个套餐的功能权限,存在 features_json 字段里:
{
"distribution": false,
"seckill": true,
"bargain": false,
"member_level": true,
"app": false,
"data_export": true,
"multi_shop": false
}
不写死在代码里,全部配置化。新增功能只要加一个 key,不用改套餐表结构。
4.2 接口层强制校验
只在前端隐藏菜单没用,后端接口必须再校验一次。
自定义注解 + 拦截器:
// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireFeature {
String value();
}
// 拦截器
@Component
public class FeatureInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
Method method = ((HandlerMethod) handler).getMethod();
RequireFeature anno = method.getAnnotation(RequireFeature.class);
if (anno != null) {
String feature = anno.value();
if (!PlanService.canUse(TenantContext.getTenantId(), feature)) {
throw new BizException("当前套餐不支持该功能");
}
}
return true;
}
}
用的时候直接在接口上加注解:
@RequireFeature("distribution")
@PostMapping("/distributor/apply")
public R applyDistributor() {
// ...
}
4.3 用量限制
除了功能开关,还有用量限制:商品数上限、月订单数上限、存储空间上限、短信条数上限。
@Service
public class GoodsService {
public void createGoods(GoodsDTO dto) {
Tenant tenant = tenantService.getById(TenantContext.getTenantId());
Plan plan = planService.getById(tenant.getPlanId());
if (plan.getGoodsLimit() > 0
&& tenant.getGoodsCount() >= plan.getGoodsLimit()) {
throw new BizException("商品数量已达套餐上限,请升级");
}
// ... 创建商品
}
}
用量数据每天定时任务刷新,冗余到租户表,不用每次 count。
五、全链路隔离的完整路径
真正可靠的租户隔离,不是只在数据库加个字段。它是一条完整的链路:
请求接入 → 租户识别 → 上下文绑定 → 功能权限校验 → DAO自动加tenant_id → 业务执行 → 结果过滤 → 前端按权限渲染
每一层都有自己的职责,哪一层漏了都可能出问题。
额外还要注意运维侧的隔离:
- 平台超管可以跨租户看数据,但每一步操作都要写审计日志;
- 商家客服只能看自己租户的数据,天然隔离;
- 数据导出接口必须强制校验 tenant_id,不能导出全平台数据。
六、常见坑与最佳实践
坑 1:原生 SQL 绕过插件
最常见的事故源:写了原生 SQL(@Select 写死 SQL 字符串),租户插件不解析,结果没加 tenant_id,全表数据都出来了。
最佳实践:
- 优先使用 MP 的 QueryWrapper、Lambda 表达式;
- 必须写原生 SQL 时,手动加
tenant_id = #{tenantId}; - 代码审查重点查原生 SQL。
坑 2:异步 / 多线程丢失上下文
ThreadLocal 是线程绑定的,异步任务、线程池里的线程拿不到租户 ID。
最佳实践:
- 自定义线程池包装,提交任务时传递 TenantContext;
- 或者用 TransmittableThreadLocal 替代 ThreadLocal。
坑 3:新增表忘记加 tenant_id
业务迭代加新表,开发忘了加 tenant_id 字段,插件配置里也没加排除,结果 SQL 报错或者数据串。
最佳实践:
- 建表规范强制要求业务表必须有 tenant_id;
- 上线前跑 SQL 审计脚本,检查所有业务表是否都有该字段。
坑 4:管理员越权操作
平台超管账号权限太大,能看所有租户数据,操作没留痕,出了事查不到。
最佳实践:
- 超管操作全部留审计日志;
- 敏感操作(改套餐、改数据)二次确认;
- 定期审计超管操作日志。
坑 5:分库模式下的事务问题
分库后,跨租户事务失效,不能用本地事务。
最佳实践:
- 尽量避免跨租户操作;
- 必须跨租户的场景,用最终一致性方案,不要强事务。
结语
SaaS 租户隔离,从来都不是"加个 tenant_id 字段"这么简单的事。它是从接入层、框架层、数据层、业务层到运维层的全链路设计。
LikeShop 单商户 SaaS 版的这套方案,核心思路就是分层隔离、按需演进:
- 小规模用分表,成本低、上线快;
- 大规模转分库,物理隔离、满足合规;
- 功能权限配置化,套餐灵活调整;
- 框架层强制注入,避免人为疏漏。
对做 SaaS 的团队来说,隔离属于"地基"级别的设计——地基打稳了,上面加功能、加套餐、扩租户,才是顺理成章的事。