ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot多租户数据隔离实战:动态数据源与ThreadLocal全解析

SpringBoot多租户数据隔离实战:动态数据源与ThreadLocal全解析 简介多租户架构是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概念等价于DatabasePostgreSQL里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 ThreadLocalString 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 拿连接完全感知不到路由过程但连接的归属已经被切换了。结合第三章的 ThreadLocaldetermineCurrentLookupKey 只需要从 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 MapString, 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。connectionTimeout3000L是另一个关键参数租户数据库故障时连接获取会快速失败不会像默认30秒那样拖垮整个线程池。idleTimeout600000L在多租户场景下格外重要默认配置下 HikariCP 的 minimumIdle 等于 maximumPoolSize连接永远不会释放上千个租户时闲时连接会白白占用数据库资源。poolName带上租户特征方便排查连接泄漏时定位到具体租户。路由实现和注册表就绪后还需要一个配置类把它们组装起来。SpringBoot 的 DataSourceAutoConfiguration 会在 classpath 下检测到 HikariCP 时自动配置数据源所以这里必须用 Primary 声明主数据源覆盖默认的自动配置行为。Configuration public class DataSourceConfig { Bean Primary public DataSource dataSource() { MapObject, 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。更稳妥的做法是仿照独立库模式按租户创建不同的 DataSourceJDBC 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 的配置问题也不是换个框架就能解决的事。解决改用阿里开源的 TransmittableThreadLocalTTL。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模式的示例工程再改成独立数据库模式对比差异比只看代码有效得多。数据隔离这件事数据库和缓存两条腿都要站住异步链路和定时任务的上下文传递也要一并纳入评审范围。希望这些实践能帮你把多租户改造的路上少翻几次车先保证租户数据是安全的再去谈性能优化和扩展性。本文还有配套的精品资源点击获取
返回列表