☰
tau 项目 kvdb 设计守则:基于 CRDT 键值存储的正确性工程实战
2026/10/13 3:06:56 网站建设 项目流程
  • 后端
  • 云原生
  • 微服务
  • MCP 服务

【免费下载链接】tau

Fullstack Workspace for Humans & Machines

项目地址:https://gitcode.com/gh_mirrors/ta/tau
点击查看免费下载

导读

tau 是一个面向人与机器的全栈工作区(Fullstack Workspace),其核心基础设施之一是基于 CRDT(go-ds-crdt)实现的分布式键值存储 kvdb。与常规数据库不同,kvdb 没有事务、没有 compare-and-swap、没有跨键原子性,节点间通过异步复制合并并发写入,且每个键只遵循 last-write-wins(LWW)语义——这意味着"怎么设计 key"直接决定数据会不会丢失。本文以仓库根目录的 AGENTS.md 为骨架,系统讲解在 kvdb 之上安全建模数据的九条设计规则与对应的底层实现依据,帮助开发者与 AI Agent 在改动代码库时避免踩中真实出现过的数据丢失 Bug。

一、理解前提:kvdb 是 CRDT 键值存储,不是数据库

core/kvdb/ifaces.go 中定义了 kvdb 的核心接口KVDB,其能力清单非常克制:

  • Get(ctx, key)/Put(ctx, key, v)/Delete(ctx, key):单键读写删;
  • List(ctx, prefix)/ListAsync/ListRegEx/ListRegExAsync:基于前缀的枚举是唯一的查询手段;
  • Batch(ctx):批量提交写操作;
  • Sync(ctx, key)、Stats(ctx):同步与统计。

接口中Type只有一个取值TypeCRDT,接口的Stats还返回Heads() []cid.Cid——即该键值库的复制状态由 IPLD DAG 头部(heads)描述。从 pkg/kvdb 的实现(merklecrdt.go、delta.go、compact.go、crdt_test.go等文件)以及 pkg/kvdb/kvbench_test.go 的注释可以看出,这是基于 ipfs/go-ds-crdt 的 fork 与深度改造:写入会产生 delta,经过 DAG 节点在节点间扩散,并支持 compaction 对版本链收敛。

由此得到三条必须内化的基础事实:

  1. 复制是对等的、异步的:数据在多个节点间复制,没有单一权威主节点;
  2. 合并语义是每键 LWW:同一键的并发写入,只有一个会"赢",另一个被静默丢弃,不报错;
  3. 没有任何跨键原语:没有事务、没有 CAS、没有跨键原子性,任何依赖这些语义的设计都会在真实并发下出错。

正因为如此,AGENTS.md 开篇就警告:"Design for that or lose data"(按这个模型设计,否则就会丢数据)。下面九条规则全部围绕如何在这种约束下构建不丢数据的应用层。

二、九条核心设计规则

规则 1:每个键只对应一条条目,绝不设计"被争用的键"

这是最重要的一条规则:如果两个节点可能对同一个 key 写入不同的内容,那么其中一次写入会被 LWW 静默丢弃。AGENTS.md 给出的经典反例与正例:

BAD /lookup/org/{provider}/{owner} → account_id GOOD /lookup/org/{provider}/{owner}/{account_id} → linked-at

坏布局中,两个节点声称同一个命名空间(provider/owner),都向同一个键写入各自的account_id,LWW 二选一,落败方"无痕消失"。好布局中,双方写入的是不同键,任何一方都不会丢失。

同一条规则也宣判了"读-改-写一个集合"模式死刑:绝不要存储一个 CBOR 切片或 map 并让调用方去追加。读者 A 和读者 B 都读到[x],分别写成[x,y]和[x,z],合并后一条条目会消失。正确做法是把集合拆成"一个元素一个键"。

AGENTS.md 指出,services/accounts服务的路径约定(lookup 索引、passkey 子集合)都遵循这一布局。注:该服务目录属于企业版(ee/)范畴,未在当前开源仓库中完整公开;从仓库可确认的是 core/services/seer/service_type.go 中注册了ServiceTypeAccounts这一服务类型。

规则 2:把判别符(discriminator)放进 key 路径

如果一条条目从属于(provider, owner, account)这样的元组,那么这三个字段全部要进 key 路径。任何你从 key 里省略的部分,就是两个写者可能碰撞的部分。

推论:key 本身就是索引。List(ctx, prefix)是唯一的查询机制,所以要这样布局路径:让你需要的 prefix 扫描代价低廉,让不需要的扫描"根本不可能发生"(即通过路径结构把无意义查询排除在外)。

规则 3:让冲突可见,再用确定性方法解决

规则 1 的正面意义在于:真实的冲突——例如两个账户都合法声称同一个命名空间——会以"两条条目并存"的形式暴露出来,而不是一个悄悄胜出。这正是设计目的。不要试图用你并不拥有的锁去阻止冲突(kvdb 没有跨节点锁),而应在读取时解决冲突:

  1. 扫描该前缀;
  2. 按条目内部携带的稳定值(例如时间戳)排序,再以第二键(id)打破平局,形成全序;
  3. 取第一个。

这样每个节点从相同的复制状态计算出相同的答案,无需协调,也不依赖写入顺序或节点间时钟偏差。这就是"冲突可见 + 确定性收敛"的组合拳。

规则 4:读后写防护只是用户体验,不是正确性

写入前检查"这个是不是已经被占用了"仍然值得做——在绝大多数无竞争的常见场景下它能给出清晰的错误提示。但它在并发下什么也保证不了:另一个节点可以在你的读和写之间完成写入。所以绝不能让正确性依赖读后写防护,规则 3 才是让结果变得安全的手段。防护的意义是让用户在常见的无竞争场景获得即时反馈,而不是充当并发安全网。

规则 5:删除也是写

一次Delete在与同键并发写入竞争时同样按 LWW 裁决——一次删除与一次重建赛跑,删除可能输掉。如果删除/重建的顺序对业务有影响,就把顺序信息携带在 value 里,在读取时自行裁决。

规则 6:只有"打开且订阅"的实例才会复制

kvdb 只向**打开(open)并订阅(subscribed)**的实例复制数据。一个单节点确认的写入,如果该节点宕机而当时没有其他持有者打开这个库,那么这次写入就随节点一起死亡。任何做 load/unload 的逻辑必须:

  • 在活跃写入期间保持多个 claimant 同时加载(co-loaded);
  • 在确认写入前设置屏障(barrier),要求不止一个持有者确认。

这一点在 hoarder 服务中有完整的工程化实现:查看 services/hoarder/kvdb.go 中replicateWrite的注释,它实现了一个K=2 持久化屏障——本地提交之后、向调用方返回 ack 之前,必须同步地把同一次写入投递给一个在线共持有人(co-claimant),使被 ack 的写入同时存在于 ≥2 个节点,从而在"本节点立即死亡"的情况下存活。共持有人集合由注册表 claim 与活跃成员资格求交集得到(见 services/hoarder/discovery.go 的liveClaimants与 services/hoarder/helpers.go 的currentHolders);仅在确实没有在线共持有人(如集群只剩一个节点)或共持有人持续无法拨通(不对称分区)时才退化为仅本地 ack,并交由后续 reconcile 与 CRDT 追赶补齐。

规则 7:字节序是你唯一的排序

List按字节序返回 key。要做时间序扫描,就必须把数字段零填充到固定宽度,使字典序与数值序一致。AGENTS.md 明确指出 pkg/raft/storage.go 与 pkg/raft/queue.go 为此把索引填充到 20 位数字。源码确凿:

  • pkg/raft/storage.go 中logKey用fmt.Sprintf("%020d", index)生成日志存储键;
  • pkg/raft/queue.go 中sequenceID同样用fmt.Sprintf("%020d", seq)把队列序号转为键段。

不填充的后果是数值1, 10, 2会按字典序排成1, 10, 2——时间序扫描立刻失效。

规则 8:key 命名用路径段,不要用复合词

应使用/lookup/account/slug/{slug},而不是/lookup/account_slug/{slug}。原因:路径段才是前缀扫描器能理解的结构;下划线对扫描器不可见,会把"将来扫描中间层级"的可能彻底封死。

规则 9:进 key 之前先规范化

现实世界中任何大小写不敏感的东西(邮箱、provider 命名空间)在成为 key 的一部分之前必须先做规范化(canonicalise),否则Acme和acme会变成同一件事的两个条目,所有建立在该 key 之上的唯一性保证都会悄悄失效。仓库中 kvdb 存储层已在多处对邮箱做小写化处理;任何新增的标识符都应照此办理。

三、附带的工程化检查清单

AGENTS.md 在九条规则之后给出了一份可直接用于代码评审的自检清单:

  • 两个节点能否对这个完全相同的 key 写入不同的内容?能,就重新设计。
  • 我是不是在往一个存储的集合上追加?是,就拆成一个元素一个键。
  • 条目身份的每一个组成部分是否都在 key 路径里?
  • 万一冲突真的发生了,每个节点是否都能以相同方式解决?
  • 正确性是否依赖读后写防护?绝对不能。
  • 数字 key 段是否零填充到固定宽度?
  • 大小写不敏感的标识符在成为 key 之前是否已规范化?

建议把这份清单固化为 kvdb 相关变更的强制评审项:逐条回答,任何一条答案为"否"都意味着该变更在分布式环境下存在数据丢失或唯一性失效的路径。

四、从规则到实现:仓库中的底层支撑

九条规则并非抽象说教,仓库实现处处与之呼应:

1. 单键 LWW 与版本链的收敛:pkg/kvdb 是 go-ds-crdt 的 fork 与增强,写入生成 delta,经 DAG 扩散;pkg/kvdb/compact.go 实现版本链压缩——把元素与墓碑(tombstones)打包为快照 delta,避免删除操作沿整条版本链回溯(见 pkg/kvdb/kvbench_test.go 中BenchmarkKVDB_DeleteManyVersions对多版本键删除路径的基准关注)。同时 pkg/kvdb/migrations.go 专门处理旧版 tombstone 布局向新布局的迁移,这正说明"删除也是写、墓碑要可压缩"是被反复打磨过的问题。

2. 前缀扫描与索引:List(ctx, prefix)是唯一查询手段(core/kvdb/ifaces.go),因此"key 即索引"不仅是风格偏好,而是 API 层面唯一可用的检索原语——规则 2、8 本质上是让查询计划蕴含在路径布局中。

3. 复制范围与持久化屏障:规则 6 的 K=2 屏障在 services/hoarder/kvdb.go 有完整实现与详尽注释:屏障目标集合由"基于成员资格计算的期望持有人(HRW 分配)∪ 注册表 claim"构成,总努力量被界定为 3 倍存活窗口(3 * hoarderSpecs.LivenessTimeout),防止在不对称分区下写路径被无限挂起。这与 services/hoarder/helpers.go 中"Claim 写入即就绪:kvdb 已打开且 K=2 屏障同步投递写入"的注释互相印证。

4. 字节序排序的工程痕迹:raft 的日志存储与队列都依赖%020d固定宽度填充(pkg/raft/storage.go、pkg/raft/queue.go),让 Raft 索引与队列序号的字典序遍历等价于数值遍历——这是规则 7 在生产代码中的直接体现。

五、给开发者的落地建议

  1. 把"键布局设计"当作 API 设计:先画 key 路径树,标注哪些前缀会被扫描、哪些写者可能碰撞,再动笔写Put。规则 1、2、8 可以在一张路径表上一次性完成自检。
  2. 永远假设并发写:即使今天只有一个节点,也要按"任意时刻存在两个写者"来设计;规则 3 的"冲突可见 + 确定性排序"应成为读取路径的标配。
  3. 持久化以"多持有人确认"为准:任何业务关键的写入确认,都应参照 hoarder 的 K=2 屏障思路,确保被 ack 的数据至少存在于两个活节点,否则就该在文档中明确标注"该数据可瞬时丢失"。
  4. 评审时逐条过检查清单:第三部分的七项检查是真实 Bug 换来的教训——AGENTS.md 明确写道,这里的每条规则都源于"违反它真的产生过 Bug,而不是听起来整齐"。

围绕 AGENTS.md 建立起的这套 kvdb 设计守则,本质上是把分布式系统最难的部分(并发合并语义)前置为编码阶段的硬约束。对任何在 tau 工作区内新增持久化能力的开发者或 Agent 而言,先读透这九条规则,再写第一行Put,是避免数据静默丢失的最短路径。

  • 后端
  • 云原生
  • 微服务
  • MCP 服务

【免费下载链接】tau

Fullstack Workspace for Humans & Machines

项目地址:https://gitcode.com/gh_mirrors/ta/tau
点击查看免费下载
上一篇:OpenCloud 中的 mimetype 库解析:基于魔数的高性能 Go MIME 类型与扩展名检测
下一篇:LeetCode 134 加油站(Gas Station)解法全解:从暴力模拟到双指针与贪心的线性最优解

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询