缓存是万恶之源( 二 )


读操作实现的修正 , 以防突发流量和陈旧值保护 。
这种方法能使缓存有效地保护记录系统以免遭突发流量的冲击 。 在缓存未命中的情况下 , 只有一个幸运的请求能够成功添加租约并与真实源交互 , 而其他请求将被降级为轮询租约 , 直到幸运的请求将其计算出来的值填充完缓存为止 。
这种机制还可以保护我们免受上述竞争状况的影响 。 当记录系统发生突变 , 并且读操作从真实源中获取数据并将其放入缓存的过程中产生了缓存无效 , 就会发生缓存中毒 。 该模型可以防止读操作毒害缓存 , 因为如果写操作更改了缓存下面的记录 , 则其原子检查和设置就会失败 。
缓存是万恶之源文章插图
尽管它暂时感到困惑 , 但不幸的是 , 缓存“恶魔”确实有更多的锦囊妙计 。 考虑这样一个用例:数据始终被频繁地读取 , 但是部分数据也会被频繁地周期性写入:
缓存是万恶之源文章插图
读操作 1 徒劳地等待读操作 3 和读操作 2 填充感兴趣的缓存键时 , 会经历一段荒谬的延迟 。
在冗长的写操作过程中 , 可能会出现一种病态的情况 , 读操作最终会轮流获得租约 , 并查询记录系统 , 结果却被一个写操作清除了 。 实际上 , 这会序列化来自真实源的读取 , 但不会消除重复数据 , 最终会导致过高的读延迟和超时 , 因为读需要等待轮流从真实源中获取所需的值 。
我们在 Box 的分布式关系数据服务中遇到了这个问题 , 并想出了一些与之相关的解决方案 。 我们最终采用的方法是基于这样一种观点:任何正在等待租约的读操作都可以安全地使用持有租约的读操作检索到的值 , 即使租约最终被写操作清除 , 并且atomic_check_and_set失败 。 实际上 , 如果某个读操作遇到了另一个读操作的租约 , 则该读操作必须在写操作清除缓存值之前到达 , 因此在确认写操作之前 , 这两个读操作都可以返回由租约持有者检索到的值 , 而不会牺牲读写(read-after-write)一致性 。 为了利用这一观点 , 除了执行atomic_check_and_set尝试将根据真实源计算出来的值存储到缓存中之外 , 获得租约的读操作还需将该值存储在缓存的其他位置 , 从而可以被等待租约的读操作发现 。
使用流程图来说明该算法会变得复杂且难以阅读 , 但下面是执行该算法的代码片段 。 该代码片段是用 Java 过程编写的 , 旨在使高级方法更加清晰明了 , 而没有考虑错误处理、编译时的安全性、可维护性等 。
public class LookasideCache {private CacheCluster cacheCluster;private LeaseUtils leaseUtils;/*** 使用 lookaside 缓存读取某个值** @param key 要返回谁的值* @param sourceOfTruthComputation 如果有必要的话 ,*用于从真实源中获取值的函数 。*计算被认为是昂贵的*并且会加大真实数据存储源的负载 。** @return a byte[] 表示与查找 key 相关联的值 。*/public byte[] read(byte[] key, Function sourceOfTruthComputation) {return read(key, sourceOfTruthComputation, Collections.EMPTY_SET);}/*** 用于上述 read 方法的私用辅助方法 , 允许我们在堆栈上携带之前看到的租约集 **/private byte[] read(byte[] key,Function sourceOfTruthComputation,Set previouslySeenLeases) {// 首先查找所提供的 key 以及以前看到的所有租约 。List cacheKeysToLookUp = new ArrayList<>();cacheKeysToLookUp.add(key);for (Lease previouslySeenLease : previouslySeenLeases) {cacheKeysToLookUp.add(previouslySeenLease.getNonce());}List valuesFromCacheServer = cacheCluster.get(cacheKeysToLookUp);byte[] valueForKey = valuesFromCacheServer.remove(0);// 检查该值是否隐藏在我们前面看到的某个租约的后面 。for (byte[] valueForPreviouslySeenLease : valuesFromCacheServer) {if (valueForPreviouslySeenLease != null) {return valueForPreviouslySeenLease;}}if (valueForKey == null) {// 我们试着获取该 key 的租约 。Lease newLease = leaseUtils.createNew();boolean leaseAddSucceeded = cacheCluster.atomicAdd(key, newLease.getNonce());if (leaseAddSucceeded) {// 我们设法获取该 key 的租约 , 这意味着需要我们去寻找真实源 ,// 然后用真实源里的值填充缓存 。byte[] valueFromSourceOfTruth = sourceOfTruthComputation.apply(key);boolean checkAndSetSuccceeded = cacheCluster.atomicCheckAndSet(key,newLease.getNonce(),valueFromSourceOfTruth);// 让我们使用租约的 nonce 作为 key ,并将我们计算的值与之关联 。cacheCluster.set(newLease.getNonce(), valueFromSourceOfTruth);return valueFromSourceOfTruth;} else {// 另一个请求比我们先获得了租约 。 让我们重试 。return read(key, sourceOfTruthComputation, previouslySeenLeases);}} else if (leaseUtils.isCacheValueLease(valueForKey)) {// 另一个缓存请求持有该 key 的租约 ,// 让我们给它一些时间来填满混存 , 然后再重试 。sleep(100);previouslySeenLeases.add(leaseUtils.fromCacheValue(valueForKey));return read(key, sourceOfTruthComputation, previouslySeenLeases);} else {// 从缓存服务中获取值 , 然后返回 。return valueForKey;}}private void sleep(int millis) {try {Thread.sleep(millis);} catch (InterruptedException e) {System.err.println("Sleep interrupted.");e.printStackTrace();}}}interface CacheCluster {/*** 从缓存集群获取键的列表 。* @param keys 要从缓存集群中获取键* @return byte[] 列表表示与提供的键相关联的值*返回的列表将与提供的键列表并行 ,* 换句话说 , 与某个键关联的值在返回列表中的位置与键在键列表中的位置相同 。* 返回列表中的空值表示缓存未命中 。* 虽然这不是一个好的设计 API 的方法* 但对于这个算法的高层演示 , 它能很好的运行 。*/List get(List keys);/*** 设置值与缓存集群中所提供的键的关联关系*/void set(byte[] key, byte[] value);/*** 当且仅当键尚未被设置时 , 为该键设置所提供的值 。* 否则 , 操作失败 。* @return 如果操作成功且设置了值 , 则返回 true , 否则返回 false 。*/boolean atomicAdd(byte[] key, byte[] value);/*** 当且仅当键刚好与 expectedValue 相关关联时 , 为所提供的键设置 valueToSet 。* @return 如果操作成功且设置了值 , 则返回 true , 否则返回 false 。*/boolean atomicCheckAndSet(byte[] key, byte[] expectedValue, byte[] valueToSet);}interface Lease {byte[] getNonce();}interface LeaseUtils {Lease createNew();Lease fromCacheValue(byte[] nonce);boolean isCacheValueLease(byte[] value);}