Spring Boot + MyBatis Plus后台管理系统实战复盘:权限、分页与常见坑
上个月刚交付了一个后台管理系统代号“蓝鲸云”。这套系统从需求确定到上线大概花了六周时间技术栈是 Spring Boot 3.x MyBatis Plus 3.5.x前端配合当前主流的管理端框架数据库用的 MySQL 8.0。立项时需求方只给了一句话“做一个内部管理系统把人员、权限、基础数据和业务流程管理起来。”说实话后台管理系统在很多人眼里是最简单的一类项目——无非就是登录、用户、角色、菜单、CRUD看起来毫无技术含量。但真正动手做过的人都知道这类系统恰恰是最容易在细节上翻车的权限设计漏了数据权限、分页查询一拖慢整个页面、逻辑删除没配好导致唯一索引炸了、事务嵌套导致更新丢失……每个坑都能让你在交付前夜加班。这篇把蓝鲸云项目从设计到上线的完整过程复盘一遍包括整体设计、技术选型的理由、核心模块的实现思路以及我实际踩过且花时间最长的几个坑。适合正在搭建后台管理系统、或者准备用 Spring Boot MyBatis Plus 做企业级项目的开发者参考。1. 蓝鲸云后台系统的定位一个后台管理系统到底要管什么1.1 从需求清单反推系统边界接这个项目之前需求描述其实很模糊就一句话“做一个内部管理系统把人员、权限、基础数据和业务流程管理起来。”这种描述几乎等于没说。所以第一步不是选技术栈而是把需求拆成功能清单。我的做法是分三层来梳理基础层登录认证、退出登录、修改密码、个人信息维护。管理层用户管理、角色管理、菜单管理、部门管理、操作日志审计。业务层根据项目具体需求展开。蓝鲸云里实际接入了客户信息管理、订单数据导入导出和审批流配置。把需求拆成这样之后“后台管理系统”的边界就清楚了。很多人一开始就把系统做得过重上来就想搞工作流引擎、多租户、分布式任务调度结果核心的增删改查还没做稳。蓝鲸云的做法是先把基础层和管理层做扎实业务层用可扩展的模块结构按需加功能。后台管理系统最核心的价值不是页面多好看而是“权限不乱、数据不错、审计可查”。后面所有的技术选型和代码设计都是围绕这三点展开的。1.2 单体优先为什么后台管理系统不适合一上来就微服务蓝鲸云这套系统的部署规模就是几十个内部用户并发量也不高日活甚至都在几百以内。这种场景下如果引入微服务架构从注册中心到网关再到链路追踪整套基础设施的维护成本可能比业务代码还高。我的判断标准很简单如果团队在一年内都不会有超过三个服务并行演进的需求那就别碰微服务。单体应用配合模块化分包后续真有拆分需求时按模块边界切分成本远比一上来就搞分布式低得多。蓝鲸云最终采用的是标准的单体分层架构controller 层负责接收请求、参数校验、返回统一结果。service 层业务逻辑与事务边界所在。mapper 层数据访问全部基于 MyBatis Plus 的 BaseMapper 扩展。通用模块统一返回体、全局异常处理、权限注解、字典工具、操作日志切面。这种结构在后台管理系统里属于标配但很多人会在层与层之间画不清边界出现 controller 里写 SQL、service 里做参数校验、mapper 里堆业务逻辑的情况。蓝鲸云在代码评审时对这一点卡得比较严我后文还会专门展开。1.3 数据库设计先定表和约束再写代码我习惯在写任何业务代码之前先把核心表结构和约束定下来。后台管理系统最常见的表就是权限模型那几张后面单独讲。这里先强调一个容易被忽视的点JSON 字段和唯一索引的取舍。蓝鲸云的用户表里有一个 extra_info 字段用来存一些非结构化的扩展信息比如头像地址、偏好设置。这种字段在 MySQL 8.0 里可以直接用 JSON 类型配合 MyBatis Plus 的 TypeHandler 做自动映射省掉大量 DTO 转换代码。唯一索引的坑比较隐蔽。用户表里如果做了逻辑删除而 user_name 字段上有唯一索引删除一条记录后再次插入同名的用户就会报 Duplicate entry。这个问题在踩坑部分会详细说但在设计阶段就要提前想好方案不要等到开发中再改表结构。表名用途关键字段sys_user用户表id, user_name, password, status, dept_id, extra_infosys_role角色表id, role_code, role_name, data_scopesys_menu菜单/权限表id, parent_id, menu_name, path, permissionsys_user_role用户角色关联表id, user_id, role_idsys_role_menu角色菜单关联表id, role_id, menu_idsys_operation_log操作日志表id, user_id, module, action, detail, ip这张表结构在整个开发周期里几乎没有变动原因就在于设计阶段把所有约束都想到了后面只是往里面填内容。2. 技术选型Spring Boot MyBatis Plus为什么是这个组合2.1 对比 JPA 和原生 JDBC我为什么坚持 MyBatis Plus项目组里当时有两派意见。一派说用 Spring Data JPA因为开发速度快实体关系映射省事另一派说干脆用原生 JDBC 或 MyBatisSQL 完全可控。最后选了 MyBatis Plus理由有三个。第一后台管理系统的查询场景非常灵活列表页的筛选条件经常是动态拼接的。JPA 的 Specification 虽然能写动态查询但可读性很差复杂点的查询改起来很痛苦。MyBatis Plus 的条件构造器把动态拼接这块处理得非常直观代码里直接能看到查询条件后续维护的人不用翻 XML 也能理解。第二MyBatis Plus 对单表 CRUD 的零 SQL 支持能让开发效率明显提升。用户管理、角色管理、菜单管理这些模块绝大部分操作就是单表的增删改查用 BaseMapper 自带的方法就够了不需要额外写 XML。这省下来的时间在六个星期的排期里相当可观。第三MyBatis 生态在复杂 SQL 上更可控。一旦遇到多表联查、报表统计这种场景直接写 XML 里的 SQL优化起来也方便。蓝鲸云的订单统计报表就是手写 SQL 实现的用 JPA 写这种统计查询会非常痛苦既要考虑返回类型的投影又要应对动态分组条件。2.2 版本兼容性Spring Boot 3 与 MyBatis Plus 的坑蓝鲸云用的是 Spring Boot 3.2.x而 MyBatis Plus 从 3.5.3 开始才比较好地支持 Spring Boot 3。这里要提醒一点网上搜到的大量教程还在用 3.4.x 甚至更早的版本那些配置类在 Spring Boot 3 下几乎都会报错。Spring Boot 3 最大的变化是使用了 Jakarta EE 9 的命名空间也就是 javax 变成了 jakarta。MyBatis Plus 老版本里的分页插件、乐观锁插件都是基于 javax 接口实现的直接搬到 Spring Boot 3 项目里会启动失败报错信息通常是 ClassNotFoundException 或者 NoClassDefFoundError一搜全是英文问答但本质都是版本不匹配。所以项目的依赖版本要统一我用的组合是依赖版本说明Spring Boot3.2.4基础框架MyBatis Plus3.5.7ORM 框架MySQL Connector/J8.3.0JDBC 驱动Druid1.2.21数据库连接池Java17与 Spring Boot 3 匹配配置分页插件的时候注意旧代码里常见的写法是Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个写法在 3.5.7 里依然有效但如果你的项目同时引入了 PageHelper会存在插件冲突。蓝鲸云在迁移分页方案时踩过这个坑后面的踩坑章节会详细说。这里先给一个结论如果是从老项目升级上来的最好在 pom 里全局搜索 pagehelper 和 mybatis-plus 相关依赖确认没有重复和冲突。2.3 统一返回体与全局异常处理后台管理系统前后端分离后接口返回格式必须统一否则前端处理状态码得写一堆分支。蓝鲸云定义了一个 Result 类包含 code、message、data 三个字段所有 controller 方法统一返回这个类型。这里有一个经验全局异常处理一定要在项目一开始就写好不要等接口报错再补。因为如果前面几十个接口都返回自己拼的 JSON 格式后面再想统一返回体所有接口都要改成本非常大。蓝鲸云的全局异常处理分两层一层捕获业务异常比如登录失败、权限不足返回 code2001 这类业务码一层兜底捕获所有未预料的异常统一返回 code500。这样做的好处是前端只需要判断 code不需要判断 HTTP 状态码对错误提示的处理也只需要写一个统一的拦截逻辑。顺带把参数校验异常、HTTP 请求方法错误这类常见错误码也提前定义好避免开发到中途再补状态码表。3. 权限模型与核心模块用户、角色、菜单三件套3.1 表结构设计RBAC 模型怎么落地蓝鲸云的权限模型采用标准 RBAC但做了一些实际调整。先看表设计sys_user用户表包含用户名、密码、昵称、状态、部门 ID。sys_role角色表包含角色编码、角色名称、数据权限范围。sys_menu菜单表包含菜单名称、父级 ID、路由路径、权限标识。sys_user_role用户角色关联表一个用户可拥有多个角色。sys_role_menu角色菜单关联表一个角色可关联多个菜单/权限。这套表结构不算复杂但有两个细节要注意。第一密码字段存的不是明文也不是简单的 MD5而是 BCrypt 加密后的密文。Spring Security 自带的 BCryptPasswordEncoder 就能做不需要额外引入工具库。BCrypt 的特点是每次加密结果都不同同一密码两次加密的密文不同但校验结果一致这样可以防止彩虹表碰撞。第二sys_menu 表里的权限标识字段是真正的鉴权依据。比如用户管理页面对应的权限标识是 system:user:list新增按钮对应 system:user:add。后端在接口鉴权时调用方需要持有当前接口所需的权限标识否则拒绝访问。菜单、按钮、接口这三者通过权限标识关联起来前端根据权限集合控制按钮显隐后端根据权限集合控制接口访问两道防线配合。3.2 登录认证流程从登录接口到 token 校验蓝鲸云的认证方案选择的是 JWT但其实是把 JWT 和 Redis 组合起来用的不是纯粹的 JWT。登录流程是这样的用户提交用户名、密码后端先用 BCryptPasswordEncoder 校验密码。密码校验通过后从数据库查询用户的角色、权限标识集合。生成 JWT token将 token 存入 Rediskey 是用户 IDvalue 是 token过期时间和 token 的有效期保持一致。把权限集合也缓存到 Redis避免每次接口鉴权都查数据库。JWT 生成的核心代码大概长这样String token Jwts.builder() .setSubject(userId.toString()) .claim(username, user.getUserName()) .setExpiration(new Date(System.currentTimeMillis() 3600_000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();接口鉴权链路则是这样的前端每次请求在 Header 里带上 Authorization 字段。后端通过拦截器解析 token从 Redis 获取用户信息。将用户信息和权限集合放入当前线程的 ThreadLocal 中。在需要权限的接口上标注 PreAuthorize(hasAuthority(system:user:list))。权限校验由 Spring Security 完成。为什么要把 token 也存 Redis因为纯 JWT 方案有个无法解决的问题用户被禁用后已签发的 token 依然有效直到自然过期。把 token 存在 Redis 里禁用用户时直接删除 Redis 中的 token就能实现强制下线。蓝鲸云的运维需求里有“一键禁用并踢下线”这个操作因此必须有服务端会话状态不能只依赖 JWT 本身。3.3 数据权限RBAC 之外的另一层控制RBAC 管的是“能不能访问某个功能”数据权限管的是“能访问哪些数据”。蓝鲸云是给多个部门使用的同一个客户列表部门经理能看到本部门所有人的客户普通员工只能看到自己的客户。这就是典型的数据权限问题。数据权限的实现一般有三种思路在 SQL 层加条件比如查询客户列表时自动拼接 dept_id 当前用户部门。在 Service 层过滤查出全部数据后在内存中过滤。用 MyBatis Plus 提供的数据权限插件按注解和解析规则动态拼接 SQL。蓝鲸云采用的是第一种思路在 Service 层手动处理。原因是项目里涉及数据权限的表不多就三四张不值得为这个引入复杂插件。实现方式是在查询条件的构造器上追加一层条件LambdaQueryWrapperCustomer wrapper Wrappers.lambdaQuery(); if (!isDeptManager(currentUser)) { wrapper.eq(Customer::getDeptId, currentUser.getDeptId()); }前提是 Service 层所有涉及数据权限的查询都必须走统一入口不能散落在其他方法里。否则你会发现有的接口有权限控制有的接口裸奔排查起来非常痛苦。这块属于架构纪律比写代码本身更重要。4. MyBatis Plus 在蓝鲸云里的高频实战姿势4.1 条件构造器把写复杂查询的习惯改掉蓝鲸云大部分列表查询用的都是 LambdaQueryWrapper。为什么强调 Lambda 版本因为非 Lambda 版本直接用字符串列名一旦数据库字段改名编译器不会报错运行期才发现问题。Lambda 版本直接把实体字段引用带入重命名时 IDE 可以自动感知这个差别在重构时体会最深。一个典型的用户列表查询LambdaQueryWrapperSysUser wrapper Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(keyword), SysUser::getUserName, keyword); wrapper.eq(StringUtils.hasText(status), SysUser::getStatus, status); wrapper.orderByDesc(SysUser::getCreateTime);第一个参数是是否执行该条件这是 MyBatis Plus 条件构造器最好用的特性。前端传来的筛选条件经常为空如果不用这个特性你就得写一堆 if 判断代码既长又容易漏条件。这里要注意条件构造器适合单表查询多表查询还是得写 XML。很多初学者误以为 MyBatis Plus 的所有查询都能用构造器解决结果在关联查询处卡了半天最后用嵌套子查询硬凑出来性能差还难维护。正确的方法就是单表 CRUD 交给构造器多表联查走 XML各司其职。蓝鲸云的客户列表带部门名称、角色名称展示时就是走 XML 里的 JOIN 查询完成的。4.2 分页插件列表页性能的隐形杀手分页插件是 MyBatis Plus 的标配组件配置后调用 Page 对象即可PageSysUser page new Page(pageNum, pageSize); PageSysUser result userMapper.selectPage(page, wrapper);分页插件默认会执行 count 查询来获取总数。如果列表查询里带了多个左连接count 查询也会带上这些连接导致分页接口慢得出奇。蓝鲸云里有一个角色列表关联了用户表和部门表总数据量不大但每次打开页面要两三秒。排查后发现 count 查询执行了将近两秒原因是优化器对 count 语句选择了错误的索引路径。解决方案有两个方向如果不需要精确总数直接把分页的搜索模式设置为不执行 count前端用“是否有下一页”来替代如果需要精确总数则针对慢查询单独写一个更轻量级的 count SQL在 XML 里指定。蓝鲸云最终选择了前者因为内部系统的列表页基本不需要精确到“共多少条”。4.3 自动填充、逻辑删除、乐观锁内置能力用好省一半代码这三个内置能力是 MyBatis Plus 比较有代表性的特性但使用上都有需要注意的点分别说。自动填充一般用于 create_time、update_time 这类字段。蓝鲸云里定义了一个 MetaObjectHandler 的实现insert 时自动填充 create_time 和 update_timeupdate 时自动填充 update_time。这里有个隐藏问题如果实体字段上标注了 TableField(fill FieldFill.INSERT_UPDATE) 但没实现 MetaObjectHandler字段就是 null报错时提示的是数据库 not null 约束冲突完全不会联想到 MyBatis Plus 的填充机制。所以这个配置要么别用要么一次性配到位。逻辑删除配置 TableLogic 后默认 delete 操作会变成 update 语句将 deleted 字段置为 1。查询会自动拼接 deleted 0 的条件。逻辑删除最大的坑就是它和唯一索引的冲突也就是前面提到的 Duplicate entry 问题。蓝鲸云用户表的 user_name 有唯一索引删除用户 A 后再次新增同名用户 B因为 A 记录的 deleted 已经是 1唯一索引依然生效于是报错。解决方案是去掉物理唯一索引改成逻辑唯一判断新增前先查一下是否存在 deleted0 的同名记录。数据量不大时这种方案完全够用。乐观锁在蓝鲸云里用得不多主要是在订单编辑场景。原理是在表中加 version 字段更新时比对版本号是否一致不一致则更新失败。MyBatis Plus 需要配置乐观锁插件并且实体字段上标注 Version。要特别注意乐观锁适用于更新频繁、冲突可能性低的数据如果冲突率高频繁失败的体验很不友好就要考虑悲观锁或其他方案了。5. 蓝鲸云开发过程中的踩坑记录5.1 字段映射实体类的驼峰命名与数据库下划线这个坑我在项目开始的第一周就踩了。MyBatis Plus 默认开启了 mapUnderscoreToCamelCase所以 user_name 能自动映射到 userName。问题出在我给实体类加了一个布尔类型的字段 deleted对应的数据库列是 is_deleted。MyBatis 在映射 is_deleted 到 isDeleted 或者 deleted 时出现了混乱导致逻辑删除条件一直是错的。排查过程其实很枯燥但思路值得说一下先看控制台打印的 SQL发现 where 条件里拼接了 deleted 0但数据库列实际叫 is_deleted这就说明映射没对上。最后把实体字段改成 isDeleted并且在字段上显式标注 TableField(is_deleted)才彻底解决。这个坑给蓝鲸云的代码规范加了一条所有实体字段只要与数据库列名不完全一致必须显式写 TableField不允许依赖默认规则。靠默认规则省那几行代码后面排查问题的时间成本远高于省下的时间。5.2 PageHelper 与 MyBatis Plus 分页插件冲突项目里有一个模块最初用了 PageHelper 做分页后来统一迁移到 MyBatis Plus 的分页插件时没有把 PageHelper 的依赖完全移除。结果出现了一个诡异现象部分接口分页正常部分接口分页查出的总条数还是上一页的或者列表数据变了但总数不对。定位过程是这样的先复现接口发现出问题的接口都是先执行了一次查询再执行第二次查询的而正常接口都是单次查询。这是因为 PageHelper 的 ThreadLocal 里还残留着上一次的分页参数如果没有执行 clearPage 就会污染下一次查询。而 MyBatis Plus 的分页插件同样在拦截器层面处理分页两个插件叠加后内层拦截器被外包了一层逻辑就变得混乱。最后的处理方式是彻底移除 PageHelper 的依赖统一使用 MyBatis Plus 的 Page 对象同时做了全局检查确保项目里不存在任何 import com.github.pagehelper 的代码。建议大家在项目早期就统一分页方案不要混用这个坑排查起来非常费时而且现象没有规律。5.3 默认方法和代理this 调用导致事务失效蓝鲸云有一个批量导入客户的操作Service 方法 insertBatch 上标注了 Transactional。这个方法内部调用了同一个类里的另一个方法 validateAndInsert后者本身也有事务需求。结果测试时发现如果 validateAndInsert 抛了异常insertBatch 里已经插入的部分数据没有被回滚。原因很好理解Spring 的事务是通过代理对象实现的通过 this 调用本类方法时走的是当前对象而不是 Spring 生成的代理对象所以方法上的 Transactional 没有生效。解决办法是把被调用的方法拆分到另一个 Service 类或者通过注入自身代理对象来调用。蓝鲸云最终选择把 validateAndInsert 移到独立的 ImportService 里这样事务边界清晰代码结构也更好理解。这类问题在代码评审阶段很难发现因为逻辑看起来完全正常。我的建议是在一个类内部尽量避免通过 this 调用带有事务、异步、缓存注解的方法这些注解全都要走代理才有效。6. 上线前的检查清单与性能调优6.1 蓝鲸云上线前整理的配置注意事项项目上线前我把容易出问题的配置项梳理了一遍这里挑几个关键的。数据库连接池选用的是 Druid配置了初始连接数、最小空闲连接数和最大活跃连接数。内部系统的并发量有限最大连接数设置 20 就够了没必要追求大数值。关键是连接池的监控页面要打开方便上线初期观察连接的使用情况。MyBatis Plus 的全局配置里我开启了 log-impl 为 org.apache.ibatis.logging.stdout.StdOutImpl开发阶段能看到每个 SQL 的执行情况。上线前会把这个配置关掉避免日志刷屏同时开启慢 SQL 日志让数据库把执行时间超过阈值的 SQL 打印到日志文件里。配置项开发环境生产环境SQL 日志开启方便调试关闭慢 SQL 阈值不关注500msDruid 监控开启开启但限制访问逻辑删除开启开启统一异常处理返回详细错误返回通用错误信息6.2 慢 SQL 优化后台管理系统最容易出现瓶颈的地方后台管理系统的列表页往往绑定了大量筛选条件和关联查询数据量一旦涨上来慢 SQL 就不可避免。蓝鲸云在上线前做了一轮针对慢 SQL 的优化几个典型场景分享下。第一个场景是订单列表查询。订单表关联了用户表、部门表、客户表查询时使用了三表 JOIN。数据量在十万级时没问题到了百万级就明显变慢。优化的方案是把 JOIN 改成冗余字段存储或者拆成两次查询。蓝鲸云最后把客户姓名和部门名称冗余到了订单表的展示字段里查询逻辑简化了一大截响应时间从一秒钟降到了几十毫秒。第二个场景是模糊查询。客户姓名、订单编号这类字段经常被 like 查询而且是用 %keyword% 的形式。这种写法无法命中普通索引数据量一大必慢。方案是引入全文索引或搜索引擎但蓝鲸云的数据量还没到非引入搜索引擎不可的程度所以先限制模糊查询必须附带时间范围条件缩小扫描范围实测效果立竿见影。6.3 部署方式与后续可扩展方向蓝鲸云最终采用 Docker 容器化部署前端静态文件通过 Nginx 托管后端打成 Jar 包运行在容器里数据库独立部署在宿主机上。服务器配置不高两核四G压测后发现接口响应时间都能控制在 200ms 以内完全满足内部系统使用需求。考虑后续可能有移动端或大屏展示的需求蓝鲸云的模块边界划分得比较清晰后续扩展主要看两个方向一是把报表模块独立出来接入更专业的数据展示方案二是如果并发量上涨明显可以把网关层加进来实现接口限流和灰度发布。但这些都建立在业务确实需要的基础上不会为了技术而技术。写到这里蓝鲸云项目里我觉得值得展开讲的东西基本都覆盖了。做这个项目最大的感受是后台管理系统的难度从来不在增删改查而在权限边界、数据一致性和查询性能这些边缘地带。Spring Boot 和 MyBatis Plus 都提供了非常方便的能力但每个能力都有它的前提条件和失效场景用得不好反而会引入新问题。如果这篇复盘能帮你少踩几个我已经踩过的坑那这六周的加班就没白费。