eladmin二次开发数据库设计:权限模型、JPA陷阱与慢SQL优化实践

发布时间:2026/10/5 7:54:13
eladmin二次开发数据库设计:权限模型、JPA陷阱与慢SQL优化实践 我一直觉得接手任何一个基于 eladmin 的二次开发项目第一步不该是急着翻代码而是先把数据库表结构完整扒一遍。代码写得烂你花几周时间总能重构掉表结构要是定错了后面所有查询、索引、接口、页面全都得围着它将就改起来伤筋动骨。这篇文章是这两年我在几个 eladmin 项目里做数据库设计评审和性能优化的记录核心围绕这么几个问题权限模型到底是怎么通过表结构串起来的、代码生成器对表设计有什么隐藏要求、JPA 项目里那些不容易发现的查询坑在哪以及几个真实到不能再真实的慢 SQL 排查案例。如果你正准备基于 eladmin 做二次开发或者正在被越来越慢的列表查询折磨这篇文章应该能给你一套可以直接复用的思路。1. 先看懂eladmin的权限模型用户-角色-部门-菜单是怎么串起来的eladmin 的权限模型并不是什么稀奇的东西本质上就是经典的 RBAC 变体——用户关联角色角色关联权限。但第一次看源码的人经常会被绕一下因为它的权限点并不是单独建一张 permission 表而是直接挂在菜单表的 perms 字段上。想搞懂这套数据库设计得先把表之间的关系捋顺。1.1 核心表与关联关系以常见的 eladmin 分支为例数据库里真正承担核心业务权限的表大概是下面这一组表名职责关键字段sys_user用户表id, username, password, dept_id, job_id, enabledsys_role角色表id, name, level, data_scope, paramssys_menu菜单与权限点表id, pid, name, type, path, component, perms, iconsys_dept部门表id, pid, name, enabledsys_users_roles用户-角色关联user_id, role_idsys_roles_menus角色-菜单/权限关联role_id, menu_idsys_roles_depts角色-部门数据权限关联role_id, dept_idsys_user_job用户-岗位关联user_id, job_id这里比较容易被忽略的是 sys_roles_depts 这张表。eladmin 的数据权限做得比一般脚手架细它通过角色上的 data_scope 字段来决定用户能看到哪些部门的数据全部、本级及以下、本级、自定义、仅本人。自定义这个选项就是靠 sys_roles_depts 把角色和具体的部门范围绑定在一起的。你在设计业务表的时候如果想要复用这套数据权限能力几乎所有需要做数据隔离的表上都应该预留 dept_id 字段不然将来想按部门控制数据范围会发现根本没有抓手。1.2 为什么权限点要放在菜单表里很多人第一次看到 sys_menu 的时候会问菜单表和权限点混在一起这设计是不是有点草率其实不是这是 Shiro、Spring Security 这类框架里很常见的一种做法——把权限标识符比如user:add、user:del当作 authority 挂在菜单节点上。前端遍历菜单树的时候能知道该渲染哪些按钮后端在接口上用PreAuthorize(hasAuthority(user:add))做方法级校验两边共用同一份数据源维护成本反而最低。但这里有坑。菜单和权限共用一张表时间一长 sys_menu 特别容易变脏。我在项目里见过菜单表里躺着几百个 perms 全是空值的目录节点也见过同一个权限字符串复制到三四条数据上的情况。这样导致的直接后果是改一个按钮权限要同时改好几个地方漏一个就出现明明配了权限但前端还是看不到按钮的灵异事件。接手这类项目我建议先跑两条 SQL 体检一下-- 查重复的权限标识 SELECT perms, COUNT(*) AS cnt FROM sys_menu WHERE perms IS NOT NULL AND perms ! GROUP BY perms HAVING COUNT(*) 1; -- 查空权限的按钮 SELECT id, name, type, component FROM sys_menu WHERE type 2 AND (perms IS NULL OR perms );这条体检记录下来你会对项目的权限健康程度有个非常直观的判断。如果没有重复项说明前任开发维护得还算用心如果重复项几十条那就别急着加功能先花点时间把权限标识整理清楚不然这个项目越往后越难养。2. 代码生成器的元数据依赖表结构设计直接决定生成代码的质量eladmin 最有名的功能就是代码生成器很多人觉得用它就是建表、点生成、完事。但如果你只把它当成一个模板填充工具那就浪费了。实际上生成器输出的实体类、DTO、前端表单控件长什么样完全取决于你表结构设计得有多讲究。2.1 生成器到底在读取什么信息eladmin 的代码生成器本质上是读数据库的信息模式information_schema把表注释、字段注释、字段类型、长度、是否必填、默认值这些元数据挖出来再映射到一组生成模板里。换句话说你在 Navicat 里写的每一句字段注释最后都会变成代码里的中文字段说明和前端表单的 label你选择的字段类型会直接决定实体里是 Long 还是 String前端控件是输入框还是日期选择器。我整理过一个比较常用的映射对照按照这个思路去建表生成出来的代码几乎是免调的数据库字段类型实体映射前端控件倾向bigintLong文本框主键/外键场景varcharString输入框textString标记 Lob多行文本datetimeLocalDateTime日期时间选择器bit / tinyint(1)Boolean开关decimalBigDecimal数字输入框这里最容易踩的坑是很多人嫌麻烦建表的时候所有字符串一律给 varchar(255)注释顺手写个名称备注也不说明是学号、手机号还是邮箱。结果生成出来的前端表单全是清一色输入框用户填什么全看运气后端也拿不到任何格式校验的线索最后这些校验代码还得自己在 Service 层补一遍。2.2 建表之前先过一遍这几条规范以 eladmin 的代码生成器作为反馈标准我现在建表之前都会先过一遍自己的检查清单每张表必须有表注释每个字段必须有字段注释。注释不是写给自己看的是生成器传输到前端的唯一语义通道。注释里最好把格式要求也写清楚比如手机号11位生成代码后校验逻辑可以照着补。字段命名统一用下划线风格比如create_time生成器能自动帮你转成createTime。如果你建表时混用了大小写和奇怪的分隔符生成的实体属性会非常不优雅。避免使用数据库保留字作为字段名。比如order、desc、level。eladmin 的sys_role里就有level字段查询时稍微一不注意就要加反引号这种尴尬我遇到过不止一次。主键类型统一。建议全部用bigint自增或者应用层生成 ID 都可以。不要一张表用 int另一张表用 varchar 存雪花 ID到时候写关联查询时隐式类型转换会毁掉索引。审计字段尽量补齐。create_time、update_time这类字段 eladmin 的基类里就有监听器自动填充建表时直接带上省得后面每个接口都要手动 set 时间。我个人的体会是如果你把建表这件事当成在写一份给未来的同事看的技术文档那么后面生成代码、写查询、做联调都会非常顺。反过来为了省五分钟随便建的表往往会在后期消耗你五十分钟去填坑。3. 真正藏雷的地方JPA懒加载与N1查询在eladmin里的表现eladmin 底层用的是 Spring Data JPA开发效率确实高但 JPA 这玩意儿的脾气也大。它是典型的用起来一时爽排查火葬场尤其是懒加载带来的 N1 查询问题在列表接口里几乎是必现的。3.1 一条列表接口背后到底执行了多少条 SQL假设你要在用户管理页面展示用户列表界面上需要显示用户名、部门名称、岗位名称。正常思路是查询用户表然后通过 dept_id 和 job_id 关联查询部门表、岗位表。用 MyBatis 写的话你会明确写一条 join SQL。但在 JPA 里如果你在SysUser实体上配置了ManyToOne并且没有指定fetch FetchType.EAGER那么 Hibernate 默认会按 Lazy 来处理。这种情况下如果你在循环里一个个取部门名称比如ListSysUser users userRepository.findAll(); for (SysUser user : users) { String deptName user.getDept().getName(); // 这里触发额外SQL }问题就来了。查询 100 个用户会先执行 1 条查用户的 SQL再执行 100 条查部门的 SQL这就是经典的 N1。我在一个 eladmin 项目里实测过用户列表接口在本地跑日志里一口气刷了差不多 80 条 SQL页面转圈转了三四秒你根本不知道瓶颈在哪。排查方法其实不复杂本地环境把 JPA 的 SQL 日志打开spring: jpa: show-sql: true properties: hibernate: format_sql: true打开之后你会看到接口执行的实际 SQL 数量。一旦发现列表接口执行了几十条 SQL基本就坐实了 N1。解决办法有两个方向一是写Query用join fetch一次性把关联对象查出来二是直接查 DTO 投影只拿需要的几个字段。eladmin 自身的 Repository 里有很多现成的例子直接照抄它的风格就行不需要自己在外面另起炉灶。3.2 批量操作与事务边界除了查询JPA 里批量更新也是一个容易出问题的地方。eladmin 后台经常要做批量启停用户、批量分配角色这类操作如果直接在 Service 方法上标个Transactional然后在方法里 for 循环调用save()那就等着挨打吧。每条save()都会触发一条 select 再触发一条 update5000 条数据就是上万条 SQL事务再一包锁的持有时间长得吓人。我现在的做法是批量更新尽量走ModifyingQuery写一次性 update 语句或者用 EntityManager 的批处理能力控制好批量的大小。这种操作对数据库的压力是完全不同的。另外事务尽量只包住真正的业务边界不要把导出文件、远程调用这些耗时操作放在一个大事务里否则连接池很容易被占满后面所有请求都卡在等连接上。4. 生产环境慢SQL实战从诊断日志到索引重建的完整链路前面讲的都是设计层面的问题接下来聊聊线上环境里真正把数据库拖垮的慢 SQL。这部分是我觉得最有价值的内容因为我见过太多人遇到慢查询就上来加索引也不看执行计划最后加了等于白加。4.1 慢SQL怎么定位定位慢 SQL 的标准步骤第一步是先确认你确实打开了慢查询日志-- 查看当前状态 SHOW VARIABLES LIKE slow_query_log%; SHOW VARIABLES LIKE long_query_time; -- 打开慢查询日志阈值设置为1秒 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL slow_query_log_file /var/log/mysql/slow.log;注意这个设置只是临时生效实例重启后会丢。生产环境建议直接写进 MySQL 配置文件顺手把log_slow_admin_statementsON也加上这样像ALTER TABLE这种管理语句也会被记录。慢查询日志收集一段时间之后不要直接拿记事本打开数据量大的时候很容易崩用mysqldumpslow按总耗时排个序更靠谱mysqldumpslow -s t -t 20 /var/log/mysql/slow.log这条命令会把所有慢 SQL 按总执行时间排前 20 名你一眼就能看到最凶的是哪几条。拿到慢 SQL 之后千万不要急着加索引先 EXPLAINEXPLAIN SELECT ... FROM sys_user u LEFT JOIN sys_dept d ON u.dept_id d.id WHERE u.dept_id 2 AND u.enabled 1 AND u.username LIKE %admin% ORDER BY u.create_time DESC LIMIT 0, 20;重点看几个地方type是 ALL 还是 range/refkey是不是用到索引了rows估算扫描了多少行Extra里面有没有Using filesort和Using temporary。ALL 加 Using filesort 的组合基本就是慢的根源。4.2 一个真实的用户列表查询优化过程我在一个 eladmin 项目里接手过一个用户列表接口数据量大概 300 万行前端翻页卡得不行。初始 SQL 长什么样我已经忘了但问题本质和上面那条差不多主要卡在三个点上第一username LIKE %admin%。这个写法因为%在开头索引直接失效必须全字段扫描。如果改成admin%这种前缀匹配只要 username 上有索引就能走 range 扫描。第二深分页问题。假设前端手动把页码改到第 2500 页实际执行的 SQL 会变成LIMIT 49980, 20MySQL 需要先把前 49980 行全部查出来再丢掉扫描量巨大。这个不是加索引能彻底解决的要从查询方式上改。第三排序字段没有索引。ORDER BY u.create_time DESC如果 create_time 不在索引里就会产生 Using filesort全量数据在内存或磁盘排序一遍代价极高。当时我做的优化是分两步走的。第一步调整索引结构建一个复合索引ALTER TABLE sys_user ADD INDEX idx_dept_enabled_time (dept_id, enabled, create_time);这能让 where 条件里的 dept_id 和 enabled 快速过滤同时 create_time 已经钦定排序直接用索引序就能出结果不需要再排序。第二步深分页场景改成基于游标的写法。具体逻辑是前端不再传页码而是传上一页最后一条记录的 create_time 和 id查询时直接定位SELECT ... FROM sys_user u WHERE u.dept_id 2 AND u.enabled 1 AND u.create_time 2024-06-01 18:00:00 ORDER BY u.create_time DESC LIMIT 20;这样无论翻到多深数据库都只扫描目标范围内的数据不会越翻越慢。改完这个接口同样的列表查询从原来的快 2 秒降到了 90ms 以内而且数据量再翻一倍性能也不会明显退化。这一步要记住一个理念深分页适合游标浅分页用传统 limit 没问题。如果你的业务场景就是需要随便跳页那建议把跳转上限限制住不要让人能直接跳到几百万页。5. 深分页、多条件筛选与数据导出的数据库压力治理慢 SQL 优化解决的是一条接口的问题但 eladmin 这类后台管理系统里还有一类隐蔽的数据库压力源就是列表页的深分页、多条件筛选以及 Excel 导出功能。这些场景如果不提前设计数据量上来以后系统表现会非常恐怖。5.1 多条件筛选该怎么建索引eladmin 的列表页一般都有好几组筛选条件比如按用户名、邮箱、部门、时间范围、状态。理论上每个字段都建索引查询确实会快但写入性能会断崖式下降索引文件也会膨胀得离谱。正确做法是分析哪些条件是高频组合的针对组合建复合索引而不是给每个字段单独建索引。举个具体例子。用户列表里如果 dept_id 和 enabled 是每次都要一起过滤的而 create_time 是常态排序字段那就建(dept_id, enabled, create_time)这样的复合索引。底层原理是最左前缀原则MySQL 可以先用这个索引过滤 dept_id接着过滤 enabled最后顺着 create_time 已经把排序顺序定好了不需要额外 filesort。但这里有个非常容易踩的坑如果你在查询条件里对字段做了函数操作哪怕字段在索引列里索引也会失效。比如WHERE DATE(create_time) 2024-06-01这种写法会让整个索引失去作用。正确写法是WHERE create_time 2024-06-01 00:00:00 AND create_time 2024-06-02 00:00:00。我在代码评审里见过太多这种低级但伤害极大的写法了。5.2 导出功能怎么避免拖垮数据库eladmin 自带的数据导出功能如果数据量小直接用 POI 一次性查出来写 Excel 也还行。但是数据量一上来比如一次性导出 50 万行日志查询就会把数据库的连接占住很久内存里也要堆几十万个对象轻则接口超时重则把生产环境整个拖挂。我的处理思路是三个方向第一导出必须改为分批查询。比如按主键范围每批取 5000 条查完一批写入 Excel 文件后再处理下一批。这避免了单条大 SQL 长事务也让内存峰值大幅下降。第二导出操作一定要异步化。eladmin 里有现成的 Quartz 任务也有Async能力完全可以把导出做成一个后台任务前端先收到导出中的状态完成后提供下载链接。同步导出的用户体验本来就是伪需求等久了页面超时谁都用不了。第三如果导出的数据需要跨多张表 join优先考虑是否可以用宽表冗余。比如日志导出场景可以把用户名、部门名快照到日志表里导出时就只查单表避免几张大表 join 造成大量临时表操作。这里其实就是用空间换时间数据库压力会明显下降。这些优化做完之后接口的稳定性会有一个质的提升。我这里说的稳定不是指响应更快而是指不会因为一个导出请求把整个数据库打满。6. 结合MySQL 8的全局优化字符集、连接池与缓存策略的配合表结构、查询语句都理顺了剩下的就是 MySQL 实例本身的一些配置优化。这部分不如前面那些听起来高级但真到了生产环境这些全局参数往往决定了系统峰值时是稳如老狗还是当场翻车。6.1 连接池与InnoDB参数eladmin 默认的数据库配置走的是 HikariCP 连接池。很多人图省事直接套用默认配置其实 HikariCP 默认的核心线程数是 10对于秒级接口完全够用但如果你在里面跑导出任务、跑慢 SQL10 个连接分分钟被打满业务线程全在等连接表现就是接口集体变慢甚至超时。我一般这样调spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000注意连接池不是越大越好。每个连接背后都对应着 MySQL 线程和内存挤占过多会适得其反。一般一个实例 20 到 50 个连接已经足够大多数后台系统使用。另外max-lifetime一定要比 MySQL 的wait_timeout短否则连接会被 MySQL 端主动断开应用层拿到的连接已经失效第一次查询就会报通信异常这个问题特别隐蔽。MySQL 本身我最关注的是innodb_buffer_pool_size。如果是专门的数据库实例这个值可以设到物理内存的 60% 到 70%。如果数据库和应用在同一台机器上那就往下调留给应用进程足够的空间。改一个参数可能比你加一堆索引更有效因为这个参数决定了索引和行数据在内存里的命中率。6.2 Redis缓存怎么用才能真正减轻数据库压力eladmin 里 Redis 用得不算少在线用户、验证码、部分配置都有缓存。但很多人在做二次开发的时候并没有把通用的业务查询缓存起来每次请求都直接打到 MySQL 上索引再怎么优化也扛不住高并发。合理的做法是给热点查询加上 Spring Cache 注解。比如字典数据在菜单下拉框里会被频繁查询完全可以缓存起来毕竟字典表一个月也改不了几次。可以用Cacheable把字典列表接口的结果缓存在 Redis 里后台修改字典时再通过CacheEvict把对应缓存清掉。这样数据库的查询压力能下降一大截。做缓存的时候要特别留意缓存穿透。如果一个 ID 在数据库里不存在每次查询都会穿过缓存打到底层 MySQL。最粗的解决办法是查不到的空结果也缓存一下TTL 设置短一点比如 60 秒。这一点在高并发下非常重要否则有人恶意穷举不存在的 ID数据库会被瞬间打穿。最后再说一个和 eladmin 本身关系不大但值得留意的趋势如果你的项目里有日志搜索、历史数据语义检索这类需求而 sys_log 表已经胀到了几百万上千万行那就要考虑把日志同步到专门的检索系统去了。表结构上提前预留一个修改时间位点字段比如update_time就很重要后面无论是捞增量同步、还是按时间归档都有抓手。甚至你将来想做向量化的语义搜索也需要依赖这个位点去做增量索引。这个东西现在不一定用得上但建表时留一手总没坏处。整理这些内容的时候我又翻了一遍自己之前给 eladmin 项目做的压测记录发现最值得反复提醒自己的还是那句话别看这个项目自带代码生成器就随手建表表结构和查询习惯才是二次开发真正的瓶颈。如果你正打算基于 eladmin 起一个新项目我的建议很明确——先把权限模型那张表关系图画清楚再统一字段规范最后才轮到写业务代码。磨刀不误砍柴工这个顺序别搞反了。