第4章 数据库表设计:用户、角色、权限与关联

说到RBAC的表结构,很多人上来就抄网上的五张表模板。但我自己的经验是,表结构的设计直接决定后续权限查询的复杂度。你想想看,如果关联表设计得不好,每次判断用户有没有权限都得做三次JOIN,性能能好吗?

这节我们就来把四张核心表(用户表、角色表、权限表、用户-角色关联表、角色-权限关联表)一张一张拆开,顺便聊聊索引策略。我建议你跟着我把每个字段的目的想明白,别只记DDL。

4.1 用户表:最简单的但最容易埋坑

用户表是所有权限体系的起点。它记录了「谁」来访问系统。但有一件事我踩过坑——不要把权限逻辑直接塞到用户表里。比如加个 role_id 字段表示角色,那多对多关系就没法玩了。

字段名类型说明
idBIGINT UNSIGNED主键,自增
usernameVARCHAR(64)登录名,唯一
password_hashVARCHAR(256)哈希后的密码
statusTINYINT0-禁用 1-启用
created_atDATETIME创建时间
我的习惯:用户名一定要加唯一索引,但不要用聚簇索引,否则插入热点会成为瓶颈。曾经有个项目用户名用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 权限表:不要让字段泛滥

权限表记录系统里每一个「可操作」的原子权限点。比如“创建订单”、“删除用户”、“导出报表”。

字段名类型说明
idBIGINT UNSIGNED主键
perm_codeVARCHAR(128)权限标识,如 system:user:del
perm_nameVARCHAR(128)权限名称
moduleVARCHAR(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

索引设计是权限表的核心。我一般遵循三个原则:

  1. 唯一索引用于业务唯一键(如 uk_user_role)——防止同一用户重复绑定同一角色。
  2. 联合索引用于最频繁的查询条件。比如查找用户所有角色时,用 (user_id, role_id);查找角色下所有权限时,用 (role_id, perm_code)。
  3. 覆盖索引:如果查询只需要返回角色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)

把上面几张表的关系画出来,你就能一眼看清整个权限数据模型了。

RBAC表关系结构图 用户表 user_id (PK) username, status 角色表 role_id (PK) role_code, data_scope 权限表 perm_code (业务PK) perm_name, module 用户-角色关联表 user_id + role_id (联合唯一) 索引: idx_role_id 角色-权限关联表 role_id + perm_code (联合唯一) 索引: idx_perm_code 箭头方向表示“1对多”关系 所有外键列都需建立索引,避免全表扫描

4.7 小结:表设计三要素

  • 职责单一:每张表只描述一种实体或一种关系,不要混用。
  • 索引精准:唯一约束用联合唯一索引,外键列用单列索引,不走偏。
  • 预留扩展:角色表和权限表都要留 remark 或 extra 字段,业务变化时不用改表结构。
我的经验:表结构确定后,先写几个核心查询(比如查用户权限列表、判断单个权限),跑一下 EXPLAIN。如果看到 Using filesort 或者 type 是 ALL,说明索引没设计好,趁早上线前改掉。等数据量上来了再改表,代价至少要翻10倍。