深度解析 Google Zanzibar:全球级分布式权限控制系统的基石
深度解析 Google Zanzibar:全球级分布式权限控制系统的基石
当你在 Google Docs 里点击“分享”按钮,将一份文档开放给整个研发部门,或者给某位同事单独赋予“仅评论”权限时,Google 是如何在几毫秒内、在分布于全球的数据中心里,稳定地做出授权判断的?
这个问题的难点并不只是“判断谁能访问谁”。真正棘手的是下面几件事必须同时成立:
- 权限关系很复杂,既有直接授权,也有群组嵌套、文件夹继承、跨产品协作。
- 授权检查处在业务请求的关键路径上,延迟不能高。
- 一旦权限被撤销,系统又不能因为缓存或副本延迟而出现“明明该拒绝,却暂时放行”的安全漏洞。
Google 内部解决这类问题的核心系统,就是 Zanzibar。Google 在 2019 年的 USENIX ATC 上公开了论文 《Zanzibar: Google’s Consistent, Global Authorization System》,它也因此成为现代细粒度权限系统设计里最有影响力的参考之一。
很多文章会把 Zanzibar 简化成一句话:“把权限存成一张关系图,然后做路径搜索。”这个说法不算错,但远远不够。Zanzibar 的真正价值,来自三件事同时成立:
- 用统一的关系模型表达大规模授权数据。
- 用可组合的规则定义“权限如何从关系推导出来”。
- 用一致性协议保证“权限撤销”不会被旧快照和旧缓存绕过。
本文会从这三个角度,比较完整地拆开 Zanzibar。
如果你想先抓住全文结构,可以先看下面这张“阅读地图”:
为什么传统 ACL / RBAC / ABAC 不够
在介绍 Zanzibar 前,先看几种常见权限模型各自的边界。
ACL (Access Control List)
最直观的做法是给每个资源挂一张白名单,例如:文档 A -> Alice 可读, Bob 可写, TeamX 可评论
它适合简单系统,但在群组嵌套、资源继承、跨服务共享场景下会迅速失控。RBAC (Role-Based Access Control)
RBAC 把权限先绑定到角色,再把角色赋给用户。例如:Alice -> 编辑,编辑 -> 可修改文章
它很适合“岗位职责稳定”的后台系统,但一旦资源是用户自己创建、分享和协作的,就容易出现“角色爆炸”。ABAC (Attribute-Based Access Control)
ABAC 通过属性和策略来决策,例如:部门 = 研发 且 登录地点 = 内网 且 文档等级 != 机密
它非常灵活,但规则解释成本高、实时求值复杂,而且很难天然表达“你能访问这个资源,是因为你属于某个群组,而这个群组对父文件夹有权限”这种关系链。
Google 的问题在于:它既要支持 Google Drive 这种“人与资源之间的复杂共享关系”,又要支持 Cloud / Calendar / Photos / YouTube 这类跨产品、跨区域、超大规模系统。单纯依赖 ACL、RBAC 或 ABAC,都会在某些维度上变得笨重。
于是 Zanzibar 采用了一个更适合表达协作式授权的范式:ReBAC (Relationship-Based Access Control,基于关系的访问控制)。
Zanzibar 到底抽象了什么
Zanzibar 的核心思想是:
授权的基础事实,不是“用户拥有什么静态角色”,而是“主体与对象之间存在什么关系”。
这里有四个基本概念。
- Object:资源对象,例如文档、文件夹、仓库、组织、团队。
- Relation:关系类型,例如
owner、editor、viewer、member、parent。 - Subject:授权主体。它可以是一个具体用户,也可以是一组用户。
- Userset:一个“用户集合”的引用,例如“某个群组的成员”“某个文件夹的查看者”。
Zanzibar 用一种统一的数据结构存这些“基础事实”,通常写成:
<object>#<relation>@<subject>例如:
doc:readme#owner@user:alicedoc:readme#viewer@user:bobgroup:eng#member@user:charlie
最关键的一步在于:<subject> 不一定是“一个人”,它也可以是“另一个对象上的一个 userset”:
doc:readme#viewer@group:eng#member
它的含义是:eng 群组的成员,都是 readme 文档的 viewer。
这时你就能感觉到 Zanzibar 的威力了。它并不是把每一位用户对每一份资源的最终权限都写死,而是只存储基础关系,再在校验时根据规则去推导。
先抓住一条主线
有了前面的阅读地图,这里再把系统运行主线压缩成一句更工程化的话:
- 业务先把“谁和谁之间有什么关系”写成 relation tuple。
- Schema 再定义“这些关系如何组合成 permission”。
- 当业务发起
Check(user, permission, object)时,系统会按 schema 在某个一致性快照上递归求值。 - 当某类子问题特别昂贵时,例如深层群组 membership 展开,系统再用缓存和索引去优化。
也就是说,Zanzibar 不是“存一张权限图然后搜一下”,而是:
写入关系事实 -> 用 schema 定义权限 -> 在一致性快照上执行 Check -> 用缓存和索引优化热点子问题关系不等于权限
这是很多初学者第一次读 Zanzibar 时最容易混淆的地方。
关系是“存下来的事实”
例如:
doc:spec#owner@user:alicedoc:spec#editor@group:eng#memberdoc:spec#parent@folder:design
这些都是数据库里真实存储的关系元组。
权限是“根据规则算出来的结果”
例如,“谁可以查看 doc:spec?”未必只看 viewer 这一个关系。它通常是多个关系求值后的结果:
- 直接被授予
viewer的人可以看。 editor通常也应该隐含view。owner通常也应该隐含edit和view。- 如果文档在某个文件夹下,那么父文件夹上的
view可能也会继承下来。
在 Zanzibar 论文里,核心术语是 relation 和 userset rewrite。而在很多开源实现里,例如 SpiceDB / OpenFGA,通常会进一步把“可存储的 relation”和“计算出来的 permission”区分开来。这样建模更清楚,也更不容易把“基础事实”和“推导结果”混在一起。
如果你只记一句话,可以记这个:
Zanzibar 存的是关系,回答的是权限;权限不是预先展开写入的,而是在检查时按规则求值出来的。
一个完整例子:群组、文件夹、文档
只看概念很容易“好像懂了”,但没形成真正的心智模型。下面用一个更完整的例子,把“直接授权”“群组授权”“父对象继承”串起来。
假设系统里有三类对象:
groupfolderdocument
我们用一种接近开源实现的伪 schema 表示规则:
type user
type group
relation member: user
type folder
relation owner: user
relation editor: user | group#member
relation viewer: user | group#member
permission edit = owner + editor
permission view = edit + viewer
type document
relation parent: folder
relation owner: user
relation editor: user | group#member
relation viewer: user | group#member
permission edit = owner + editor
permission view = edit + viewer + parent->view上面最关键的是最后一行:
permission view = edit + viewer + parent->view它表示:一个人可以查看文档,当且仅当他满足以下任意一条:
- 对当前文档拥有
edit - 对当前文档直接拥有
viewer - 沿着
parent关系走到父文件夹,再看他是否拥有该文件夹的view
这才是 Zanzibar 最擅长处理的能力:不是同一个对象内的简单权限包含,而是跨对象关系链上的推导。
现在写入几条关系元组
group:eng#member@user:charlie
folder:design#viewer@group:eng#member
folder:design#owner@user:alice
doc:spec#parent@folder:design
doc:spec#editor@user:bob看三次检查
1. Check(user:alice, view, doc:spec)
Alice 没被直接写成 doc:spec 的 viewer,也没被写成 doc:spec 的 owner。
但 doc:spec 的父文件夹是 folder:design,而 Alice 是该文件夹的 owner,因此:
folder:design.edit包含ownerfolder:design.view包含editdoc:spec.view包含parent->view
所以 Alice 可以查看 doc:spec。
2. Check(user:bob, edit, doc:spec)
Bob 被直接写成 doc:spec#editor@user:bob,因此 Bob 可以编辑 doc:spec。
3. Check(user:charlie, view, doc:spec)
Charlie 不是文档的直接查看者,但:
- Charlie 是
group:eng的成员 group:eng#member被授予了folder:design#viewerdoc:spec.view又继承自parent->view
因此 Charlie 也能查看 doc:spec。
从这个例子里可以看出,Zanzibar 的价值不在于“能不能表达 owner / editor / viewer”,而在于:
- 它把授权事实统一存成关系元组。
- 它允许主体是一个 userset,而不只是单个用户。
- 它允许权限跨对象传播,而不是只在当前对象里做简单包含。
为了直观地感受 ReBAC 的推导过程,我们提供了一个交互式模拟器:
<template>
<div class="zanzibar-simulator">
<div class="panel">
<h3 class="panel-title">关系元组 (Relation Tuples)</h3>
<ul class="tuple-list">
<li v-for="(t, i) in tuples" :key="i">
<code>{{ t.object }}#{{ t.relation }}@{{ t.user }}</code>
</li>
</ul>
</div>
<div class="panel">
<h3 class="panel-title">Userset Rewrite 规则 (隐含的权限继承)</h3>
<ul class="rule-list">
<li><code>owner</code> ⇒ 仅直接分配</li>
<li><code>editor</code> ⇒ 直接分配 ∪ <code>owner</code></li>
<li><code>viewer</code> ⇒ 直接分配 ∪ <code>editor</code> ∪ <code>owner</code></li>
</ul>
</div>
<div class="panel check-panel">
<h3 class="panel-title">Check API 模拟</h3>
<div class="inputs">
<input v-model="checkObj" placeholder="对象 (如 doc:readme)" />
<input v-model="checkRel" placeholder="关系 (如 viewer)" />
<input v-model="checkUser" placeholder="用户 (如 user:bob)" />
<button @click="runCheck">Check 校验</button>
</div>
<div v-if="result !== null" class="result" :class="{ granted: result === true, denied: result === false }">
{{ result ? '✅ GRANTED (允许访问)' : '❌ DENIED (拒绝访问)' }}
</div>
<div class="log" v-if="logs.length">
<div v-for="(log, i) in logs" :key="i">{{ log }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ZanzibarSimulator',
data() {
return {
tuples: [
{ object: 'doc:readme', relation: 'owner', user: 'user:alice' },
{ object: 'group:eng', relation: 'member', user: 'user:bob' },
{ object: 'doc:readme', relation: 'viewer', user: 'group:eng#member' },
{ object: 'doc:design', relation: 'editor', user: 'user:charlie' }
],
checkObj: 'doc:readme',
checkRel: 'viewer',
checkUser: 'user:bob',
result: null,
logs: [],
rewriteRules: {
owner: ['owner'],
editor: ['editor', 'owner'],
viewer: ['viewer', 'editor', 'owner'],
member: ['member']
}
};
},
methods: {
evaluate(obj, rel, targetUser, depth = 0) {
const indent = ' '.repeat(depth);
this.logs.push(`${indent}Evaluating ${obj}#${rel} for ${targetUser}...`);
if (depth > 5) {
this.logs.push(`${indent}Max depth reached (cycle detected?)`);
return false;
}
const allowedRels = this.rewriteRules[rel] || [rel];
for (const tuple of this.tuples) {
if (tuple.object === obj && allowedRels.includes(tuple.relation)) {
if (tuple.user === targetUser) {
this.logs.push(
`${indent}Found match: ${tuple.object}#${tuple.relation}@${tuple.user}`
);
return true;
}
if (tuple.user.includes('#')) {
const [groupObj, groupRel] = tuple.user.split('#');
this.logs.push(`${indent}Expanding group: ${tuple.user}`);
if (this.evaluate(groupObj, groupRel, targetUser, depth + 1)) {
return true;
}
}
}
}
this.logs.push(`${indent}No path found for ${obj}#${rel}`);
return false;
},
runCheck() {
this.logs = [];
this.result = this.evaluate(this.checkObj, this.checkRel, this.checkUser);
}
}
};
</script>
<style scoped>
.zanzibar-simulator {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
color: #2c3e50;
margin: 20px 0;
}
.panel {
background: white;
padding: 15px;
margin-bottom: 15px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.panel-title {
margin-top: 0;
margin-bottom: 10px;
font-size: 1.1em;
color: #2c3e50;
border-bottom: 1px solid #eaecef;
padding-bottom: 5px;
}
.tuple-list, .rule-list {
margin: 0;
padding-left: 20px;
}
.tuple-list li, .rule-list li {
margin-bottom: 5px;
}
code {
background: #f3f4f4;
padding: 2px 6px;
border-radius: 4px;
color: #d63200;
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
}
.inputs {
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
input {
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
flex: 1;
min-width: 150px;
font-size: 14px;
}
button {
padding: 8px 20px;
background: #3eaf7c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: background 0.2s;
}
button:hover {
background: #33996b;
}
.result {
font-size: 1.2em;
font-weight: bold;
padding: 12px;
text-align: center;
border-radius: 4px;
margin-bottom: 15px;
}
.granted { background: #e6f4ea; color: #1e8e3e; border: 1px solid #ceead6; }
.denied { background: #fce8e6; color: #d93025; border: 1px solid #fad2cf; }
.log {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 0.85em;
white-space: pre-wrap;
background: #282c34;
color: #abb2bf;
padding: 15px;
border-radius: 6px;
max-height: 250px;
overflow-y: auto;
line-height: 1.5;
}
</style>离散数学视角:Zanzibar 的形式化语义
这一节会稍微偏理论。
如果你更关心工程实现,可以先记住下面三句话,再直接跳到后面的 “一致性难题:New Enemy Problem”:
- tuple store 存的是基础关系事实
- schema / rewrite 定义的是“如何从关系算出 permission”
Check本质上是在某个一致性快照上判断“用户是否属于这个 permission 对应的用户集合”
如果只看 relation tuple,很容易误以为 Zanzibar 只是“把授权存成一张图”。这个直觉有帮助,但还不够严谨。
从离散数学角度看,Zanzibar 更接近下面这个对象:
一个由类型化有向关系、集合表达式和递归语义共同构成的授权求值系统。
为了避免公式只是在“看起来专业”,我们从最基础的数学对象开始定义。
1. 基本域
设:
- 为所有用户的集合。
- 为所有对象的集合。
- 为对象类型集合,例如
document、folder、group。 - 为对象到类型的映射。
- 对每个类型 ,设 为该类型允许出现的 relation / permission 名称集合。
于是我们可以定义所有“对象上的关系槽位”构成的集合:
这里的 可以理解为“对象 上的关系 / 权限 对应的那个用户集合节点”。
2. 元组存储不是普通边集,而是类型化关系
为了严谨起见,我们把元组允许指向的目标分成三类:
- 具体用户
- 具体对象
- 另一个 userset 引用
记目标空间为:
那么整个 tuple store 可以抽象为一个类型化二元关系:
当我们写:
doc:readme#viewer@user:bob它对应的是 。
当我们写:
doc:readme#viewer@group:eng#member它对应的是 。
而像:
doc:spec#parent@folder:design则对应 。
这一步非常关键,因为它说明 Zanzibar 里的“边”其实不是单一类型的边,而是带类型的关系边。
也因此,它不能被粗暴地化简成“对一张普通图做任意可达性搜索”。
3. Userset 的语义:它是一个用户集合
对每个 ,我们真正关心的不是“它指向了什么”,而是:
它最终表示了哪一批用户?
因此引入语义函数:
表示在数据集 下,对象 的关系 / 权限 所对应的用户集合。
于是 Check(u, r, o) 的数学含义就是:
到这里为止,我们已经把“授权检查”从口语描述,变成了一个标准的集合成员判定问题。
4. Rewrite 规则对应集合表达式
Zanzibar 的核心不是元组本身,而是:如何根据 schema 把 定义出来。
对固定对象 ,一个 relation / permission 的定义可以看成一个表达式 ,它由下列构造子组成:
_this(r):直接写入到 上的主体computed(r'):当前对象 上另一个 relation / permissionarrow(r_1, r_2):先沿 找到关联对象,再在那些对象上求union(e_1, e_2):并集intersect(e_1, e_2):交集diff(e_1, e_2):差集
它们的语义可以逐条定义。
_this 的语义
设 ,那么:
第一部分表示“直接写给具体用户的授权”。
第二部分表示“直接写给某个 userset 引用的授权”,例如 group:eng#member。
computed 的语义
它表示在同一个对象上继续引用另一个 relation / permission。
arrow 的语义
设 是一个“对象关系”,例如 parent; 是目标对象上的 relation / permission。则:
这就是文件夹继承那类规则的数学本质。
例如 parent->view,就是先找出文档的所有父文件夹,再把这些父文件夹的 view 用户集合全部并起来。
集合运算的语义
所以,userset rewrite 的本质并不是“写一堆业务规则”,而是在定义一个以用户集合为值域的递归表达式语言。
5. 文档示例的形式化展开
前文文档的规则是:
permission edit = owner + editor
permission view = edit + viewer + parent->view把它翻成严格的集合表达式,就是:
于是 charlie 能否查看 doc:spec,就不再是“感觉上可以”,而是下面这条链条是否成立:
因此:
6. 为什么递归定义不会让语义失控
到这里,一个更深的问题出现了:
既然 relation / permission 可以互相引用,甚至沿 parent->view 递归跳转,那 如何保证“有定义”?
这就进入离散数学里非常经典的不动点语义。
把所有节点的用户集合一次性收集起来,定义:
这里 表示:给每个节点 都分配一个用户子集。
按逐点包含关系排序:
于是 构成一个完全格。
每一套 schema 加上数据集 ,都会诱导出一个算子:
它的作用是:假设你暂时猜测每个 的用户集合是什么,那么 就按照 rewrite 规则重新算出一轮新的结果。
在只包含以下构造时:
_thiscomputedarrowunionintersect
算子 对偏序 是单调的。
根据 Tarski 不动点定理,单调算子在完全格上一定存在最小不动点:
这个最小不动点,就是 Zanzibar 求值语义里最自然、也最保守的解释:
只承认那些能够被 rewrite 规则有限次推出的用户,不多给任何一个主体额外权限。
这也解释了为什么工程实现里常见的做法是:
- 从空集开始逐步展开
- 做 memoization
- 对子问题求闭包
- 直到结果稳定为止
本质上,这些实现都是在近似或高效地求解 。
7. 差集为什么更难
上面的单调性结论,对 diff(e_1, e_2) 这种差集要格外小心。
原因很简单:如果某条规则通过“否定”依赖自己,单调性就可能被破坏。
例如下面这种递归负依赖就会让语义变得微妙:
view = viewer - banned
banned = suspended + view这时系统不再适合直接套最小不动点的单调框架,而需要:
- 分层语义
- 负依赖无环约束
- 或对差集的使用范围做限制
这也是为什么现实中的 Zanzibar 风格系统,通常会对 schema 施加额外约束,避免“带否定的递归”把求值问题变成一个更复杂的逻辑程序语义问题。
8. 所以 Check 的数学本质是什么
现在可以更严谨地总结:
- Zanzibar 把授权事实编码成类型化二元关系 。
- Schema 把每个 relation / permission 定义成一个集合表达式。
- 整个系统的语义由一个格上的算子 给出。
- 授权检查本质上是在问:某个用户是否属于最小不动点 在节点 上的取值。
所以你可以把 Check 理解成:
一个在有限关系结构上、按 schema 约束进行的集合成员判定问题。
这比“图上找路径”准确得多,因为它同时保留了:
- 图结构
- 类型约束
- 集合运算
- 递归定义
- 不动点语义
到这里,我们已经回答了“权限是怎么算出来的”。
接下来的关键问题是:即使系统会算,如果它拿的是一个过旧快照,结果仍然可能在安全上出错。
这正是 Zanzibar 真正难、也真正有工程价值的下一层问题。
一致性难题:New Enemy Problem
Zanzibar 最难的地方并不是“如何表达权限”,而是“如何在全球分布式系统里让授权判断既快又不犯错”。
这里有一个经典漏洞场景,论文把它称为 New Enemy Problem。
场景
- Alice 和 Bob 之前都能访问文件夹
FolderA。 - Alice 先把 Bob 从
FolderA的授权里移除。 - 紧接着,Alice 又在
FolderA下创建了一个新文档SecretDoc,并写入新的敏感内容。
从业务语义上看,Bob 绝对不应该看到 SecretDoc。
因为“撤销 Bob 的访问权”发生在“创建并写入这个新文档”之前。
问题出在哪里
如果授权系统只是“最终一致”,并且前面还有多层缓存,那么 Bob 发起读取时,某个副本可能还没看到“Bob 被移除”的最新关系元组,却已经能看到 SecretDoc 的存在。
这时系统就会在一个过旧的授权快照上做检查,错误地放行 Bob。
这个问题的本质不是普通的缓存延迟,而是:
授权判断所使用的数据快照,必须尊重业务事件之间的因果顺序。
也就是说,只要系统已经能看到“新内容已经出现”,它就必须也能看到“更早发生的权限撤销”。
Zookie 究竟是什么
Zanzibar 解决这个问题的关键机制,是 Zookie。
很多文章会把 Zookie 粗暴理解成“时间戳”或“要求读最新”。这都不够准确。
更准确的理解是:
Zookie 是一个不透明的一致性令牌,它为后续授权检查提供了“快照不能早于这里”的下界约束。
先看一条业务调用链
如果只看定义,Zookie 很容易显得抽象。
先把它放回一个真实业务流程里:
- 业务先执行一次授权变更,例如撤销 Bob 对
folder:design的查看权限。 - 写入成功后,Zanzibar 返回一个 Zookie,表示“后续检查不能比这个修订点更旧”。
- 业务再去创建新内容、写入版本,或者生成可访问链接时,把这个 Zookie 一起保存或向后传递。
- 之后有人访问新内容时,业务在调用
Check时把这个 Zookie 带上。 - Zanzibar 只会在满足该下界约束的授权快照上完成检查,因此不会出现“看到了新内容,却没看到更早撤权”的情况。
所以从工程视角看,Zookie 的作用不是“要求每次读全局最新”,而是:
把一次安全相关写入的因果影响,显式传递到后续授权检查里。
如果把这个过程画成时序,大致是这样:
一个更严谨的理解方式
设想一次 ACL 写入:
Remove(folder:design#viewer@user:bob)写入成功后,Zanzibar 会返回一个令牌。这个令牌对应的是某个外部一致的修订点,你可以把它理解为:
至少从这个修订点开始,系统已经确定看得到刚才那次授权变更如果业务系统随后基于这次变更去创建内容、写入版本、发送分享链接,或者触发别的安全敏感操作,那么它就应该把这个令牌随业务流程一起保存或传递下去。
稍后,当有人请求访问新内容时,业务方在调用授权检查时把这个令牌带上,意思是:
请不要在比这个修订点更旧的授权快照上做判断只要底层存储能提供外部一致的快照顺序,Zanzibar 就能保证:
- 如果检查发生在满足该下界的快照上,
- 那么这个快照一定已经包含了那次更早的“撤权”操作,
- 因而不会让 Bob 通过旧权限看到新的敏感内容。
偏序、线性扩展与快照下界
如果只说“令牌保证足够新”,仍然偏工程直觉。
从离散数学和分布式系统理论角度,可以把这个问题形式化得更严格。
设 为系统中所有相关事件的集合,并在其上定义一个偏序关系 ,表示因果先后,也就是经典的 happens-before:
在 New Enemy Problem 中,至少有下面几个事件:
- :撤销 Bob 对
FolderA的访问权 - :创建
SecretDoc - :Bob 发起对
SecretDoc的读取请求
根据业务过程,有:
如果底层存储只给出“副本最终会收敛”,但不保证用于授权的快照和业务内容所见的快照遵守这个偏序,那么系统可能在 时刻读到一个旧授权快照 ,使得:
这里的 可以理解为“快照 已经反映了事件 的结果”。
这正是漏洞产生的根源:同一个判断过程中,看到了“新内容存在”,却没看到“更早的撤权”。
为了避免这种情况,Zanzibar 依赖底层外部一致存储给事件赋予一个全序修订号:
其中 是一个全序集合,并满足:
这意味着 可以看成因果偏序的一种线性扩展,并且与真实提交顺序保持兼容。
此时,Zookie 本质上就是某个修订下界 的封装。
当业务方持有一个 Zookie,对应的约束就是:
也就是:授权检查所用的快照修订号,不允许早于这个下界。
在 New Enemy Problem 里,若创建 SecretDoc 的业务操作继承了撤权事件带来的 barrier,那么一定存在某个 满足:
随后,只要授权检查在满足 的快照上进行,就可推出:
因此该快照必然已经包含撤权结果,Bob 不会因为旧授权而通过检查。
如果要把“内容可见性”和“授权可见性”一起写得更紧凑,可以写成下面这个安全条件:
凡是已经能够观察到内容版本 的请求,其授权检查必须在不早于生成 时所携带 barrier 的快照上执行。
用符号表示就是:
而 通常来自该内容版本依赖的所有先行安全相关事件的修订下界。
所以,Zookie 的数学本质不是“当前时间戳”,而是:
一个把业务因果链投影到全序快照空间中的下界约束。
它不等于“永远读最新”
Zookie 很关键的一点在于:它要求的是不早于某个下界,而不是“总是使用全局最新快照”。
这两者差别很大。
如果系统每次都强制读“此刻最新”,代价会非常高,缓存命中率也会很差。
而 Zanzibar 做的是:
- 在安全上,确保快照足够新,不会越过因果边界。
- 在性能上,允许不同请求在满足下界约束的前提下,共享缓存、共享中间结果、复用较新的快照结果。
这正是 Zanzibar 的精妙之处:它不是在“极致性能”和“绝对正确”之间二选一,而是用一致性令牌把二者重新协调起来。
为什么 Zanzibar 能同时做到快和稳
论文里给出的代表性指标包括:系统管理着数万亿级 ACL,处理超过千万次每秒的授权检查,请求 95 分位延迟低于 10 ms,并在长期运行中达到 99.999% 的可用性。做到这一点,不靠某一个“神奇算法”,而是靠一整套分层设计共同作用。
更容易理解的方式,是把它拆成四层:
- 建模层:用统一关系模型降低跨产品接入成本。
- 执行层:把一次
Check拆成可组合的子问题求值。 - 缓存层:复用高频子问题的中间结果,而不只是缓存最终
allow / deny。 - 热点层:针对最容易拖慢尾延迟的子问题,做请求去重、专门索引和特殊优化。
也就是说,Zanzibar 的快,不是某个局部优化单独带来的;它更像是“统一模型 + 可拆分求值 + 缓存复用 + 热点专项治理”的组合结果。
1. 关系模型足够统一
统一的数据模型让不同 Google 产品可以共享一套授权基础设施,而不是每个业务都自己实现一套权限引擎。
2. 求值过程天然可拆分
view = viewer + editor + parent->view 这种定义天然可以拆成多个子问题并行求值,然后再合并结果。
3. 可以缓存中间结果
授权检查并不只是缓存最终的 allow / deny。
很多实现还会缓存:
- 某个子关系的展开结果
- 某个群组的成员集合
- 某个对象上某个 permission 的局部求值结果
这样大量相似请求就可以复用之前的计算。
4. 可以做请求去重和热点治理
如果某个热门资源在短时间内触发了大量相同检查,系统可以把重复求值合并,减少底层存储和递归求解压力。
而在所有热点子问题里,最容易形成尾延迟的,往往不是简单的单跳关系判断,而是深层群组 membership 的递归展开。
Leopard 就是围绕这个热点专门设计的一类优化。
深度嵌套群组的性能优化:Leopard
到这里,性能问题里最值得单独拎出来看的,就是深层群组 membership 展开。
它之所以特殊,是因为很多看起来普通的 Check,最终都会卡在“某个用户是否经由多层群组嵌套成为目标 userset 的成员”这个子问题上。
例如:
group:a#member@group:b#membergroup:b#member@group:c#membergroup:c#member@user:alice
当系统要判断 alice 是否属于 group:a#member 时,如果没有额外索引,就往往要沿着群组图在线递归展开。
这类查询的麻烦不在于公式本身,而在于它会把多跳读取、重复子问题合并,以及深层图遍历一起带进请求关键路径。
平均延迟未必总是很差,但它非常容易拉高尾延迟。
Zanzibar 为此设计了一个专门针对嵌套群组 membership 的索引子系统:Leopard。
论文公开了 Leopard 的定位和目标,但没有完整公开所有内部数据结构与更新协议,因此下面更适合把它理解为一种“合理的工程抽象”。
Leopard 到底在优化什么
Leopard 不是“另一个通用授权引擎”,也不是对整个 Check 求值过程做统一替换。
它主要把下面这一类最昂贵的子问题单独拿出来优化:
某个用户是否经由若干层群组嵌套,成为目标 userset 的传递成员?
换句话说,Leopard 主要想加速的是:
user:alice ∈ closure(group:a#member) ?而不是完整的:
Check(user:alice, view, doc:spec)因为完整的 Check 除了 membership 之外,还可能包含当前对象上的直接 relation、局部权限包含、跨对象跳转,以及交集 / 差集等组合规则。
所以更准确地说,Leopard 是 Check 求值器里的一个高价值加速模块,而不是新的通用授权执行器。
从逻辑上看,Leopard 的索引是什么
虽然论文没有公开 Leopard 的全部内部细节,但从它要解决的问题形态上看,最合理的工程理解是:
- tuple store 仍然保存“直接边”,例如
group:a -> group:b - Leopard 额外维护某种面向查询的“传递 membership 索引”
- 这个索引回答的不是“直接成员是谁”,而是“在传递意义上,谁属于这个 group / userset”
普通路径和 Leopard 路径的差别,可以粗略理解为:
- 普通路径:运行时沿 group graph 一层层追
- Leopard 路径:优先查询预处理好的 membership 索引,把一部分多跳展开改成索引判断或快速剪枝
从工程取舍上看,这本质上是在做一件事:
用更多的预处理、更多的索引维护成本,去换取在线授权检查路径上的低延迟。
写入更新后,为什么索引维护很难
这件事并不是免费午餐。
一旦系统为传递 membership 建立索引,它就必须处理写入传播问题。
例如,新增或删除一条关系:
group:x#member@group:y#member影响的就不只是这一条直接边,而可能是:
group:x的所有祖先群组group:y的所有传递成员- 以及所有依赖这些 membership 结果的更高层 userset 判断
这意味着 Leopard 带来的不仅是查询加速,还有额外的:
- 索引维护成本
- 增量更新复杂度
- 存储放大
- 更新传播延迟
也正因为如此,Leopard 不太可能通过“每次变更都全量重建整张群组图”来工作。
更合理的工程直觉是:它需要做局部更新、批量刷新、增量修补或其他形式的索引维护,以避免写入成本失控。
为什么它必须服从 Zanzibar 的一致性语义
这也是理解 Leopard 时最容易忽略的一点:
Zanzibar 不是一个“只要快就行”的系统,它还必须保证撤权正确性。
如果 tuple store 已经反映了最新 membership 变更,而 Leopard 的索引仍停留在旧状态,那么系统就不能盲目依赖这份旧索引结果。
否则它可能会在撤权之后,仍然按旧成员关系放行请求。
因此,更合理的工程理解是:
- Leopard 必须和 Zanzibar 的快照 / 修订语义协同工作
- 它只能在满足一致性要求的前提下提供加速
- 当索引结果无法满足所需快照下界时,系统仍然需要回退到更保守的求值路径
换句话说,Leopard 只是把某一类高成本子问题“尽量提前算好”,但它不能凌驾于 Zanzibar 的一致性语义之上。
Leopard 的边界到底在哪里
Leopard 很强,但它的强项非常具体:
- 它主要优化深层群组 membership 这类高成本子问题
- 它不是对所有 rewrite 规则都通用生效
- 它不能替代 Zanzibar 整体的授权求值流程
- 它也不意味着所有
Check都会变成O(1)
如果一个权限规则还包含对象继承、交集、差集,或者需要继续跨对象跳转,那么整个授权检查仍然需要其他求值逻辑参与。
所以 Leopard 的真正意义,不是“Google 发明了一个神奇索引,让 Zanzibar 自动变快”,而是:
在所有可能拖慢授权检查的子问题里,Google 识别出“深层 membership 展开”是最值得单独系统化优化的一类,并为它专门设计了索引子系统。
Zanzibar 不是万能银弹
Zanzibar 很强,但它解决的是一个非常明确的问题:大规模、细粒度、关系驱动的授权判断。它并不天然等于“所有安全策略都应该塞进去”。
1. 它解决的是 Authorization,不是 Authentication
Zanzibar 回答的是:
- 这个主体能不能对这个对象执行这个动作?
它不负责回答:
- 这个主体到底是谁?
- 这个身份是不是已经完成 MFA?
- 登录会话是不是过期了?
这些仍然属于认证系统、身份系统和会话系统的职责。
2. 它不天然替代所有 ABAC
如果你的规则高度依赖实时环境属性,例如:
- 当前请求 IP
- 当前时间窗口
- 设备风险评分
- 地域合规限制
那么纯粹的 Zanzibar 模型未必是最自然的表达方式。
更常见的工程做法是:
- 用 Zanzibar 负责“稳定的关系授权”
- 用应用层或策略引擎叠加“动态上下文约束”
3. 它会带来建模和迁移成本
从传统权限表迁移到 Zanzibar 风格系统,真正困难的地方往往不是把数据换个库,而是:
- 重新抽象对象类型和关系边界
- 把历史上分散在代码里的权限逻辑收束为统一 schema
- 为业务方建立一致的调用方式和一致性契约
- 解决“为什么这次被拒绝 / 被放行”的可解释性问题
因此,Zanzibar 更像一套授权基础设施架构,而不是“装上就好”的组件。
开源生态:如果你想在业务里落地
Google 并没有开源内部 Zanzibar,因为它深度依赖 Google 自家的基础设施,例如 Spanner 以及围绕它搭建的大规模分布式系统。
不过论文发布后,社区出现了几类非常有代表性的实现:
SpiceDB (AuthZed)
当前最活跃的 Zanzibar 风格开源实现之一。它在 schema 语言、开发体验、可观测性和一致性接口上做了很多工程化增强,适合认真构建统一授权服务的团队。OpenFGA
由 Auth0 / Okta 推动的开源实现,模型和 API 设计相对简洁,生态也在持续完善,适合希望采用 Zanzibar 思路、同时强调易集成性的团队。Ory Keto
也是较早的相关实现,适合希望将细粒度授权纳入更广义身份与访问管理体系的场景。
如果你要选型,真正该问的不是“谁最像论文”,而是下面这些问题:
- 你是否真的需要统一的细粒度授权中心?
- 你的业务里是否存在大量共享、继承、群组嵌套和跨服务授权?
- 你能否接受引入一套新的 schema、写入 API、检查 API 和一致性语义?
- 你的团队是否有能力维护授权建模和排障链路?
什么时候值得考虑 Zanzibar
下面这些场景,通常很适合认真评估 Zanzibar 风格的方案:
资源共享关系天然复杂
比如文档、项目、代码仓库、知识库、数据空间、多租户组织结构。权限逻辑已经散落在多个服务里
导致各处实现不一致、重复开发、排障困难。传统 RBAC 已经开始失控
角色越来越多,但仍然表达不了“某人因某个资源关系而获得访问权”。撤权一致性真的很重要
例如文档协作、企业数据平台、面向外部客户的细粒度共享能力。
反过来说,如果你的系统只是一个后台管理台,权限模型很稳定,几类角色就能搞定,那么引入 Zanzibar 往往属于过度设计。
延伸阅读
如果你想继续深入,下面几份资料最值得看:
- Zanzibar 论文原文:最权威的第一手材料,重点看数据模型、一致性语义和 Leopard。
- AuthZed 对 Zanzibar 的讲解:适合把论文概念和工程实践联系起来。
- SpiceDB 文档:适合从“理念理解”走向“真实建模与落地”。
- OpenFGA 文档:适合对 API、模型定义和服务化接入感兴趣的读者。
总结
Zanzibar 的伟大之处,不在于它发明了某一种单独的数据结构,而在于它把下面几层能力组合成了一套真正可落地的全球级授权系统:
- 用关系元组表达基础授权事实
- 用 userset rewrite 组合出复杂而可维护的权限语义
- 用一致性令牌和外部一致快照解决撤权与内容更新之间的因果一致性问题
- 用缓存、请求去重和专门索引优化高成本求值路径
如果只把 Zanzibar 理解成“图上的权限搜索”,你会低估它。
如果把它理解成“面向超大规模协作系统的授权求值引擎 + 一致性协议 + 工程化优化体系”,你就更接近它的本质了。
对今天的工程团队来说,Zanzibar 最重要的启发并不是“照搬 Google 的实现”,而是:
当权限开始变成一张真实的关系网络时,授权系统就不该再只是几张表和几段
if语句,而应该被当成一层独立、严谨、可演化的基础设施来设计。