微服务之间共享数据

当我开始使用微服务时,我对“两个服务不能共享数据源”这一常见规则的理解过于字面化了。

我在互联网上到处都看到这个规定:“你不应该在两个服务之间共享数据库”,这确实有道理。一个服务必须拥有自己的数据,并保留按照自己的意愿更改其模式的自由,而不需要更改其面向外部的API。

但这里有一个重要的微妙之处,直到后来我才理解。为了正确应用这一规则,我们必须区分共享数据源和共享数据。

为什么共享数据源是不好的

以一个例子来说明:产品服务应该拥有产品表及其中的所有记录。它们通过API,产品GraphQL查询,以及通过createProduct变异来创建这些记录,将这些数据暴露给其他团队。

产品服务拥有产品真相的所有权,其他团队绝不应该直接访问这个数据源。如果他们需要数据,他们应该通过他们遵守的合同(API)向产品服务请求。在任何情况下,你都不应该允许直接访问数据库,否则你将失去更改模式的自由。我是通过艰难的方式学到这一点的。

共享数据是可以的

事实是,服务需要属于其他服务的数据。

例如,行程服务将需要访问乘客(来自乘客服务)和司机(来自司机服务)的数据,以提供行程概览。

一个简单的三个服务架构

行程服务同步地向每个相应的服务请求其数据,以满足原始请求(getTrips)。我们可以确信数据是新鲜的,请求的客户端将得到数据的强一致性视图(你们中的一些人可能知道我接下来要说什么;))。

这种同步请求/响应模型在微服务之间传输数据,对于刚开始使用微服务的团队来说,是一个非常自然的心智模型,至少在我的经验中是这样。你需要一些数据,你知道在哪里可以得到它,你向拥有该数据的服务请求,它会按需提供数据给你。

除此之外,提供新鲜、强一致性的数据对我来说是不言而喻的。强一致性的数据意味着最新的数据,绝对是“最新鲜”的数据,直接来自真相的源头。对我来说,过去提供任何不一致的数据都是不可接受的。你怎么能提供不是最新的数据呢?其他的都会是谎言!

我们把这些模式当作信条来应用,因为我们没有看到其他的方式,而且最重要的是,这感觉很自然。

同步和强一致性不扩展

严重依赖同步请求和强一致性的架构不容易扩展。有时,总是直接去源头获取数据需求是不可行的,或者严格来说并不必要。

上面提到的行程服务例子一开始看起来不错,但系统很少保持那么简单。新服务诞生了,它们需要从现有服务获取数据。坚持同步请求模式,最终会让你陷入服务之间的混乱请求网。
这里有一个场景:

使用同步请求的示例流程

  1. 一个用户完成了一个挑战,运行了一个completeChallenge变异到挑战服务
  2. 在存储完成后,挑战服务让排行榜服务知道,这样它就可以更新排行榜
  3. 排行榜服务向用户服务请求用户的显示名称和头像,以构建新的排行榜状态
  4. 排行榜服务发现新排行榜状态中有一个新的领导者,并让通知服务知道,这样它就可以通知参与者有一个新的领导者!
  5. 通知服务向用户服务请求该特定排行榜中用户的最新的电子邮件地址,以便发送电子邮件

用户服务显然是这里的争议点:每个人都以这样或那样的方式依赖它。想象一下,如果这个服务下线了:它也会使大多数其他服务下线。不仅如此,你还得确保始终让这个服务器保持强大,拥有更多的副本和高性能的数据库,以满足需求。

除此之外,请求链中的每个跳转都为整个请求增加了延迟。每个跳转都有可能增加指数级的延迟,因为依赖链中的每个服务都可能向自己的依赖项发出多个请求。在你意识到之前,你已经达到了难以忍受的延迟水平。

最后,请求链中的每个额外依赖都增加了整个请求链失败的可能性。在一个涉及五个服务的请求链中,每个服务的SLA为99.9%(大约9小时的年停机时间),复合SLA变为99.5%。这几乎是每年将近2天的停机时间!

我们可以通过问一个问题来避免所有这些缺点:服务真的需要最新的数据吗?

通知服务(第5步)可以说是需要的。如果用户更改了他们的地址,而通知服务不知道,它就有将电子邮件发送到错误地址并无法将通知发送给预期用户的风险。

另一方面,排行榜服务可能不需要最新的显示名称和头像来构建排行榜——如果用户看到一些旧的头像或显示名称,那也不是什么大问题。

正如你所看到的,服务对数据一致性的需求是不同的。我们可以使用这些权衡作为杠杆,应用不同的数据共享方法,构建一个更健壮的分布式系统。

最终一致性的引入

在我的职业生涯中,我意识到服务可以在它们自己的数据库表中维护其他服务数据的副本。这带来了通过事件或轮询保留数据的责任。

这包括数据可能在一段时间内是陈旧的,但最终会被更新,这意味着数据最终是一致的。我们不能保证数据不陈旧,但我们可以保证我们最终会赶上。

我“开窍”的那一刻是当我从依赖公共天气API获取天气数据的后端服务的角度来考虑这个问题。与其每次需要天气数据时都检索普里什蒂纳或柏林的天气数据,不如通过将其在本地表中具体化并缓存(可能是一天多次)来缓存它,并将缓存的数据提供给这些用户。我选择了最终一致性的权衡,因为对我的用户来说,看到最新数据并不关键,如果数据有几小时的延迟也是可以接受的。

回到挑战的例子:我们可以通过在服务中维护用户的本地副本来切断对用户服务的许多同步依赖:

  1. 排行榜服务可以维护用户的本地副本,避免不得不向用户服务发出请求。如果数据有点旧,没有人会介意,如果有人看到一个稍微旧的头像,那也不是一个阻碍。
  2. 挑战服务也可以做同样的事情;比如说,如果它公开了一个getChallengeDetails查询,并需要显示名称和头像来显示当前挑战的参与者——它也可以从自己的具体化用户表中提供这种最终一致性的数据。
  3. 通知服务,虽然更敏感一些,也可以利用数据共享来消除对用户服务的依赖。它可以在本地具体化用户,并通过监听用户更新事件来维护最佳努力更新状态,以确保它拥有最新的电子邮件。
    尽管我们没有太多讨论服务如何共享这些数据(这是另一个时间的话题),但最终的示例架构将使用事件源和缓存的组合。

示例架构,展示了服务之间共享数据的两种主要方法:事件源和缓存
如果你想看更多的例子,请查看Fiverr的工程师Shiran Metsuyanim的《如何在大规模上在微服务之间共享数据》。这是一个很好的帖子,展示了如何在添加新服务时保持健壮性。它首先列出了限制,然后讨论了同步、异步和混合解决方案之间的权衡。

结论

我想向像我几年前一样陷入“不共享数据”字面意义的开发者传达这个观点,但必须意识到这只适用于不共享真相的源头。在另一个服务的领域内维护服务数据的副本是完全可以接受的,并拥抱了最终一致性的精神。