- 后端
- 认证鉴权
- 密钥管理
- 密码学
【免费下载链接】openbao
OpenBao is a software solution to manage, store, and distribute sensitive data including secrets, certificates, and keys.
OpenBao(OpenBao 是管理、存储和分发机密数据——包括密钥、证书和密钥材料——的开源软件解决方案)在其存储模型中引入了完整的交互式事务能力:与上游 Vault 仅具备“单次批处理”式弱事务不同,OpenBao 允许插件在读写操作之间自由穿插代码逻辑,并对 Raft 与 PostgreSQL 两种受支持的后端提供一致的原子性与一致性保证。本文以 OpenBao 官方博客《Transactional Storage》为主线,结合 sdk/logical/storage.go、sdk/logical/storage_transactions.go、internal/physical/raft/transaction.go、internal/physical/postgresql/transaction.go 以及 internal/builtin/logical/pki/storage.go 等源码,完整剖析事务接口的演进、Raft/PostgreSQL 两套实现原理,以及 PKI 引擎中的真实快照一致性故障复现。读完本文,你将理解 OpenBao 事务模型为何比 check-and-set 语义更强、为何能解决“备份快照无法恢复/不一致”问题,以及如何在自己的插件中安全使用事务。
为什么需要事务存储:单操作原子性与跨操作一致性的缺口
在 OpenBao 及其上游(Vault)fork 起点(fork-point)的存储模型中,逻辑后端(logical backend)访问数据的接口非常朴素,只有四个基本操作:
// Storage is the way that logical backends are able read/write data. type Storage interface { List(context.Context, prefix string) (entries []string, err error) Get(context.Context, path string) (entry *StorageEntry, err error) Put(context.Context, entry *StorageEntry) error Delete(context.Context, path string) error }(对应 fork 起点的 sdk/logical/storage.go 的早期形态。)
值得注意的是,当前仓库中的 sdk/logical/storage.go 已经在原接口基础上增加了第五个操作ListPage(...)(分页列出)以及ErrReadOnly、ErrSetupReadOnly等哨兵错误,这是 OpenBao 2.0.0 GA 之前为支持分页列表而做的扩展(相关演进见 2026-07-01 分页列表博客)。
这套模型中,每个单独操作(Get/Put/Delete/List)是原子的:要么成功、要么报错,绝不留下部分修改。但跨操作没有任何一致性保证——这是问题的核心:
- 两个并行到达同一插件的请求,可能产生彼此冲突的存储操作而互不知晓;
- 一个操作流程中先后写入多个 key,若系统在中间时刻崩溃,存储中就会留下“写了一半”的状态;
- 做备份快照时,快照可能截取在两次写入之间,导致快照本身不一致、无法恢复。
正如 2024-10-16 事务概述博客 中举的例子:PKI 引擎读取默认签发者(default issuer)至少需要两步读取——先读/config/issuers解析default的值,再读/config/issuer/:id取回实际签发者。如果并发请求在这两步之间删除了该签发者,第一步就可能因存储不一致而失败。KVv2 引擎中,一个被取消的删除请求也可能留下损坏条目。
历史遗留:一次性批处理(One-shot)事务接口及其局限
在 fork 起点的存储模型中,虽然只在一小部分后端(Raft、CockroachDB、Consul、FoundationDB、Spanner)实现了基本的批处理应用机制,但这一机制存在结构性缺陷。当时的事务接口形如:
// TxnEntry is an operation that takes atomically as part of // a transactional update. Only supported by Transactional backends. type TxnEntry struct { Operation Operation Entry *Entry } ... // Transactional is an optional interface for backends that // support doing transactional updates of multiple keys. This is // required for some features such as replication. type Transactional interface { // The function to run a transaction Transaction(context.Context, []*TxnEntry) error }(见 fork 起点的 sdk/physical/transactions.go 对应版本。)
从physical.GenericTransactionHandler的实现可以看出,这并不是 check-and-set 语义:
LIST操作被完全忽略;- 任何
GET操作都在写入之前被提前派发执行; - 虽然会创建回滚日志并在发起写入前读取条目,但这些读取结果不会与待写入内容做任何比对。
这意味着如果外部没有(分布式)锁或其他独占所有权语义保护,多个在途事务可能同时写入相同存储条目,产生不可预期的结果。幸运的是,这个机制没有暴露到逻辑层——它被隐藏在 Core 以及所有 auth/secret 插件内部,从而避免了误用。从上游提交记录可以推断,这其实是专有的 Vault Enterprise Performance Replication 模式的内部实现细节,与改善快照一致性并无关系。
关键演进:当前仓库的 sdk/physical/transactions.go 已在文件头部注明“该文件在之前的提交中被完全删除并写入全新内容”,如今它承载的正是下面要讲的交互式事务接口,一次性批处理版本已不复存在。
OpenBao 的交互式事务模型
OpenBao 放弃了一次性批处理模型,转而采用更强大的交互式事务模型——其风格与 Go 标准库database/sql的事务范式一致,允许“代码与存储操作交错执行”,从而在任意多次读写与外部逻辑之间获得整体一致性。
逻辑层接口定义在 sdk/logical/storage_transactions.go:
// Transactional is an optional interface for backends that support // interactive (mixed code & statement) transactions in a similar // style as Go's Database paradigm. This is equivalent to // physical.Transactional, not the earlier, one-shot version of the // interface. type Transactional interface { // This function allows the creation of a new interactive transaction // handle, only supporting read operations. Attempts to perform write // operations (Put(...) or Delete(...)) will err. BeginReadOnlyTx(ctx context.Context) (txn Transaction, err error) // This function allows the creation of a new interactive transaction // handle, supporting read/write transactions. In some cases, the // underlying physical storage backend cannot handle parallel read/write // transactions. BeginTx(ctx context.Context) (txn Transaction, err error) } // Transaction is an interactive transactional interface: backend storage // operations can be performed, and when finished, Commit or Rollback can // be called. When a read-only transaction is created, write calls (Put(...) // and Delete(...)) will err out. type Transaction interface { Storage // Commit a transaction; this is equivalent to Rollback on a read-only // transaction. Either Commit or Rollback must be called to release // resources. Commit(ctx context.Context) error // Rollback a transaction, preventing any changes from being persisted. // Either Commit or Rollback must be called to release resources. Rollback(ctx context.Context) error }这个模型的关键能力:
- 读写分离:
BeginReadOnlyTx创建只读事务,对只读事务调用Put(...)或Delete(...)会立即返回错误;BeginTx创建可读写事务。 - 显式终结:每个事务必须且只能调用一次
Commit或Rollback以释放底层资源(只读事务的Commit等价于Rollback)。 - 完整一致性:调用方可以在事务内执行任意存储操作,并与其他非存储调用交错进行,所有操作看到的是同一时间点的一致性视图;要么全部持久化,要么全部不生效。
sdk/logical/storage_transactions.go中还定义了TransactionalStorage组合接口(Storage + Transactional),用于标注同时实现了两种能力的后端。物理层对应版本见 sdk/physical/transactions.go,其中额外定义了三个哨兵错误:ErrTransactionReadOnly、ErrTransactionCommitFailure、ErrTransactionAlreadyCommitted,用于保证错误语义一致。
这套接口目前在两个受支持的后端中实现:Raft 与 PostgreSQL。
后端实现(一):Raft —— bbolt 只读事务 + WAL 验证哈希
Raft 后端是 OpenBao 最常用的内部存储引擎。它在 internal/physical/raft/raft.go 中显式声明实现了physical.Transactional接口:
var ( _ physical.Backend = (*RaftBackend)(nil) _ physical.Transactional = (*RaftBackend)(nil) _ physical.HABackend = (*RaftBackend)(nil) ... )其事务入口(raft.go)最终都汇聚到newTransaction:
func (b *RaftBackend) BeginReadOnlyTx(ctx context.Context) (physical.Transaction, error) { return b.newTransaction(ctx, false) } func (b *RaftBackend) BeginTx(ctx context.Context) (physical.Transaction, error) { return b.newTransaction(ctx, true) }newTransaction 的四重准备
在 internal/physical/raft/transaction.go 中,newTransaction依次完成:
- 获取事务许可池(
b.txnPermitPool.Acquire()):限制并发在途事务数量; - 持有 FSM 读锁(
b.fsm.l.RLock()):防止事务进行期间底层键发生变化; - 记录 WAL 索引(
b.AppliedIndex()):快照事务开始前的最后索引,保证事务内看到的一切都被包含在底层事务视图之中; - 打开 bbolt 只读事务(
b.fsm.db.Begin(false)):底层 bbolt 事务一律是只读的,借此获得存储的一致性视图,写入则由 Raft WAL 另行跟踪。
对于可写事务,还会调用b.fsm.fastTxnTracker.trackTransaction(index)登记起始索引,用于后续“快速应用”优化:若事务开始后没有后续 WAL 条目修改其验证过的条目,则可跳过部分验证。同时通过runtime.AddCleanup注册泄漏检测——若事务被创建后既未 Commit 也未 Rollback,垃圾回收时会在日志中打印“transaction was leaked”,并附上起始索引、读写过的 key 列表与调用栈,便于定位泄漏。
写入路径:把整个事务编码为一条复杂 WAL 操作
Raft 本身没有原生事务支持,OpenBao 的设计是:把一次事务的所有读写验证与写入请求,编码为一条结构化的 WAL 日志条目,格式如下:
[ { beginTxnOp } { ... verifyReadOp ... } { ... verifyListOp ... } { ... perform all writes ... } { commitTxnOp } ]- 对事务内的每次
Put/Delete/Get,同时记录读取条目及其值的哈希; - 对每次
List,除记录结果条目外,还会额外记录结果末尾之后的一个条目,确保没有人为遗漏; - 日志在所有节点达成共识后被应用,应用侧(FSM)先验证当前存储状态与事务记录的期望哈希一致,不一致则拒绝执行任何写入,并向请求方返回事务提交失败——而不是在 Raft FSM 层面报错(后者会引发 panic 和领导者重选)。
验证哈希:SHA-384 + 常量时间比较
验证哈希相关实现在 internal/physical/raft/transaction.go:
- 默认哈希算法为SHA-384(
sha512.New384()),注释说明选择它是出于“性能适中且抗长度扩展攻击”的考虑,未来硬件普及 SHA-3 后可考虑切换; - 哈希输入包含带花括号包裹的 key 与 value,防止歧义;
- 比对使用
subtle.ConstantTimeCompare(常量时间比较),规避时序侧信道。
这些设计共同实现了数据库意义上的write-committed 事务语义:冲突时事务整体失败,调用方整体重试即可,不会产生部分写入。
后端实现(二):PostgreSQL —— RepeatableRead 与事务许可池
PostgreSQL 后端在 internal/physical/postgresql/transaction.go 中实现了同一套接口,底层直接映射到数据库原生事务:
func (b *PostgreSQLBackend) BeginTx(ctx context.Context) (physical.Transaction, error) { return b.newTransaction(ctx, false) } func (b *PostgreSQLBackend) BeginReadOnlyTx(ctx context.Context) (physical.Transaction, error) { return b.newTransaction(ctx, true) }newTransaction(transaction.go)的核心动作:
- 从
b.txnPermitPool获取许可,限制并发事务数; - 调用
b.client.BeginTx,设置Isolation: sql.LevelRepeatableRead与ReadOnly标志——可重复读隔离级别保证事务内多次读取看到一致的快照; - 注册
runtime.AddCleanup泄漏检测:事务泄漏时会打印调用栈并自动Rollback。
事务内的Put/Delete会在readOnly时立即返回physical.ErrTransactionReadOnly;对已结束的事务操作则返回ErrTransactionAlreadyCommitted(transaction.go)。Commit(transaction.go)时:
- 若为只读或从未写入,则退化为
Rollback; - 否则先调用
b.validateFence(ctx)(栅栏校验),再提交底层sql.Tx; - 提交失败时包装为
physical.ErrTransactionCommitFailure。
由于 PostgreSQL 内部加锁机制不允许同一线程并发执行两个事务,OpenBao 相应地放宽了部分事务语义测试,以保证 Raft 与 PostgreSQL 两种实现都能通过交叉一致性测试。测试见 internal/physical/crosstest/cross_test.go——它会对所有physical.TransactionalBackend统一执行 BeginTx/BeginReadOnlyTx、提交/回滚、只读写保护等用例(如allDoBeginTx、allDoSameBeginTx等辅助函数)。
快照一致性问题的复现:PKI 引擎的多步写入
博客原文指出:审视 fork 起点builtin/目录下的插件代码,任何多步写入流程都可能受快照一致性问题的困扰——前提是该流程的服务器假设“要么两次写入都成功,要么都不成功”。PKI 引擎正是这样一个典型案例。
当通过<mount>/root/generate/internal创建新的签发者(issuer)时,底层执行的操作如下(相关实现见 internal/builtin/logical/pki/storage.go 与 internal/builtin/logical/pki/path_root.go):
| 序号 | 存储路径 | 用途 |
|---|---|---|
| 1 | config/key/<id> | 存储新根 CA 的私钥 |
| 2 | config/keys | 存储新的默认密钥配置 |
| 3 | config/issuer/<id> | 导入新根 CA 的证书 |
| 4 | config/issuers | 存储新的默认签发者配置 |
| 5 | crls/<id> | 存储初始空 CRL |
其中第 3 步与第 4 步之间的一致性至关重要:如果未能把新签发者的标识符写入默认签发者配置,就会破坏与旧版 Vault(以及众多不了解 multi-issuer 特性的第三方应用)的 API 兼容性。此时 API 会持续返回如下错误,直到操作者手工创建签发者与default的关联:
err=Error making API request. URL: GET http://localhost:8200/v1/058a0c0f-2dc3-a4a3-2e22-b00811700bac/issuer/default Code: 500. Errors: * 1 error occurred: * no default issuer currently configured resp=<nil>类似的问题在轮换签发者时同样可能出现:私钥已经持久化,但签名证书却在备份中丢失——备份快照被截取在两步写入之间,导致快照自身不一致、无法恢复。这正是博客标题所说的“在 Vault 上无法恢复且不一致的快照”。
从当前源码看,PKI 的写入路径将importKey与importIssuer组合进writeCaBundle(internal/builtin/logical/pki/storage.go),再被 path_root.go 调用——importKey在 storage.go 第 370 行、importIssuer在第 822 行。若这些组合操作不放在同一事务中,任何中间失败都会在存储中留下“有 key 无证书”或“有证书无默认配置”的残缺状态。
为什么 check-and-set 语义不适用:组合性才是关键
有人可能会问:为什么不给 PKI 的importKey/importIssuer套用 check-and-set(比较后写入)语义?博客给出的答案是:组合性(composability)问题。
writeCaBundle需要把importKey与importIssuer组合成一个整体并保持事务性质。用 check-and-set 实现这种跨条目的组合非常困难——你需要为每个参与条目手工构造前置条件断言,且一旦断言与并发修改冲突,整个操作就得重来。而 OpenBao 的交互式事务模型天然支持这种组合:在一个事务句柄内依次执行多次读、多次写,最后一次 Commit 统一生效。
这也是 OpenBao 选择更强的交互式事务模型而非 check-and-set 的根本原因——即便这意味着可实现的存储后端范围受到限制,因为并非每个潜在存储引擎(例如 S3)都支持交互式事务。
实践指引:插件如何接入事务
对于插件开发者,事务能力是可选能力,可通过运行时类型断言检测:
// 从传入的 logical.Storage 断言出事务能力 if txStorage, ok := storage.(logical.Transactional); ok { txn, err := txStorage.BeginTx(ctx) if err != nil { /* ... */ } defer txn.Rollback(ctx) // ... 交错执行 Get / Put / Delete 与业务逻辑 ... if err := txn.Commit(ctx); err != nil { // 处理 physical.ErrTransactionCommitFailure,整体重试 } }值得注意的几点实践约束:
- 只读优先:对只需一致性读取的场景,使用
BeginReadOnlyTx,既避免误写,也降低后端负担。例如 sdk/logical/storage.go 的ScanViewPaginated就会在视图实现Transactional时自动开启只读事务进行扫描,扫描完成后Rollback释放。 - 必须显式终结:Commit 或 Rollback 二选一,否则会触发后端的泄漏检测(Raft 与 PostgreSQL 均有
runtime.AddCleanup日志)。 - 提交失败可重试:事务提交可能因冲突或后端状态变化而失败(
ErrTransactionCommitFailure),客户端与插件都应把“事务提交失败”作为一类可重试错误处理。 - 错误语义一致性:只读事务写操作报
ErrTransactionReadOnly,已终结事务再操作报ErrTransactionAlreadyCommitted。
更完整的设计背景(需求调研、与 etcd / rqlite 事务模型的对比、Raft 只读/可写事务的分别实现、PostgreSQL 注意事项、全局更新与后续工作)可阅读 RFC:website/content/community/rfcs/transactions.mdx;事务实现的深入技术细节见 2024-10-27 事务实现细节博客。
结语
从“四个基本操作 + 一次性批处理”到“交互式事务”,OpenBao 的存储模型完成了一次根本性升级:Raft 后端通过“bbolt 只读事务 + WAL 验证哈希”实现了数据库级 write-committed 语义,PostgreSQL 后端则直接映射RepeatableRead原生事务。对 PKI 引擎这类多步写入场景,事务让“要么全部成功、要么全部失败”成为可依赖的保证,从根本上消除了备份快照不一致、无法恢复的隐患,也为插件与 Core 提供了更安全、更耐久的存储语义基础。
- 后端
- 认证鉴权
- 密钥管理
- 密码学
【免费下载链接】openbao
OpenBao is a software solution to manage, store, and distribute sensitive data including secrets, certificates, and keys.
相关推荐
OpenBao 在 Raft 存储后端中实现事务(Transactions)的技术详解
OpenBao 在 Raft 存储后端中实现事务(Transactions)的技术详解 导读 本文深入剖析 OpenBao 集成存储(Integrated St
后端认证鉴权密钥管理密码学终极指南:Apache RocketMQ事务消息如何利用RocksDB保障分布式事务一致性
终极指南:Apache RocketMQ事务消息如何利用RocksDB保障分布式事务一致性 Apache RocketMQ是一个高性能、可靠的分布式消息中间件,
消息队列后端微服务流处理云存储事务难题终结者:Cloudreve分布式一致性方案深度解析
云存储事务难题终结者:Cloudreve分布式一致性方案深度解析 你是否曾遭遇过文件上传一半数据库崩溃?多节点同步时数据出现诡异偏差?作为一款支持多家云存储的分
后端对象存储
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考