Garnet 存储引擎 Tsavorite 架构深度指南:从 FASTER 分叉而来的分层存储核心
2026/9/15 13:50:02 网站建设 项目流程

Garnet 存储引擎 Tsavorite 架构深度指南:从 FASTER 分叉而来的分层存储核心

【免费下载链接】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

Tsavorite 是 Garnet 的存储层,源自微软开源项目 FASTER,为 Garnet 提供了线程级可扩展性、内存/SSD/云存储分层存储、非阻塞检查点与恢复、操作日志持久化、多键锁与事务,以及改进的内存管理与空间复用能力。本文以官方开发文档website/docs/dev/tsavorite/intro.md为主体骨架,结合仓库源码(libs/storage/Tsavorite/cs/)与同目录系列技术文档,逐层拆解 Tsavorite 的核心设计,帮助读者理解 Garnet 高性能背后的存储原理,并掌握各子系统的配置与工作机制。

Tsavorite 是什么:Garnet 的存储层

Garnet 是一个远程缓存存储(remote cache-store),其底层存储引擎 Tsavorite 从微软此前的开源项目 FASTER 分叉而来。website/docs/dev/tsavorite/intro.md明确指出,Tsavorite 继承并强化了 FASTER 的存储能力,形成以下核心特性集合:

  • 线程可扩展性(thread scalability):通过 LightEpoch 无锁懒同步机制 减少跨线程同步频率,使读写路径在高并发下依然保持扩展性;
  • 分层存储(tiered storage):同一份数据可以驻留在内存(hybrid log)、SSD 与云存储上,由统一抽象管理;
  • 快速非阻塞检查点(fast non-blocking checkpointing):检查点过程中不阻塞主操作路径;
  • 恢复(recovery):从检查点与日志中恢复出完整一致的状态;
  • 操作日志持久化(operation logging for durability):所有写操作落盘,保证崩溃后的持久性;
  • 多键锁与事务支持(multi-key locking and transaction support):详见 锁定机制文档;
  • 改进的内存管理与空间复用(memory management and space reuse):通过 Revivification(记录复活/空间复用) 降低高删除场景下的日志膨胀。

从代码结构看,Tsavorite 位于libs/storage/Tsavorite/cs/目录,包含src/(核心实现)、test/(测试)与benchmark/(基准测试),并有独立的Tsavorite.slnx解决方案文件;在 Garnet 服务端,存储层通过libs/server/Storage/下的StoreWrapperGarnetDatabase等类型向上层暴露 API。Garnet 官方命令文档中将 Tsavorite 定位为“Garnet 存储层”,其核心类型是TsavoriteKV<TKey, TValue, TStoreFunctions, TAllocator>

核心设计思想:混合日志(Hybrid Log)与就地更新

Tsavorite 沿用了 FASTER 的混合日志模型:主存储是一个混合日志(hybrid log,hlog),记录按时间顺序追加在日志尾部(TailAddress),旧记录从日志头部(HeadAddress)逐页回收或刷盘。每个键的多个版本通过RecordInfo.PreviousAddress组成哈希链(hash chain),哈希表桶(HashBucketEntry)指向链头。

这一设计带来两个关键能力:

  1. 就地更新(In-Place Update,IPU)与读-拷贝-更新(Read-Copy-Update,RCU):当记录仍在内存可变区(mutable region)且值能容纳新版本时,直接在原地写入;否则追加新记录并回填PreviousAddress。RCU 过程中源记录会被标记Sealed以避免与其他线程的 IPU 竞争(见 锁定机制文档)。
  2. 日志即数据库:持久化只需将日志尾部追加内容写入磁盘,检查点则捕获索引与日志的关键状态,二者配合实现崩溃恢复。

与之配套的还有可选的读缓存(Read Cache):一个固定大小的内存循环日志(readcacheBase),位于主混合日志之前,缓存从磁盘读出的“热”记录,避免重复磁盘 IO。读缓存记录是主日志数据的冗余副本,因此可以随时丢弃、下次读取时重新提升(详见 读缓存设计文档)。

线程可扩展性:LightEpoch 无锁懒同步

Tsavorite 之所以能支撑高并发,核心在于LightEpoch提供的无锁懒同步(latch-free lazy synchronization)机制,其实现位于 LightEpoch.cs。常规的 Mutex/信号量要求线程频繁互相同步,代价高昂;Epoch 保护机制则降低了跨线程同步的频率

工作模型(10,000 英尺视角)

  • 写路径不需要阻塞当前线程,而是被封装为回调动作(callback action)交给LightEpoch
  • 线程通过“我在当前 epoch 中处于活动状态”来保护一个 epoch(epoch 是一个计数器);操作结束时撤销保护;
  • 当某个会改变共享变量(如HeadAddress)的操作要执行时,通过bump epoch增加计数器,并等待没有其他线程再使用旧值;
  • 变量在 bump 之前设置,因此任何看到新计数器值的线程也必然看到更新后的变量,从而保证“操作到当前HeadAddress是安全的”。

关键实现细节

  • 系统维护一个全局的 LightEpoch 线程表,条目数N = max(128, ProcessorCount * 2)
  • 每个线程加入时在线程表中占一个条目,并将线程本地 epoch 初始化为当前全局 epoch;
  • 每次“acquire”epoch 时,线程会认领一个 epoch 计数器,后来的线程只能认领更新的 epoch;
  • 当所有线程都越过某个安全 epoch(对每个线程 T 满足SafeEpoch <= 线程本地 Epoch <= 全局 Epoch)时,可以执行注册的触发器动作,保证动作恰好执行一次且无并发代码在执行。

常用公共方法

website/docs/dev/tsavorite/epochprotection.md列出了 5 个核心方法及其用途:

方法用途
ThisInstanceProtected判断调用线程当前是否持有 epoch 表条目(是否参与 Epoch 保护)
ProtectAndDrain将当前线程标记为更新后 epoch 的持有者,并排空该时刻之前的待执行动作;Resume内部会调用它,常用于循环中渐进排空
Suspend释放 epoch 所有权;若调用线程是系统中最后一个活动线程,则触发挂起的动作/写操作
Resume让线程查看刷新到“所有线程均认为安全”的最新共享变量状态,是应用挂起动作/写入的时间边界
BumpCurrentEpoch(Action)调度一个写操作/动作,在安全的 temporal boundary 执行;调用过程中可能顺带排空可排空的动作

从源码结构看,LightEpoch相关的线程表管理拆分为LightEpoch.EntryTable.csLightEpoch.TestHooks.cs,epoch 表按 64 字节缓存行对齐(tableAligned),这是针对现代处理器 L1–L3 缓存的优化。Epoch 保护不仅服务于哈希链遍历,还承担了读缓存、日志页回收等场景的内存回收职责(见下文)。

多键锁与事务:按哈希桶加锁的两种模式

Tsavorite 的锁定“始终开启”,其粒度是**哈希索引桶(HashIndex bucket)**而非单个键:键被哈希到桶索引,桶内含 7 个条目的 tag 向量与一个溢出桶指针,桶的 tag 位(15 位共享锁 + 1 位独占锁)即锁状态。因此锁定一个桶可能同时锁定“哈希到该桶的所有键”——这是为了换取极低的加锁开销。

根据会话类型的不同,存在两种自动选择的加锁模式(详见 锁定机制文档):

  • 手动锁定(Manual):由 Garnet 处理层在事务开始时调用TransactionalContext/TransactionalUnsafeContextLock方法,传入有序的键数组,事务结束时调用Unlock。Tsavorite 不再为单个操作加锁。
  • 瞬时锁定(Transient):Tsavorite 为单个键在数据操作(Read / Upsert / RMW / Delete,统称InternalRUMD)期间自动获取与释放锁。

所有锁通过Interlocked.CompareExchange+Thread.Yield()自旋获取,且限制自旋次数以避免死锁——若超时未获得锁则释放已持锁并让操作以RETRY_LATER重试(重试会刷新 epoch,从而让OnPagesClosed等动作得以排空)。

四种 Context

ClientSession上按名称暴露了 4 个*Context(全部为struct以支持内联):

  • BasicContext:等价于ClientSession,提供安全的 epoch 管理(每次调用获取/释放 epoch)与瞬时锁;
  • UnsafeContext : IUnsafeContext:提供瞬时锁,但 epoch 由客户端通过BeginUnsafe()/EndUnsafe()手动管理;
  • TransactionalContext : ITransactionalContext:提供安全 epoch 管理,但要求通过BeginTransactional/EndTransactional手动加锁;
  • TransactionalUnsafeContext : ITransactionalContext, IUnsafeContext:手动 epoch 管理 + 手动加锁的组合。

此外还支持TryLock(尝试获取一组键的锁,全部成功才返回 true,否则释放已获取的锁)与TryPromoteLock(将单个键的锁从读锁提升为独占锁)。

示例:手动锁事务

以下示例浓缩自TransactionalUnsafeContextTests.cslibs/storage/Tsavorite/cs/test/):

var luContext = session.TransactionalUnsafeContext; luContext.BeginUnsafe(); luContext.BeginTransaction(); var keys = new[] { new FixedLengthTransactionalKeyStruct(readKey24, LockType.Shared, luContext), // 源,共享锁 new FixedLengthTransactionalKeyStruct(readKey51, LockType.Shared, luContext), // 源,共享锁 new FixedLengthTransactionalKeyStruct(resultKey, LockType.Exclusive, luContext), // 目标,独占锁 }; // 对键排序以防止死锁 luContext.SortKeyHashes(keys); Assert.IsTrue(luContext.TryLock(keys)); luContext.Read(key24, out var value24); luContext.Read(key51, out var value51); luContext.Upsert(resultKey, value24 + value51); luContext.Unlock(keys); luContext.EndTransaction(); luContext.EndUnsafe();

要点:手动加锁必须按确定顺序加锁、按相反顺序解锁;事务处理层中锁的源码级结构(HashEntryInfoRecordSource<TKey, TValue>OperationStackContext等)均在栈上组织,配合哈希链遍历与 CAS 更新。

空间复用:Revivification 与记录复活

高删除负载会导致日志中出现大量 Tombstone(墓碑记录),造成空间浪费。Tsavorite 的 Revivification 机制复活(revivify)已删除记录以及 RCU 的源记录,最小化日志增长。其完整设计见 Revivification 文档,核心包括两种形式:

  • 链内复活(In-Chain):Tombstone 记录留在哈希链中,后续同一键的 Upsert/RMW 若值长度足够则直接复用该记录;
  • 空闲列表(FreeList):维护一个按 2 的幂分桶(RevivificationBin)的循环缓冲集合,处于哈希链尾部(被哈希表直接指向)的 Tombstone 记录从链中 CAS 摘除并放入空闲列表,供后续分配复用。

(第三种复用路径是CreateNewRecordXxx返回RETRY时的重试复用,它始终开启,与 Revivification 相互独立。)

配置项:RevivificationSettings

配置含义
EnableRevivification是否启用(至少启用链内复活)
FreeRecordBins非空则启用 FreeList,数组元素为RevivificationBin(含RecordSize记录最大尺寸、NumberOfRecords桶容量、BestFitScanLimit最优适配扫描上限)
NumberOfBinsToSearch首选桶无记录时,额外搜索的更高尺寸桶数量
RevivifiableFraction限定可复用记录为TailAddress下方该比例内存内的记录(语义同LogSettings.MutablePercent,不能大于它)
RestoreDeletedRecordsIfBinIsFull桶满时是否将待入桶记录恢复进哈希链(对反复增删同键的应用建议置 true)
UseFreeRecordPoolForCopyToTail显式 CopyToTail(压缩、不可变区读取、磁盘 IO)是否允许从 FreeRecordPool 分配

GarnetServer 命令行参数

GarnetServer.exe对应的命令行开关(源码中对应RevivificationSettings解析逻辑):

  • --reviv:按默认 2 的幂尺寸分桶启用 Revivification;
  • --reviv-bin-record-sizes:按递增顺序指定各桶的记录尺寸,取代默认--reviv,不能与--reviv-in-chain-only同用;
  • --reviv-bin-record-counts:各桶记录数(默认RevivificationBin.DefaultRecordsPerBin;可单值统一,也可多值按桶分别指定);
  • --reviv-fraction:可变区内可复用的日志空间比例;
  • --reviv-search-next-higher-bins:最佳适配桶无法满足时搜索更高尺寸桶的数量;
  • --reviv-bin-best-fit-scan-limit:first-fit 后继续扫描做 best-fit 的记录数(UseFirstFit直接用首个适配地址;BestFitScanAll扫描整桶找最优;其他数值则限长扫描);
  • --reviv-in-chain-only:仅链内复活,不使用 FreeList。

底层实现要点

  • 填充字(FillerWords):记录在RecordDataHeader(RDH)中以 8 位计数器记录值末尾的 8 字节填充字数,值的增长/收缩通过“移动填充与值之间的空间”实现,分配尺寸不变;
  • 日志完整性:定义记录长度的全部字段位于单个 8 字节 RDH 字内,长度变更先构建局部副本再以一次原子字写入发布,保证并发扫描者永远看到一致长度;
  • 回调传达ISessionFunctions的 writer/updater 回调通过RecordSizeInfo获知AllocatedInlineRecordSizeMaxInlineValueSizeIsRevivifiedRecord,从而知晓是否写入复用的记录;
  • FreeRecordPool 设计FreeRecordPool(按尺寸选桶)→FreeRecordBin(按尺寸分段的循环缓冲)→FreeRecord(一个 long:48 位地址 + 16 位尺寸)。分段按 8 字节对齐的尺寸区间划分,使 Take 大概率落在目标尺寸附近,兼顾 first-fit 与 best-fit;CheckEmptyWorker以每秒一次的独立 Task 扫描各桶并置空标志,避免在热路径维护计数器。

分层存储与直接 IO:Sector-Aligned Buffer Pool

Tsavorite 对磁盘执行直接、无缓冲 IO(Linux 的O_DIRECT,Windows 的FILE_FLAG_NO_BUFFERING),操作系统要求 IO 缓冲区按扇区对齐(512 B 或 4 KB)。为此设计了默认的SectorAlignedBufferPool(实现见 BufferPool.OriginReturn.cs,公共缓冲区类型在BufferPool.cs,旧版池在BufferPool.Legacy.cs),其完整设计文档为 Sector-Aligned Buffer Pool。

核心洞察是“origin return(归还原主)”:Garnet 的实际访问模式中,分配缓冲区的线程几乎从不负责释放它(缓冲区通常由随机的 IO 完成线程归还)。若所有线程共享一个队列,将导致严重的缓存行乒乓,吞吐量随核数增加反而崩溃(文档记录的单共享队列方案在 64 线程时从约 35 Mops/s 跌至 1 Mops/s 以下)。因此:

  • 每个(pool, thread, size-class)组合有一个私有ThreadShardBucketBucket维护两条位于不同 64 字节缓存行上的链表——仅属主线程的localHead(无原子、无锁的热路径)与锁自由的 MPSCcrossThreadHead(外来线程归还缓冲区的入口,属主线程一次 CAS 批量认领整条链,规避 ABA);
  • 共享后备池Depot按尺寸类做lock-striping(条带数 =2 × ProcessorCount向上取 2 的幂,下限 8、上限 64),用于线程本地缓存溢出与跨线程再分配(work-stealing);
  • 大尺寸类(> 256 KB)完全绕过线程本地层直接入 Depot,因为大缓冲区的“同线程连续复用”模式不成立;
  • 每个池有字节预算ManagedBudgetBytes,默认 1 GiB,由--buffer-pool-memory-budget配置,置 0 则禁用池化),并拆分为 small/large 两个独立BudgetState(默认四分之一给 small),每个缓冲区在“出生”时获取一次 permit、死亡时释放一次,permit 在各级缓存间流转时不再触碰预算计数器。

用户可在启动参数中使用--use-legacy-buffer-pool选择旧版池、--buffer-pool-memory-budget设置字节预算(详见 Managing memory usage 文档,即仓库中 memory.md)。

读缓存:无锁的哈希链前缀

读缓存将磁盘上的“热”记录保留在内存中以避免磁盘 IO。它是可选的、固定大小的内存循环日志,位于主混合日志之前;记录是已提交数据的冗余副本,可以随时丢弃。其无锁正确性建立在单一设计规则上(详见 读缓存设计文档):

所有结构性 compare-and-swap 只针对哈希表条目(hash-table entry)这一个字。没有任何操作通过修改现有读缓存记录的PreviousAddress来发布新值。

由此产生两个推论:链内指针发布后不可变;哈希条目 CAS 是唯一的线性化点。链结构如下:

|------- read-cache prefix -------| |-------- main log --------| hashEntry -> rcN -> ... -> rc2 -> rc1 -> mM -> ... -> m1 -> 0
  • 读缓存记录通过RecordInfo.kIsReadCacheBitMask(48 位地址的最高位)标记,LogAddress.IsReadCache/AbsoluteAddress负责测试与剥离;
  • 读缓存记录构成链头的连续前缀,且从前缀内向下地址严格递减——循环日志的淘汰(eviction)总是回收最低(最旧)地址,即主日志边界的记录先被回收;
  • 读提升(read promotion):TryCopyToReadCache分配新读缓存记录并 CAS 插入链头,是“保留值”的尽力而为操作;
  • 改值更新(Upsert/RMW/Delete):通过一次哈希条目 CAS 整体分离(detach)读缓存前缀——新主日志记录先以“前缀之下第一个主日志地址”为PreviousAddress,CAS 成功后原子地提交新值并孤立整个前缀,无需逐条失效;“旁观键”的缓存副本一并被丢弃,下次读取时重新提升;
  • 淘汰(eviction):在 epoch 保护下从OnPagesClosedWorker执行,通过把最低幸存记录的PreviousAddressCAS 前移来跳过被淘汰记录,是唯一允许修改既有记录PreviousAddress的位置——因为淘汰是“保留值”的,读者无论看到旧链还是新链都读到同一值 V;
  • 值变更 vs 值保留的分界线:更新是值变更操作,需要“一次 CAS 分离”的单一线性化点;淘汰是值保留操作,链内 CAS 安全无锁。

测试方面,仓库的libs/storage/Tsavorite/cs/test/下覆盖了确定性链/淘汰场景(ReadCacheChainTests.cs)、多线程压力(ReadCacheStressTests.cs)以及堆追踪器平衡性断言(ReadCacheHeapSizeTrackerReturnsToZeroAfterUpdateAndEvict)。

检查点与恢复:非阻塞的持久化基础

检查点(checkpoint)与操作日志共同构成 Tsavorite 的持久化与恢复机制。相关类型位于 CheckpointSettings.cs 与 Checkpoint.cs:

  • 索引检查点(index checkpoint):捕获哈希索引状态。SkipReadCacheBucket在索引检查点期间遍历每个桶页的拷贝,将磁盘上的条目直接指向第一个主日志记录——读缓存地址永不持久化,这依赖“读缓存必须是连续前缀”的不变量;
  • 混合日志检查点(hybrid log checkpoint):捕获日志的尾部状态,配合BeginAddressHeadAddress等地址边界实现恢复。由于 epoch 机制与非阻塞设计,检查点过程不会阻塞常规读写路径(即文档所称 fast non-blocking checkpointing);
  • 恢复(recovery):先加载索引检查点,再重放日志到Recovery逻辑(Recovery.cs)确定的恢复点,使哈希链与日志重新一致。

Garnet 将这一能力暴露为GarnetCheckpointManager(GarnetCheckpointManager.cs),支持定期检查点配置;分布式场景下,集群迁移(Migration)与复制(Replication)也建立在日志与检查点机制之上。

记录模型:LogRecord、分配器与回调架构

Tsavorite 的记录与分配架构通过ISessionFunctions回调与两类分配器向业务层开放,细节见 LogRecord 文档、StoreFunctions 与分配器包装文档 与 ObjectAllocator 文档。

LogRecord:统一的记录抽象

LogRecord结构体将原先散落的ref key/ref value/ref recordInfo参数合并为单一参数,并把 ETag、Expiration 提升为一等属性(不再编码进 Value),还自动管理FillerLength以便记录原地伸缩。要点:

  • 所有键在 Tsavorite 层都是ReadOnlySpan<byte>;Garnet 处理层先用PinnedSpanByte,在 GarnetApi/StorageApi 边界转换为ReadOnlySpan<byte>
  • Tsavorite 只有两个分配器SpanByteAllocator(字符串/内联值)与ObjectAllocator(对象值,也是 Garnet 的“统一分配器”);BlittableAllocator已更名为TsavoriteLogAllocator,仅供TsavoriteLog使用;
  • ObjectAllocatorObjectIdMap:日志记录中只存 4 字节ObjectIdMultiLevelPageArray槽位索引),为 .NET 对象提供 GC 根并管理其生命周期,空闲槽位由SimpleConcurrentStack空闲链表回收;
  • 溢出键/值(Overflow):超过内联上限的大键值对作为byte[]单独分配,记录中存ObjectId,以控制对象页粒度与内存预算;
  • RecordDataHeader(RDH):单字管理可变长度布局——指示字节、Namespace、RecordType、RecordLength、KeyLength、ExtendedNamespace、键、值/溢出/对象 ID、可选字段(ETag → Expiration → ObjectLogPosition)与 Filler;不直接存储 Value 长度,而是由不可变字段推算,以保证扫描时记录长度始终可得;
  • RecordSizeInfo:分配前由IVariableLengthInputGetUpsertFieldInfo/GetRMWInitialFieldInfo/GetRMWModifiedFieldInfo)填充键值尺寸,再由分配器PopulateRecordSizeInfo补齐内联/溢出判定,贯穿ISessionFunctions回调。

DiskLogRecord与迁移/复制

DiskLogRecord是磁盘记录的ISourceLogRecord容器,绑定SectorAlignedMemory缓冲区与值对象回收器;键迁移与无盘复制在发送端将记录序列化为DiskLogRecord,接收端通过接受TSourceLogRecordUpsert重载落库——序列化模拟写盘流程,但目标是一块容纳内联部分及后续离线段的大块网络缓冲(离线段容量受单网络缓冲限制,仓库有“chunked 输出”的待办项)。

StoreFunctions与分配器包装:面向内联的类型参数

为最大化 JIT 内联,TsavoriteKV<TKey, TValue, TStoreFunctions, TAllocator>新增两个类型参数(同样作用于*Context):

  • TStoreFunctions:存储级回调集合(键比较、值对象序列化器工厂、记录回收、检查点完成回调),提供StoreFunctions结构体实现以换取内联——Tsavorite 刻意不提供类形式的StoreFunctionsBase(类类型参数无法内联);
  • TAllocator:分配器包装。SpanByteAllocator/ObjectAllocator现在是包装结构体(实现非泛型IAllocator,含AllocatePage/FreePage/PopulateRecordSizeInfo/GetPageOfAddress等热路径),内部持有XxxAllocatorImpl类实例(继承AllocatorBase,实现泛型IAllocator<TStoreFunctions>)。TsavoriteKV内部同时保留hlog(包装结构体类型)与hlogBaseAllocatorBase),前者用于需内联的调用,后者用于其余调用;
  • TsavoriteKV构造函数简化为 3 个参数:KVSettings<TKey, TValue>TStoreFunctions实例、TAllocator工厂Func<AllocatorSettings, TStoreFunctions>

结语:从文档到源码的阅读路径

本文梳理了 Tsavorite 的六大支柱——混合日志、Epoch 无锁同步、哈希桶锁与事务、Revivification 空间复用、扇区对齐缓冲池与读缓存前缀模型——它们共同支撑起 Garnet 的高吞吐与低延迟。如需继续深入,可按以下路径在仓库中对照阅读:

  • 官方系列文档:Tsavorite 开发文档目录(intro.md为入口,另有locking.mdreviv.mdepochprotection.mdreadcache.mdbuffer-pool.mdlogrecord.mdobject-allocator.mdstorefunctions.md);
  • 核心实现:Tsavorite/cs/src(Epoch 位于core/Epochs/,分配器位于core/Allocator/,检查点位于core/Index/Checkpointing/);
  • 测试与基准:Tsavorite/cs/test 与 Tsavorite/cs/benchmark,其中测试覆盖锁定、读缓存链、Revivification 与记录生命周期等场景;
  • 存储层的 Garnet 侧封装:GarnetCheckpointManager.cs 与 StoreWrapper.cs。

从源码结构看,上述机制的设计目标高度一致:把同步点压缩到单个原子的字写入(CAS)或单个缓存行内,用 epoch 边界替代细粒度锁,以换取多核扩展性——这正是 Tsavorite 作为 Garnet 存储层的核心工程哲学。

【免费下载链接】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),仅供参考

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

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

立即咨询