ARTICLE DETAIL

资讯详情

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

Spring Boot + MyBatis-Plus多租户数据隔离与权限控制实战

Spring Boot + MyBatis-Plus多租户数据隔离与权限控制实战 1. 项目概述与整体设计思路1.1 什么是多租户为什么要做数据隔离多租户这个词这几年在SaaS圈子里几乎成了标配但你真去问十个开发可能有八个说不清它和“普通项目加个用户表”到底差在哪。简单说一套系统要同时服务多个客户租户每个租户的数据必须严格隔离你不能让A公司的订单跑到B公司的后台里去这就是多租户的核心诉求。用生活类比就是同一栋写字楼不同公司租了不同楼层电梯门禁各管各的你不能让前台小姐姐随便刷开别家公司的办公室门。在Spring Boot项目里落地多租户常见方案无非三种独立数据库、共享数据库独立Schema、共享Schema共享表通过租户ID区分。前两种成本高运维麻烦除非客户有硬性合规要求否则绝大多数SaaS团队最终都会走向第三种——一张订单表里带上tenant_id查询时强制带上这个字段作为过滤条件。我这篇文章要讲的正是第三种方案在Spring Boot MyBatis-Plus这个技术栈下的完整落地路径。从数据隔离到权限控制不是给你贴一段代码就跑而是把所有设计取舍、坑点、排查思路都摊开讲清楚。无论你是刚接手SaaS项目的新人还是想重构老系统多租户模块的资深开发这篇都能给你省下至少一周的踩坑时间。1.2 为什么选Spring Boot MyBatis-Plus这套组合先聊技术选型。Spring Boot作为Java后端的事实标准生态成熟起步快这没什么好说的。重点在于MyBatis-Plus很多人觉得它就是个增强版MyBatis多几个CRUD方法而已但真到多租户场景MyBatis-Plus提供的租户插件TenantLineInnerInterceptor简直就是救命稻草。为什么这么说如果是纯手写MyBatis你要维护SQL中所有涉及业务表的租户ID条件哪怕只漏掉一张表数据就串了而且排查起来极其痛苦。MyBatis-Plus的租户拦截器可以在SQL执行前自动解析把租户条件统一拼接到SELECT、UPDATE、DELETE语句里你不用再手动写tenant_id ?从机制上就堵住了漏加条件的隐患。当然它也有一堆限制。最典型的就是SQL别名的解析一旦你写了复杂的子查询、JOIN、CTE表达式租户插件偶尔会“看不懂”导致租户条件没拼进去或者拼错位置。这个问题我们后面实操章节专门讲怎么破。另外权限控制这块Spring Boot通常会配套Spring Security或Sa-Token。我文章里用Sa-Token做演示因为它对多租户场景的支持更轻量权限注解用起来也顺手。你要用Spring Security也完全可以思路是通用的。2. 数据隔离方案从整体架构到单表拦截2.1 租户上下文如何贯穿整个请求链路真正驱动数据隔离的不是SQL里那个tenant_id字段本身而是“当前请求属于哪个租户”这个上下文信息。它需要从一个HTTP请求进来开始一直传递到Service层再到MyBatis-Plus执行SQL的那一刻。我习惯的做法是自定义一个TenantContext内部用ThreadLocal存储当前线程的租户ID。为什么用ThreadLocal因为每个请求在Tomcat线程池里是独占一个线程的异步场景除外这样租户ID天然跟随当前处理流程不需要在Controller、Service、DAO的每个方法里把租户ID当参数传来传去。但这里有三个必须注意的坑。第一ThreadLocal在线程池复用场景下会残留数据所以必须在请求结束时手动清理否则下一个请求会读到上一个请求的租户信息。第二异步任务如Async、MQ消费者它们运行在独立线程里ThreadLocal根本传不过去需要额外使用TransmittableThreadLocal或手动传递租户ID。第三登录接口本身没有租户信息因为用户还没认出是谁属于哪个租户这类白名单接口必须放过租户拦截器。更稳的做法是把租户ID放进请求头由网关或统一拦截器解析后写入TenantContext。这样下游服务只要信任上游传过来的租户ID就无需再查库确认性能更好链路也更清晰。2.2 MyBatis-Plus租户插件配置的完整清单核心配置如下基于MyBatis-Plus 3.5.x版本Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); TenantLineInnerInterceptor tenant new TenantLineInnerInterceptor(); TenantLineHandler handler new TenantLineHandler() { Override public Expression getTenantId() { // 从上下文获取当前租户ID return new LongValue(TenantContext.getTenantId()); } Override public String getTenantIdColumn() { // 全局统一租户字段名 return tenant_id; } Override public boolean ignoreTable(String tableName) { // 需要忽略的表比如系统表、字典表 return ignoreTables.contains(tableName); } }; tenant.setTenantLineHandler(handler); interceptor.addInnerInterceptor(tenant); // 注意分页插件要放在租户插件之后 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这段配置有几个关键决策点。getTenantIdColumn()里返回“tenant_id”意味着你的所有业务表都必须有这个字段。如果你历史表已经用了别的命名比如custom_id就得在这里统一改掉否则插件匹配不上。ignoreTable这个方法必须认真对待。不是所有表都需要租户隔离例如存储所有租户共用的数据字典表、地区表、配置表这些一旦被加上租户条件反而查不出数据。我见过有人图省事全部业务表都隔离结果系统启动后连基础数据都查不到排查半天才发现是字典表被拦截了。另外一个容易被忽略的点是insert语句的租户字段赋值。MyBatis-Plus的租户插件并不会自动帮你在insert时补tenant_id它只管查询和修改的过滤。所以你在实体插入前必须手动给tenant_id赋值或者写一个MetaObjectHandler来做自动填充。我项目里用的方法是统一给实体类加一个tenantId字段然后在Controller入口或Service实现里调用TenantContext获取当前租户ID再手动set进去。虽然多写一行代码但胜在直观可控团队新人也看得懂。下面是一段ServiceImpl里插入数据的示例public void createOrder(Order order) { order.setTenantId(TenantContext.getTenantId()); orderMapper.insert(order); }2.3 忽略逻辑删除表时会碰到什么奇怪问题如果你同时使用了MyBatis-Plus的逻辑删除TableLogic恭喜你大概率会遇到一个很刁钻的组合坑。逻辑删除本质上是在SQL里拼接DELETE语句转成UPDATE而租户插件的工作顺序是先解析原SQL再添加租户条件。当逻辑删除的UPDATE语句里含有多表JOIN时租户条件可能会被挂到错误的表名下。我举个例子订单表orders和订单明细表order_items逻辑删除订单表数据MyBatis-Plus生成的UPDATE是UPDATE orders SET deleted 1 WHERE id ?这是单表操作没问题。但如果你自定义了批量逻辑删除SQL里面带JOINUPDATE orders o LEFT JOIN order_items oi ON oi.order_id o.id SET o.deleted 1 WHERE o.id ?租户插件解析时可能会把tenant_id条件加到order_items的ON子句里或者直接拼到SET后面导致语法错误或条件错位。解决思路有两个一是尽可能把逻辑删除写成单表操作二是在自定义SQL里自己手动带上租户条件并在InterceptorIgnore注解里声明忽略租户插件。InterceptorIgnore(tenantLine true) Update(UPDATE orders o LEFT JOIN order_items oi ON oi.order_id o.id SET o.deleted 1 WHERE o.id #{id} AND o.tenant_id #{tenantId}) int logicDeleteWithJoin(Param(id) Long id, Param(tenantId) Long tenantId);这种手动接管的方式虽然多写条件但至少不会再出现租户插件自动拼接出错的情况执行计划也可控。3. 权限控制让不同租户的用户看到不同世界3.1 租户内用户角色与数据范围数据隔离只是第一层解决的是“租户A看不到租户B的数据”。但同一个租户内部不同角色能看的数据范围也是不一样的。比如运营人员只能看本部门订单财务人员能看全租户订单管理员啥都能看。这就是权限控制要解决的问题。权限控制通常分两块功能权限和数据权限。功能权限指“你能不能点这个按钮”数据权限指“你点进去能看哪些数据”。Spring Security或Sa-Token擅长控制前者通过角色、权限码去匹配注解或标签让你在Controller方法上写SaCheckPermission(order:view)即可。数据权限则需要更细粒度的SQL拦截。我常用的组合是Sa-Token负责认证和功能权限然后在Mapper层根据当前用户的角色动态拼接额外的数据范围条件。比如最朴素的实现——把数据权限条件做成一个包装类在Service层拼接QueryWrapper时调用public void applyDataScope(QueryWrapperOrder wrapper) { // 超级管理员不看数据权限直接返回 if (StpUtil.hasRole(admin)) return; // 普通用户只能看自己创建的订单 wrapper.eq(creator_id, StpUtil.getLoginIdAsLong()); }这个方案简单直接缺点是在每个查询里都要记得调用容易遗漏。更体系化的做法是参考MyBatis-Plus官方文档里的DataPermissionInterceptor它本质上也是SQL解析根据当前用户角色替换掉SQL里约定的占位符条件。3.2 前端按钮权限怎么跟后端呼应热词里有一个“vue按钮权限怎么控制”这确实是在多租户SaaS项目里经常被问到的。权限控制绝不只是后端的事前端按钮的显隐也需要配合。你后端接口拦截得再严前端把所有按钮都渲染出来用户一点就报错体验极差。我的做法是登录成功后后端一次性返回当前用户的权限码集合比如{ roles: [operator], permissions: [order:view, order:export] }前端拿到这个集合存到Pinia或Vuex里。在Vue组件里封装一个权限指令v-permission按钮显示前先判断当前用户是否有对应权限码// directive.js app.directive(permission, { mounted(el, binding) { const required binding.value const hasPermission userStore.permissions.includes(required) if (!hasPermission) { el.parentNode?.removeChild(el) } } })模板里这样用el-button v-permissionorder:export导出/el-button这样后端返回的权限码就同时驱动了接口鉴权和前端按钮显隐两边对得上。而且千万别只在按钮上加指令路由守卫也要加否则用户直接输URL还是能跳转页面。虽然前端隐藏不是安全边界但至少可以提升使用体验。3.3 超级管理员与租户管理员的权限边界在多租户系统里有两类“管理员”特别容易被搞混。一类是平台超级管理员他可以管理所有租户比如开通新租户、查看所有租户的统计数据、封禁某个租户。另一类是某个租户自己的管理员他只能管理自己租户下的用户、角色、业务数据。这两类角色绝不能共用一套权限模型。我见过一个项目把所有管理员都塞进同一个角色结果就是租户管理员通过越权调用平台接口强行看到了其他租户的订单数据。数据隔离瞬间破功。比较稳妥的设计是两张角色表platform_role和tenant_role权限码设计时区分作用域。平台权限码格式如system:tenant:create租户权限码格式如tenant:order:view。登录时根据登录入口平台后台还是租户后台加载不同的权限集合。更要紧的是在后端接口层做双重校验先验登录态再验租户ID归属确保租户管理员请求任何接口时路径上的企业ID参数必须和Token绑定的租户ID一致。4. 实操过程从零搭一个带多租户的订单模块4.1 建表与实体设计假设我们做一个极简的SaaS订单系统只有订单表。DDL如下CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, order_no VARCHAR(64) NOT NULL, customer_name VARCHAR(128) NOT NULL, amount DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, creator_id BIGINT NOT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, deleted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_tenant_order_no (tenant_id, order_no) );重点说一下唯一索引。如果你不分租户order_no直接建唯一索引就够了。一旦分了租户唯一约束必须包含tenant_id否则租户A创建了一个order_no租户B再用同样的order_no就会插入失败。uk_tenant_order_no这个联合唯一索引不是可有可无而是多租户表设计的铁律。实体类我用MyBatis-Plus注解Data TableName(orders) public class Order { TableId(type IdType.AUTO) private Long id; private Long tenantId; private String orderNo; private String customerName; private BigDecimal amount; private Integer status; private Long creatorId; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; }4.2 使用MyBatis-Plus内置方法时的野路子当你配置好租户插件后MyBatis-Plus自带的selectById、selectList、updateById等等都会自动带上租户条件。这意味着你根本不需要在Service层传租户IDTenantContext里的值会自动拼到SQL里。但要注意这些内置方法生效的前提是你的表配置了TableName而且租户字段名和全局一致。如果你用LambdaQueryWrapper代码写起来更优雅public ListOrder listByCustomer(String customerName) { LambdaQueryWrapperOrder wrapper Wrappers.lambdaQuery(); wrapper.like(Order::getCustomerName, customerName) .eq(Order::getStatus, 0) .orderByDesc(Order::getCreateTime); return orderMapper.selectList(wrapper); }这段代码里没有出现tenantId但实际执行的SQL会自动加上AND orders.tenant_id 当前租户ID。这也是MyBatis-Plus租户插件最让人舒服的地方业务代码干净很多。4.3 自定义SQL里如何处理租户条件内置方法照护不到的场景就得自己写XML或注解SQL。这里有两个选择要么继续依赖插件解析要么自己写条件。插件能处理的常见自定义SQL比如在Mapper接口里写Select(SELECT * FROM orders WHERE status #{status}) ListOrder listByStatus(Param(status) Integer status);这个SQL是单表插件会帮你追加tenant_id条件。但如果你在SQL里写了别名Select(SELECT * FROM orders o WHERE o.status #{status}) ListOrder listByStatus(Param(status) Integer status);插件依然能识别出来。但是如果别名和表名有冲突或者用了子查询Select(SELECT * FROM (SELECT * FROM orders WHERE status 0) tmp WHERE tmp.amount 100) ListOrder listExpensive();这种情况插件就懵了它不知道要不要给子查询的表也加条件加了可能语法错误不加又漏数据。我的建议是遇到复杂SQL就不要依赖插件直接在SQL末尾手动追加AND tenant_id #{tenantId}然后在Mapper方法上加上InterceptorIgnore(tenantLine true)明确告诉MyBatis-Plus这个SQL我自己管理租户条件。InterceptorIgnore(tenantLine true) Select(SELECT * FROM (SELECT * FROM orders WHERE status 0) tmp WHERE tmp.amount 100 AND tmp.tenant_id #{tenantId}) ListOrder listExpensive(Param(tenantId) Long tenantId);别小看这个InterceptorIgnore它是复杂SQL场景下的安全阀。我接手过一个项目子查询里漏了租户条件数据串了一个多月才被发现损失惨重。能用插件省事但复杂SQL一定要手动接管。5. 常见问题与排查技巧实录5.1 租户条件为什么没拼上排查经验排序先看拦截器是否被加载再看ignoreTable有没有误匹配最后看是不是子查询。我在项目里遇到过最奇葩的一次是某个Mapper方法返回了BaseMapper 但在XML里写了嵌套子查询插件只管了外层表内层子查询的表被漏掉数据查出来带出了别的租户的数据。排查方法很简单打开MyBatis-Plus的SQL日志logging: level: com.example.mapper: debug然后执行一条查询看日志里打印的SQL是不是带了tenant_id。如果没带先用排除法单独测试一个最简单的selectList如果最简SQL都不带条件那就是全局配置的问题如果最简SQL带条件复杂SQL不带那就是SQL解析的兼容性问题基本只能手动接管。5.2 缓存了不该缓存的数据权限控制和数据隔离经常会被人忽略结合起来我说的就是Redis缓存。你以租户ID为维度做了缓存结果缓存key里忘记拼租户ID那租户A请求时把订单列表缓存进去了租户B请求时命中同一份缓存看到租户A的订单这比SQL漏条件还隐蔽。我处理方案非常固定缓存key必须包含tenant_id前缀比如order:list:{tenantId}:{customerName}。如果是租户内共享的基础数据缓存key可以不包含租户ID但必须在写缓存时确认这份数据是所有租户共享且一致的。否则宁可多存几份也别让缓存串租户。还有一个坑线程池 缓存组合。异步线程里没有TenantContext如果你在线程里先查了数据库再写缓存写的缓存key可能缺失租户ID导致后面所有租户都拿到这份脏缓存。所以异步任务里一定要先获取主线程传入的tenantId再去做数据和缓存操作。5.3 跨租户查询和数据迁移怎么破有没有需要跨租户刷数据的场景有比如平台的全量表统计、运营报表、定时任务清理过期数据。这些场景下TenantContext里没有租户ID租户插件拿空值容易报错。我处理这类场景的做法是在定时任务入口处通过InterceptorIgnore(tenantLine true)配合自定义SQL手动控制租户条件。或者干脆在TenantContext里设置一个“平台模式”标识让getTenantId()返回null并通过ignoreTable或ignoreStrategy配置告诉插件当租户ID为null时就不进行租户过滤。但这里必须谨慎平台模式一旦开放给普通用户或普通接口就是数据泄露的入口。我建议平台模式只允许在特定包路径的Service方法里触发并配合AOP切面做注解校验且强制留存操作日志。数据迁移同理凡是需要在多个租户间游走的批量任务都要先明确是平台级操作还是租户内操作避免误伤。5.4 索引优化与多租户字段的相爱相杀最后分享一个性能方面的经验。多租户表最常见的查询模式是WHERE tenant_id ? AND [业务条件]所以组合索引必须把tenant_id放在最左侧。比如你要经常按订单状态和时间筛选索引可以这样建(tenant_id, status, create_time)。如果只给order_no建了唯一索引而忘了加tenant_id查询计划经常会走全表扫。还有一个容易忽略的点尽量不要在租户表上搞外键约束。因为租户ID是逻辑隔离的理论上不同租户的数据可能分布在不同的物理库里未来分库分表外键约束在跨库、跨租户场景下就是灾难。用业务字段order_no做关联配合索引查询完全够用。我的经验是多租户项目里放弃外键用应用层校验代替运维和扩展都会轻松很多。至于分页查询MyBatis-Plus的分页插件在租户插件之后执行顺序必须保持tenant在pagination之前否则分页SQL在COUNT查询时不会带上租户条件统计总数会莫名变大。这个顺序我在配置类里已经写好了实际开发中如果你手动调整过插件顺序一定记得这句经验。
返回列表