当一个系统的权限规则复杂到「某个文档的查看者包含其所在文件夹的查看者,而文件夹的查看者包含所属组织的管理员」这种递归关系时,传统的 RBAC 表和 ABAC 策略都会开始显得笨拙。Google 在 2019 年发表的 Zanzibar 论文,描述了他们如何用一个全球一致的关系元组存储,为 Drive、YouTube、Photos、Maps、Cloud 等几十亿用户规模的产品提供统一的授权服务。这篇文章拆解 Zanzibar 的设计思想、一致性模型,以及主流开源实现(SpiceDB、OpenFGA、Ory Keto)的工程取舍。
一、Google Zanzibar 的背景与贡献
1.1 论文出处与业务规模
Zanzibar 的正式名称是「Google’s Consistent, Global Authorization System」,由 Ruoming Pang 等人发表在 USENIX ATC 2019。论文披露了几个关键指标:
- 管理对象:Google Calendar、Cloud、Drive、Maps、Photos、YouTube 等几十个产品。
- 数据规模:超过 20 亿个用户、数万亿条关系元组(trillions of tuples)。
- 流量规模:全球峰值超过 1,000 万次 check QPS。
- 延迟目标:check 请求在 p95 下低于 10 毫秒,p99 低于 100 毫秒,可用性达到 99.999%。
这不是一篇理论论文,而是一个跑了十几年、服务全球用户的生产系统的工程总结。因此它的设计选择每一项都有明确的代价与收益——理解这些权衡,比单纯模仿它的数据模型更重要。
1.2 Zanzibar 解决了什么问题
在 Google 之前,每个产品都有自己的授权逻辑:Drive 有文档 ACL,YouTube 有频道权限,Photos 有相册共享。这带来三个头疼的问题:
- 跨产品分享:在 Gmail 里分享一个 Drive 文档,需要两个系统协商权限;如果各自实现,一致性极难保证。
- 审计和合规:没有统一的「某个用户能访问哪些资源」的视图。
- 开发重复:每个团队都在重新实现「继承」「组」「公开 / 私有」这些相似的语义。
Zanzibar 的核心贡献是给出一个统一的、通用的、足够表达力的权限模型,让上层产品只需配置 Namespace,而不必自己写授权代码。
1.3 为什么不用传统 RBAC / ABAC
- RBAC:角色到资源的映射是二维的,无法表达「文档 A 的查看者 = 文件夹 B 的查看者」这种跨资源的递归关系。
- ABAC:基于属性的判断适合「部门 = HR 且密级 <= 2」这类固定规则,但不适合「我把这个文档分享给了 Bob」这种动态、细粒度、与用户交互强相关的权限。
Zanzibar 属于 ReBAC(Relationship-Based Access
Control)家族,把授权问题统一成图上的可达性问题:从
(user, object)
出发,能否通过若干关系边到达?这在 Drive、Docs
这种有「分享给 X,继承自文件夹 Y」语义的产品里极其自然。
关于 RBAC、ABAC、ReBAC 的取舍,可以参考 RBAC、ABAC、ReBAC:权限模型怎么选。
二、核心数据模型
2.1 Relation Tuple:最小单元
Zanzibar 的所有权限数据都是关系元组(relation tuple),格式如下:
<namespace>:<object_id>#<relation>@<user_or_userset>
举几个例子(用 Drive 类比):
doc:readme#owner@user:alice
doc:readme#viewer@user:bob
doc:readme#parent@folder:engineering
folder:engineering#editor@group:eng-team#member
group:eng-team#member@user:carol
拆解每个字段:
- namespace:资源类型,例如
doc、folder、group、user。每个 namespace 有独立的配置,定义它支持哪些关系。 - object_id:具体资源的 ID,例如
readme。 - relation:关系名,例如
owner、editor、viewer、parent、member。 - user:关系的另一端,可以是一个具体用户
user:alice,也可以是一个集合(userset),写成<namespace>:<object>#<relation>——意思是「某个对象上某个关系的所有主体」。
第四个例子
folder:engineering#editor@group:eng-team#member
读作:「engineering 文件夹的编辑者,是 eng-team 这个组的
member 关系所指向的所有人」。这就是 ReBAC
的精髓——关系可以嵌套指向另一个集合,而不只是具体用户。
2.2 Namespace 配置
光有元组还不够,因为很多权限是推导出来的(「owner 天然是 editor」「文件夹的 viewer 自动成为子文档的 viewer」)。Zanzibar 用 Namespace 配置来描述这些推导规则。下面是一个 Drive 风格的 document namespace(用论文里的 protobuf 语法):
name: "document"
relation { name: "owner" }
relation {
name: "editor"
userset_rewrite {
union {
child { _this {} }
child { computed_userset { relation: "owner" } }
}
}
}
relation {
name: "viewer"
userset_rewrite {
union {
child { _this {} }
child { computed_userset { relation: "editor" } }
child { tuple_to_userset {
tupleset { relation: "parent" }
computed_userset { object: $TUPLE_USERSET_OBJECT relation: "viewer" }
}}
}
}
}逐段解释:
relation { name: "owner" }:owner 没有 rewrite 规则,只有通过doc:X#owner@user:Y这种直接写入的元组才算数。editor的userset_rewrite是一个union,包含:_this {}:所有直接写成doc:X#editor@...的元组。computed_userset { relation: "owner" }:所有doc:X#owner@...的元组都自动算作 editor。
viewer的union包含:- 直接的
doc:X#viewer@...。 - 所有 editor(因此也间接包含 owner)。
tuple_to_userset:这是关键——找到所有doc:X#parent@folder:Y的元组,然后把folder:Y#viewer的全部集合作为 viewer。
- 直接的
用一句话描述:owner ⊆ editor ⊆ viewer;同时,文件夹的 viewer 也是文档的 viewer。
2.3 三种 Userset Rewrite 原语
Zanzibar 只定义了少量组合原语,就能表达绝大多数实际需求:
| 原语 | 含义 | 典型用法 |
|---|---|---|
_this |
当前 relation 直接写入的元组 | 所有基本关系 |
computed_userset |
同一个 object 的另一个 relation 的集合 | 角色继承(owner → editor) |
tuple_to_userset |
通过 tupleset 跳到另一个 object,再取它的某个 relation | 资源层级(文件夹 → 文档) |
union / intersection / exclusion |
集合的并、交、差 | 「是 editor 但不是 banned」这种复合规则 |
这四类原语的组合构成一个有限的、可静态分析的规则语言,这一点对后面讨论的一致性、缓存和性能非常关键——因为任何 check 请求都可以在编译期确定递归展开路径。
2.4 元组在存储里的样子
为了让后面讨论性能更具体,先把元组的物理形态画清楚。一条
doc:readme#viewer@group:eng-team#member 在
Spanner / Postgres 里通常是八列:
| shard_id | namespace | object_id | relation | user_type | user_id | user_relation | commit_time |
|---|---|---|---|---|---|---|---|
| 42 | doc | readme | viewer | group | eng-team | member | 2026-04-01T10:11:12Z |
几条关键的索引设计原则:
- 主索引按
(namespace, object_id, relation)聚簇,因为最常见的 check 路径是「给定 object、relation,遍历所有 user」。 - 反向索引按
(user_type, user_id)建立,用来支持「这个用户能访问什么」这类列表查询(ListObjects / ReverseExpand)。 - 如果需要分片,
shard_id = hash(namespace, object_id) mod N是最常见做法,保证同一 object 的所有元组在同一分片,减少分布式 join。
这三条索引决策直接决定了 check 的延迟、ListObjects 的成本和 watch 的吞吐,后面几章会反复回到它们。
2.5 用户(User)其实也是对象
一个经常被忽略的细节:在 Zanzibar
里,user:alice 也只是一个 namespace 为
user、object 为 alice
的对象。它没有 relation
配置,本质上是一个叶子节点。这让模型保持高度一致——doc:readme#owner@user:alice
和 folder:eng#editor@group:team#member
在结构上是同一件事。
某些 namespace 甚至有一种特殊的 ...(通配
relation)写法,表示「any user in this
namespace」:doc:public#viewer@user:*
意味着这个文档对所有登录用户可见。
三、Userset Rewrite 与权限推导
3.1 Check API 的语义
Zanzibar 的主接口是
check(namespace, object, relation, user, zookie),返回一个布尔值。它等价于判断:
在所有 rewrite 规则展开之后,
user是否属于<namespace>:<object>#<relation>所表达的集合?
展开过程是递归的。以
check("document", "readme", "viewer", "user:bob", ...)
为例:
- 读取
document的viewerrewrite,得到一个 union。 - 对 union 的每个子节点并行展开:
_this:查doc:readme#viewer@user:bob是否存在;查doc:readme#viewer@<some userset>中是否包含 bob。computed_userset { relation: "editor" }:递归调用check("document", "readme", "editor", "user:bob", ...)。tuple_to_userset:找到所有doc:readme#parent@folder:X,对每个 X 递归调用check("folder", X, "viewer", "user:bob", ...)。
- 任一子节点返回 true 即整体返回 true;对 intersection 则要求全为 true;exclusion 则要求第一个 true 且第二个 false。
3.2 Expand API 与集合可视化
除了 check,Zanzibar 还提供
expand(namespace, object, relation),返回一棵子集树(subset
tree),描述该 userset 由哪些子集并 / 交 / 差构成。expand
不直接判断某用户,而是给出集合的结构。
expand 在三种场景特别有用:
- 调试:产品想看「这个文档到底谁有权查看」。
- 审计:定期导出所有敏感文档的 viewer 列表。
- 下游索引:Leopard 构建用的就是 expand 结果(见第六章)。
expand 的代价比 check 高得多,因为它必须展开整棵树而不能短路返回。生产中通常不作为在线 API 使用。
3.3 Read / Write / Watch API
除了 check / expand,Zanzibar 还暴露:
read:读指定条件的元组,例如doc:readme#editor@*,配合分页。write:写入 / 删除元组。支持 precondition(「只有当doc:X#owner@user:Y存在时才允许写入doc:X#editor@user:Z」)来做原子操作。watch:订阅某个 namespace 的增量变更,返回 changelog 流。用于下游缓存失效、Leopard 索引增量构建。
3.4 一次完整 check 的伪代码
把前面几节串起来,一次 check 的实现用伪代码大致是:
func Check(ns, obj, rel string, user User, zookie Zookie) bool {
// 1. 查 namespace 配置,拿到这个 relation 的 rewrite
rewrite := lookupNamespace(ns).Relation(rel).Rewrite
// 2. 本地缓存命中?
key := cacheKey(ns, obj, rel, user, zookie)
if v, ok := cache.Get(key); ok {
return v
}
// 3. 递归展开
result := evalRewrite(rewrite, ns, obj, user, zookie, visited{})
cache.Put(key, result)
return result
}
func evalRewrite(node Node, ns, obj string, user User, z Zookie, v visited) bool {
switch n := node.(type) {
case *This:
return checkDirect(ns, obj, n.relation, user, z) ||
checkIndirectViaUserset(ns, obj, n.relation, user, z, v)
case *ComputedUserset:
return Check(ns, obj, n.relation, user, z) // 递归
case *TupleToUserset:
for _, t := range readTupleset(ns, obj, n.tupleset, z) {
if Check(t.userType, t.userId, n.targetRelation, user, z) {
return true
}
}
return false
case *Union:
for _, c := range n.children {
if evalRewrite(c, ns, obj, user, z, v) { return true }
}
return false
case *Intersection:
for _, c := range n.children {
if !evalRewrite(c, ns, obj, user, z, v) { return false }
}
return true
case *Exclusion:
return evalRewrite(n.base, ns, obj, user, z, v) &&
!evalRewrite(n.subtract, ns, obj, user, z, v)
}
}真实实现里还要加并行化(union 子节点并发执行)、短路(union 任何一个 true 就取消其他)、hedging、visited 环检测等,但主干就是这个形状。理解这段代码,就理解了 90% 的 Zanzibar。
3.5 展开图的形状决定延迟
需要额外强调:Userset Rewrite 让授权判断变成图遍历。这意味着:
- 宽浅的图(一个文档有 1000 个直接 editor):查找 O(1) ~ O(log n),靠哈希 / B+ 树索引。
- 窄深的图(文档 → 文件夹 → 父文件夹 → … → 组织根):每一层都要递归一次 check,延迟累加。
- 深嵌套的组(group 里包含 group 里包含 group):最糟糕的情况,Zanzibar 用 Leopard 索引单独处理(后面讲)。
这也是为什么 namespace 设计时要警惕无限深度的层级——每多一层,check 就慢一点。
四、一致性模型:Zookies 与快照读
4.1 New-Enemy Problem
先讲一个经典陷阱。假设 Alice 和 Bob 本是朋友,Alice 共享了一个文档给 Bob。某天两人闹翻:
- Alice 先调用
remove(doc:secret#viewer@user:bob)——把 Bob 从 viewer 里移除。 - 紧接着 Alice
写入一条敏感评论:
doc:secret#comment内容「Bob is a jerk」。
现在 Bob 发起请求读取评论:
- 应用查 doc 的评论:读到了新评论(读到了 T2 之后的状态)。
- 应用 check
doc:secret#viewer@user:bob:如果 check 走到了 T1 之前的 replica,它会说「Bob 是 viewer」,返回 true。
于是 Bob 读到了本不该看到的、专门骂他的评论。这就是 Zanzibar 论文里专门命名的 new-enemy problem:ACL 变更没有和内容读取严格按顺序看到。
Zanzibar 的解法不是简单地全部强一致读——那样延迟会爆炸——而是引入一种「因果一致性」凭证。
4.2 Zookie 是什么
Zookie 是 Zanzibar 返回给客户端的一个不透明令牌,本质上是一个 Spanner 的 TrueTime 时间戳(或足够精确的等价物)。它的生命周期如下:
- 客户端调用
write,Zanzibar 写入 Spanner 成功后返回 zookie_v1。 - 客户端把 zookie_v1 和业务内容一起持久化(例如存到评论表里)。
- 后续
check调用时带上这个 zookie;Zanzibar 保证 check 的结果至少反映了 zookie 对应那一刻的权限快照。
回到 new-enemy 例子:
- Alice 的 T1 remove 返回 zookie_T1。
- Alice 写评论时,应用层把 zookie_T1 存到评论行里,或者在写评论前做另一次 write 得到 zookie_T2,存 zookie_T2。
- Bob 读评论时拿到 zookie_T2,用它调 check——Zanzibar 保证 check 看到的是 T2 之后的快照,T1 的 remove 一定生效,返回 false。
关键洞察:客户端自己决定「检查时要不少于哪个版本」。如果你读的内容是老内容,就用老 zookie,允许读到便宜的、缓存的权限数据;如果你要读最新内容,就用最新 zookie,代价是可能需要等一小会儿。
4.3 快照读 vs 强一致读
因此 Zanzibar 同时支持两种 check:
- Snapshot read at zookie:指定 zookie,返回该时间戳的快照,可以用任意 replica 的本地缓存,延迟最低。
- Content-change check:不带 zookie 或带「at least as fresh as now」,需要走主 replica 或等 quorum,延迟更高但最新。
实践中 > 99% 的 check 都是 snapshot read,只有涉及安全关键的「改密码后立即撤销所有 session」「revoke share 后立即生效」这种场景才用强一致。
4.4 Zookie 的另一个好处:缓存
因为 zookie
等价于时间戳,缓存键可以天然加上它:(object, relation, user, zookie) -> bool。两次相同
zookie 的 check 可以直接命中缓存。配合 watch API
做失效,缓存命中率可以做到 99%+。
4.5 Zookie 的序列化与不透明性
Zookie 对客户端必须是完全不透明的。论文里反复强调这一点:客户端不应该解析、比较、或者构造 zookie。理由有二:
- 实现自由度:Zanzibar 最初用 Spanner 的 TrueTime,后来可能换成混合时钟或其他方案,如果客户端硬编码了格式就锁死了。
- 跨区迁移:如果业务数据迁移到另一个 Zanzibar 集群,zookie 的语义可能变化;不透明 token 可以在迁移时做翻译层。
开源实现里,SpiceDB 的 ZedToken 是 base64 编码的 protobuf,OpenFGA 的 consistency token 是 base64 编码的分页 / 版本游标。都按不透明字符串对待。
4.6 一致性与缓存如何共存
初学者容易有个疑问:既然要缓存,又要一致性,不是矛盾吗?zookie 恰好解决了这个矛盾:
- 缓存键里包含 zookie,不同 zookie 自然是不同缓存项,不会读到别的时间戳的脏数据。
- 相同 zookie 的 check 可以安全复用缓存——因为定义上 zookie 是个版本快照。
- 当你想读「最新的」权限时,用当前时间戳对应的 zookie,代价是大概率 miss 缓存要回源。
所以 Zanzibar 的设计思路不是「一致性 OR 性能」的二选一,而是「把一致性要求下放到客户端,按业务敏感度分档取舍」。
五、开源实现对比
Google 只发了论文没有开源代码。社区基于论文做了多个实现,其中最主流的三个是 SpiceDB(Authzed)、OpenFGA(Auth0 / Okta 捐给 CNCF) 和 Ory Keto。还有一些早期项目比如 Warden(Twitch 的内部实现,后来部分思路进入 Twitch 的生产系统但未完全开源)。
5.1 对比表
| 维度 | SpiceDB | OpenFGA | Ory Keto | Warden |
|---|---|---|---|---|
| 发起方 | Authzed | Auth0 / Okta / CNCF | Ory | Twitch(部分) |
| 协议 | gRPC + REST | HTTP + gRPC | REST + gRPC | gRPC |
| 模式语言 | 自定义 Schema DSL | 自定义 DSL + JSON | Keto 配置 + OPA | 接近论文 protobuf |
| Zookie 等价物 | ZedToken | continuation token | 版本化 revision | 未完全实现 |
| 一致性选项 | 5 档(minimize_latency → fully_consistent) | 3 档(minimize_latency, at_least_as_fresh, higher_consistency) | 最终一致为主 | - |
| 存储后端 | Postgres、CockroachDB、Spanner、MySQL、内存 | Postgres、MySQL、内存 | Postgres、MySQL、CockroachDB | 内部 KV |
| Watch API | 有 | 有 | 有 | - |
| 生态 / 成熟度 | 高,商业公司主推 | 高,CNCF 毕业路径 | 中,Ory 体系集成 | 低 |
| 语言 | Go | Go | Go | Go |
5.2 SpiceDB Schema 示例
SpiceDB 的 schema DSL 相当贴近论文的 protobuf,但更易读:
definition user {}
definition usergroup {
relation member: user | usergroup#member
}
definition folder {
relation parent: folder
relation owner: user
relation viewer: user | usergroup#member
permission view = viewer + owner + parent->view
}
definition document {
relation parent: folder
relation owner: user
relation editor: user | usergroup#member
relation viewer: user | usergroup#member
permission edit = editor + owner
permission view = viewer + edit + parent->view
}
几个语义要点:
relation editor: user | usergroup#member:editor 允许是 user,也允许是某个 usergroup 的 member 集合——这就是 Zanzibar 的@group:eng-team#member语义。permission edit = editor + owner:+是并集。SpiceDB 还支持&(交集)和-(差集)。parent->view:tuple_to_userset 的语法糖,表示「沿着 parent 边过去的对象的 view 权限」。
客户端 check 调用(Go 示例):
resp, err := client.CheckPermission(ctx, &v1.CheckPermissionRequest{
Consistency: &v1.Consistency{
Requirement: &v1.Consistency_AtLeastAsFresh{
AtLeastAsFresh: &v1.ZedToken{Token: storedZedToken},
},
},
Resource: &v1.ObjectReference{ObjectType: "document", ObjectId: "readme"},
Permission: "view",
Subject: &v1.SubjectReference{
Object: &v1.ObjectReference{ObjectType: "user", ObjectId: "bob"},
},
})5.3 OpenFGA 模型示例
OpenFGA 的 DSL 和 SpiceDB 类似但关键字不同:
model
schema 1.1
type user
type usergroup
relations
define member: [user, usergroup#member]
type folder
relations
define parent: [folder]
define owner: [user]
define viewer: [user, usergroup#member]
define view: viewer or owner or view from parent
type document
relations
define parent: [folder]
define owner: [user]
define editor: [user, usergroup#member]
define viewer: [user, usergroup#member]
define edit: editor or owner
define view: viewer or edit or view from parent
check 调用(HTTP):
POST /stores/{store_id}/check
{
"tuple_key": {
"user": "user:bob",
"relation": "view",
"object": "document:readme"
},
"consistency": "HIGHER_CONSISTENCY"
}OpenFGA 把「relation 和 permission」合并成了
define——有直接数据的是 relation,有表达式的是
permission。和 SpiceDB 的显式区分是一种风格差异。
5.4 怎么选
- 如果你想要最贴近论文、最完整的功能(包括 Caveats、Wildcards、Expand API、Schema 迁移工具),选 SpiceDB。
- 如果你需要更温和的学习曲线、云厂商背景和 CNCF 生态兼容性,选 OpenFGA。
- 如果你已经在用 Ory 的 Hydra / Kratos / Oathkeeper,想要全家桶集成,Ory Keto 是自然选择,但它的功能覆盖和性能在三者中最弱。
- 如果团队规模小、预期规则不复杂,也许根本不需要 Zanzibar 风格——见第八章。
5.5 迁移示例:从 RBAC 表到 SpiceDB
假设原来有一张 user_roles 表和一张
role_resources 表,典型 RBAC:
SELECT 1 FROM user_roles ur
JOIN role_resources rr ON ur.role = rr.role
WHERE ur.user_id = ? AND rr.resource_id = ? AND rr.action = 'read';迁移到 SpiceDB 分三步:
- 设计 schema:
definition user {}
definition role {
relation member: user
}
definition resource {
relation reader: user | role#member
permission read = reader
}
- 批量导入:把现有数据转成 relation tuple。
role:admin#member@user:alice
resource:doc1#reader@role:admin#member
- 双写双读:新老系统并行运行一段时间,check 结果做 diff 校验,没有偏差后切换。
这种迁移模式在任何授权系统演进中都值得参考:永远不要一刀切,先并行、再对比、最后切换。
六、工程实践:存储、缓存与延迟
6.1 存储:窄表 vs 宽表
Zanzibar 的存储在 Spanner 里就是一张极窄的关系表:
CREATE TABLE tuples (
shard_id INT64,
namespace STRING,
object_id STRING,
relation STRING,
user_type STRING,
user_id STRING,
user_relation STRING,
commit_time TIMESTAMP,
PRIMARY KEY (shard_id, namespace, object_id, relation, user_type, user_id, user_relation)
) INTERLEAVE IN PARENT ...;开源实现也大多沿用这个思路。以 Postgres 为例,SpiceDB 的主表近似:
CREATE TABLE relation_tuple (
namespace VARCHAR NOT NULL,
object_id VARCHAR NOT NULL,
relation VARCHAR NOT NULL,
userset_namespace VARCHAR NOT NULL,
userset_object_id VARCHAR NOT NULL,
userset_relation VARCHAR NOT NULL,
created_xid xid8 NOT NULL,
deleted_xid xid8 NOT NULL DEFAULT '9223372036854775807',
PRIMARY KEY (namespace, object_id, relation, userset_namespace, userset_object_id, userset_relation, created_xid)
);「为什么不做宽表、把一个 object 的所有 viewer 放一行」?三条理由:
- 单用户取消:从一条 10 万 viewer 的长 JSON 里删一个,锁争用和写放大都非常恐怖。
- 索引和 join:窄表让「给定 user 反向查能看哪些 doc」变成简单的二级索引扫描。
- Watch 流:逐条 tuple 的变更天然是一个 changelog;宽表得做 diff,复杂度高。
6.2 缓存:多层 + zookie 分片
Zanzibar 生产集群里每层都在做缓存:
- aclserver 本地进程缓存:key =
(object, relation, user, rounded zookie)。 - aclserver 共享分布式缓存:基于一致性哈希把同一 object 的所有 check 路由到同一台,提高命中率。
- 客户端缓存(可选):对读多写少的 namespace 有效。
Zookie 在缓存中作为版本一部分,天然避免了脏读:一次写入后新的 zookie 必然与旧 zookie 不同,旧缓存项不会被新 zookie 命中。
6.3 Hedged Requests 与 Tail Latency
论文里提到 Zanzibar 用了 Google 多个系统的老套路 hedged requests:并发给两台 replica 发同一个请求,谁先回就用谁。这把 p99 从数百毫秒压到几十毫秒,代价是后端多 20~30% 的负载。
开源实现里 SpiceDB
支持类似机制(--backend-hedging),在跨区部署时效果明显。
6.4 Fan-Out:Leopard 索引
最棘手的是深嵌套 group。假设
group:eng#member 里有
group:backend#member,group:backend#member
里有
group:infra#member,这种嵌套五六层后,check
的递归次数爆炸。
Zanzibar 构建了一个专门的索引服务 Leopard:
- 对所有 group namespace 做离线 expand,把每个 group 的最终成员展平成一个 set。
- 用集合运算原语(union / intersect)构建增量索引。
- watch Spanner 的 changelog 做流式更新,保证索引延迟在秒级。
- aclserver 遇到嵌套 group check 时,先问 Leopard;非 group 部分走在线递归。
开源实现里,OpenFGA 有 ListObjects / ListUsers 的专门路径但不是独立服务;SpiceDB 暂时没有完全等价的 Leopard。对嵌套不深(<= 3 层)的场景,单纯的在线递归 + 缓存已经够用。
6.5 延迟预算
Zanzibar 论文报告的典型 check 延迟:
| 百分位 | 延迟 |
|---|---|
| p50 | 约 3 毫秒 |
| p95 | 约 10 毫秒 |
| p99 | 约 50 毫秒 |
| p99.9 | 约 100 毫秒 |
自建时可以按这个做目标。SpiceDB 在高性能 Postgres + 本地缓存下,单实例 check p95 能做到 5~15 毫秒级别。OpenFGA 视存储不同在 10~30 毫秒。
6.6 容量规划的几个数量级
真实项目上 Zanzibar 风格服务前,几个数量级要心里有数:
- 元组数 ≈ 用户数 × 平均共享资源数 × 平均共享人数。一个中型 SaaS 常见 1 亿级。
- check QPS ≈ 业务 QPS × 每请求 check 次数。一个请求检查 3~5 条权限是常态,所以 check QPS 通常是业务 QPS 的 3~10 倍。
- 写 QPS ≈ 业务写 QPS × 平均影响权限行数。每次新建文档 + 默认权限写 3~5 条元组很常见。
- 单节点 check 吞吐:SpiceDB / OpenFGA 单节点(8C16G,本地缓存命中率 80%)大约 3000~8000 QPS。
- 单节点写吞吐:受限于后端数据库,Postgres 约 1000~3000 QPS,CockroachDB / Spanner 可线性扩展。
6.7 监控指标清单
至少采集以下指标:
check_latency_seconds{quantile, consistency}:按一致性级别分开的延迟分布。check_cache_hit_ratio:缓存命中率;低于 70% 说明 zookie 刷太勤,高于 99% 要警惕是否读到老数据。tuple_writes_total/tuple_reads_total:写读比例,帮你估算 watch 延迟。watch_lag_seconds:watch changelog 的延迟。expand_duration_seconds:expand 的延迟分布,这是最容易爆炸的 API。recursion_depth_max:单次 check 最深递归深度,超过阈值要告警。
七、工程坑点
7.1 递归组环与无限展开
如果用户能自由创建组嵌套,就一定会有人(不管是恶意还是手滑)造出
group:A#member@group:B#member 和
group:B#member@group:A#member。朴素递归会栈溢出或死循环。
Zanzibar / SpiceDB / OpenFGA 都会在 check 过程中记录已访问节点,遇到环就截断。但截断只影响这一次 check——环本身存在于数据中,第一次看到环的 expand 会报错或返回部分结果。
建议:写入时做环检测(见 SpiceDB 的
check_permission_relation 策略),或者在 schema
中用类型约束禁止特定嵌套。
7.2 Watch API 滞后导致脏缓存
Watch 本质是 polling 某个全局 commit 时间戳的增量,有几百毫秒到秒级的延迟。如果你用 watch 做跨服务缓存失效,那期间走老缓存的 check 会返回过期结果。
对策:
- 安全关键操作(revoke、delete share)走强一致 check,不依赖缓存。
- Watch 只用于降低命中率低的冷缓存流量,不用来保证正确性。
7.3 Schema 迁移
加一个 relation 或 permission 几乎总是安全的;删除或重命名就非常麻烦——因为元组里引用了旧 relation 的名字,schema 变了之后那些老元组要么变成孤儿,要么导致 check 出错。
SpiceDB 提供 Schema Validation 和 Zed CLI 做迁移演练;OpenFGA 用「多模型版本」让新老 tuple 并存。但根本上,权限 schema 应当像数据库表结构一样谨慎演进,不要频繁 breaking change。
7.4 深层次的 check 延迟
前面提到的
parent->view->parent->view->...
这种层层递归,每加一层就多一轮网络 / 存储查询。真实项目里
Drive 风格的文件夹树深度偶尔超过 10
层,在跨区部署里延迟就会飙。
对策:
- 控制资源树的最大深度,给产品约束(「文件夹最多嵌套 20 层」不是限制,是保护)。
- 对根权限做单独缓存(组织 admin 总是有权限,不用每次递归)。
- 使用 Leopard 式的离线展平索引(开源实现目前需要自己搭)。
7.5 大 object 的 viewer 列表
一个 namespace 下的 object 如果有 100 万 viewer(例如一个公司全员可见的公告),任何需要枚举 viewer 的操作(expand、ListUsers)都会 OOM 或超时。
对策:
- 用
user:*通配替代大列表(「所有登录用户」)。 - 用 group 代替直接列表(「org-everyone#member」),扩展时走 Leopard。
- 避免做「列出所有有权限的用户」的业务需求——这本身就是反模式。
7.6 多租户隔离
SaaS 场景里不同租户的权限不能互相影响。Zanzibar
本身没有「tenant」概念,但可以通过 object_id
前缀(doc:tenant_123_readme)或独立
namespace(doc_tenant_123)做隔离。前者 schema
简单但有误拼风险;后者隔离强但 schema 数量爆炸。
实践中一般选前者 + 在 API 网关层做强校验,保证查询里必须带 tenant 前缀。相关模式在 多租户授权设计 里有更详细讨论。
7.7 调试困难
「为什么 Alice 能 / 不能访问这个文档」是运维最常被问的问题。没有 expand 或者 debug 工具的系统,运维只能靠翻日志猜。
所有主流 Zanzibar 实现都提供:
expand返回展开树。- SpiceDB 的
ExperimentalAPI 有DebugCheckPermission,返回整条决策路径。 - OpenFGA 的 playground 和 CheckTrace。
建议:上线前一定要把 check trace / expand 集成到内部工具里,不要等到用户投诉了再补。
7.8 写入幂等与 precondition
Zanzibar 的 write API 支持 precondition:「只有当某条 tuple 存在(或不存在)时,才执行写入」。这对避免并发写冲突极其重要。典型场景:撤销某个分享时,你想保证「如果 viewer 已经被别的并发撤销,就不要报错」。
write {
delete: doc:readme#viewer@user:bob
precondition: doc:readme#viewer@user:bob MUST EXIST
}
没有 precondition 的话,客户端就要自己做 read-modify-write 循环,碰到并发时容易死循环或误撤。
7.9 跨 namespace 引用的时序
如果 document namespace 引用了 folder namespace 的 viewer,而 folder 的 schema 还没 deploy,check 会失败。schema 演进必须先加被依赖方,再加依赖方;删除时反过来。这在微服务架构里经常踩——A 团队发布了引用 B schema 的配置,B 还没上,整个权限瘫痪。
7.10 Zookie 存储成本
给每条业务数据附加一个 zookie 字段(通常 40~100 字节)在数据量大的场景里会有感知。如果一张表每天新增千万行,这是每天 GB 级别的额外存储。折中方案:每分钟只存一个 zookie(全表共享),牺牲一点精度换存储。
八、适用与不适用场景
8.1 适用场景
协作型 SaaS(Google Docs、Notion、Figma 风格):资源有所有者、编辑者、查看者;资源有层级(workspace → folder → doc);分享关系频繁变化。Zanzibar 几乎是为这种场景设计的。
文档 / 对象存储权限:S3 式 bucket / object 继承、内部知识库的文档权限。
社交图 / 关注关系:Twitter 的「following」「blocked」本身就是 relation tuple,Pinterest 的 board permission 是 Zanzibar 的典型用例。
多租户 B2B 平台:每个客户组织内部有独立的角色树,客户自己还能嵌套子组织。ReBAC 的表达力在这里很关键。
带继承的大资源树:Kubernetes 集群的 namespace → workload、云控制台的 org → folder → project → resource。
8.2 不适用场景
简单 CRUD 应用,三五个角色:一个用户表加一个 role 字段就能解决的事情,别上 Zanzibar。一个授权服务的运维成本远高于 RBAC。
纯属性决策(合规、密级、地理位置):「只有 HR 部门且在办公网内的员工能看人事数据」属于 ABAC 的天然场景,Zanzibar 的 relation 模型没有属性概念(SpiceDB 的 Caveats 是一种补丁,但不如 OPA / Cedar 灵活)。
亚毫秒级延迟要求:Zanzibar p99 是 10~100 毫秒量级。如果是交易系统的每笔订单都要 check,且不能容忍缓存穿透,请考虑把权限预计算进请求上下文(例如在 JWT 里放权限位图)。
不允许外部依赖:授权服务作为集中组件是一个额外的失败点。如果业务的可用性预算不允许引入新的关键路径,可以考虑把权限数据 sidecar 化(SpiceDB 支持 read-only replica)或者完全嵌入到业务服务里。
权限规则高频变化且无法复用:Zanzibar 的优势在于 schema 稳定、元组频繁变。如果你的权限规则本身每天都在变(比如营销活动的动态白名单),那本质是业务数据,用业务表更合适。
8.3 决策简表
| 特征 | 推荐 |
|---|---|
| 资源有层级、共享关系复杂 | Zanzibar / ReBAC |
| 只有用户 → 角色 → 权限三层 | RBAC |
| 决策依赖用户 / 资源属性 | ABAC(OPA、Cedar) |
| 纯策略代码、无持久关系数据 | 策略引擎(见下一篇) |
| 混合:既有关系又有属性 | ReBAC + Caveats,或 Zanzibar + OPA 联动 |
授权引擎的另一派——OPA、Cedar 等策略代码方案——在 OPA、Cedar 与策略引擎落地 里专门展开,它们和 Zanzibar 是互补而非竞争关系:很多大规模系统是 Zanzibar 管关系、OPA / Cedar 管属性规则,两层组合使用。
如果你在下面任一问题上回答「是」,那通常意味着现在还不该自建 Zanzibar:
- 你的权限模型还在频繁变,连 namespace / relation 命名都没稳定。
- 业务里真正复杂的主要是属性判断,而不是共享关系或资源继承。
- 团队还没有准备好为授权服务承担独立的可用性、缓存、一致性和审计责任。
- 当前系统用 RBAC + 少量 ACL 就能覆盖 90% 需求,痛点只是个别边角。
九、参考资料
- Pang, R. et al. “Zanzibar: Google’s Consistent, Global Authorization System.” USENIX ATC 2019. 论文 PDF
- SpiceDB 文档:https://authzed.com/docs
- OpenFGA 文档:https://openfga.dev/docs
- Ory Keto 文档:https://www.ory.sh/docs/keto
- Authzed 博客「Zanzibar Academy」系列:https://authzed.com/zanzibar
- Google Cloud 「Check API Design」设计参考:https://cloud.google.com/iam/docs
- 论文解读博客「Rewriting the Zanzibar paper」:Authzed 工程博客
- Aserto / Topaz(另一类嵌入式 ReBAC):https://www.topaz.sh/
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】RBAC、ABAC、ReBAC:权限模型怎么选
从角色爆炸问题切入,深入对比 RBAC(NIST 四级模型)、ABAC(XACML)、ReBAC(Zanzibar 启发)三种权限模型的数据结构、SQL 建模、策略表达能力与工程取舍,给出混合模型实践与选型决策树。
【身份与访问控制工程】OAuth 2.1 与 PKCE:现代授权主路径
从一次 SPA 安全事故出发,系统梳理 OAuth 2.1 相对 2.0 的收敛动作、PKCE 的密码学原理、授权码流程的完整参数细节,以及 DPoP、PAR、JAR、RAR 等现代扩展与常见攻击面
【身份与访问控制工程】B2B SaaS 多租户权限设计
B2B SaaS 的权限问题远比 B2C 复杂:多个企业客户、各自的内部角色体系、跨租户协作、行列级数据权限、租户自助管理。本文从隔离模型出发,给出租户内 RBAC + 租户间 ReBAC 的混合方案、超级管理员设计、行级权限实现,以及 GitHub、Slack、Notion 的权限模型速览。
【身份与访问控制工程】API Gateway、BFF 与边界认证授权
API Gateway 能做粗粒度的认证鉴权,但细粒度授权必须下沉到服务层。本文梳理 Gateway/BFF/Service 三层认证职责边界,讨论 Token 验证位置选择、RFC 8693 Token Exchange、服务间身份传播(JWT/mTLS/SPIFFE),以及 Istio、Kong、APISIX 的认证授权能力对比。