Beads 的 Dolt 并发模型:基于事务的单分支(All-On-Main)共享数据平面设计
【免费下载链接】beadsBeads - A memory upgrade for your coding agent项目地址: https://gitcode.com/GitHub_Trending/beads1/beads
本文基于仓库设计文档 engdocs/design/dolt-concurrency.md 展开,结合 internal/storage/dolt/transaction.go、internal/storage/dolt/store.go、cmd/bd/dolt_autocommit.go 等源码与测试,完整还原 Beads 从"分支隔离"到"事务约束下单主分支"的并发模型演进,以及它在多 Agent 系统、联邦同步(Federation/Wasteland)和独立 Beads 场景中的落地方式。
Beads 是面向多 Agent 系统的"通用数据平面"(universal data plane):worker、coordinator、observer、processor、patrol 等所有角色都通过读写 beads 来完成相互协调。因此,底层的 Dolt 存储并发模型必须同时服务于全部角色,而不只是单个 worker。本文讲解 Beads 采用的All-On-Main(全部写入 main 分支)+ 显式 SQL 事务 + 事务内 DOLT_COMMIT并发方案:它为何取代旧的 branch-per-worker 策略、两层并发架构的原理、三条事务规则、连接池配置、冲突处理、分阶段迁移策略,以及它对联邦同步与独立 Beads 部署的影响。读完本文,你将掌握如何在一个共享 Dolt 分支上让数十个并发 Agent 安全读写同一份数据,并获得可复制的 SQL 事务模板与 Go 实现对照。
1. 背景:为什么 branch-per-worker 必须退休
文档指出,Beads 此前使用branch-per-worker策略:每个 worker 拥有独立的 Dolt 分支,在隔离状态下写入,稍后再合并回 main。该策略本意是消除并发写者之间的乐观锁竞争,且实测有效——测试中 50 个并发写者、250 个 Dolt 提交、成功率 100%。
但文档明确判定这些并发收益是虚幻的(illusory),理由有四:
- Agent 之间互相看不见 beads。Agent A 创建的 bead,在 A 的分支合并回 main 之前,对 Agent B 完全不可见。这破坏了调度(dispatch)、依赖跟踪和状态查询所需的跨 Agent 可见性。
- 共享状态必须放在 main 上。作为整个系统的协调层,所有角色都需要对 bead 状态持有同一份视图;分支隔离与共享数据平面的需求背道而驰。
- 完成时合并带来陈旧性(staleness)。长期运行的 Agent 不断累积分叉,完成时的合并只是一次批量对账点,而非持续共享视图。
- 分支爆炸(branch proliferation)。每次 sling 都会创建分支,清理依赖
bd done或专门的分支清理流程,孤儿分支不断累积;为保障安全引入的BD_BRANCH分析(issue #1796)又给代码库增添了大量复杂度。
外部输入是决定性的:Dolt 联合创始人 Tim Sehn 在 2026-02-21 给出建议(DoltHub 2026-02-18 并发博客)——"It is far simpler to use one branch, so start there. You can get hundreds of transactions per second on a single branch. We fixed the bug you ran into."(用单分支要简单得多,从那里起步;单分支上每秒可获得数百个事务。你遇到的问题我们已修复。)
Tim 的关键洞察:事务边界缺失
Tim 进一步点出了问题的核心:
"I think you dolt commit every sql statement. If you don't you want to wrap writes in a BEGIN and finish with a CALL DOLT_COMMIT(), ie in a transaction otherwise connections will commit each other's writes."
即:在 Dolt 的 auto-commit 模式下,如果没有显式 SQL 事务边界,任何连接都可能不经意地把另一条连接的未提交工作集变更一并提交掉。解决方案是建立正确的事务边界,让DOLT_COMMIT与它对应的写入处于同一个事务内。
这一洞察在源码中得到了印证:当前实现中RunInTransaction的commitMsg参数正是"用于让常规写入在 Dolt 历史中可见的 DOLT_COMMIT"(见 internal/storage/dolt/transaction.go 的注释),而 internal/storage/dolt/transaction.go 也明确要求"SQL 事务、config 保护、DOLT_COMMIT 必须运行在同一条 Dolt session 上"(GH#2455)——这正是对 Tim 洞察的工程化落地。
2. Dolt 两层并发架构(背景知识)
Dolt 的并发能力来自两个叠加的层次(源自 DoltHub 并发博客)。
2.1 Layer 1:SQL 事务(MVCC)
标准 SQL 事务语义,但有一个关键差异:冲突检测采用针对分支 HEAD 的三路合并(three-way merge),而非行级锁:
- 不同单元格被并发修改:无冲突(自动合并)
- 同一单元格被更新为相同值:无冲突
- 同一单元格被更新为不同值:冲突(必须解决)
这比传统数据库更宽容:两个 Agent 并发更新同一个 bead 的不同字段会成功,不会互相阻塞。隔离级别为repeatable read(可重复读),代价是对"读后写同一单元格"的场景存在丢失更新(lost update)风险。
2.2 Layer 2:提交图(串行化)
版本控制操作(DOLT_COMMIT、DOLT_MERGE、DOLT_BRANCH等)会获取全局锁,原子执行后释放。合并工作(merge work)在锁外进行,只有最终的图写入被串行化。性能上,正常运行时可达到每秒数百次提交图操作;全局锁计划在未来改为每分支粒度,但实践中从未成为瓶颈。
2.3 两种模式对比
模式 1:事务包裹的 Dolt 提交(推荐 Beads 采用)
BEGIN; INSERT INTO issues (id, title, status) VALUES ('gt-abc', 'Fix bug', 'open'); INSERT INTO dependencies (issue_id, depends_on_id, type) VALUES ('gt-abc', 'gt-def', 'blocks'); CALL DOLT_COMMIT('-Am', 'bd: create gt-abc'); -- 事务结束,变更原子可见模式 2:每客户端分支(Beads 正在退休的旧方案)
CALL DOLT_BRANCH('worker-ace-1708642800'); CALL DOLT_CHECKOUT('worker-ace-1708642800'); -- 隔离写入,对其他 Agent 不可见 -- 稍后合并3. 目标设计:All-On-Main + 事务纪律
3.1 设计原则
所有 beads 都位于main分支上。并发访问通过带显式DOLT_COMMIT的 SQL 事务来管理,不再为 worker 创建任何分支。
3.2 规则 1:每个写入组都必须包在事务里
每个逻辑写操作(创建 bead、更新状态、关闭 bead、添加依赖等)都必须包裹在BEGIN…CALL DOLT_COMMIT()中。文档给出了重构前后的 Go 对照:
// BEFORE(旧):每条语句自动提交,无事务边界 func (s *DoltStore) CreateIssue(ctx, issue, actor) { s.db.Exec("INSERT INTO issues ...") // auto-committed // DOLT_COMMIT 稍后由 maybeAutoCommit() 触发 } // AFTER:显式事务,DOLT_COMMIT 在事务内 func (s *DoltStore) CreateIssue(ctx, issue, actor) { tx, _ := s.db.BeginTx(ctx, nil) tx.Exec("INSERT INTO issues ...") tx.Exec("INSERT INTO labels ...") // 如适用 tx.Exec("INSERT INTO dependencies ...") // 如适用 tx.Exec("CALL DOLT_COMMIT('-Am', ?)", msg) tx.Commit() }这样做保证了:
- 一次逻辑操作内的所有写入原子可见;
- 其他连接无法提交我们的未提交写入;
DOLT_COMMIT属于事务的一部分,因此只包含我们的变更;- 若事务回滚,不会产生 Dolt 提交。
这一模式在当前源码中已是主线:RunInTransaction(ctx, commitMsg, fn)接收提交消息并调用runDoltTransaction,事务成功后由finishDoltTransaction依次完成"常规 SQL 事务提交 →versioncontrolops.StageAndCommit生成 Dolt 修订 → ignored 表事务提交"(见 internal/storage/dolt/transaction.go)。其中StageAndCommit只提交脏表集合(tx.dirty.DirtyTables()),并带上commitAuthorString()作为作者,正是文档"事务内 DOLT_COMMIT 只包含我们的变更"的精确实现。
3.3 规则 2:读操作不需要事务
简单读取(对issues、dependencies等的SELECT)可以直接使用裸连接,读取 main 上最新已提交状态;事务内的读取在 repeatable-read 隔离下看到一致快照。
3.4 规则 3:批处理模式变为事务作用域
原有的批处理模式(累积变更、在逻辑边界处提交)可以自然映射为长事务:
// 批处理:开启事务,多次写入,结束时一次性 DOLT_COMMIT tx.Begin() for _, issue := range issues { tx.Exec("INSERT INTO issues ...") } tx.Exec("CALL DOLT_COMMIT('-Am', ?)", batchMsg) tx.Commit()4. Beads 侧的代码改动(对照当前仓库实现)
4.1store.go:移除分支初始化,固定 main
文档要求删除BD_BRANCH初始化块(原 store.go 第 336-358 行)、删除SetMaxOpenConns(1)/SetMaxIdleConns(1)限制,以启用连接池。当前仓库中这一迁移已经完成:在 internal/storage/dolt/store.go 可以看到明确的落点——"All writers operate on main — transaction isolation via RunInTransaction replaces the former branch-per-worker approach (BD_BRANCH)",随后store.branch = "main"。此外 internal/storage/dolt/store.go 还注册了 OTel 连接池指标(registerPoolGauges,对应 GH#3140),用于诊断共享服务器性能退化——这是文档设计之上新增的运维增强。
4.2transaction.go:把 DOLT_COMMIT 纳入事务
文档指出旧runDoltTransaction只做sql.BeginTx/Commit,从不调用DOLT_COMMIT,导致 SQL 变更只进工作集、不进 Dolt 版本历史,其他连接可能把这些变更卷进自己的提交。重构后的目标形态:
func (s *DoltStore) runDoltTransaction(ctx, fn, commitMsg) error { sqlTx, _ := s.db.BeginTx(ctx, nil) tx := &doltTransaction{tx: sqlTx, store: s} if err := fn(tx); err != nil { sqlTx.Rollback() return err } // Dolt 提交位于 SQL 事务内部 —— 与写入原子 _, err := sqlTx.Exec("CALL DOLT_COMMIT('-Am', ?, '--author', ?)", commitMsg, s.commitAuthorString()) if err != nil && !isNothingToCommit(err) { sqlTx.Rollback() return err } return sqlTx.Commit() }当前实现的runDoltTransaction更进一步(internal/storage/dolt/transaction.go):
- 使用
s.db.Conn(ctx)钉住单条连接执行整个操作,保证 SQL 事务、config 保护与 DOLT_COMMIT 处于同一条 Dolt session(GH#2455); - 读取
SELECT active_branch()确认当前分支; - 在 journal 模式(
eventsJournalEnabled)下把 ignored 表事务与常规事务合并到同一条 SQL 事务,保证事件日志与数据变更同事务提交; - 对事务期间 panic、回调失败分别回滚;回调至多调用一次(at-most-once),提交结果不确定时以
ErrCommitIndeterminate上报并停止重放(internal/storage/dolt/transaction.go)。
这与文档的"AFTER"形态语义一致,并额外补齐了连接亲和性、事件日志原子性和不确定提交的处理——是生产化后的演化版本。
4.3dolt_autocommit.go:外部自动提交退居安全网
文档建议:DOLT_COMMIT移入事务后,外部自动提交包装器maybeAutoCommit对大多数操作不再必要,但可保留为裸写入(逃离事务模式)的安全网,例如迁移脚本、一次性修复。当前 cmd/bd/dolt_autocommit.go 保留了maybeAutoCommit(第 103-116 行)、maybeAutoCommitStore(第 129 行起)、isDoltNothingToCommit(第 161 行)等函数,且文件开头注释明确说明事务路径已让maybeAutoCommit冗余("DOLT_COMMIT occurred, preventing the redundant maybeAutoCommit"),同时writesCommitNow()/embeddedWritesCommitNow()控制提交时机——正是"安全网 + 双路径"的形态。
4.4versioned.go:合并/分支操作降级为迁移期专用
Merge()与DeleteBranch()仅在迁移期(清理既有 worker 分支)需要;迁移完成后,它们对正常写入路径成为死代码,但保留给联邦(DoltHub 远程合并)与独立 Beads 场景。
5. 编排器(Orchestrator)侧的改动
文档在此处引用的编排器内部命令(
gt sling、gt done)属于历史上下文——该设计最初是为编排器迁移撰写的,Beads 仓库中对应逻辑已随迁移演进或移除。
- Worker 派发:停止创建分支。旧的派发逻辑会创建 Dolt 分支并把
BD_BRANCH注入 worker 环境。迁移后:派发时不再创建分支、不注入BD_BRANCH、所有 Agent 共用同一 main 分支连接池。 - 任务完成:停止合并分支。旧的任务完成逻辑会 checkout main、合并 worker 分支并删除它。迁移后:无合并、无分支删除,任务完成只是关闭 bead(它已在 main 上、已经可见)。
BD_BRANCH安全基础设施(#1796)可整体退役:包括bdbranch/analyzer.go、OnMain()、StripBdBranch()与 arch 测试注册表,代码库显著简化。当前仓库仅在 internal/storage/dolt/store.go 与 CHANGELOG.md 中残留历史说明,印证该基础设施已移除。session_manager.go:移除 DoltBranch 选项。从会话选项中删除DoltBranch字段及向 tmux 会话注入BD_BRANCH的逻辑。
6. 连接池配置
所有连接都落在 main 上之后,需要合理的连接池配置:
db.SetMaxOpenConns(10) // 允许并发读者 + 写者 db.SetMaxIdleConns(5) // 保持热连接 db.SetConnMaxLifetime(5 * time.Minute)具体数值取决于 rig(一套运行编排器的机器)的并发级别。典型编排器 rig 为 6 个 worker + coordinator + observer + processor + patrol ≈10 个并发 Agent,每个 Agent 可能持有一条连接。文档建议以 10 为最大连接数的保守起点,压测后调优。
补充两个当前实现中的池化细节(见 internal/storage/dolt/transaction.go):
- ignored 事务的第二条连接通过
beginIgnoredTxOnBranch从主池"借用"一条热连接(ignoredTxBorrowTimeout = 250ms为上限),借用不可得时回退到专用单连接池(db.SetMaxOpenConns(1))——这解释了为何源码中仍会出现SetMaxOpenConns(1),它服务于 ignored 表事务的专用回退路径,而非旧的整库单连接限制; - 连接获取耗时(
connAcquireMs)、池等待次数(poolWaitCount)被记录为 OTel 指标,供容量评估与退化诊断。
7. Wisps:无需改动
Wisps 已经位于dolt_ignore的表中:不受版本跟踪、不建分支、不参与联邦(digest 发布除外)。其并发模型纯粹是 SQL——标准的 MySQL 兼容并发写入,无 Dolt 特有顾虑。
Wisps 按设计是worker 本地的:一个 Agent 的 wisps 只对该 Agent 的会话有意义。跨 Agent 的 wisp 可见性(如 molecule 步骤协调)走 events/comments 表,这些表同样被 dolt_ignore。本次迁移对 wisps 零改动。
8. 冲突解决
多个连接并发写入 main 时,冲突"可能但罕见",原因在于 Dolt 的单元格级合并语义:
| 场景 | 冲突? | 解决方式 |
|---|---|---|
| 两个 Agent 创建不同的 bead | 否 | 不同行,自动合并 |
| 两个 Agent 更新不同的 bead | 否 | 不同行,自动合并 |
| 两个 Agent 更新同一 bead 的不同字段 | 否 | 不同单元格,自动合并 |
| 两个 Agent 更新同一 bead 的同一字段 | 是 | Last writer wins(updated_at) |
| 一个 Agent 写、另一个 Agent 读 | 否 | 读端看到已提交状态 |
"同一 bead 的同一字段"在实践中的确罕见——bead 通常在同一时刻只归属一个 Agent(通过 sling 指派)。主要风险是并发状态更新(如一个 Agent 关闭 bead 的同时另一个 Agent 也在更新它)。缓解手段:在必要时使用乐观并发检查(更新前校验期望状态)。仓库中的 internal/storage/dolt/concurrent_test.go 专门覆盖了这类并发场景——它验证多个 writer 并发创建 issue、更新状态、添加依赖、关闭 issue 时系统正确收敛,并专门测试了 autocommit 模式下同单元格分支冲突被拒后工作集保持干净、可安全重放(TestDoltAutocommitRollbackContentionConverges),与文档"可能但罕见 + 乐观并发兜底"的判断互为印证。
9. 分阶段迁移策略
Phase 1:加事务纪律(不破坏现有行为)
修改RunInTransaction,把DOLT_COMMIT放进 SQL 事务内。该改动无论是否启用 branch-per-worker 都成立,属于纯增量安全增强。
- 改
transaction.go:增加提交消息参数,在tx.Commit()前调用DOLT_COMMIT; - 为所有
RunInTransaction调用方补提交消息; - 保留
maybeAutoCommit作为裸写入的兜底; - 测试:既有 internal/storage/dolt/concurrent_test.go 应继续通过。
Phase 2:移除 Branch-Per-Worker(编排器侧)
以 Phase 1 在生产环境稳定为前提。
- 从 worker 派发与会话管理移除
BD_BRANCH注入; - 派发时不再创建分支、完成时不再合并分支;
- 从
store.go移除BD_BRANCH环境变量处理; - 清理
OnMain()、StripBdBranch()、analyzer 基础设施; - 测试:用 2-3 个 Agent 部署,验证跨 Agent 的 bead 可见性。
Phase 3:退役分支基础设施(清理)
- 移除
bdbranch/analyzer.go与 arch 测试注册表; - 从文档中清除
BD_BRANCH引用; - 更新
dolt-storage.md设计文档; - 清理既有安装中的孤儿分支;
- 测试:全规模 swarm(6+ Agent),并发写入压力测试。
10. 对联邦同步(Federation / Wasteland)的影响
全量写入 main简化了联邦:
- Push/pull 分支干净。单一 main 分支使
dolt push/dolt pull作用于单一线性历史(除了 Dolt 内容寻址的合并提交之外),无分支命名空间污染。 - 提交图更简单。branch-per-worker 产生的复杂 DAG 在向 DoltHub 远程同步时难以推理;all-on-main 带来更干净的提交历史。
- 跨 rig 的 bead 可见性即时生效。rig A push 到 DoltHub 后,rig B pull 即可看到全部 beads,无需分支对账。
- 联邦事务。同样的
BEGIN…DOLT_COMMIT模式适用于联邦同步:拉取远程变更、解决冲突、提交——与 Dolt 原生复制流程一致。
Wisps 与联邦
Wisps 被dolt_ignore且从不 push,这是正确的:
- Wisps 是易失的、worker 本地的运行状态;
- Wisp digest(摘要)可选地结晶为 beads 并发布到 main 以参与联邦;
- digest 发布流程本身已是独立操作,创建的是正规 bead(而非 wisp)。
11. 对独立 Beads 的影响
bdCLI 同时支持嵌入式 Dolt 与 server 模式。本设计仅适用于 server 模式(编排器的部署形态);嵌入式模式是单进程,不存在本文所述的多连接并发问题。
对使用嵌入式 Dolt 的独立bd:
- 单连接,auto-commit 即可;
- 无 branch-per-worker(单用户);
- 事务包裹仍属良好实践,但非关键。
12. 性能预期
Tim 的指导是:单分支每秒数百个事务。Beads 的典型负载(文档估算):
- 典型编排器 rig:6-12 个并发 Agent(全角色合计);
- 写模式:创建/更新/关闭 bead,每 Agent 每分钟约 1-10 次写;
- 读模式:状态查询、bead 查找,每 Agent 每分钟约 10-100 次读;
- 峰值总量:约60-120 次写/分钟、600-1200 次读/分钟。
这远在 Dolt 单分支能力之内。即使放大到 10 倍规模(大型 rig 上 60+ Agent),也只是约 1200 次写/分钟 ≈20 次写/秒,远低于每秒数百次的上限。
13. 遗留的开放问题
- 提交粒度:是否每个
bd create都产生一个 Dolt 提交,还是在更高层级批量(如按 molecule、按 formula 步骤)?按操作提交审计性更好;批处理可减少提交图体积。Tim 的模型表明在当前规模下按操作提交没有问题。 - 连接池大小:每个 rig 的正确池大小需在负载下测试。建议从保守的 10 个最大连接起步再调优。
- 丢失更新防护:Dolt 的 repeatable-read 隔离不防丢失更新。对
status、assignee等高竞争字段,是否需要应用层乐观锁(如WHERE updated_at = ? AND status = ?)? - 既有分支清理:生产 rig 已累积 worker 分支,切换 all-on-main 前需要迁移脚本执行 merge-or-delete。
- 嵌入式模式兜底:若独立
bd用户对同一嵌入式 Dolt 运行多个进程(罕见但可能),会遭遇相同问题。应文档化为不支持,还是也给嵌入式模式加事务纪律?
14. 参考与延伸阅读
- 设计文档:engdocs/design/dolt-concurrency.md
- DoltHub 并发架构博客(2026-02-18);Tim Sehn 2026-02-21 给 Steve Yegge 的邮件(文中已引述核心观点)
- 核心实现:internal/storage/dolt/transaction.go(RunInTransaction / runDoltTransaction / finishDoltTransaction)、internal/storage/dolt/store.go(
store.branch = "main"落点) - 自动提交安全网:cmd/bd/dolt_autocommit.go
- 并发与冲突验证:internal/storage/dolt/concurrent_test.go
- 历史上下文(已移除的编排器组件):
done.go合并流程、session_manager.go的 BD_BRANCH 注入、bdbranch/安全分析器
【免费下载链接】beadsBeads - A memory upgrade for your coding agent项目地址: https://gitcode.com/GitHub_Trending/beads1/beads
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考