Garnet 事务(Transactions)完全指南:从 RESP 命令到 MULTI/EXEC/WATCH 源码级原理
【免费下载链接】garnetGarnet is a remote cache-store from Microsoft Research that offers strong performance (throughput and latency), scalability, storage, recovery, cluster sharding, key migration, and replication features. Garnet can work with existing Redis clients.项目地址: https://gitcode.com/GitHub_Trending/garnet4/garnet
本文是 Garnet(Microsoft Research 开源的远程缓存存储)事务机制的技术指南,覆盖两类事务:客户端发起的事务(Redis 风格 MULTI/EXEC/WATCH)与服务端自定义事务。文章以
website/docs/commands/transactions.md与website/docs/dev/transactions.md为骨架,结合libs/server/Transaction/下源码与TxnPerfBench基准深入剖析状态机、2PL 锁、WATCH 乐观锁、版本映射(WatchVersionMap)、AOF 恢复与检查点一致性,助你从“会调命令”进阶到“懂原理”。
1. 概述:Garnet 的两类事务
Garnet 支持两种事务(见 website/docs/dev/transactions.md):
- 客户端发起的事务(Redis 风格):通过
MULTI/EXEC/DISCARD/WATCH/UNWATCH命令在客户端与服务器之间建立一个原子执行区间。 - 服务端自定义事务(Custom Server-side Transactions):在服务器侧注册一个新的自定义事务,随后任何 Garnet 客户端都可以调用它执行。其开发方法见 Extendsions 的 transactions 文档。
本文以客户端事务为主线:其命令参考见 website/docs/commands/transactions.md,实现原理见 website/docs/dev/transactions.md。
1.1 客户端事务的命令总览
| 命令 | 语法 | 作用 | RESP 返回 |
|---|---|---|---|
MULTI | MULTI | 标记事务块开始,后续命令进入排队状态 | Simple string:OK |
EXEC | EXEC | 原子执行所有排队的命令,并恢复连接为正常状态 | Array reply(每个元素是每条命令的结果),或 Nil reply(因 WATCH 的 key 被修改而中止) |
DISCARD | DISCARD | 清空所有排队命令,恢复连接为正常状态 | Simple string:OK |
WATCH | WATCH key [key ...] | 监视指定 key,用于条件执行事务 | Simple string:OK |
UNWATCH | UNWATCH | 清空之前所有被监视的 key | Simple string:OK |
事务命令参考的权威描述位于 website/docs/commands/transactions.md,其中所有命令在命令文档页面都有对应条目(在 website/docs/commands 目录下)。
2. 客户端事务的用法与语义
2.1 基本流程:MULTI → 命令排队 → EXEC
MULTI SET key1 value1 SET key2 value2 EXECMULTI之后,连接进入“事务排队”状态,其后的命令不会被立即执行,而是被排队;EXEC触发原子执行——要么全部执行,要么全部不执行;- 执行完成后连接恢复为正常状态,
EXEC返回一个数组,每个元素对应该事务中一条命令的返回值。
2.2 乐观锁与条件事务:WATCH / UNWATCH
Garnet 的 WATCH 用于实现**乐观锁(optimistic locking)与CAS(check-and-set)**语义:
- 客户端在
MULTI之前用WATCH监视一个或多个 key; - 在
MULTI/EXEC区间外读取这些 key 并计算结果; EXEC时,若任何一个被监视的 key 在此期间被修改,事务整体中止(返回 Nil reply);否则正常提交。
官方文档给出的经典 CAS 示例(见 website/docs/dev/transactions.md):
WATCH mykey val = GET mykey val = val + 1 # 非 Redis 命令,在客户端侧完成 MULTI SET mykey $val EXEC在上面的示例中,如果在
EXEC之前mykey发生了变化,事务将中止,因为基于旧值计算的val已经失效。
该模型的特点是:不允许在 MULTI/EXEC 区间内使用读操作的结果,但允许在区间外先读再监视 key;若执行时 key 未变化则提交(website/docs/dev/transactions.md)。
2.3 取消与清理
DISCARD:清空此前排队的全部命令并恢复连接正常状态;UNWATCH:清空全部被监视的 key;- 注意:在
EXEC、DISCARD、UNWATCH执行之后,Garnet 都会清空被监视的 key(见 website/docs/dev/transactions.md 的 Unwatch 一节)。
3. 事务后端:核心类与状态机
从源码结构看(libs/server/Transaction 目录),客户端事务由以下类协作实现:
| 类 | 文件 | 职责 |
|---|---|---|
TransactionManager | libs/server/Transaction/TransactionManager.cs | 事务总控:状态管理、命令排队、加锁、执行、提交 |
WatchVersionMap | libs/server/Transaction/WatchVersionMap.cs | 记录被监视 key 的版本号,用于 WATCH 校验 |
WatchedKeysContainer | libs/server/Transaction/TxnWatchedKeysContainer.cs | 每会话保存被监视 key 及其版本快照 |
TxnKeyEntries | libs/server/Transaction/TxnKeyEntry.cs | 事务需要加锁的 key 集合(key hash + 锁类型) |
TxnState | libs/server/Transaction/TxnState.cs | 事务状态枚举 |
RespCommandsInfo | libs/server/Resp 相关文件 | 命令元数据(arity 等),用于跳过命令、检测语法错误 |
3.1 TxnState:事务状态机
libs/server/Transaction/TxnState.cs 定义四个状态:
- None:无事务在进行;
- Started:
MULTI之后进入,事务管理器会排队此状态下除EXEC外的任何命令; - Running:
EXEC之后进入,事务管理器在此状态下真正运行排队的命令; - Aborted:出现异常情况(如嵌套
MULTI)时进入。
状态迁移在 libs/server/Resp/RespServerSession.cs 的NetworkMULTI/NetworkEXEC中触发:NetworkMULTI将状态置为Started并记录txnStartHead与operationCntTxn;NetworkEXEC在Started时调用txnManager.Run()启动执行,在Running时调用txnManager.Commit()提交。
3.2 TransactionManager 的职责
TransactionManager.cs 是事务引擎的核心,职责如下。
3.2.1 存储事务状态
state(TxnState枚举)跟踪当前状态;txnStartHead记录MULTI之后网络缓冲区中第一条命令的位置;operationCntTxn记录排队命令条数;PerformWrites标记事务是否包含写操作(决定是否需要写 AOF);storeTypes(TransactionStoreTypes,Main/Object/Unified标志位)记录事务涉及哪些存储(String 主存储、对象存储、统一存储)。
3.2.2 排队命令:TrySkip 与 2PL 键锁定
Started状态下,TransactionManager会:(1) 排队后续命令;(2) 保存这些命令中用到的 key,以便在执行时按2PL(两阶段锁)加锁。
排队的实现很有特色——命令不复制到独立缓冲区,而是“留在网络缓冲区里”:通过RespServerSession的TrySkip函数跳过命令,同时保存 key 在网络缓冲区中的真实内存位置的指针,封装为TxnKeyEntry数组(包含PinnedSpanByte与锁类型 Shared/Exclusive)。
TrySkip依赖RespCommandsInfo中的Arity(参数个数)来跳过正确数量的 token 并检测语法错误:
- 例如
GET的 arity 为 2(命令 token 加一个 key); - 对于可接受可变参数的命令,以负值记录最少参数个数,例如
SET的 arity 为 -3,表示至少需要 3 个参数(含命令 token)。
在TrySkip过程中调用TxnKeyManager.LockKeys——它是key-spec 驱动的:读取命令的SimpleRespCommandInfokey 规格,为参数中的每个 key 保存一个TxnKeyEntry。
3.2.3 执行:Run()
当状态为Started且遇到EXEC时,调用TransactionManager.Run()(libs/server/Transaction/TransactionManager.cs 的Run方法),其步骤为:
- 根据存储类型获取对应的TransactionalContext(
BeginTransaction); - 遍历
TxnKeyEntries,锁定所有需要的 key(LockAllKeys/TryLockAllKeys,锁前先对 key hash 排序以保证稳定哈希表与确定性加锁顺序); - 调用
WatchedKeyContainer.ValidateWatchVersion()校验被监视 key 的版本是否与 watch 时一致;- 通过则继续执行;失败则调用
TransactionManager.Reset(true)重置(true表示需要解锁),事务中止;
- 通过则继续执行;失败则调用
- 若事务包含写操作且 AOF 开启,写入TxnStart 标记到 AOF,以保证中途失败时可原子恢复(
EnqueueTxn(AofEntryType.TxnStart, ...))。
随后state置为Running,网络readHead指向MULTI后的第一条命令,开始真正执行这些命令。
3.2.4 提交:Commit()
执行再次遇到EXEC且状态为Running时,调用TransactionManager.Commit()(TransactionManager.cs 的Commit方法):
- 解锁
Run中锁定的所有 key(UnlockAllKeys); - 重置
TransactionManager与WatchedKeysContainer; - 若事务有写操作且 AOF 开启,向 AOF 追加TxnCommit 消息(
EnqueueTxn(AofEntryType.TxnCommit, ...))。
提交时的Reset(true)还会通过stateMachineDriver.EndTransaction(txnVersion)释放事务版本,并结束各存储的 TransactionalContext(TransactionManager.cs)。
4. WATCH 乐观锁:VersionMap 与 Modified Bit
4.1 WatchVersionMap:版本映射
libs/server/Transaction/WatchVersionMap.cs 是一个每服务器实例一份的版本映射,用于监控 key 的修改:每次被监视的 key 被修改,就将其版本号 +1。
- 实现上是一个固定大小的
long[]哈希表(map),大小必须是 2 的幂(构造时Debug.Assert(Utility.IsPowerOfTwo(size))); ReadVersion(long keyHash):watch 之前调用,读取某 key 的版本;IncrementVersion(long keyHash):修改被监视 key 时调用,使用Interlocked.Increment保证并发安全。
4.2 Modified Bit:Tsavorite 记录级修改标记
Modified bit 用于在 Tsavorite 中追踪记录的修改状态:记录被修改时其 modified bit 置为 1,并保持为 1,直到有人调用ResetModifiedAPI 将其重置为 0。
为支持 WATCH,Garnet 新增了ClientSession.ResetModified(ref Key key)API:接收一个TKey(Garnet 传入FixedSpanByteKey),将RecordInfo字 CAS 到同一字但modified bit 被重置的版本。
4.3 Watch 流程
- 客户端
WATCH key时,Garnet 调用ResetModifiedAPI,并把 key 存入WatchedKeysContainer; - 同时从版本映射读取该记录的版本号,与 key 一起保存;
- 事务执行时,遍历
WatchedKeysContainer中所有 key,若版本仍与 watch 时相同则继续,否则中止。
在 TxnWatchedKeysContainer.cs 的AddWatch中可以看到:key 字节被复制到独立的 scratch buffer(txnScratchBufferAllocator.CreateArgSlice),随后计算hash并记录version = versionMap.ReadVersion(hash);ValidateWatchVersion()则逐个比对versionMap.ReadVersion(key.hash) != key.version,任一不等即返回 false。
4.4 版本递增的代价控制
为避免给正常操作的关键路径带来开销,版本递增只在部分情况执行(website/docs/dev/transactions.md):
- 内存中的记录:只为被监视的 key递增版本——Garnet 中被 watch 的 key 使用 Tsavorite 的 Modified bit 来追踪修改(见上节);
- 磁盘上的记录:为copy-update的 RMW 与 Upsert 递增版本。这是有意接受的代价,因为 copy update 相对较少,开销不关键。
版本递增发生在MainSessionFunctions与ObjectSessionFunctions的以下回调中:InPlaceUpdater(若被监视)、InPlaceWriter(若被监视)、InPlaceDeleter(若被监视)、PostInitialWriter、PostInitialUpdater、PostCopyUpdater、PostInitialDeleter。
4.5 Unwatch 流程
- 记录在 Tsavorite 中被修改时,modified bit 自动置位;
- 用户调用
UNWATCH时,Garnet 只需重置WatchedKeysContainer; - 每次执行完
DISCARD、EXEC、UNWATCH命令后都会清空所有监视。
5. 加锁与存储:2PL、锁类型与统一存储
5.1 TxnKeyEntry:锁集合
libs/server/Transaction/TxnKeyEntry.cs 中TxnKeyEntry是一个 9 字节结构体([StructLayout(LayoutKind.Explicit, Size = 9)]):8 字节keyHash+ 1 字节LockType(Shared/Exclusive/None),实现ITransactionalKey。
TxnKeyEntries提供:
AddKey(PinnedSpanByte keyArgSlice, LockType type):追加待加锁 key,容量不足时倍增扩容;LockAllKeys()/TryLockAllKeys(TimeSpan lock_timeout):加锁前先对 key hash 排序(注释说明必须在稳定的 Tsavorite 哈希表——非 GROW 阶段——下排序,排序本身也依赖此稳定性),然后对统一存储上下文一次性加锁;UnlockAllKeys():释放统一存储上的锁并清空集合;IsReadOnly:若所有锁都是 Shared 则为只读事务。
5.2 事务涉及的存储类型
TransactionStoreTypes是一个[Flags]枚举(TransactionManager.cs):
Main(1):String 主存储;Object(2):对象存储(Hash/List/Set/SortedSet 等对象类型);Unified(4):统一存储。
AddTransactionStoreType将StoreType映射为事务存储类型;BeginTransaction/LocksAcquired/EndTransaction会对storeTypes中涉及的所有存储上下文执行对应操作。由此可以看出,Garnet 的事务可以横跨字符串与对象类型,并统一通过 unified store 的 transactional context 加锁。
5.3 WATCH 键在 EXEC 时的加锁
在Run()的第一步(非内部事务时)会调用watchContainer.SaveKeysToLock(this):将WatchedKeysContainer中所有仍处于 watched 状态的 key 以Shared 锁注册进TxnKeyEntries(TxnWatchedKeysContainer.cs),与事务命令自身的 key 一起参与排序与加锁。
6. 集群模式下的事务:slot 验证
当集群启用时(clusterEnabled == true),TransactionManager会为事务收集所有 key 以进行slot 验证:
GetSlotVerificationInput会先把排队命令的 key 复制到 scratch buffer(CopyExistingKeysToScratchBuffer),再调用watchContainer.SaveKeysToKeyList把 watched key 一并收集,构造ClusterSlotVerificationInput(只读标志 + 会话信息,不指定 key spec——验证时会遍历该上下文中的全部 key);- 在 RespServerSession.cs 的
NetworkEXEC中可以看到:执行事务前,若txnManager.txnKeysParseState.Count > 0,会调用clusterSession.NetworkMultiKeySlotVerify(..., isTxn: true)做多 key slot 一致性检查,失败则记录日志、重置事务并跳过执行。
这保证了集群下事务涉及的所有 key 落在正确的 slot/节点上,避免跨节点事务的非法访问。
7. AOF 恢复与检查点一致性优化
7.1 事务的 AOF 持久化
在Run()中,若PerformWrites && appendOnlyFile != null && !StoredProcMode,会写入AofEntryType.TxnStart;在Commit()中写入AofEntryType.TxnCommit(TransactionManager.cs、#L512-L517)。这样 AOF 重放时可以成对识别 TxnStart/TxnCommit 边界,原子恢复事务——即使事务执行中途失败,也能准确回滚到事务开始前的一致性状态。
对于多日志(MultiLog)配置,ComputeSublogAccessVector会从已加锁的 key hash(等于GarnetLog.HASH,无需重算)计算出 physical/virtual sublog 访问位图与参与重放的 replay task 数量,随 TxnStart/TxnCommit 头写入,供并行重放协调使用。
7.2 检查点一致性
Garnet 定期做检查点,并在检查点之间推进版本号。为保证检查点一致性,要求一个事务的所有操作处于同一版本 / 同一检查点窗口内。当前强制执行方式(website/docs/dev/transactions.md):
- 当 TsavoriteStateMachine 处于Prepare 阶段时,不允许新事务启动执行,让检查点先完成;
- 若已有事务正在运行而 TsavoriteStateMachine 进入 Prepare,则不允许版本切换,直到事务执行完毕;
- 二者通过
session.IsInPreparePhase以及Run函数开头处的两个 while 循环实现。
在源码中对应:Run()里txnVersion = stateMachineDriver.AcquireTransactionVersion()获取事务版本,执行前VerifyTransactionVersion验证版本有效,提交时stateMachineDriver.EndTransaction(txnVersion)归还版本。
8. 自定义服务端事务(Custom Server-side Transactions)
除了客户端事务,Garnet 还支持在服务器侧注册自定义事务(CustomTransactionProcedure)。其完整开发指南位于 website/docs/extensions/transactions.md。这里给出与本文主题相关的实现要点:
- 自定义事务通过
TransactionManager.RunTransactionProc运行(TransactionManager.cs),流程为Prepare → Run → Main → Log → Commit → Finalize:proc.Prepare(使用GarnetWatchApi,即带 WATCH 语义的只读 API)收集读集并校验;Run(加锁 + WATCH 版本校验)后进入Main,在锁定的数据上执行主体逻辑(TransactionalGarnetApi);- 若有写操作则
Log到 AOF(AofEntryType.StoredProcedure); Commit解锁并写提交标记;最后执行Finalize(AOF 重放期间跳过,因为提交会由 AOF 重放接管);
- 自定义事务同样受
PerformWrites、AOF 与事务版本管理约束; - 支持
FailFastOnKeyLockFailure与KeyLockTimeout(对应TryLockAllKeys(lock_timeout)的可失败快速路径,见 TransactionManager.cs)。
9. 性能验证:TxnPerfBench 微基准
为了验证客户端事务性能,仓库提供了TxnPerfBench(位于 benchmark/Resp.benchmark/TxnPerfBench.cs),包含四种负载:
- READ_TXN:一个事务内执行
readPerTxn个GET; - WRITE_TXN:一个事务内执行
writePerTxn个SET; - READ_WRITE_TXN:
SET与GET混合(readPerTxn+writePerTxn); - WATCH_TXN:先 watch
readPerTxn个 key,再开事务读取这些被监视的 key 并写入writePerTxn个 key。
示例运行命令(见 website/docs/dev/transactions.md):
# 纯 WATCH 事务负载 dotnet run -c Release -f net10.0 -- -t 2 -b 1 --dbsize 1024 -x --client SERedis --op-workload WATCH_TXN --op-percent 100 # 混合 READ_TXN 与 WRITE_TXN,各占 50% dotnet run -c Release -f net10.0 -- -t 2 -b 1 --dbsize 1024 -x --client SERedis --op-workload READ_TXN,WRITE_TXN --op-percent 50,50 # 纯 READ_WRITE_TXN 负载 dotnet run -c Release -f net10.0 -- -t 2 -b 1 --dbsize 1024 -x --client SERedis --op-workload READ_WRITE_TXN --op-percent 100参数说明:
- 运行前会先加载
opts.DbSize条记录作为数据底数; TxnPerfBench(..., int readPerTxn = 4, int writePerTxn = 4)可调节每事务的读写条数;- 当前限制:仅支持 batch size 为 1;仅支持 SE.Redis 客户端(与在线基准共用同一套选项体系)。
WATCH_TXN 负载的典型事务形如(readPerTxn = 2, writePerTxn = 2):
WATCH x1 WATCH x2 MULTI GET x1 GET x2 SET x3 v3 SET x4 v4 EXEC10. 小结与进一步阅读
本文从命令语义出发,一直深入到状态机、2PL 键锁、WatchVersionMap/Modified Bit 乐观锁、统一存储事务上下文、集群 slot 验证、AOF 原子恢复与检查点一致性,再到自定义事务与基准测试,覆盖了 Garnet 客户端事务的完整链路。
想继续深入,可在当前仓库中查阅:
- 事务命令参考:website/docs/commands/transactions.md
- 事务实现文档:website/docs/dev/transactions.md
- 自定义事务开发:website/docs/extensions/transactions.md
- 事务核心源码:libs/server/Transaction/TransactionManager.cs、libs/server/Transaction/TxnKeyEntry.cs、libs/server/Transaction/TxnWatchedKeysContainer.cs、libs/server/Transaction/WatchVersionMap.cs、libs/server/Transaction/TxnState.cs
- 事务命令的会话层处理:libs/server/Resp/RespServerSession.cs
- 事务基准:benchmark/Resp.benchmark/TxnPerfBench.cs
提示:Garnet 兼容现有 Redis 客户端,事务命令(MULTI/EXEC/DISCARD/WATCH/UNWATCH)与 Redis 协议语义对齐,可直接使用 SE.Redis 等客户端库进行开发与验证。
【免费下载链接】garnetGarnet is a remote cache-store from Microsoft Research that offers strong performance (throughput and latency), scalability, storage, recovery, cluster sharding, key migration, and replication features. Garnet can work with existing Redis clients.项目地址: https://gitcode.com/GitHub_Trending/garnet4/garnet
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考