每个系统都是一个日志


每个系统都是一个日志:避免分布式应用程序中的协调。

构建一个不容易出问题的分布式应用程序还是很困难的。

本来你应该只需要关心业务逻辑和问题本身的复杂性。但实际上,你得一行一行地检查代码,想着:“如果服务在这里崩溃了怎么办?”、“如果我们调用的 API 暂时不能用怎么办?”、“如果同时有很多请求过来怎么办?”、“如果这个进程在执行任务时卡住了,我该怎么防止它破坏数据?”。

所以,你得花很多时间去考虑怎么处理故障、重试、竞争条件、锁、操作顺序、数据一致性等问题。你可能会加队列、键值存储、锁服务、调度器、工作流管理器,然后想办法让它们一起好好工作。但残酷的事实是,很多应用程序都没做好这些,一旦遇到故障或者高负载,就会出问题。

分布式应用程序和服务中的问题
我们怎么才能从根本上简化这个过程呢?在本文中,我们会介绍一个核心思想:通过避免分布式协调来解决很多问题。这个想法很大程度上来自于我们构建Apache Flink 时的经验。

每个系统都是一个日志
让我们从观察分布式应用程序和基础设施开始:每个系统都是一个日志。

  • 消息队列就是日志:比如 Apache Kafka、Pulsar、Meta 的 Scribe,它们都是日志的分布式实现。消息代理(比如 RabbitMQ、SQS)在内部也是通过日志来复制消息的。
  • 数据库(和键值存储)也是日志:数据的更改首先会写入预写日志,然后再更新到表中。数据库社区有句名言:“日志就是数据库;其他一切都是缓存(或物化视图)”——这句话通常归功于 Pat Helland。“彻底颠覆数据库”的想法就是从日志开始的。
  • 分布式锁和领导者选举服务(比如 ZooKeeper、Etcd 等)的核心也是共识日志。共识算法(比如 Raft)本质上就是在模拟日志复制。
  • 持久状态机也会把状态转换的日志具体化。
举个例子,当你构建一个与数据库、消息队列和服务 API(由另一个数据库支持)交互的应用程序或微服务时,你其实是在业务逻辑中协调一些不同的日志。

应用程序需要协调很多日志
在这个例子中,我们想实现一个 processPayment 处理程序。付款有一个 ID 来标识它。处理程序由队列触发(如果处理程序失败或超时,队列会重新发送事件),处理过程包括检查欺诈检测模型、更新账户余额、存储状态和发送通知。还有其他处理程序可以处理相同的付款 ID,比如取消付款、阻止付款、解除阻止付款、撤销付款。

为什么让这个看似简单的处理程序可靠地工作这么难?因为我们的目标是根据不同系统的状态做出一致的更改,每个系统都有自己的世界观,并在其单独的日志中维护。

分布式应用程序通常需要对与之交互的所有系统进行复杂的编排,精心设计所有状态和操作,以确保正确性。这是现代分布式应用程序中许多复杂性的核心。

如果所有系统都使用同一个日志会怎么样?
现在让我们假设所有这些系统(队列、数据库、锁等)都使用同一个日志进行操作——为了这个思想实验——将消息队列的日志传递 PaymentEvent 给 processPayment 处理程序(上游日志)。

每当我们的 processPayment 处理程序想要更改另一个系统的某个状态时,它都会将记录写入上游日志。这个新记录会链接到原始的 PaymentEvent 记录。

每当队列决定重新传递 PaymentEvent 记录时(比如在超时或假定故障的情况下),它还会附加所有链接的日志条目(日志)。

  • 在日志中实现步骤日志
  • 通过有条件地附加到日志来保证安全性
  • 通过日志进行锁定和状态管理

避免协调是分布式系统中为数不多的灵丹妙药之一 - 一种降低复杂性而不是转移复杂性的方法。

  • 例如,使用 ZooKeeper 锁保护我们的第二个代码片段只会转移复杂性。它减少了代码对并发性的担忧,但引入了丢失锁和清除持久锁的问题。
  • 相比之下,将不同状态统一到一个日志中的方法实际上减少了工作量,从而提高了效率,减少了极端情况,并简化了操作。