...或者如何将网站的正常运行时间从 9 点 5 分提高到 5 点 9 分 三个臭皮匠是一部闹剧喜剧三重奏(如果您未满 40 岁,请咨询您的父母)。他们经常尝试在简单的日常任务上进行协作,但最终总是妨碍对方并伤害对方。在一个这样的草图中,他们试图穿过一个门口。但是因为他们试图同时并肩穿过,他们撞到了对方;最终,没有人能够通过。就像迫使 Stooges 通过门口一样,我们也遇到过通过分布式微服务架构推送请求的类似模式。在这篇博文中,我们将讨论 Reddit 搜索基础设施的流量如何让人想起 The Three Stooges 的门口草图,并概述我们修复这些请求模式的方法。我们将逐步介绍我们的方法,我们希望您能使用它来使您自己的微服务边界门户对喧闹的闹剧流量更具弹性。在 Reddit,我们在从中断中恢复时遇到了一个有趣的规模问题。我们在微服务上游的 API 网关级别有一个响应缓存;和缓存的响应有一个 TTL。现在想象一下该站点停机的时间比该 TTL 长,因此缓存已被刷新。当站点恢复时,我们会收到大量请求 (F5F5F5),其中许多是在短时间内发出的重复请求。在正常操作期间,大多数这些重复请求将从缓存中提供服务。但是当从这样的中断中恢复时,没有缓存任何东西,所有重复的请求都会同时命中我们的微服务、底层数据库和搜索引擎。这会导致流量泛滥,以至于在请求超时内没有任何请求成功,因此没有响应被缓存;并且该站点立即再次进行了修复。我们将这种情况称为“三个臭皮匠问题”,尽管它更常被称为“雷霆之群”、“狗堆效应”或“缓存踩踏”。我们对三个臭皮匠问题的解决方案是在微服务级别对请求进行重复数据删除和缓存响应。请求重复数据删除(也称为请求折叠或请求合并)意味着重新排序重复的请求,以便它们一次执行一个。这个解决方案之所以有效,是因为从概念上讲,它重新排序请求,使得重复项永远不会并发执行,即使在不同的后端实例上也不行(分布式锁强制执行此操作)。然后第一个请求被处理并且它的响应被缓存。然后,该请求的所有后续重复项将被串行执行并从缓存中得到满足。这使我们能够更有效地利用我们的缓存,并为我们节省了底层数据库和搜索引擎上重复请求的负载。在较高的层面上,以下是我们如何对我们的微服务进行 Stooge-proofing:将重复数据删除视为迫使 The Stooges 在通往厨房的门口形成一条有序的队伍。然后第一个 Stooge 进入厨房,带着一碗扁豆汤离开,那碗汤被缓存起来。然后另外两个臭皮匠得到了缓存的碗汤。好吧,这个比喻并不完美,但这个解决方案大大减少了厨房的负担。为了使这个解决方案起作用,您需要一个可以处理许多并发请求的网络堆栈。 Reddit 的大多数微服务堆栈是 Python 3、Baseplate 和 gevent。 Django/Flask 在与 gevent 一起运行时也能很好地工作。 gevent 是一个 Python 库,它透明地使您的微服务能够处理高并发和 I/O,而无需更改您的代码。它是一种秘密武器,它允许您在少量实例上运行数万个称为 greenlets(每个并发请求一个)的伪线程。它允许处理并发重复请求的线程在等待获取锁时排队,然后在线程获取锁并串行执行时排空这些队列,所有这些都不会耗尽线程池。
我们将为 Python/Flask 勾勒出这个解决方案,但您可以使其适用于任何可以处理许多并发 I/O 绑定请求并在所有后端实例之间共享数据存储的 Web 或微服务堆栈。分布式锁和响应缓存。为了消除重复请求或缓存响应,我们需要一种方法来识别对同一内容的不同请求。我们通过为每个 HTTP GET 请求计算一个散列来做到这一点:如果您的后端根据请求的 Content-Type 标头返回不同的响应(例如,XML 与 JSON),那么您应该在计算请求散列时包括该标头的值.如果您使用 UTM 参数进行跟踪但不以任何方式影响响应,那么您应该在计算请求哈希时排除那些特定的 UTM 查询参数。如果您根据一天中的小时返回不同的响应,那么您应该在计算请求哈希时包括一天中的小时。换句话说,在计算请求哈希时,您应该包含可能以任何方式影响响应的每个变量。在这个例子中,我们使用了 Python 的内置 hash() 函数来计算请求哈希。但是,当 Python 进程启动时,Python 会随机化其哈希种子。因此,为了使这个请求哈希在我们不同的微服务实例和实例重启之间保持一致,我们需要在所有微服务实例中将 PYTHONHASHSEED 环境变量设置为相同的非零整数值。或者,不同的哈希函数可能对您更有意义。
现在我们有了散列请求的方法,我们可以实现一个装饰器来包装端点函数以消除重复请求,如下所示:这个装饰器的工作原理是通过请求散列上的分布式锁强制控制流,确保没有两个重复的请求可以同时进行。在这个例子中,我们使用了 Pottery 的 Redlock 实现(由共享 Redis 实例支持),它尽可能接近地实现了 Python 优秀的 threading.Lock API。重要的是,分布式锁有一个自动释放超时以保持活性。想象一个线程获取锁然后在临界区死亡的情况。如果没有自动释放超时,锁将永远不会被释放,从而导致死锁。在上面的示例中,我们将 auto_release_time 设置为 5,000 毫秒。您可以将其设置为您想要的任何值,只要您的关键部分在该超时内很好地完成(除了在罕见的病态情况下,例如您的底层基础设施问题;自动锁定超时将在本博客的“问我任何问题”部分进一步讨论邮政)。请注意,自动释放计时器在线程获取锁后开始计时,因此不包括线程排队等待锁所花费的时间。我们需要解决三个臭皮匠问题的最后一个构建块是缓存响应。同样,我们可以实现一个装饰器来包装端点函数以缓存响应,如下所示:这是一个典型的缓存装饰器,它使用请求哈希作为缓存键。它尝试查找该缓存键,并在命中时返回缓存的响应。如果未命中,它会调用底层端点函数,缓存后续重复请求的响应,然后返回计算出的响应。在这里,我们使用 pickle 在未命中时缓存响应对象之前序列化响应对象,并在命中时反序列化响应。为简单起见,我们在此示例中选择了泡菜;但是对于您的用例,JSON、MessagePack 或其他一些序列化格式可能更有意义。现在我们拥有解决三个臭皮匠问题所需的所有构建块。我们可以围绕端点函数组装它们,如下所示:
为什么不在 CDN 或边缘而不是在微服务级别对请求和缓存响应进行重复数据删除?请求来自不同的平台和不同的形式。所有这些请求都由我们的 API 网关整理成标准形式。因此,通过在我们知道它们不相关的层扔掉不相关的变量,我们的更多请求看起来是一样的。这提高了我们识别重复请求的能力并最大化我们的响应缓存命中率。此外,作为微服务所有者,我们的团队可以更好地控制微服务中请求和响应发生的事情,而配置边缘发生的事情的能力则更弱。这不仅仅是一种所有权权衡;它还允许我们在我们的微服务中进行权限检查、个性化等操作。最后,通过在微服务级别进行重复数据删除和缓存,我们有更多机会为我们的原始请求流进一步检测、记录和触发事件。在请求重复数据删除期间,如果您的底层基础架构出现问题并且分布式锁自动超时,会发生什么情况?我们使用分布式锁只是为了防止重复请求造成负载。我们不会使用锁来强制执行数据一致性、防止竞争条件或出于任何其他原因。因此在最坏的情况下,如果锁超时,一些重复的请求可以立即执行临界区。即使在这种情况下,锁也有助于通过防止所有重复同时执行来减轻我们苦苦挣扎的基础设施的负载。这是一种有效的方法,您可能会考虑在微服务中执行此操作。您可以使用函数的参数来构造锁定/缓存键,并且可以缓存昂贵函数的返回值。由于参数排列较少,在微服务中进行更深层次的重复数据删除和缓存可以提供更高的缓存命中率。
另一方面,在您的微服务中更高层进行重复数据删除和缓存可以节省更多工作。您可能有一个昂贵的 I/O 绑定函数来查询您的数据存储,以及另一个昂贵的 CPU 绑定函数来呈现响应。更高级别的缓存,例如围绕端点函数,将节省对两个昂贵函数的调用。在此示例中,为简单起见,我们对端点函数进行了重复数据删除和缓存。微服务通常被认为是将应用程序所需的 API 导出到底层数据存储之上的适配器。但是微服务的一个正交的重要功能是它们是您的用户和底层数据存储之间的最后一道防线。当我们第一次遇到 The Three Stooges Problem 时,我们考虑在 API 网关或负载均衡器级别解决它。但是将我们的微服务视为我们的最后一道防线,导致我们在本地解决问题;我们相信这个解决方案是自然的、易于推理的、灵活的、可维护的和有弹性的。