角色继承:把权限树搭稳,别让它变成死循环

角色继承,说白了就是把组织里那种「领导权限自然包含下属权限」的直觉,翻译成计算机能理解的规则。我一开始做权限系统时觉得这东西很简单:经理能做的,主管肯定也能做嘛。但真的上手才发现,树怎么建、权限怎么向上传、万一有人不小心搞出循环继承怎么办——坑一个接一个。

今天我们就来拆开这几个问题。我会结合自己在几个项目里的踩坑经历,把角色层级树、权限向上传递和循环避免策略说清楚。

角色层级树:不是随便画棵二叉树就行

我见过很多初学者把角色继承理解成「总经理 → 经理 → 员工」这种单链条。但实际业务里哪有这么整齐?一个公司可能有产品经理、项目经理、研发经理,他们可能都继承自一个「基础员工」角色,同时又各自拥有自己的子角色。这其实是一棵多叉树,不是二叉树。

关键约束:每个角色只能有一个直接父角色,但可以有多个子角色。这样结构才是树,不是图。树才有明确的根和单向传递。

举个例子:

角色树:
  └── 基础员工
       ├── 开发工程师
       │    └── 高级开发工程师
       │         └── 架构师
       ├── 测试工程师
       └── 产品助理
            └── 产品经理

我曾在项目中遇到过这样的事:产品经理继承自产品助理,同时又继承自基础员工——这就成「多父节点」了。树变成图,权限传递路径就乱了。所以我建议,在设计阶段就用「每个角色只一个父节点」的硬约束,数据库里加个唯一索引都不过分。

权限向上传递:顺着树爬,权限一层层叠加

权限传递是什么意思?假如架构师角色有「发布生产环境」的权限,那架构师的所有父角色(高级开发工程师、开发工程师、基础员工)并不会自动拥有这个权限。注意,这里是子角色继承父角色的权限,而不是反过来。你想想看,总经理的权限更大,不可能因为下属有某个权限就自动也拥有——那是向下传递,不对。

所以「向上传递」这个说法其实容易引起误解。更准确的表述是:子角色在计算有效权限时,要沿着父链向上累加所有祖先角色的权限。我习惯把这个过程叫做「权限累积路径」。

角色 直接拥有的权限 继承自父角色的权限 最终有效权限
基础员工 查看公告、提交日报 无 查看公告、提交日报
开发工程师 查看代码仓库 查看公告、提交日报 查看公告、提交日报、查看代码仓库
高级开发工程师 合并代码到主分支 查看公告、提交日报、查看代码仓库 以上全部 + 合并代码
架构师 发布生产环境 以上全部 以上全部 + 发布生产

实际代码里怎么做?我一般用递归或迭代向上遍历父链,把权限集合求并集。伪代码大概长这样:

fun getEffectivePermissions(role):
    permissions = emptySet
    current = role
    while current != null:
        permissions.addAll(current.directPermissions)
        current = current.parentRole
    return permissions

这个算法的时间复杂度取决于树的深度。如果角色层数很深(比如超过10层),建议做个缓存,把角色及其祖先的权限集合提前算好存起来。我在一个老项目里见过每次请求都递归向上查数据库,结果角色树深度只有5层,但并发一高就慢得不行——后来用Redis缓存角色全量路径权限,问题就解决了。

循环继承避免策略:别让角色把自己当成自己的爷爷

这是最让人头疼的问题。什么叫循环继承?就是A继承自B,B继承自C,C又继承自A。你想想看,如果数据库里允许这种关联,那计算A的有效权限时就会陷入无限递归。

为什么会这样?大部分时候是人为配置失误。我记得有一次,运维同事在界面上配置角色时,不小心把「研发总监」的父角色设成了「研发经理」,而「研发经理」的父角色本来就已经是「研发总监」了——好嘛,循环了。系统瞬间CPU飙到100%,接口全部超时。

重要提醒:必须在每次创建或修改角色父节点时做循环检测,不能等到计算权限时再处理。检测成本远低于事后修复的成本。

常用的检测方法有三种:

  • 拓扑排序法:把所有角色节点按父子关系画成有向图,然后做一次拓扑排序。如果能生成完整排序,说明无环;如果排序过程中发现剩余节点数不为0,说明有环,且环内节点全部标记出来。(我比较推荐这个方法,因为能同时定位出环中的具体角色)
  • DFS 着色法:从每个节点做深度优先遍历,用三种颜色标记(未访问、正在访问、已访问)。如果在遍历中遇到「正在访问」的节点,就说明有环。优点是空间复杂度低,缺点是难以一次找出所有环。
  • 增量检测:当要设置某个角色的父角色时,只从这个角色开始向新的父链方向反向遍历一遍(也就是不断找父角色的父角色),看会不会回到自己。这个方法只检查单条修改,不涉及全局,适合高频修改场景。

我在实际项目中用的是一种混合策略:

  1. 每次角色关系变更前,用增量检测快速否决明显有环的修改(比如自己不能继承自己,或者不能继承自己的子角色)。
  2. 增量检测通过后,再对整张角色关系图做一次按需拓扑排序(只检查受影响的子树)。
  3. 如果拓扑排序失败,回滚操作并记录日志。

这个方案在性能和安全性之间找到了平衡。下面我画一张图让你看得更清楚:

角色继承与循环检测流程 角色A(基础员工) 角色B(开发工程师) 角色C(高级开发) 继承 继承 新增继承关系 例:C→A(非法) 触发增量检测 增量检测通过? 拓扑排序检查 是 拒绝修改,报错 否 无环,允许修改 有环,拒绝修改 有环 增量检测快速过滤

避坑指南:我曾经踩过的三个坑

第一个坑,是没有考虑角色树深度限制。有一次业务方的角色层级深到了12层,每次权限计算都要递归12次,性能直接崩了。后来我加了一个配置项,限制最大深度不超过6层,超出就告警。

第二个坑,是循环检测只放在了新增父节点时,但没考虑批量导入场景。批量导入可以绕过单条检测的钩子,直接导入带环的数据。我后来在导入脚本里也加了全量拓扑检测,才算堵死。

第三个坑,是缓存了角色权限但不缓存祖先链。结果每次修改角色关系后,大量缓存过期,所有子角色的权限都要重新计算,数据库瞬间被压垮。我建议缓存时以「角色ID→有效权限集合」的粒度,并且在角色关系变更时批量失效涉及角色的缓存。

小技巧:如果你用关系型数据库保存角色关系,可以用「闭包表(Closure Table)」来存储所有祖先-后代路径。这样查询有效权限时只需要一次SQL JOIN,不需要递归。空间换时间,在角色树变化不频繁的场景非常划算。

好了,角色继承这一块的核心内容基本就这些。你在实际项目中遇到的最棘手的继承问题是什么?欢迎交流。嗯,记住一句话:继承关系一旦有环,权限系统就变成了死循环。前端告警、后端熔断、运维报警,三管齐下才能防住。