HydraDB如何防止写者脑裂?对象存储CAS租约与写者围栏机制详解
【免费下载链接】hydradbHydraDB - fast graph database on object storage项目地址: https://gitcode.com/gh_mirrors/hyd/hydradb
HydraDB 是一个构建在 S3 兼容对象存储上的分布式图数据库,用 Rust 编写。当多个数据节点同时想写同一份数据时,"脑裂"(split-brain)就是最危险的故障——两个节点各自认为自己是唯一写者,就会产生数据丢失或覆盖。HydraDB 用对象存储 CAS 租约 + SlateDB 写者围栏三层防御彻底解决这个问题,且全程不需要任何外部协调服务。
什么是写者脑裂?为什么在对象存储上更危险
传统分布式数据库通常靠 ZooKeeper、etcd 等共识组件来选主。而 HydraDB 的存储与计算完全分离:对象存储是唯一的持久化真相,所有计算节点(graph-node)只持有可丢弃的内存和本地 SSD 缓存,随时可以被替换或扩缩容。
这种设计带来一个诱人但也危险的问题:如果节点 A 的网络短暂抖动、进程崩溃后又被拉起,而系统里还有一个"长得一模一样"的节点 A',谁来保证同一时刻只有一个写者?
HydraDB 的核心不变式只有一条:
每个
(scope, cell)最多只有一个被准入的写者,可以有任意多个读者。
围绕这条不变式,HydraDB 设计了职责各异的三层防线。
三层防线:写者所有权的全景图
| 层级 | 机制 | 职责 | 源码位置 |
|---|---|---|---|
| 1️⃣ 放置(Placement) | 心跳对象 + 协调哈希 | 选出"预期候选写者",提供路由就近性 | crates/placement/src/heartbeat.rs |
| 2️⃣ 持久化租约 | 对象存储 CAS 条件更新 | 正式"准入"唯一写者,崩溃后自动过期 | src/engine/writer_lease.rs |
| 3️⃣ 写者围栏 | SlateDB 写者纪元(writer epoch)+ WAL 屏障 | 最终防线:物理上阻止过期写者提交 | src/core/state.rs |
前三层的关系很像"先敲门、再核验工牌、最后还有门禁系统":前两层是"软性"的协调,只有第三层是硬性的存储层围栏——即使前两层全部出现判断偏差,过期写者的提交也会被存储引擎直接拒绝。
第一层:心跳与放置——选出一个"预期候选写者"
每个就绪的 graph-node 会周期性地在对象存储中写入一个心跳对象:
<base>/_graph_nodes/v1/<node-id>这里有两个精巧的设计(见 crates/placement/src/heartbeat.rs 的模块注释):
- 一切信息都放在对象名字里:一次 LIST 请求就能拿到所有节点的名字和
LastModified时间戳,放置判断永远不需要 N 次 GET 请求扇出; - 存活状态以对象存储的
LastModified为准,而不是各节点本地时钟:所有节点读同一份时间戳,就能在同一瞬间算出同一个"存活集合"。
在这个共享存活集合之上,HydraDB 用协调哈希(rendezvous hashing)为每个(scope, cell)稳定地选出一个候选写者。
⚠️ 关键点:放置只是新候选者的"门禁",不是持久化的所有权凭证。如果已有合法租约持有者,它可以安全地穿过一次心跳视图的短暂分歧,不会因为一次 LIST 抖动就被踢掉——这避免了"因为网络闪一下就丢失健康写者"的抖动问题。
第二层:对象存储 CAS 租约——真正的"准入证"
候选者要真正打开写者,必须先拿到持久化写者租约。租约是一个存放在对象存储里的小对象:
<graph-scope>/_writer_leases/v2/<cell-id>租约里记了什么?
| 字段 | 含义 |
|---|---|
node_id | 持有租约的节点 |
holder_id | 进程级 ULID 标识(重启后必变) |
generation | 代数,每次易主 +1 |
heartbeat | 续租计数 |
duration | 租约时长,默认30 秒 |
state | active或released |
这个文本格式定义在 src/engine/writer_lease.rs 中,简单到你可以直接在对象存储里人肉审计。
CAS 抢占的"读—比—写"循环
抢占或续租的核心逻辑在 acquire_or_renew_inner:
- 读取当前租约对象,同时记下它的版本(eTag);
- 如果发现租约仍然有效且属于别的进程(node_id 或 holder_id 不匹配),立刻返回
NotCellWriter错误,并告诉调用者"真正的主人是谁"; - 否则构造新租约(代数 +1),用条件更新(
PutMode::Update(旧版本))写回对象存储——只有当对象还是刚才读到的那个版本时写入才成功; - 如果 CAS 因并发竞争失败(
Precondition/AlreadyExists),重试,最多 16 次。
这就是CAS(compare-and-swap):对象存储充当了一个天然的"原子锁",不需要任何额外的协调数据库。两个节点同时抢占同一个 cell 时,物理上只有一个 CAS 能成功——这正是仓库内置测试 concurrent_contenders_produce_exactly_one_owner 验证的场景:并发竞争者中恰好产生一个主人。
两个容易忽视的细节
时钟不是本地的,是"服务器时钟"。判断租约剩余时间用的是对象存储的LastModified时间戳,而不是节点自己的时钟。节点通过探测_coordination/v1/server-clock获取共享服务器时间,再在进程内单调递减——新启动的观察者不会给一个老租约"续上新的本地 TTL"。
本地视图永远比持久化视图更保守。本地租约的有效窗口从发起S3 请求之前开始计时(L314-L318 的注释写得很直白):一次慢响应只会让本地权限缩短,绝不会让本地权限活得比持久化对象更久。续租也会提前在租约的 1/3 剩余处触发,为网络延迟留出余量。
第三层:SlateDB 写者围栏——"门禁系统"兜底
前两层都是"协调",理论上仍可能被极端场景绕过(比如旧进程从长时网络分区中"复活")。所以 HydraDB 把最终裁决权交给了 SlateDB 存储引擎:
- 每个 cell 的 SlateDB 数据库有一个写者纪元(writer epoch),由持久化的 manifest 管理;
- 新写者被提升(promote)时会拿到更高的纪元,WAL 屏障会物理上阻止旧纪元的写者追加任何记录;
- 旧写者下一次写操作时,存储引擎返回
Closed(Fenced)错误,写句柄被关闭。
注意一个重要的错误分类:写到非主人的节点上时,HydraDB 返回的是NotCellWriter这类路由类错误(见 src/core/error.rs)——Bolt 路由驱动收到后会刷新路由表、改写真正的主人,这属于正常路由周转;而Fenced才是真正的围栏事件,会被归类为围栏遥测,方便在监控面板上单独统计"围栏频率"。
被围栏后:退避门(WriterReopenGate)
被围栏的节点不会疯狂重试。WriterReopenGate 规定了重新打开写者的节奏:
- 遇到围栏:等待恰好一个心跳间隔(默认 5 秒,见 src/core/config.rs),并重置退避阶梯——这个时长专门"标定"得让对手有时间刷新视图并停手;
- 遇到普通失败:指数退避,从 2 秒开始翻倍,上限 60 秒;
- 重新打开之前必须先重新推导所有权:门禁只负责"限速","是否还有权"的裁决永远回到租约检查这一步。
这个顺序是刻意的——如果围栏后的重试绕过所有权检查直接重新提升,一个失去纪元的非主人节点会立刻把纪元"抢回来",围栏就形同虚设了。
脑裂实战演练:fence_worker 验证全流程
仓库自带了一个教科书级的围栏验证程序 examples/fence_worker.rs,它用三个角色演示完整的脑裂→围栏→验证闭环:
| 角色 | 动作 | 预期结果 |
|---|---|---|
incumbent(在任写者) | 写入边100→10后"假死",等待接管信号 | 恢复后尝试写入100→777,必须收到Fenced错误 |
takeover(接管者) | 打开同一 cell 的写者,写入边100→99 | 成功提交,并反过来围栏掉在任写者 |
reader(只读验证者) | 检查三条边的可见性 | 10可见 ✅、99可见 ✅、陈旧的777不可见✅ |
reader的断言是整套机制的"验收标准":接管者的写入必须持久化,而被围栏写者的陈旧写入绝对不能出现在任何快照里。如果777这条边哪怕出现一次,就宣告围栏失败——程序会直接以错误退出(L100-L110)。
用一句话概括整个防脑裂故事:
节点崩溃 → 30 秒后对象存储中的租约自然过期 → 新候选者 CAS 抢占租约并提升写者纪元 → 旧写者"复活"后第一次写就被 SlateDB 以
Fenced拒绝 → 节点安静等待一个心跳间隔后重新推导所有权 → 路由表更新,流量指向新主人。
全程零外部协调服务,零数据丢失。
相关源码导读
想深入这条代码路径,建议按下面顺序阅读:
- 架构总览(含故障语义表):architecture.md
- CAS 租约实现:src/engine/writer_lease.rs
- 心跳与存活判断:crates/placement/src/heartbeat.rs
- 围栏退避门与归属日志:src/core/state.rs
- 围栏等待参数:src/core/config.rs
- 集群提升/纪元检查:src/engine/cluster.rs
- 端到端围栏验证:examples/fence_worker.rs
总结
HydraDB 防脑裂的设计哲学可以浓缩为三句话:
- 放置只管推荐,租约才管准入——CAS 条件更新让"单一写者"在对象存储层面可验证;
- 服务器时钟管过期,本地时钟只管限速——所有节点共享同一时间基准,判断天然收敛;
- 协调可以失败,存储不会说谎——SlateDB 写者纪元是最后一道不可绕过的硬围栏,任何"复活"的过期写者都写不进一个字节。
正是这种"软协调 + 硬围栏"的组合,让 HydraDB 在完全无中心协调的架构下,依然能给每个 cell 提供单写者语义——这也是它能在对象存储上放心做到存储与计算完全分离的底气所在。
【免费下载链接】hydradbHydraDB - fast graph database on object storage项目地址: https://gitcode.com/gh_mirrors/hyd/hydradb
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考