如果你之前维护过稍微复杂一点的权限系统,一定对“暴力扫图”这四个字不陌生。每次处理一个“用户能不能访问这个文档”的请求,最常见的做法就是把所有与该文档相关的权限关系全捞出来,一条一条比对;数据量一上来,响应时间直接崩到几百毫秒甚至秒级。SpiceDB 这个开源项目进入视野之后,我花了不少时间研究它是怎么处理 ReBAC(基于关系的访问控制)的性能问题的。它不仅把 Zanzibar 那套架构思想做了落地,还通过一套组合拳:图数据模型、批量读取、缓存和成本估算,把性能优化从“凭感觉加索引”变成了“先把账算清楚再动手”。这篇文章我会把整个重构链路拆开讲,包括我实际迁移和压测过程中踩过的坑。
正文部分我会从暴力扫图的本质问题开始,讲到 SpiceDB 的数据模型、查询引擎和成本估算体系,最后一并给出可复现的实操路径,希望能给正在做权限系统选型或者打算迁移到 SpiceDB 的同学一些参考。
1. 先看暴力扫图为什么撑不住
1.1 ReBAC 和传统权限模型的本质差别
在动手折腾 SpiceDB 之前,我一直在维护一套基于 ACL 和 RBAC 的老权限系统。ACL 的模型很简单:资源上挂着用户列表,判断时查一下列表就行。RBAC 稍微好一点,引入了角色,用户通过角色间接获得权限,但还是逃不开“角色表、用户角色关联表、权限表”这三张表的关联查询。
ReBAC 和它们最大的区别在于:权限不再是一个静态的列表,而是一组图上的可达性计算。比如“文档 A 可见于文件夹 B,文件夹 B 的成员包括团队 C,团队 C 的成员是用户张伟”,那么张伟能看文档 A 这个结论,需要沿着“文档 A -> 文件夹 B -> 团队 C -> 张伟”这条路径遍历出来。
打个比方,ACL 就像是直接有个通讯录,翻到名字就能打电话;ReBAC 更像是社交网络中“好友的好友”这种关系链,你需要沿着关系网络走三步才知道能不能触达。模型表达能力大大增强,但也把查询对实时性的要求拉高了。
1.2 暴力扫图到底扫的是什么
所谓暴力扫图,本质上就是不做任何预处理,在请求到达时从数据库里把所有可能相关的边一次性捞出来,然后在应用内存里递归判断。
我当时的实现大致是这样:一个用户请求判断“是否可读文档 A”,程序先查出文档 A 的直接授权关系,再查出文档 A 所属父目录的授权关系,然后逐层向上递归,直到找到匹配项或遍历完整个层级。如果文档有共享链接、部门角色、项目分组等多套关系并存,这个递归的层级会越来越深,每个层级都要多一次甚至多次数据库查询。
最难处理的是间接关系。用户可能因为“属于团队 C”“团队 C 参与项目 P”“项目 P 关联文件夹 B”这条链路获得权限,但程序只有在递归到第三步时才能发现。这个递归过程中,任何一环数据没查全,或者关系有环,处理逻辑都会变得异常复杂而且容易出错。更麻烦的是,权限判断这种操作往往是高频调用,每次判断都做全量递归,这就是性能灾难的开始。
1.3 查询爆炸:一次 Check 请求要遍历多少数据
我在一次压测中记录过典型场景。系统里有 10 万个文件夹、50 万个文档,参与用户大概 2 万人。一个文档挂在一个五层目录结构下,平均每个目录节点关联 200 个用户或 20 个用户组;同时文档自己还挂了 30 个直接授权用户。一次简单的“用户能否访问该文档”判断,触发的最坏情况需要查询 6 层目录数据,加上文档本身,等于要做 7 轮数据库查询,每轮把一整层授权关系拉到内存。
在并发 100 的情况下,数据库连接池直接被打满,平均响应时间从最初设计的 50ms 一路飙到 800ms。我用了各种手段优化:加 Redis 缓存、按路径前缀做索引、批量预取授权关系,效果有,但始终没有质变——因为问题的根源不在某条 SQL,而在于“权限需要靠运行时遍历才能得出结果”这个模型本身。只要关系图变大、变深,查询的开销就一定跟着涨。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SpiceDB 的数据模型:把权限固化成一张图
2.1 Schema、Relation 和 Permission 的分工
SpiceDB 的文本 Schema 定义了一个权限系统的骨架,它比普通关系型数据库的建表语句要抽象得多。Schema 描述了三类东西:对象类型、关系(relation)和权限(permission)。
对象类型对应现实中的实体:用户、文档、文件夹、团队。关系定义实体之间的关联,比如 folder 对象有一个 member 关系,指向 user 或 team。权限则是基于关系计算出来的“可判断动作”,比如 view 权限定义为 viewer 关系关联到的那些用户,或者通过父文件夹的 view 权限推导出来。
这和我们写 SQL 建表最大的不同是,SpiceDB 的 Schema 是给“关系”设计的,它不关心某个表中的某个字段怎么存,而是关心“谁和谁以什么方式关联”。这种建模方式天然贴合 ReBAC 场景,因为权限判断本质就是关系推算,不需要在业务代码里手写多层 JOIN。
2.2 一个文档共享场景的完整 Schema 示例
我用实际例子说明。假设要支持“文档归属文件夹,文件夹里的成员可以看文档”这个最基本的模型,Schema 可以写成:
zed复制definition user {}
definition folder {
relation admin: user
relation member: user | folder#member
}
definition document {
relation parent: folder
relation viewer: user | folder#member
permission view = viewer + parent.admin + parent.member
}
这里 parent.member 表示如果 parent 是个人或嵌套目录,文档的 view 权限会把 parent 的 member 关系包含进来。这个 + 表示并集,SpiceDB 在检查时会把并集展开成一次递归遍历。
实际运行中,这种依赖父层级的写法,比我之前递归查目录树要清晰得多。权限语义直接在 Schema 里声明出来,变更权限模型时只需要改 Schema 并重新部署,不用动业务代码。
2.3 Caveat 带来的动态能力与性能影响
SpiceDB 的 Schema 还支持 caveat(条件限制),也就是在 relation 上附加一个表达式,比如“只有当文件没有过期时,viewer 关系才生效”。这看起来非常灵活,但它对性能是有影响的。
带有 caveat 的关系,在查询过程中必须逐条判断条件是否满足,而普通关系可以直接通过索引命中。可以理解为普通关系是一张已经筛选好的清单,caveat 关系则是一张装着清单和筛选规则的混合包,每次判断都要现场计算。
我在一次测试中给一个只读场景加上“有效期校验”的 caveat 后,Check 请求的耗时从 3ms 涨到了 12ms,虽然对单次请求来说还能接受,但在高并发场景下这个差距会被放大到几倍。所以我的建议是:能不加 caveat 的地方尽量不加,尤其是高频的权限判断接口,caveat 更适合低频或后台管理操作。
3. 性能引擎重构的四个关键动作
3.1 CheckPermission 的执行路径是怎么设计的
SpiceDB 处理一次权限检查请求时,流程大致是这样:先解析请求中的对象、关系和用户,然后从 Schema 里找到对应的权限定义,接着会在所有“能带来权限”的关系集合上做递归遍历,最终返回 True/False。
这个过程和我之前暴力扫图最大的区别在于:SpiceDB 的遍历是“有界”的。它不会查询整张关系表,而是根据 Schema 精确知道要查哪些关系类型、哪些对象类型、哪些 id 范围。比如检查用户张伟能否看文档 A,它只会查询 document A 的 viewer、parent 相关的关系,并且只递归到 folder 和 user 类型,不会把无关类型的数据卷进来。
这个“有界性”来自 Schema 的强约束。相当于数据库的查询优化器知道你的表结构,可以在执行计划层面把扫描范围缩小到最小,而我在老系统里完全靠业务代码控制,动不动就全量捞数据。
3.2 存储引擎与索引:把关系遍历变成索引跳跃
SpiceDB 的底层默认使用 CockroachDB 或 PostgreSQL 作为存储。它的关系数据表结构是固定的:包含 namespace、object_id、relation、subject_namespace、subject_object_id、subject_relation 等字段。这张表就像一个边上带标签的邻接表,存储了图的所有边。
索引设计是性能的重头戏。SpiceDB 针对这张核心关系表建了多个复合索引,覆盖了不同查询方向:
- 正向索引:以(对象类型、对象 ID、关系)为前缀,用于查询“某个对象的某个关系关联了哪些主体”,这是最常见的 Check 方向。
- 反向索引:以(主体类型、主体 ID)为前缀,用于查询“某个主体能触达哪些对象”,这是 LookupResources 方向。
为什么要强调这个?因为我在老系统里加索引时,往往只针对某一两个查询模式,一旦业务加了新的权限关联方式,判断性能就下降。SpiceDB 因为存储和 Schema 是固定的,索引可以做得非常规整,几乎所有查询都能命中复合索引,不需要频繁调整。
3.3 缓存:一致性优先,但也不是不用
缓存是所有权限系统的双刃剑。权限变更不像商品详情,不能接受“缓存五分钟后再生效”——如果用户刚被移出团队,下一秒还能访问机密文档,那就是安全事故。
SpiceDB 的实现在我看来比较务实:它对单个关系对象的检查结果做短期缓存,但会通过版本号机制在关系数据发生变化时主动失效。也就是说它不会像普通 Web 应用那样设置一个长长的过期时间,而是把缓存 TTL 控制在很短的窗口内,同时依赖 watch 机制实时感知数据变更。
我在自己的 Demo 环境中测试,当一条关系被删除后,大约几十毫秒内后续的 Check 请求就能感知到变化。这对大多数业务系统来说足够了。如果业务要求更强的实时一致性,可以关闭缓存或调小 TTL,代价是吞吐量下降,需要根据场景权衡。
3.4 LookupResources 如何避免逐条扫描
很多权限场景需要的不是“判断某个用户能不能访问某个对象”,而是“列出这个用户能访问的所有文档”。老系统的做法是先查出所有文档,逐条判断权限,结果就是一个全量扫表操作,性能完全不可控。
SpiceDB 的 LookupResources 走的是反向索引路径。它会从用户出发,沿着关系的反向边,一步步扩展出用户通过团队、文件夹、项目等路径能触达的所有对象集合。这个集合在正常情况下远小于全表数据量,所以查询速度会快几个量级。
不过这里有个需要留意的点:当用户的权限路径特别多或者对象层级特别深时,反向遍历的中间集合也会变大。比如一个用户同时属于 50 个团队,每个团队挂 100 个项目,每个项目挂 50 个文档,那用户的可访问集合就有 25 万个对象。这种情况下 LookupResources 照样会慢。解决的思路一般是配合分页、结合业务限定范围,或者对高频用户做预热缓存。
4. 成本估算:把性能账本从玄学变成预算表
4.1 为什么性能优化最终会走向成本估算
我在早期优化权限系统时,判断一个查询“能不能撑住”完全靠压测:并发 100 不超 200ms 就行。但生产环境的数据量、关系复杂度、用户行为模式是变化的,压测通过不代表上线后没问题。等到线上爆出慢查询再回头优化,成本高得多。
SpiceDB 带来的一个新思路是:在查询真正执行前,就估算出这个查询大概要扫描多少数据、做多少次判断、需要多少内存和耗时。这种估算不是简单地猜,而是根据 Schema 定义的关系类型、索引命中情况和历史数据分布,算出一个可预期的成本范围。有了估算,你可以在设计阶段就把可能超标的查询改掉,而不是等问题暴露。
4.2 一次 Check 请求可以被拆解成哪些成本项
我习惯把一个 Check 请求拆成下面几个成本项来做预算:
- 访问的关系行数:这是最核心的成本。比如 check 需要遍历 3 层目录,每层平均 50 条关系,那总访问行数约 150 行。行数越多,IO 开销越大。
- 索引命中的类型:如果全部走正常复合索引,成本较低;如果某个循环依赖需要跨类型扫描,成本就高。
- 递归深度:每多一层递归,就多一次数据库往返或者内存关联操作,深度从 1 涨到 5,成本不是线性增长,而是可能指数级增长。
- Caveat 的计算次数:每条带条件的 relation 都需要执行表达式,表达式越复杂 CPU 成本越高。
把这些指标结合数据分布,就能大致估算出一次请求的成本。SpiceDB 虽然没有直接提供“这个请求要花多少钱”的可视化面板,但通过它的 tracing 和观察指标,可以反推每个环节的耗时和行数。
4.3 数据规模、关系深度与并发模型怎么一起算
成本估算不能只看单次请求,还需要结合容量规划。我比较看重三个数字:关系总数、单次请求平均成本、峰值 QPS。
举个例子,如果关系表有 5000 万行,单次 check 平均访问 200 行关系,那命中率 99% 以上的情况下,每秒 1000 次 check 就需要每秒最多 20 万行的读取能力。这是普通 PostgreSQL 单实例可以扛住的,但如果并发到 5000 且平均成本升到 1000 行,压力就到了需要分片或者更大实例的级别。
做容量规划时,我最关注的是“最坏情况”而不是平均数。权限系统请求一旦出现热点(比如某个大型项目被全公司访问),所有请求可能都指向同一个高成本路径,这时候平均成本没有意义,必须按 P99 甚至 P999 的计算方式预留资源。
4.4 实际中最有效的成本优化顺序
按我自己的实践,成本优化优先级应该这么排:
- 第一优化 Schema,减少递归深度。比如把多层嵌套的层级关系扁平化,能省下大量遍历成本。
- 第二优化关系数据分布。把高频用户直接关联到资源上,而不是让每个请求都走一条长链路。
- 第三优化查询模式。把高频的 LookupResources 改成预计算结果,或者用缓存顶住热点。
- 第四才是加机器。加机器能解决并发问题,但解决不了单次请求的成本问题。
我见过不少团队卡在第四步,疯狂加节点发现 QPS 还是上不去,原因就是单次请求成本太高,节点增加带来的吞吐增量被复杂度吃掉了。
5. 从暴力扫图迁移到 SpiceDB 的实操复盘
5.1 第一步:梳理现有权限模型
迁移的第一件事不是写 Schema,而是把老系统的权限模型完整梳理出来。我把自己维护过的老系统拆成了三块:资源层级(文件夹与文档的父子关系)、直接授权(文档上挂的具体用户或用户组)、间接授权(通过部门、项目、团队获得权限的路径)。
梳理过程中最容易遗漏的是“隐含权限”和“管理员绕过”。比如老系统里管理员可以看到所有文档,这看起来是个简单的功能,但迁移到 ReBAC 后怎么表达?我当时的做法是给系统根节点单独建一个 system_admin 对象,并让所有文档的 view 权限带上对它的引用。这个设计让管理员权限和普通权限走同一条查询路径,避免在业务代码里写死逻辑。
5.2 第二步:数据迁移与一致性校验
数据迁移不是简单的 INSERT,而是要保证关系数据从关系型数据库到 SpiceDB 的过程中不丢、不重、不错。
我写了一个导出脚本,把老系统里的用户分组、文件夹层级、文档授权全部转换成 SpiceDB 的 relationship 格式。转换过程最需要注意的是关系方向的对应。老系统里“用户 123 被授权访问文档 456”对应的是 document:456#viewer@user:123;而“团队 7 是文件夹 12 的成员”对应的是 folder:12#member@team:7。
迁移完成后必须做校验。我把老系统核心业务的所有权限请求采样出来,在 SpiceDB 里重新执行一遍,对比结果是否一致。对比过程中发现了十几个不一致,绝大多数是“老系统里某些授权记录挂在已删除资源上”,SpiceDB 因为查询路径更严格,直接返回了 False,而老系统因为扫描不完整返回了 True。这种边界情况必须在上线前明确处理策略。
5.3 第三步:性能基线测试怎么做
迁移完不能直接上生产,必须先跑性能基线。我用的工具是 ghz,主要测试以下三类请求:
- Check 请求:样本覆盖直接授权、一层间接授权、深度嵌套授权、带 caveat 授权。
- LookupResources 请求:样本包括用户可访问资源少、中、多三种情况。
- Watch 场景:测试实时变更感知的延迟。
测试结果出来后,我最关注的是 P99 延迟和每类请求的成本估算值。如果深度嵌套请求的 P99 超过 50ms,我会回到 Schema 层面看能否减少递归深度;如果 LookupResources 的 P99 超过 100ms,我会考虑加缓存或者拆分查询范围。
5.4 常见问题与排查技巧实录
迁移和压测过程中,我遇到了一些典型的坑,这里整理成速查表。
| 问题现象 | 可能原因 | 解决措施 |
|---|---|---|
| Check 请求返回错误结果 | 老系统存在“资源不存在但授权仍在”的脏数据 | 迁移前先清理孤儿授权,或迁移后再单独处理 |
| 高并发下延迟抖动明显 | 单条关系遍历成本高,触发了大量递归 | 减少递归深度,加入预聚合关系 |
| LookupResources 返回结果集巨大 | 用户权限路径过多,扩展集爆炸 | 分页查询,结合业务限制范围 |
| 带 caveat 的请求性能骤降 | 条件表达式未走索引,需要逐条计算 | 尽量用普通关系替代 caveat,或压缩表达式范围 |
| 迁移后权限出现短暂不一致 | 数据是异步导入,导入期间有写入冲突 | 使用事务批量写入,并在导入期间暂停写入流量 |
实际排查中,我习惯先把 Tracing 开起来,看一次 Check 请求访问了多少行。如果行数超过预估成本模型的行数,就能判断是成本模型预估偏乐观,还是 Schema 定义出了问题。这个思路比直接看 CPU 和内存占用要精准得多,能直接定位到具体是哪条关系路径导致的开销膨胀。
说实话,从暴力扫图走到成本估算这一步,最大的转变不是技术上换了什么组件,而是思维模型变了:权限查询不再是一个“事务性”的动作,而是一个可以被预算、被度量、被优化的数据图访问过程。SpiceDB 只是承载这套思维的一个载体,但它确实把 ReBAC 从概念带到了可实际落地的工程化层面。如果你也在折腾权限系统,别急着抄代码,先把自己的权限模型画成图,再算算每类请求要访问多少条边,走一步看一步,后面会顺畅很多。
