第4章 数据库表设计:用户、角色、权限与关联
说到RBAC的表结构,很多人上来就抄网上的五张表模板。但我自己的经验是,表结构的设计直接决定后续权限查询的复杂度。你想想看,如果关联表设计得不好,每次判断用户有没有权限都得做三次JOIN,性能能好吗?
这节我们就来把四张核心表(用户表、角色表、权限表、用户-角色关联表、角色-权限关联表)一张一张拆开,顺便聊聊索引策略。我建议你跟着我把每个字段的目的想明白,别只记DDL。
4.1 用户表:最简单的但最容易埋坑
用户表是所有权限体系的起点。它记录了「谁」来访问系统。但有一件事我踩过坑——不要把权限逻辑直接塞到用户表里。比如加个 role_id 字段表示角色,那多对多关系就没法玩了。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT UNSIGNED | 主键,自增 |
| username | VARCHAR(64) | 登录名,唯一 |
| password_hash | VARCHAR(256) | 哈希后的密码 |
| status | TINYINT | 0-禁用 1-启用 |
| created_at | DATETIME | 创建时间 |
我的习惯:用户名一定要加唯一索引,但不要用聚簇索引,否则插入热点会成为瓶颈。曾经有个项目用户名用UUID做主键,结果磁盘碎片严重,后来改成自增ID + 唯一索引才搞定。
4.2 角色表:粒度控制的关键
角色本质上是一组权限的集合。但角色本身也需要一些元信息,比如角色编码、是否内置、数据范围等。
CREATE TABLE `roles` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`role_code` VARCHAR(64) NOT NULL COMMENT '角色编码,如 admin/editor',
`role_name` VARCHAR(128) NOT NULL COMMENT '角色显示名',
`is_system` TINYINT NOT NULL DEFAULT 0 COMMENT '是否系统内置,内置不可删除',
`data_scope` TINYINT NOT NULL DEFAULT 1 COMMENT '数据权限范围:1本人 2部门 3全部',
`remark` VARCHAR(512) DEFAULT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_code` (`role_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
is_system 字段我特别提一下。曾经有客户误删了 "超级管理员" 角色,所有用户瞬间没有了任何权限。从那以后,我只要涉及系统内置角色,都会加这个字段并在代码里强制保护。
4.3 权限表:不要让字段泛滥
权限表记录系统里每一个「可操作」的原子权限点。比如“创建订单”、“删除用户”、“导出报表”。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT UNSIGNED | 主键 |
| perm_code | VARCHAR(128) | 权限标识,如 system:user:del |
| perm_name | VARCHAR(128) | 权限名称 |
| module | VARCHAR(64) | 所属模块,用于分组 |
曾经我犯过的错:在权限表里加了个
parent_id 做树形结构,结果查询子权限时递归死循环导致数据库打满。后来我改用「前端按模块分组展示」+「后端平铺存储」的方式,简单多了。慎用树形结构,除非你真的需要动态层级。4.4 关联表:多对多的桥梁
用户和角色是多对多,角色和权限也是多对多。所以我们需要两张关联表。
用户-角色关联表(user_roles)
CREATE TABLE `user_roles` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL,
`role_id` BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_role` (`user_id`, `role_id`),
KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB;
角色-权限关联表(role_permissions)
CREATE TABLE `role_permissions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`role_id` BIGINT UNSIGNED NOT NULL,
`perm_code` VARCHAR(128) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_perm` (`role_id`, `perm_code`),
KEY `idx_perm_code` (`perm_code`)
) ENGINE=InnoDB;
注意:我用
perm_code 而不是 perm_id 做外键,原因是权限变化不频繁,直接用字符串可以少一次JOIN查询。如果你的权限表字段经常变动,还是用ID更灵活。具体选哪种,得看你业务场景。4.5 索引策略:别让查询变成慢SQL
索引设计是权限表的核心。我一般遵循三个原则:
- 唯一索引用于业务唯一键(如
uk_user_role)——防止同一用户重复绑定同一角色。 - 联合索引用于最频繁的查询条件。比如查找用户所有角色时,用
(user_id, role_id);查找角色下所有权限时,用(role_id, perm_code)。 - 覆盖索引:如果查询只需要返回角色ID或权限标识,索引里已经包含了这些字段,可以避免回表。
举个实际场景:判断用户是否有“删除订单”权限。SQL大概长这样:
SELECT 1 FROM user_roles ur
JOIN role_permissions rp ON ur.role_id = rp.role_id
WHERE ur.user_id = ? AND rp.perm_code = 'order:delete'
LIMIT 1;
这条SQL要走两个索引:user_roles 的 uk_user_role(用user_id定位),role_permissions 的 uk_role_perm(用role_id+perm_code定位)。实际执行基本是毫秒级的。
别踩的坑:不要在关联表上加过多的单独索引。索引不是越多越好,写入性能会下降。我曾经见过一张user_roles表上建了5个索引,结果批量导入角色时死锁不断。后来精简到两个索引(唯一索引 + 外键索引),世界清静了。
4.6 表关系总览(SVG)
把上面几张表的关系画出来,你就能一眼看清整个权限数据模型了。
4.7 小结:表设计三要素
- 职责单一:每张表只描述一种实体或一种关系,不要混用。
- 索引精准:唯一约束用联合唯一索引,外键列用单列索引,不走偏。
- 预留扩展:角色表和权限表都要留
remark或extra字段,业务变化时不用改表结构。
我的经验:表结构确定后,先写几个核心查询(比如查用户权限列表、判断单个权限),跑一下 EXPLAIN。如果看到 Using filesort 或者 type 是 ALL,说明索引没设计好,趁早上线前改掉。等数据量上来了再改表,代价至少要翻10倍。