基于资源驱动的权限缓存设计
文中所谓“权限点”是对系统中不同层次和范围的权限进行细化管理的一种方式,它可以表示某个资源在某个操作上的权限状态(例如:某个视图的“读取”权限)。这些权限点可以涵盖多个层次的权限关系,像表单、视图、字段等,因为它们覆盖了权限体系的各个角落,而不仅限于某个特定层次或操作。
叙事:
在低代码项目中,权限是必不可少的,尤其在用户权限要求比较严格的情况下,一般会涉及到多层级、多角色的权限架构设计,而在以往所接触到的 rbac 权限中,大多是以角色权限驱动资源的,在资源不多的时候或者资源颗粒度比较大的情况下,这思路是比较通用的,但是如果涉及到比较密集且频繁操作的情况下,可能需要另外去设计了。
案例背景:
要实现整套以低代码构建的权限缓存架构设计,在我的项目中可分层为 3 级:表单 - 视图 - 字段,每级都有独立且众多的权限点,例如:表单可控制是否能分享、批量操作、导入、导出、讨论等等,视图和字段则为是否可见、可写等等,其中的字段权限点很多,每个字段都需要设置不同的权限,每个用户所能看到和所能操作的都不相同。
资源驱动的意义:
这里核心思想是:针对系统中的资源&角色等进行权限集构建,然后通过这些预先构建的权限集来快速判定用户的权限。与传统的逐个用户生成权限集相比,这种方式更适合处理复杂且多变的权限关系。
为什么反向构建资源权限集?
- 减少重复计算:针对某个资源&角色构建一次权限集后,多用户共用,可以避免每次权限判断时对每个用户进行大量的权限计算。尤其是资源多、权限点细粒度、用户角色多的复杂系统,节省的计算量会非常可观。
- 易于管理权限变更:当权限发生变动时,只需更新对应资源的角色权限集,而不需要对每个用户的权限进行重新计算或更新。例如,如果一个角色的权限发生了变化,只需更新该角色的权限集即可,不需要逐个用户更新权限数据。
- 更适合分布式缓存:反向构建的权限集可以更好地利用分布式缓存,因为它在节点之间同步的是以资源为单位的权限数据,而不是每个用户的权限数据。这样可以减少缓存同步的数据量和频率,提升性能。
- 降低内存压力:相对于每个用户生成一套权限数据,将资源对应的权限集存储并通过资源的方式进行查询,可以显著降低内存压力。即使在缓存中,数据也更加精简和易于维护。
- 正确性和一致性: 通过资源驱动方式构建的权限集,可以确保在同一资源上所有用户的权限状态保持一致。例如,当某个资源权限被修改时,只需要更新对应的资源权限集,所有关联的用户立即同步到最新状态。这尽可能的避免了传统方式中,可能由于异步更新用户权限数据带来的权限不同步或不一致问题。
- 传统 RBAC 以角色用户为中心: 适用于权限关系较简单、用户数相对少的系统;而资源驱动的设计更适合权限层次复杂、资源和用户规模都较大的低代码平台。
多层级数据结构对比:
常规:
{
"roleId": "role0",
"worksheets": {
"sheet0": {
"sheetId": "sheet0",
"add": false,
"read": false,
"edit": false,
"remove": true,
"views": {
"view0": {
"viewId": "view0",
"read": false,
"edit": false,
"remove": true,
"fields": {
"field0": {
"fieldId": "field0",
"read": true,
"edit": false,
"add": false,
"decrypt": false
}
"fieldN": {...}
}
}
"viewN": {...}
}
}
"sheetN": {...}
}
}
资源驱动:
sheet:add:sheet0 -> {"role0", "role1"}
view:remove:view0 -> {"role1", "role3"}
field:edit:field0 -> {"role2"}
对比与优化:
- 常规结构:这种方式是以角色为中心,将角色的所有权限关系通过嵌套结构来描述。每个角色会维护一颗完整的权限树,从表单到视图再到字段,逐层定义其权限点。这种结构直观且层次清晰,但随着资源和权限点的增加,数据量会急剧膨胀。尤其是当角色与多个表单、视图、字段关联时,权限树的结构将变得异常庞大且错综复杂,导致在存储、计算、查询上的负担加重。
- 资源驱动结构:这种方式将权限点进行了最小化拆分,不再维护一颗完整的权限树,而是将每个权限点抽象成标识前缀+唯一坐标+角色集的形式。每个权限点(如表单的新增权限、视图的删除权限、字段的编辑权限)都单独存储其具备该权限的角色集合。这样可以针对每个权限点进行缓存,使得权限判断时只需检查该权限点对应的角色集是否包含当前用户的角色,从而大大简化了查询流程。
- 拆分的优势:
- 减少冗余数据:原本庞大的多层级权限树被拆解成了一个个独立的权限点,每个点只包含需要的信息,避免了嵌套结构中大量的无关数据。
- 提升查询效率:不再需要逐层遍历树形结构,而是直接通过权限点的标识进行快速查询。对于大规模系统中的多角色、多资源权限管理,可以显著减少计算和查询开销。
- 方便权限变更:当某个资源的权限发生变更时,只需更新对应的权限点记录,不必逐个修改角色的完整权限树结构,从而加快了权限同步的速度,减少对缓存系统的压力
数据大小&序列化&压缩:
1.权限对象树的数据体积和规模:
数据体积描述:在低代码平台中,权限对象树可能涉及到大量的表单、视图和字段。每一个表单、视图或字段都可能有多种权限配置(如增、删、查、改、导出、导入、可见性等),这些细粒度的权限配置累积在一起,会形成一个较为复杂且庞大的数据体。假设系统中有几百个表单和视图,每个表单和视图下面还有几十个字段,那么权限数据体积可能会轻松达到上百 KB。
存储问题:当权限数据需要存储到缓存中心(如 Redis)时,直接存储未经处理的数据会消耗大量内存。如果对每个用户都存储一份完整的权限数据,将导致缓存中心的存储空间急剧上升。
阻塞问题: redis 处理命令的时候是单线程处理的,如果数据体过大的时候会造成阻塞的问题,进而导致请求堆积和超时,影响正常的访问和使用。
2. 序列化与反序列化的消耗:
- 序列化的必要性:在将复杂的权限对象树存储到缓存系统中时,需要将对象序列化进行存储。相应地,从缓存中获取数据时,需要将字符串转回对象,这个过程称为反序列化。
- 性能影响:序列化和反序列化过程对 CPU 的消耗较大,尤其是当数据体积较大时,每次操作都需要耗费一定的 CPU 资源。特别是在高并发场景下,频繁的序列化与反序列化操作可能会成为系统性能的瓶颈。
- Java 虚拟机内存和 GC 压力:由于序列化后的数据体较大,存储在 JVM 中会占用大量内存。这会对 JVM 的垃圾回收机制(GC)带来不小的压力,特别是在需要频繁加载和清理这些缓存对象时,会导致 GC 的频率增加,可能出现内存回收延迟和性能抖动。
3. 数据压缩的方案与权衡:
- 压缩的目的:通过数据压缩,可以显著减少权限数据在缓存中的存储空间。对于一个 100 KB 的权限数据,如果使用 GZIP 或其他压缩算法进行压缩,通常可以减少到 10 KB 左右。
- 压缩后的优势:
- 减少存储空间:压缩后的数据占用更少的内存和存储空间,适合数据体积大且更新频率较低的场景。
- 降低网络传输压力:当系统中有多个应用实例或分布式环境时,压缩后的数据在网络中传输时也会更轻量,从而减少带宽消耗。
- 压缩的劣势:
- CPU 消耗增加:每次读取和写入权限数据时,都需要进行压缩和解压缩操作,尤其是 GZIP 这类压缩算法,会占用较多的 CPU 资源。在高并发环境下,如果频繁进行压缩操作,可能导致系统响应时间增加。
- 延迟增加:在读取数据时,解压缩的过程会带来一定的延迟,虽然压缩能减少数据量,但在响应时间要求严格的场景下需要权衡。
资源依赖变更:
在常规的实现中,角色权限资源变更通常遵循这样的路径:资源 -> 角色 -> 用户。也就是说,当某个资源的权限发生变动时,需要首先更新与之相关的角色,再依次更新这些角色下的所有用户权限。这种逐一处理的方式确保了权限的 #一致性 ,但当某角色涉及到大量用户时,特别是在资源和角色频繁更新的场景下,会产生显著的性能瓶颈。每次变更都可能触发大量的权限计算与缓存更新操作,不仅增加了系统的负担,还可能造成缓存中心(如 Redis)的高负载和响应延迟。
通过资源驱动的反向权限构建,可以有效缓解这一问题。只需针对变更的资源更新对应的权限集,用户在访问时通过资源 ID 即可快速判定权限,无需逐一遍历和更新每个用户的权限缓存。这种方式避免了大规模的数据操作,使得权限变更后的同步更加简洁和高效。
多角色权限合并:
权限对比示意
视图 | 字段 | ||||||
---|---|---|---|---|---|---|---|
查看 | 编辑 | 删除 | 新增 | 可见 | 编辑 | ||
角色 1 | 视图 A | 可见 | 可编辑 | 可新增 5 个 | 全部 | 全部 | |
角色 2 | 视图 A | 不可见 | 不可编辑 | 可新增 3 个 | 不可见 | 不可编辑所有 | |
合并后视图 A | 可见 | 可编辑 | 可新增 5 个 | 全部 | 全部 |
权限对比示意
视图 | 字段 | ||||||
---|---|---|---|---|---|---|---|
查看 | 编辑 | 删除 | 新增 | 可见 | 编辑 | ||
角色 1 | 视图 B | 可见 | 不可编辑 | 可删除 10 个 | 可查看 5 个 | 不可编辑任何 | |
角色 2 | 视图 B | 可见 | 可编辑 | 不可删除 | 不可查看任何 | 仅可编辑 1 个 | |
合并后视图 B | 可见 | 可编辑 | 可删除 10 个 | 可查看 5 个 | 仅可编辑 1 个 |
在企业权限架构中,一个员工可能会同时担任多个角色,不同的角色对于某些相同的权限点可能有不同的权限配置,如果多个权限点配置冲突了,就会涉及到多角色合并规则了。
上面的示例中,介绍了多个角色在不同层级之间的相同权限点会以最大的权限点进行合并,得到一个新的最大权限的角色。
如果以平常的做法,大概是以多层级对象树为基础,进行逐级 merge 操作,最后得到一颗最大权限的对象树进行缓存,那么如果要从这棵树得到 field
的 add
权限点,在最坏情况下,查找时间复杂度为 O(m * n * p)
,其实如果每个节点(如 sheet
、view
、field
)都有相对稳当的唯一坐标 ID,那么可以在合并之后将这棵对象树“铺平”成一个一维数组或对象字典。这样可以大大简化查找的时间复杂度,使得原本需要逐层遍历的树形结构变成了简单的键值查找,这样时间复杂度就变成了 O(1),尤其在节点数量较大时优势显著。
如果使用了资源驱动的权限缓存,它自然就是一颗以资源反向构建的一维数组,在时间复杂度上面也会是 O(1),大概的结构上面有示例可以参考。
补充说明: “最大权限”策略虽然适合大部分情况,但有些场景可能需要更精细的控制(如安全性敏感的字段,可能会合并成最小权限)。
- 最大权限合并:适用于需要保证用户在任一角色下的最大权限时,如在一般的阅读、编辑权限中。
- 最小权限合并:适用于敏感数据或操作(如删除权限)时,以防止不必要的误操作。
- 自定义策略:在某些复杂情况下,可以为特定权限点配置合并策略,如通过策略函数来选择合并规则。
其实本地缓存的方式也可以用,但觉得会有些治标不治本,遇到过客户使用开源框架导致权限缓存有好几兆的,后面还是要做拆分重构,不如早做打算。先聊到这里,下一篇打算写写如何以领域对象构建数据权限 👋🏻👋🏻