Reddit是如何解决三个臭皮匠的缓存首次更新问题?


本月,Reddit 的一名资深软件工程师分享了一个真实世界的例子,说明微服务如何帮助提高 Reddit 的弹性——一个深思熟虑的案例研究,来自他自己处理搜索请求突然激增的经验。这是一个很好的例子,可以分享您的知识,通过解决您遇到的有趣问题和解决方案来帮助更大社区中的其他人。但这也是一个例子,说明长期存在的问题如何在微服务领域找到新的解决方案。
最重要的是,他用三个臭皮匠的有趣比喻完美地说明了这一切。
三个臭皮匠是一个闹剧喜剧三重奏,他们经常尝试在简单的日常任务上进行协作,但最终总是妨碍对方并伤害对方。一个场景:他们一起试图穿过一个门口。但是因为他们试图同时并肩穿过,他们撞到了对方;最终,没有人能够通过。Reddit 也遇到过通过分布式微服务架构推送请求的类似模式。

 
问题
在 Reddit,我们在从中断中恢复时遇到了一个有趣的规模问题。我们在微服务上游的 API 网关级别有一个响应缓存;和缓存的响应有一个 TTL。现在假设该站点停机的时间比 TTL 长,因此缓存已被刷新清零。
当站点恢复时,我们会收到大量请求 其中许多是重复请求同一个资源,都是在短时间内发生的。在正常操作期间,大多数这些重复请求将从缓存中提供服务。但是当从这样的中断中恢复时,因为没有缓存任何东西,所有重复的请求都会同时命中我们的微服务、底层数据库和搜索引擎。这会导致流量泛滥,以至于在请求超时内没有任何请求成功,因此没也有响应被缓存;并且该站点会立即再次进行修复。
我们将这种情况称为“三个臭皮匠问题”,尽管它更常被称为“雷霆万钧Thundering Herd”、 “狗堆效应”或“缓存踩踏事件”。
 
解决方案
我们对三个臭皮匠问题的解决方案是在微服务级别对请求进行重复数据删除和缓存响应。请求重复数据删除(也称为请求折叠或请求合并)意味着重新排序重复的请求,以便它们一次执行一个。此解决方案有效的原因是,从概念上讲,它对请求重新排序,以便从不重复并发执行,甚至不在不同的后端实例上(分布式锁强制执行此操作)。然后第一个请求被处理并且它的响应被缓存。然后,该请求的所有后续重复项将被串行执行并从缓存中得到满足。这使我们能够更有效地利用我们的缓存,并为我们节省了底层数据库和搜索引擎上重复请求的负载。
将重复数据删除/缓存移至其堆栈中的不同点——特别是其微服务级别——以及“一个可以处理许多并发请求的网络堆栈”。然后工程师只需实现代码,确保不会无意中同时处理两个重复项(使用分布式锁)。剩下的就是检查已经检索到的响应是否存在,如果没有找到缓存的响应,则只创建新的响应。
将重复数据删除视为迫使 三个臭皮匠在通往厨房的门口排成一条有秩序的队伍。然后第一个臭皮匠进了厨房,拿着一碗扁豆汤离开,那碗汤就被收起来了。然后另外两个臭皮匠得到了缓存的碗汤。
 
Reddit 的 API 网关将来自不同平台的所有传入请求整理成标准形式,以便于处理(同时丢弃任何不相关的多余变量)。但是,当它们达到微服务级别时,重复数据删除最终会使用一种称为哈希表的简单编程结构来处理——其中一个值与一个可以在以后检索它的唯一标识符(一个键)配对。这创建了一种发现重复值的简单方法,因为它们已经被分配了一个标识符。
Reddit 提供了一个示例中的代码片段,其中使用 Pottery 的Redis Redlock实现来实现分布式锁。GitHub 存储库中,
重要的是,分布式锁具有自动释放超时以保持活性。想象一个线程获取锁然后在临界区死亡的情况。如果没有自动释放超时,锁将永远不会被释放,从而导致死锁。
解决三个臭皮匠问题的最后一个构建块是缓存响应结果。同样,我们可以实现一个装饰器来包装端点函数来缓存响应,它使用请求哈希作为缓存键。它尝试查找该缓存键,并在命中时返回缓存的响应。如果未命中,它会调用底层端点函数,缓存后续重复请求的响应,然后返回计算出的响应。
 
问答

  • 为什么不在 CDN 或边缘而是在微服务级别对请求和缓存响应进行重复数据删除?

请求来自不同的平台和不同的形式。所有这些请求都由我们的 API 网关整理成标准格式。因此,通过在我们知道它们不相关的层扔掉不相关的变量,我们的更多请求看起来是一样的。这提高了我们识别重复请求的能力并最大化我们的响应缓存命中率。
此外,作为微服务所有者,我们的团队可以更好地控制微服务中请求和响应发生的事情,而配置边缘发生的事情的能力则更弱。这不仅仅是一种所有权权衡;它还允许我们在我们的微服务中进行权限检查、个性化等操作。
最后,通过在微服务级别进行重复数据删除和缓存,我们有更多机会为我们的原始请求流进一步检测、记录和触发事件。
  • 在请求重复数据删除期间,如果您的底层基础架构出现问题并且分布式锁自动超时,会发生什么情况?

我们使用分布式锁只是为了防止重复请求造成负载。我们不会使用锁来强制执行数据一致性、防止竞争条件或出于任何其他原因。因此在最坏的情况下,如果锁超时,一些重复的请求可以立即执行临界区。即使在这种情况下,锁也有助于通过防止所有重复同时执行来减轻我们苦苦挣扎的基础设施的负载。
  • 为什么不在您的微服务中更深入地删除重复的函数调用和缓存函数返回值?

这是一种有效的方法,您可能会考虑在微服务中执行此操作。您可以使用函数的参数来构造锁定/缓存键,并且可以缓存昂贵函数的返回值。由于参数排列较少,在微服务中进行更深层次的重复数据删除和缓存可以提供更高的缓存命中率。
另一方面,在您的微服务中更高层进行重复数据删除和缓存可以节省更多工作。您可能有一个昂贵的 I/O 绑定函数来查询您的数据存储,以及另一个昂贵的 CPU 绑定函数来呈现响应。更高级别的缓存,例如围绕端点函数,将节省对两个昂贵函数的调用。
在此示例中,为简单起见,我们对端点函数进行了重复数据删除和缓存。