本文 的 原文 地址
原始的内容,请参考 本文 的 原文 地址
本文 的 原文 地址
尼恩说在前面
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,遇到很多很重要的面试题:
如果要你设计一个 SaaS 多租户平台,你会如何设计 隔离架构?
如何 自研一个非侵入式 SaaS 多租户组件?
最近又有小伙伴在面试很多 SaaS类软件公司,都遇到了相关的面试题。虽然 回答了一些边边角角,但是回答不全面不体系,面试官不满意,面试挂了。
借着此文,尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,展示一下雄厚的 “技术肌肉、技术实力”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提,offer自由”。
当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典PDF》V140版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请到文末公号【技术自由圈】获取
自研一个非侵入式 SaaS 多租户组件
在软件行业,“软件即服务”(SaaS)已成为主流商业模式。
SaaS 承诺为不同客户(即“租户”)提供标准化的服务,同时实现快速交付与规模化运维。
然而, SaaS 架构师都必须面对的核心挑战:
如何 为成百上千的租户构建一个既能共享资源以降低成本,又能严格隔离数据以确保安全的系统?
想象一下,如果 A 公司的财务报表意外地出现在了 B 公司的后台,这将是怎样一场灾难性的数据泄露事故。
因此,数据隔离不仅是技术问题,更是 SaaS 产品的生命线。
本文通过自研***mon-tenant-starter模块为例, 深入探索一套企业级的多租户数据隔离解决方案。
本文 基础的 前置知识
京东面试:说说mybatis底层原理。 MyBatis 如何实现 “面向接口” 查询的 ?
全文大纲
为了系统性地解构多租户架构的实现,我们将遵循从宏观设计到微观实现的路径,逐一攻克各个技术要点。
第一章:核心挑战:在“共享”与“隔离”之间寻求平衡
构建 SaaS 平台,首先要做的便是在多租户的数据隔离方案上做出抉择。
业界主流的方案大致可分为三层:
独立数据库(物理隔离):为每个租户提供独立的数据库实例。隔离级别最高,但成本和运维复杂度也最高。
共享数据库,独立 Schema:所有租户共享同一个数据库实例,但每个租户拥有独立的 Schema。隔离性和成本之间取得了较好的平衡。
共享数据库,共享 Schema,字段隔离:所有租户共享同一个数据库、同一套表结构。通过在每张业务表中增加一个tenant_id字段来区分数据归属。资源利用率最高,但对应用层的代码设计提出了最高的挑战。
本项目选择的正是第三种方案,这也是绝大多数 SaaS 产品的主流选择。
| 对比维度 | 1. 独立数据库 | 2. 共享库,独立 Schema | 3. 共享库,共享 Schema (本项目) |
|---|---|---|---|
| 隔离级别 | 非常高 (物理隔离) | 高 (逻辑隔离) | 一般 (应用层隔离) |
| 数据安全 | 非常好 | 好 | 依赖代码实现 |
| 开发成本 | 低 | 较高 | 高,需保证所有 SQL 正确 |
| 维护成本 | 非常高 | 较高 | 低 |
| 资源成本 | 非常高 | 较高 | 低 |
| 扩展性 | 差 | 一般 | 非常好 |
这种方案的魅力在于其极高的资源利用率和灵活性,但其最大的风险在于“忘记”添加tenant_id过滤条件。
因此,我们的核心设计目标必须是:通过一套自动化的、非侵入的机制,让开发者无需时刻关心tenant_id,系统也能百分之百正确地执行数据过滤。
第二章:一键使用我们的 ***mon-tenant-starter
在开始自研 组件之前,先了解如何为在一个新的微服务中,一键使用我们 自研的 多租户组件 ***mon-tenant-starter 开启多租户功能。
整个过程被设计得极其简单,只需三步。
第一步:引入依赖
在你的微服务模块的 pom.xml 文件中,添加 ***mon-tenant-starter 的依赖。
<dependency>
<groupId>org.dromara</groupId>
<artifactId>***mon-tenant-starter</artifactId>
</dependency>
第二步:开启租户功能
修改配置文件,在服务的 application.yml 配置文件中,添加以下配置来开启多租户功能。
tenant:
enable: true
# 配置不需要进行租户ID过滤的表, 例如全局配置表、字典表等
excludes:
- not_tenant_table
- not_tenant_column
第三步:改造数据库表
改造数据库表, 为所有需要按租户隔离的业务表添加 tenant_id 字段。
ALTER TABLE your_business_table ADD COLUMN tenant_id VARCHAR(20) NOT NULL ***MENT '租户编号';
完成以上三步,你的微服务就已经具备了全方位的数据隔离能力!就是这么简单。
第三章:自研组件第一步: TenantHelper 上下文基础类
要实现自动化的多租户 数据过滤, 首先必须能在任何时刻、任何代码位置,准确地知道“当前正在为哪个租户服务”。TenantHelper正是为此而生的核心工具类。
核心原理:ThreadLocal
TenantHelper的底层精髓是ThreadLocal。
ThreadLocal为每个线程提供独立的变量副本,确保了在同一个请求的处理流程中,无论代码调用链有多深,都能随时获取到正确的租户上下文,且不会与其它并发请求发生混淆。
获取租户id的两个场景:
当需要获取当前租户 ID 时,它遵循一个清晰的优先级顺序:
-
优先动态 获取租户 ID:
TenantHelper提供了一个dynamic(tenantId, handle)方法,允许在代码中临时切换到指定的租户上下文来执行一段逻辑。 -
其次 获取 当前登录用户的租户 ID:
如果不存在动态租户 ID,
TenantHelper会通过LoginHelper.getTenantId()获取当前登录用户的租户 ID。
public class TenantHelper {
public static String getTenantId() {
if (!isEnable()) {
return null;
}
// 1. 尝试获取动态设置的租户ID
String tenantId = TenantHelper.getDynamic();
if (StringUtils.isBlank(tenantId)) {
// 2. 获取当前登录用户的租户ID
tenantId = LoginHelper.getTenantId();
}
return tenantId;
}
}
getDynamic 、getTenantId 这两个方法的源码通常如下:
1. TenantHelper.getDynamic()方法源码
public class TenantHelper {
/**
* 动态租户ID的ThreadLocal容器
*/
private static final ThreadLocal<String> DYNAMIC_TENANT_ID = new ThreadLocal<>();
/**
* 获取动态设置的租户ID
* 这个方法通常用于在特定业务场景下临时覆盖当前线程的租户ID
*/
public static String getDynamic() {
return DYNAMIC_TENANT_ID.get();
}
/**
* 设置动态租户ID
*/
public static void setDynamic(String tenantId) {
DYNAMIC_TENANT_ID.set(tenantId);
}
/**
* 清除动态租户ID
*/
public static void clearDynamic() {
DYNAMIC_TENANT_ID.remove();
}
/**
* 判断是否启用多租户模式
*/
public static boolean isEnable() {
// 通常从配置文件中读取多租户是否启用的配置
return RuoYiConfig.getTenantEnable();
}
}
2. LoginHelper.getTenantId()方法源码
public class LoginHelper {
/**
* 获取当前登录用户的租户ID
*/
public static String getTenantId() {
try {
LoginUser loginUser = getLoginUser();
if (loginUser != null) {
return loginUser.getTenantId();
}
} catch (Exception e) {
// 忽略异常,可能是用户未登录等情况
}
return null;
}
/**
* 获取当前登录用户信息
*/
public static LoginUser getLoginUser() {
try {
// 从SecurityContextHolder中获取认证信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
Object principal = authentication.getPrincipal();
if (principal instanceof LoginUser) {
return (LoginUser) principal;
}
}
} catch (Exception e) {
// 异常处理
}
return null;
}
}
第四章: Mybatis-Plus 拦截器如何成为“SQL 非入侵修改”
…由于平台篇幅限制, 剩下的内容(5000字+),请参参见原文地址
原始的内容,请参考 本文 的 原文 地址
本文 的 原文 地址