跳到主要内容

ETags:何时以及如何使用

·阅读11分钟
Hamdaan Khalid
软件工程师,Azure资源图
Badrish Chandramouli
微软研究院合伙人研究经理

Garnet最近宣布原生支持基于ETag的命令。

缓存存储中的原生ETag能够实现真实的用例,例如保持缓存一致性、减少网络带宽利用率以及避免对多个应用程序进行全面的事务处理。

Garnet为原始字符串(使用GETSET等操作添加和检索的数据)提供原生ETag支持。它不适用于对象(如有序集合、哈希、列表)。此功能无需任何迁移即可使用,允许您现有的键值对立即开始利用ETag。您可以在此处找到ETag API文档。

本文探讨了何时以及如何将此Garnet新功能用于您当前和未来的应用程序。


为什么要阅读本文?

如果你希望

  1. 使你的缓存与后端数据库保持一致
  2. 减少缓存的网络带宽利用率
  3. 基于客户端更新逻辑原子地更新缓存记录

我们将在下面逐一讨论这些场景。


使缓存与后端数据库保持一致

在分布式环境中,在你的主要真实来源数据库前面设置一个缓存是很常见的。通常,多个客户端应用程序同时访问缓存和数据库。如果每个客户端都访问不同的键集,那么缓存可以很容易地保持一致:客户端只需写入数据库,然后更新缓存。

然而,在某个键可以被多个客户端更新的情况下,先更新数据库再更新缓存的简单方法可能会导致微妙的竞态条件。具体来说,最后写入缓存的客户端决定了缓存的最终状态。这个缓存状态可能永远不会与数据库中相同键的最终状态相对应!

为了使上述场景更清晰,考虑一个缓存-数据库设置,其中两个客户端与这对进行交互。它们都遵循相同的协议,即在写入时,它们首先更新数据库,然后更新缓存。我们将每个客户端表示为 c1 和 c2,更新数据库的请求表示为 D,更新缓存的请求表示为 C。

对于数据库的最后写入者也是缓存的最后写入者的序列,一切都很好。

然而,如果数据库的最后写入者不是缓存的最后写入者,我们就会在缓存和数据库之间引入永久性不一致,如下图所示:

在上述序列中,请注意 c2 执行了对数据库的最后写入。然而,c1 执行了对缓存的最后写入。结果,缓存和数据库不同步了。

为了处理这种情况,我们可以依赖 Garnet 中新引入的 ETag 功能来围绕更新构建一个逻辑时钟,以保护缓存一致性(*前提是你的数据库也支持 ETags 或其他形式的服务器端事务)。

在这种情况下,客户端在与缓存交互时应使用我们的 SETIFGREATER API 此处SETIFGREATER 从客户端发送一个键值对以及一个 etag,并且只有当发送的 ETag 大于该键值对当前在缓存中设置的值时才设置值。

现在每个客户端都将遵循以下协议:

  • 数据库在服务器上存储一对(值,etag)
  • 在数据库上使用事务(或 SETIFGREATERSETIFMATCH API)原子地将 (oldValue, etag) 更新为 (newValue, etag+1),并将新的 etag 返回给客户端。
  • 使用我们之前调用中检索到的 ETag 作为 SETIFGREATER 的参数来更新缓存,以便只有当新标签大于缓存中当前存储的标签时才更新缓存。

如果每个客户端都遵循上述协议,我们可以确保只有最后/最新的数据库写入才会反映在缓存中,从而实现最终一致性。与之前相同的事件序列,但客户端遵循我们新的更新协议,如下所示:

减少缓存的网络带宽利用率

每次网络调用都会产生开销:传输的数据量和传输距离。在对性能敏感的场景中,仅当数据在缓存中发生变化时才获取数据是有益的,从而减少带宽使用和网络延迟。

场景:缓存失效

考虑以下设置:

高级示意图

序列图

在没有 ETags 的情况下,每次读取都会返回 k1 的整个有效负载,无论与 k1 关联的值是否已更改。

虽然在传输少量有效负载(例如,高带宽局域网中的 100 字节数据)时这可能无关紧要,但当你在云提供商上拥有多台机器传出更大的有效负载(例如,每台 1MB)时,它就变得非常重要。你需要支付传出费用、带宽使用费用,并由于传输大量数据而导致延迟。

为了解决这个问题,Garnet 提供了 GETIFNOTMATCH API 此处,允许你仅在数据自上次检索以来发生更改时才获取数据。服务器 1 可以在应用程序内存中存储初始有效负载中接收到的 ETag,并使用 GETIFNOTMATCH 仅在值发生更改时才刷新本地副本。

这种方法在数据不经常更改的读密集型系统中特别有用。然而,对于频繁更新的键,使用常规的 GET API 可能仍然更好,因为更新的数据将始终需要传输。

查看 ETag 缓存示例,了解 GETIFNOTMATCH API 的实际用法。


在处理非原子操作时避免昂贵的事务

像 Garnet 这样的缓存存储依赖于服务器上的键级(或桶级)锁来确保多个客户端对键值对进行原子更新。我们通常希望读取远程值,执行一些本地计算来更新值,然后将新值写回服务器。由于网络往返的开销以及客户端随时可能崩溃的可能性,长时间持有服务器端锁是不可能的。ETags 在处理此类用例时提供了事务的替代方案。

场景:对同一值的并发更新

想象一下多个客户端并发修改存储在 Garnet 中的 XML 文档。

例如

  • 客户端 1 读取 XML,更新字段 A,然后将其写回。
  • 客户端 2 读取相同的 XML,更新字段 B,然后并发地将其写回。

在没有 ETags 的情况下,可能会发生以下事件序列:

  1. 客户端 1 读取键 k1 的值 v0
  2. 客户端 1 修改字段 A,创建一个本地副本 v1
  3. 客户端 2 在客户端 1 写入 v1 之前读取相同的值 v0
  4. 客户端 2 修改字段 B,创建另一个本地副本 v2
  5. 客户端 1 或客户端 2 将其版本写回服务器,可能会覆盖另一个的更改,因为 v1v2 都没有对方的更改。

这种竞态条件导致更新丢失。使用 ETags,你可以使用 SETIFMATCH API 此处 来实现比较并交换机制,该机制保证不会丢失任何更新。

  1. 客户端 1 读取键 k1 的值 v0
  2. 客户端 1 修改字段 A,创建一个本地副本 v1
  3. 客户端 2 在客户端 1 写入 v1 之前读取相同的值 v0
  4. 客户端 2 修改字段 B,创建另一个本地副本 v2
  5. 客户端 1 执行 SETIFMATCH 尝试安装其更新,并成功。
  6. 客户端 2 执行 SETIFMATCH 尝试安装其更新,但失败,因为服务器的 ETag 现已更改。
  7. 客户端 2 使用更新后的值重试,并最终成功将其更改应用于该值。

以下代码片段演示了如何实现这一点。

示例代码

static async Task Client(string userKey)
{
Random random = new Random();
using var redis = await ConnectionMultiplexer.ConnectAsync(GarnetConnectionStr);
var db = redis.GetDatabase(0);

// Initially read the latest ETag
var res = await EtagAbstractions.GetWithEtag<ContosoUserInfo>(userKey);
long etag = res.Item1;
ContosoUserInfo userInfo = res.Item2;

while (true)
{
token.ThrowIfCancellationRequested();
(etag, userInfo) = await ETagAbstractions.PerformLockFreeSafeUpdate<ContosoUserInfo>(
db, userKey, etag, userInfo, (ContosoUserInfo info) =>
{
info.TooManyCats = info.NumberOfCats % 5 == 0;
});

await Task.Delay(TimeSpan.FromSeconds(random.Next(0, 15)), token);
}
}

辅助方法

public static async Task<(long, T?)> GetWithEtag<T>(IDatabase db, string key)
{
var executeResult = await db.ExecuteAsync("GETWITHETAG", key);
if (executeResult.IsNull) return (-1, default(T));

RedisResult[] result = (RedisResult[])executeResult!;
long etag = (long)result[0];
T item = JsonSerializer.Deserialize<T>((string)result[1]!)!;
return (etag, item);
}

public static async Task<(long, T)> PerformLockFreeSafeUpdate<T>(IDatabase db, string key, long initialEtag, T initialItem, Action<T> updateAction)
{
// Compare and Swap Updating
long etag = initialEtag;
T item = initialItem;
while (true)
{
// perform custom action, since item is updated to it's correct latest state by the server this action is performed exactly once on
// an item before it is finally updated on the server.
// NOTE: Based on your application's needs you can modify this method to update a pure function that returns a copy of the data and does not use mutations as side effects.
updateAction(item);
var (updatedSuccesful, newEtag, newItem) = await _updateItemIfMatch(db, etag, key, item);
etag = newEtag;
if (!updatedSuccesful)
item = newItem!;
else
break;
}

return (etag, item);
}

private static async Task<(bool updated, long etag, T?)> _updateItemIfMatch<T>(IDatabase db, long etag, string key, T value)
{
string serializedItem = JsonSerializer.Serialize<T>(value);
RedisResult[] res = (RedisResult[])(await db.ExecuteAsync("SETIFMATCH", key, serializedItem, etag))!;
// successful update does not return updated value so we can just return what was passed for value.
if (res[1].IsNull)
return (true, (long)res[0], value);

T deserializedItem = JsonSerializer.Deserialize<T>((string)res[1]!)!;

return (false, (long)res[0], deserializedItem);
}

每次读取-(额外逻辑/修改)-写入调用都从首先使用 GETWITHETAG 此处 读取键的最新 etag 和值开始,然后将其更新逻辑封装在回调操作中,然后调用 ETagAbstractions 中的 PerformLockFreeSafeUpdate 方法安全地应用更新。

在内部,PerformLockFreeSafeUpdate 方法运行一个循环,该循环检索数据,对对象执行更新,并发送 SETIFMATCH 请求,然后服务器仅在你的 ETag 表明你在做出决策时已对最新数据副本执行了更新时才更新值。如果服务器发现你的读取和写入之间有任何更新值,服务器会发送数据的最新副本以及更新后的 etag,然后你的客户端代码在最新副本上重新应用更改并将请求重新发送到服务器进行更新,这种更新形式保证了最终所有更改都会在服务器上一个接一个地同步。

在读密集型系统中,如果同一键上的争用不高,则此更新将在第一个循环中执行,并且比使用自定义事务更容易管理。但是,在重度键争用场景中,这可能导致多次尝试写入最新副本,尤其是当你的读取和写入之间的逻辑很慢时。


ETags 更像是一种较低级别的原语,你可以使用它来构建抽象,从而构建适合你需求的逻辑时钟和无锁事务。如果你发现自己处于上述常见的分布式场景中,那么你的工具包中现在又多了一个工具来帮助你克服扩展需求。