简介:多租户架构是SaaS平台的关键设计模式,数据隔离方案直接决定系统的隔离强度与资源成本。这份SpringBoot实现参考项目面向Java后端开发者与SaaS架构学习者,聚焦独立数据库与共享数据库独立Schema两种隔离模式,通过动态数据源与租户识别机制,在保证租户数据隔离的同时提升数据库资源利用率。资源共36个文件、约94KB,主体为24个Java源码文件,配合pom.xml、yml/properties配置、HTML说明页等文件,构成可直接阅读和研究的最小工程骨架。已有75人学习下载。项目中包含本地、测试等多模块目录,核心代码覆盖租户上下文解析、动态数据源路由、事务管理和安全隔离等关键环节;说明文件与README还对搭建步骤、常见问题及排错思路做了整理,适合用于理解多租户动态切库的整体落地流程,也可作为设计类似系统的参考起点。
1. 多租户SaaS系统:数据隔离这块硬骨头从哪下口
这段时间在帮一家做餐饮SaaS的团队复盘多租户改造,发现十个项目里有八个最初都在业务代码里硬编码 tenant_id 过滤条件,上线后第一批客户批量投诉:导出报表一拉就是别人的账单。多租户系统的核心从来不是租户表怎么建,而是数据隔离策略怎么设计。这个资源包把SpringBoot框架下两种主流隔离模式——独立数据库与共享数据库独立Schema——从租户识别、动态数据源到连接切换完整捋了一遍,适合正在搭SaaS平台底座、准备把单租户项目改造成多租户、或者被数据串租户事故逼着重构的Java工程师。动手之前先搞清楚一个问题:你的租户里有多少大客户、多少小客户,这直接决定下面的选型。
2. 先算账再动手:独立数据库与共享Schema模式的选型对比
2.1 三种主流多租户模式的成本与隔离性对比
多租户系统的数据隔离,业界通常归纳为三种主流做法:独立数据库、共享数据库独立Schema、共享数据库共享表(用 tenant_id 字段区分)。三种模式在隔离强度、成本、运维复杂度上的差异非常大,适合的场景也不一样。下面这张对比表是我在做选型评审时固定使用的底稿。
| 维度 | 独立数据库 | 共享Schema | 共享表(tenant_id字段) |
|---|---|---|---|
| 数据隔离性 | 物理隔离,最强 | 逻辑隔离,中等 | 逻辑隔离,最弱 |
| 单租户成本 | 高(独立连接、独立存储) | 中(共享实例,独立Schema) | 低(一张表) |
| 备份与恢复 | 单库备份,恢复最干净 | Schema级备份,依赖数据库能力 | 按tenant_id导数据,容易漏 |
| 应用改造量 | 路由到不同数据源 | 路由到不同Schema或库 | 所有SQL强制带tenant_id |
| 适合场景 | 大客户、金融医疗等高合规要求 | 中腰部客户,SaaS标准产品 | 海量小客户、免费引流版 |
从这张表能看出一个关键结论:隔离强度直接和成本挂钩。独立数据库模式下,租户越多,数据库连接数、备份任务数、监控对象都线性增长,运维压力远大于共享Schema模式;共享Schema模式在数据库层多租户成本更低,但同一实例下所有租户共享CPU、内存和IO,大租户的慢查询可能拖垮整个实例。共享表模式在数据库层几乎没有额外开销,但应用层每个SQL都必须带租户条件,漏一个条件就是一场串数据事故。
所以选型不存在"哪个最好",只存在"哪个最匹配你的租户画像"。如果你的客户里有银行、医院这类要求数据物理隔离的,独立数据库是唯一选择;如果客户大部分是中小商家,共享Schema在隔离性和成本之间平衡得最好;如果是免费版引流产品,共享表加租户ID是务实方案,但必须在ORM层做强制约束。
2.2 独立数据库模式:每个租户一套独立连接
独立数据库模式下,每个租户拥有一个独立的物理数据库。应用层需要维护一张"租户ID → 数据源"的路由表,请求进来时根据租户信息动态选择数据源。在SpringBoot里,这个机制最自然的落点就是 AbstractRoutingDataSource,它允许你在获取数据库连接时根据当前上下文切换数据源。第四章会给出完整实现代码。
你可能会问:既然每个租户一个库,为什么不直接给每个租户部署一套应用?原因很简单:成本。假设你有100个租户,每套应用4个实例,那就是400个实例;而多租户架构下通常只需要几套应用实例共享,靠数据源路由来实现逻辑隔离。这也是SaaS平台能把客单价降下来、同时保证大租户数据安全的核心折中方案。
独立数据库模式的典型场景是:租户数量不大(几百以内)、有严格的数据合规要求(医疗、政务、金融)、或者存在超大型客户要求独享资源。它的优点是数据库级别的物理隔离,一个租户的数据泄露不影响其他租户;缺点也明显——连接数、存储量、备份任务都会随租户数量线性上升。如果租户规模预计破万,独立数据库模式基本不现实,成本算不过来。
2.3 共享数据库独立Schema:共享实例,隔离结构
共享Schema模式和独立数据库的区别在于:所有租户共用一个数据库实例,但每个租户拥有独立的Schema。MySQL里Schema概念等价于Database,PostgreSQL里Schema则是命名空间。应用层做切换时,切换的是同一实例下的不同Schema,底层仍然可以复用同一个连接池。
这个模式有两种实现方式。第一种是在JDBC URL里直接指定Schema,切换时重建连接,简单直接但连接创建开销大。第二种更优雅:连接获取后执行 USE schema_name 切换到对应租户的Schema,用完归还连接前再切回默认Schema。MySQL下通常用第二种,但这里有个致命细节:连接必须按租户隔离,否则A租户用过的连接残留了 USE 状态,归还到连接池后再被B租户拿到,数据就串了。第五章坑二会专门展开讲这个场景。
共享Schema模式的最大优势是租户成本低、迁移灵活。某个租户数据量暴涨时,可以在数据库层把它单独迁移到独立实例,应用层只需要在路由表里多配一条数据源记录。这种"先共享后独立"的演进路径也是我推荐多数SaaS平台走的路。
2.4 选型建议:按租户规模和客单价决策
我的建议是分三层来思考。第一层看合规要求,客户合同里明确要求数据物理隔离的,直接选独立数据库,没有讨论空间。第二层看租户规模,千级以下且都是付费客户的,独立数据库或共享Schema都可以,按运维能力选;上万租户且大部分是免费用户的,共享表是最现实的选择,但必须用MyBatis多租户插件这类手段强制补条件。第三层看客单价,高客单价客户的体验优先级最高,独立数据库能提供更好的故障隔离和性能保障,低客单价客户用共享Schema就够。
另一个容易被忽视的点是迁移成本。上线时选了共享Schema,后期某个大客户要求切独立库,应用层只需要在数据源路由表里多配一条记录,成本极低;反过来,共享表模式转共享Schema,需要做数据搬迁和SQL改造,工作量翻倍。所以如果你判断平台上大概率会出现"大客户要求独立部署"的情况,架构上优先选独立数据库或共享Schema,给自己留后路。
3. 租户识别与上下文传递:从Token解析到ThreadLocal的完整链路
数据源切换有个前置条件:应用在任意时刻都要知道"当前请求属于哪个租户"。这个信息从哪来、怎么传递、在异步任务中怎么不丢失,是整个多租户实现的第一个坑。下面这套链路是我在生产环境一直在用的结构,从请求进入系统开始到数据库访问为止,全程无感知。
3.1 租户识别链路总览
一次典型的多租户请求会经过这样一个流程:客户端请求携带租户标识(JWT里的tenantId、请求头X-Tenant-Id等)→ 全局过滤器或拦截器解析标识 → 放入租户上下文持有器(基于ThreadLocal)→ 路由层从上下文取租户ID → 切换到对应的数据源或追加Schema名 → 请求结束清理上下文。
为什么识别逻辑要放在Filter或Interceptor里?因为Filter比Interceptor更早执行,而且能覆盖绝大多数请求路径。我把识别逻辑放在Interceptor里,原因只有一个:它能访问到完整的Handler信息,方便按路径排除不需要租户上下文的接口。如果需要在CORS过滤器或日志过滤器里就用上租户ID,那就把识别逻辑前置到Filter,原理一样。
识别到的租户ID一定要放ThreadLocal而不是通过方法参数传递。虽然方法参数更显式,但多租户改造涉及的是全量Service层方法,每个方法都加一个tenantId参数改动量太大。而ThreadLocal配合Spring的拦截器机制,能让业务代码完全无感知,这是"最小侵入完成全链路隔离"的关键。
3.2 从JWT令牌中解析租户ID
现在大多数SaaS平台都用JWT做认证。租户ID作为JWT的一个claim,在登录时写入。下面这段代码展示了在SpringBoot拦截器里解析JWT并提取租户ID的标准写法,也是这个资源包里租户识别模块的骨架。
public class TenantInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 从请求头取token,兼容Authorization Bearer模式 String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } try { Claims claims = Jwts.parser() .setSigningKey(jwtSecret) // jwtSecret从配置文件读取 .parseClaimsJws(token) .getBody(); String tenantId = claims.get("tenantId", String.class); // 如果token里没有租户信息,说明登录态异常,直接拒绝 if (StringUtils.isEmpty(tenantId)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } TenantContext.setTenantId(tenantId); return true; } catch (Exception e) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束后必须清理,否则线程池复用会导致租户串号 TenantContext.clear(); } }这段代码里有三个值得注意的参数。setSigningKey的密钥必须放在配置中心或配置文件里,不能硬编码在代码中,否则泄露一次等于所有租户的token都能伪造。claims.get("tenantId", String.class)要求签发token时把tenantId作为字符串写入claim,如果写入的是数字类型,这里会类型转换失败。TenantContext.clear()放在 afterCompletion 里,确保无论是正常返回还是抛出异常,ThreadLocal都会被清理,避免线程池复用时的串租户问题。
JWT方案还有另一个变体:客户端直接传X-Tenant-Id请求头,服务端不解析JWT直接信任这个头。这种方式适合内部系统或网关已经完成租户解析的场景,但公网直连时安全隐患较大,因为请求头可以被任意篡改。多数情况下我建议走JWT解析,至少租户信息是签名过的,客户端改不了。
3.3 租户上下文持有器的实现
租户上下文持有器是整个链路的枢纽,所有模块都从它这里获取当前租户ID。实现上有两个关键点:用ThreadLocal存储保证线程隔离,用静态方法提供全局访问能力。下面是一个生产可用的写法。
public class TenantContext { private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>(); public static void setTenantId(String tenantId) { TENANT_ID.set(tenantId); } public static String getTenantId() { return TENANT_ID.get(); } public static void clear() { TENANT_ID.remove(); } }这个类极简,但必须注意的一个细节是:JDK的ThreadLocal如果不在请求结束时 remove,线程池复用后就会读到上一个请求遗留的租户ID。所以 clear 操作必须放在 finally 或 afterCompletion 里,不能只放在业务代码末尾。另一个细节是:如果你用了异步线程池,子线程里是拿不到父线程ThreadLocal值的,这是Java线程模型的固有限制,第五章坑三专门讲这个。
如果在SpringBoot项目里同时用了WebFlux或响应式编程,ThreadLocal方案会失效,因为响应式链路会切换执行线程。这时候需要改用ReactiveContext或者把租户ID作为参数显式传递。绝大多数SaaS后台是Servlet模型,ThreadLocal方案仍然是最实用、最省事的实现。
3.4 拦截器注册与路径排除
有了拦截器,还需要把它注册进SpringBoot的WebMvc配置中。注意拦截器只拦截进入DispatcherServlet的请求,静态资源和Servlet层的过滤器不会被拦截。如果你的租户信息需要在过滤器(比如CORS、日志)里就可用,那就要把识别逻辑前移到Filter里。
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new TenantInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/auth/**"); } }注册时机上最需要注意的是排除路径。登录、注册、健康检查这类接口不需要租户上下文,如果不排除,登录接口自己都会被拦截器拦住,因为那时还没有token。addPathPatterns("/**")覆盖所有路径,排除列表建议集中维护,方便后续补充白名单。到这里,租户ID已经能够从请求进入系统开始,在整个调用链中随时获取,下一步就可以做数据源切换了。
4. 动态数据源切换:把AbstractRoutingDataSource用到实际项目中
租户ID识别出来之后,真正干活的环节是数据源路由。SpringBoot里做多数据源切换的底层机制是 AbstractRoutingDataSource,理解它比学会用什么多数据源框架都重要——框架层出问题,你至少要能看懂源码去排查。
4.1 AbstractRoutingDataSource的路由原理
AbstractRoutingDataSource 是Spring提供的一个抽象类,它的核心机制是:在 getConnection() 时调用 determineCurrentLookupKey(),把返回的key到 targetDataSources 里找到对应的 DataSource,再由这个 DataSource 返回真正的 Connection。换句话说,它在获取连接时做了一层间接寻址。
为什么说这层间接寻址设计精巧?因为业务代码里照常通过 DataSourceUtils 拿连接,完全感知不到路由过程,但连接的归属已经被切换了。结合第三章的 ThreadLocal,determineCurrentLookupKey 只需要从 TenantContext.getTenantId() 取当前租户,再到租户数据源注册表里找对应的 DataSource 即可。整个切换过程对 Service 层完全透明。
这里必须强调一个容易翻车的点:AbstractRoutingDataSource 只在调用 getConnection() 时路由,而Spring的事务管理会把连接绑定到当前线程。如果事务先开始了(Connection 已经拿到),再设置租户上下文,不会触发重新路由——"先开事务再切数据源"必然失败,连接还是之前那个。这是所有动态数据源方案的通病,第五章坑一有具体解法。
4.2 核心实现:路由数据源与租户数据源注册表
下面给出可以落地的代码。先是路由数据源的实现,继承 AbstractRoutingDataSource 并重写 key 的获取逻辑。
public class TenantRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 从ThreadLocal取租户ID,作为查找数据源的key String tenantId = TenantContext.getTenantId(); if (tenantId == null) { // 默认租户:可能是平台自身或公共库 return "default"; } return tenantId; } }然后是租户数据源注册表,维护 tenantId 到 DataSource 的映射。新租户接入时,只需向注册表添加数据源,应用不用重启。
@Component public class TenantDataSourceRegistry { private final Map<String, DataSource> dataSourceMap = new ConcurrentHashMap<>(); public void registerDataSource(String tenantId, DataSource dataSource) { dataSourceMap.put(tenantId, dataSource); } public DataSource getDataSource(String tenantId) { return dataSourceMap.get(tenantId); } public DataSource buildDataSource(String jdbcUrl, String username, String password, int maxPoolSize) { HikariConfig config = new HikariConfig(); config.setJdbcUrl(jdbcUrl); config.setUsername(username); config.setPassword(password); config.setMaximumPoolSize(maxPoolSize); config.setConnectionTimeout(3000L); // 连接空闲超过10分钟就回收,大租户场景靠这个控制连接数 config.setIdleTimeout(600000L); config.setPoolName("tenant-" + jdbcUrl.hashCode()); return new HikariDataSource(config); } }这段代码有几个参数需要重点说明。maximumPoolSize在多租户实现里经常被忽略,所有租户共用一个池子大小,结果某个大租户的查询高峰把连接池占满,其他租户全部排队。建议按租户等级动态设置,大客户50,普通客户10。connectionTimeout=3000L是另一个关键参数,租户数据库故障时连接获取会快速失败,不会像默认30秒那样拖垮整个线程池。idleTimeout=600000L在多租户场景下格外重要,默认配置下 HikariCP 的 minimumIdle 等于 maximumPoolSize,连接永远不会释放,上千个租户时闲时连接会白白占用数据库资源。poolName带上租户特征方便排查连接泄漏时定位到具体租户。
路由实现和注册表就绪后,还需要一个配置类把它们组装起来。SpringBoot 的 DataSourceAutoConfiguration 会在 classpath 下检测到 HikariCP 时自动配置数据源,所以这里必须用 @Primary 声明主数据源,覆盖默认的自动配置行为。
@Configuration public class DataSourceConfig { @Bean @Primary public DataSource dataSource() { Map<Object, Object> targetDataSources = new HashMap<>(); // 从配置加载默认数据源和已有租户数据源 targetDataSources.put("default", buildDefaultDataSource()); TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource(); routingDataSource.setDefaultTargetDataSource(buildDefaultDataSource()); routingDataSource.setTargetDataSources(targetDataSources); return routingDataSource; } }这里setDefaultTargetDataSource设置的默认数据源很关键。当租户识别失败或请求未携带租户信息时,系统不会直接报错,而是落到默认数据源。默认数据源可以用来放公共表——租户注册信息表、费率表、全局配置表,这些数据不属于任何特定租户。
4.3 独立数据库模式下的连接归属问题
如果你选的是独立数据库模式,上面的实现已经够用。但要注意一个隐藏问题:不同租户用不同的数据库实例,连接池自然也是独立的,而 HikariCP 的池化管理是按照 DataSource 实例为单位的。每个租户注册一个 DataSource,相当于每个租户独占一组连接。池数量与租户数量成正比,这是独立数据库模式绕不开的资源开销。
针对这一点,我的实践是给租户数据源加上空闲回收机制。HikariCP 的minimumIdle参数默认等于maximumPoolSize,意味着连接永远不会释放。几十个租户同时在线问题不大,但上千个租户时空闲连接会白白占用数据库内存。把minimumIdle设成0,并设置idleTimeout为10分钟,能让长期空闲的租户连接自动释放,请求来了再重建。共享Schema模式下同样适用,因为虽然连接可以复用,但执行USE切换本身也有开销。
共享Schema模式下的动态数据源做法略有不同。MySQL里 Schema 就是 Database,所有租户的 Schema 都在同一个MySQL实例下。你可以在路由数据源里只维护一个连接池,连接获取后执行USE tenant_db_name切换。但这样做的前提是驱动层能保证每次获取连接后都执行 USE。更稳妥的做法是仿照独立库模式按租户创建不同的 DataSource,JDBC URL 中指定不同的 database,复用同一套切换逻辑。资源包里的示例用的是后一种方式,因为它更通用,切换逻辑和独立库模式完全一致。
5. 多租户落地避坑:我切数据源时踩过的五个坑
多租户系统做到"数据不串"不是靠逻辑正确,而是靠边界管理。以下五条是我在不同项目里用血泪换来的踩坑记录,每一条都按现象→原因→解决的顺序写,方便你对照排查。
5.1 坑一:事务先开启,数据源切换失效
现象:在一个 Service 方法里先调用其他租户的查询,再修改当前租户数据。结果查询返回的是上一步操作库的数据,数据反而写到了错误的库。
原因:Spring 事务开启时会从当前路由数据源获取连接并绑定到当前线程。@Transactional 的方法在进入时就已经确定了连接归属,此时再修改租户上下文,AbstractRoutingDataSource 的 determineCurrentLookupKey 虽然返回了新租户ID,但事务管理器持有的还是旧连接,切换没有生效。
解决:把数据源路由判断放在事务边界之前。常见做法是 ThreadLocal + AOP,在进入 Service 事务方法前完成租户切换;或者把切换逻辑放在 Controller 层;再或者用编程式事务,在需要切换时手动调用 transactionTemplate,确保每次事务开始时租户上下文已就绪。从那以后我每次配置事务方法都会先问一句:进入这个方法前,租户上下文设了吗?
5.2 坑二:连接池归还时没有恢复默认Schema
现象:共享Schema模式下,A租户查询正常,B租户随机出现 "Table not found" 或者查到了A租户的数据。这个问题表现得像玄学——重启后消失,过一段时间又出现,没有任何规律。
原因:连接从一个租户切换回来时没有恢复默认Schema。如果是MySQL的 USE 方式切换,连接归还池子时仍停留在上一个租户的Schema上下文,下一个租户拿到这条连接,自然会访问到不属于自己的数据。
解决:在数据源层强制每次获取连接后设置 schema。HikariCP 的 connectionInitSql 只能设置连接初始化时的语句,不能覆盖归还场景。我采用的办法是自定义一个 SchemaSwitchDataSource,在 getConnection() 中先调用 super.getConnection(),再执行 USEtenantId。执行失败就丢弃这条连接,不让它回池。归还前再执行 USE 回默认库,双保险。
5.3 坑三:子线程拿不到父线程的租户上下文
现象:接口里用 CompletableFuture 或 @Async 异步处理订单,异步子线程查询数据库时报错或落到了默认库。查日志发现子线程里 TenantContext.getTenantId() 返回 null。
原因:ThreadLocal 是线程私有的变量,子线程创建时并不会继承父线程的 ThreadLocal 值。这是 JVM 层面线程模型的限制,不是 SpringBoot 的配置问题,也不是"换个框架就能解决"的事。
解决:改用阿里开源的 TransmittableThreadLocal(TTL)。TTL 对 ThreadLocal 做了增强,在线程池提交任务时捕获父线程的上下文快照,执行时自动回放。具体做法是把 TenantContext 里的 ThreadLocal 换成 TTL,所有异步线程池统一用 TtlExecutors 包装。注意一点:一旦用了TTL,所有异步链路都要统一用TTL包装,否则部分链路传递部分不传递,问题更隐蔽。
5.4 坑四:定时任务里没有租户上下文
现象:@Scheduled 定时任务扫描"所有租户的待结算订单",结果只处理了默认租户的数据,其他租户完全没跑,还没有任何报错。
原因:定时任务由Spring调度线程触发,没有经过HTTP请求链路,所以拦截器从未执行,ThreadLocal 里根本没有租户ID。路由数据源拿不到key,落到了默认数据源。
解决:定时任务必须显式指定租户维度。两种方案:第一种是任务方法里手动循环所有租户,逐个设置上下文再处理;第二种是任务配置里带 tenantId 参数,由任务调度系统注入。我一般用第一种,因为循环内可以做租户级别的失败兜底,单个租户失败不影响其他租户。注意循环结束后一定调用 TenantContext.clear(),否则调度线程复用后还会带着上一个租户的上下文执行下一个任务。
5.5 坑五:MyBatis缓存把租户数据串在一起
现象:共享表模式下,A租户查了一批数据,B租户执行同样的SQL竟返回了A租户的结果。第一反应是数据串了,但确认WHERE条件里明明带了 tenant_id。
原因:MyBatis 的一级缓存是 SqlSession 级别的,默认开启;二级缓存是 Mapper 级别的,跨 SqlSession 共享。如果缓存key里没有租户ID,两个租户执行相同SQL时,第二次查询直接命中缓存,根本没执行带 tenant_id 的SQL。这种问题在代码review时很难发现,因为SQL看着是对的。
解决:二级缓存要么直接关闭(多租户场景下收益有限),要么在cache key里加上 TenantContext.getTenantId()。方案是自定义 Cache 实现,在 put 和 get 时用(原始key + tenantId)作为实际key。一级缓存同样要注意,同一个 SqlSession 内如果切换了租户,必须 clear 一级缓存,否则同样串数据。
6. 进阶实践:多租户系统的缓存隔离与自检手段
6.1 Redis缓存:Key前缀带上租户维度
多租户系统的常见误区是只隔离数据库,不管Redis。Redis key 如果不区分租户,A租户的数据可以被B租户通过同一个key打中,这种即时性数据泄露比数据库串号更隐蔽。最稳妥的做法是在key生成阶段就统一加上租户前缀。
@Component public class TenantCacheKeyBuilder { public String build(String rawKey) { String tenantId = TenantContext.getTenantId(); // 框架层统一拼接租户前缀,业务代码无需感知 return tenantId == null ? "global:" + rawKey : "tenant:" + tenantId + ":" + rawKey; } }关键在框架层统一处理,别让业务开发自己拼前缀,否则几百个方法里只要有一个人漏掉,就是一个串数据漏洞。带租户前缀后,大租户热点数据也不会与其他租户互相挤占缓存空间,命中率分析还能按租户拆开做,一举两得。
6.2 数据隔离自检与租户接入脚本化
上线前我强制走一遍数据隔离自检。第一步跨租户查询测试:用A租户的token请求B租户数据的接口,观察返回为空或报403。第二步连接归属测试:在日志里打印当前租户ID和实际数据源名称,对比业务入口的租户上下文和数据库连接是否一致。第三步并发后的串租户测试:压测跑一批并发请求,结束后检查各租户计数表是否有交叉写入。这三步做完,基本能挡住90%的串租户事故。
新租户接入也必须脚本化。我现在每个租户接入都强制走一遍:注册租户信息到公共库 → 创建数据库或Schema → 初始化表结构 → 加入数据源注册表 → 跑一次隔离自检。任意一步失败立即回滚,不允许手工补数据。这套流程走顺后,新租户接入从半天缩短到十分钟,再没出过串数据事故。
资源包里的实现对应上述完整链路,下载后建议先跑通共享Schema模式的示例工程,再改成独立数据库模式对比差异,比只看代码有效得多。数据隔离这件事,数据库和缓存两条腿都要站住,异步链路和定时任务的上下文传递也要一并纳入评审范围。希望这些实践能帮你把多租户改造的路上少翻几次车,先保证租户数据是安全的,再去谈性能优化和扩展性。
本文还有配套的精品资源,点击获取