函数式事件驱动架构——分布式系统

217 阅读18分钟

2.1 概述

一个由多个服务组成的分布式系统,能够带来巨大的优势,比如可扩展性、高可用性和容错性。然而,这种架构也引入了相当多的复杂性。

因此,我们在设计阶段需要尽可能细致地考虑各种边界情况,以避免常见的陷阱。

当每个服务都是无状态的(第 3 章会详细讲到)且职责单一,比如只负责用户登录时,实现系统的可扩展性相对容易。

而容错性和可用性等其他属性,则需要对系统有更深入的理解,这也是接下来两节我们要讨论的内容。

2.1.1 识别故障点

要实现容错能力,首先要理解每个服务的重要性,因为整个系统只有在所有关键服务都正常运行时才能保持正常运作。

因此,在设计分布式系统时的首要任务,就是识别那些保证系统核心功能的关键服务。例如:

image.png

图 2.1 展示了一个由五个不同服务和一个事件总线组成的系统。通过识别哪些服务是核心服务,我们可以确定系统中的关键故障点以及相对次要的部分。

在我们的示例中,服务 A 和服务 B 必须始终保持在线,系统才能正常运作。我们假设对于其他服务(用绿色表示的部分)出现临时故障是可以接受的(也许事件总线短暂不可用也能容忍,但这需要具体场景具体评估)。

这意味着服务 A 和 B 需要特殊对待。如果它们都是无状态服务,我们只需为每个服务再启动至少一个实例,就可以轻松实现容错。具体需要多少实例,还要根据实际流量和每个服务的处理能力来决定。

相反,如果某些服务是有状态的(关于有状态服务的详细定义请参见后文“有状态服务”部分),那我们就需要重新评估是否真的必须这么设计。

如果要扩展一个负责写数据库的服务,就需要通过事务或其他机制来协调操作,以确保幂等性和一致性。

而扩展一个需要“记住”之前状态(比如过去会话信息)的服务就更复杂了。理想情况下,我们应该尽量避免在应用层维护状态(比如性能优先时将状态存到 Redis 等缓存里,需要强一致性时用事务型数据库)。不过,状态总要存放在某处,彻底消除应用状态有时并不现实。在这种情况下,我们可以尝试减少该服务的职责,同时为它分配更多运行资源(也就是纵向扩展,scale up)。

第 3 章会专门详细讨论无状态服务和有状态服务,所以此处你只需知道这两类服务需要区别对待即可。

回到第 1 章中的用户登录场景(详见“它解决了哪些问题?”那一节),我们同样可以用图 2.2 标注出关键组件。

image.png

HTTP 用户登录服务和用户 SQL 数据库是系统中的关键故障点,因为没有这两个组件,系统将无法正常运行。其他服务则可以在恢复后通过补偿或重放事件来追赶数据进度。

2.1.1.1 一致性协议(Consensus protocols)

值得一提的是,有状态服务的容错性问题可以通过一致性协议来解决。特别是在处理状态机时,可以借助如 Paxos 和 Raft 这类著名的一致性算法,将单节点状态机实现为分布式系统。

很多分布式数据库都是基于这些协议构建的。例如,分布式键值数据库 TiKV 就采用了 Raft 协议。

其他一些知名系统,比如 Google Spanner(分布式 SQL 数据库)、Ceph(分布式存储系统)和 Neo4j(图数据库),则建立在 Paxos 协议之上。

虽然对于依赖 Kafka、Pulsar 这类有状态消息中间件的应用工程师来说,这些底层机制无需直接操心,但理解架构背后的原理依然非常重要,有助于打下坚实的基础。

2.1.2 一致性与可用性

这两个概念共同构成了著名的 CAP 定理(如图 2.3 所示)。

image.png

分布式系统无法完全避免网络故障。因此,我们总是需要在一致性(CP)和可用性(AP)之间做出权衡。

理解 CAP 定理与 NoSQL 数据库之间的关系也非常有趣。

即使选择了可用性优先(AP),我们仍然需要选择一致性模型,这决定了数据在各个节点上的查看与更新方式。

当然,架构设计并不只有 CAP 定理。一般来说,大多数系统大部分时间都运行在正常状态下,这时我们还需要在延迟和一致性之间做取舍,这正是 PACELC 定理所强调的(它是 CAP 定理的扩展)。

2.1.2.1 一致性:最终一致性 vs 强一致性

最终一致性(eventual consistency),又称为乐观复制,是分布式计算中常用的一致性模型,用于实现高可用性。它的非正式保证是:如果对某个数据项不再有新的更新操作,最终对该项的所有访问都会返回最新值。

最终一致性的服务通常被归为 BASE(基本可用、软状态、最终一致性)语义体系,相对传统的 ACID(原子性、一致性、隔离性、持久性)语义。不过,这些定义其实是有争议的(参见 Martin Kleppmann 在《数据密集型应用系统设计》中的相关观点)。

带有“点赞”功能的社交媒体应用,就是最终一致性的典型用例,这类系统更看重可扩展性和高可用性。比如用户刚点了个赞,另一个用户可能不会立即看到点赞数增加,但几秒钟后刷新页面就能看到数据已经同步。

如果某些场景下强一致性比高可用性更重要,现在有些数据库(如 Google Spanner 和 Fauna)承诺在分布式系统中同时提供强一致性和高可用性,并声称网络分区其实很少见。虽然确实如此,但一旦发生网络分区,这些实现也都会在可用性上做出妥协。

还有一种非常实用的模型叫“强最终一致性”(SEC),它可以通过 CRDT(无冲突复制数据类型)在应用层直接实现,弥补了普通最终一致性在安全性上的不足。

事件驱动应用大多倾向于采用最终一致性。不过,我们也可以针对某些关键业务选择强一致性。因此,可以说一致性模型是可以灵活组合的,视具体需求而定。

最后要记住的是,最终一致性在绝大多数场景下其实是“读”的一致性问题。但在多主集群(multi-master cluster)中,如果多个实例同时可写(比如跨地域副本数据库),也可能成为“写”一致性的问题。

2.2 幂等性

在我们开发 PFPS 购物车应用时,曾使用过一个号称“幂等”的第三方支付客户端。对于同一个支付请求,它能保证信用卡最多只会被扣款一次。

如果支付已经处理过了,服务会返回不同的 HTTP 状态码(409: conflict),并附上 payment ID,告诉你不必继续重试。

client.run(POST(payment, uri)).use { resp =>
  resp.status match {
    case Status.Ok | Status.Conflict =>
      resp.asJsonDecode[PaymentId]
    case st =>
      PaymentError(
        Option(st.reason).getOrElse("unknown")
      ).raiseError[F, PaymentId]
  }
}

有了这样的幂等保证,我们就不用担心因重试导致重复扣款,从而避免客户被多次扣费等一系列麻烦。

大多数情况下,每个支付请求只会调用一次远程 HTTP 服务。但如果出现异常(比如网络故障或者本地服务崩溃),我们可以放心地重试请求,因为服务本身是幂等的。如果没有这种幂等保证,失败恢复会变得极其复杂。

幂等性(idempotence, 或称 idempotency)在最终一致性的服务中非常关键。因为任何流程都可能在任意时刻中断,重启时会重新执行一遍操作,所以我们必须确保同一个操作无论执行多少次,效果都只有一次。

尤其在保证“至少一次投递”(at-least-once delivery,详见“投递保障”)的场景下,幂等性是必不可少的属性——因为消息有可能被重复投递。

2.2.1 去重(Deduplication)

在我们的交易系统中,有些服务必须保证消息去重,以便在重启或升级时实现幂等。

如今大多数现代消息中间件,比如 Kafka 和 Pulsar,都能在发送到消费者之前自动去重(详细内容见第 3 章的 Apache Pulsar),因此我们基本不用自己处理,专注于业务开发即可。

另一个常见的去重和原子性保障方案是分布式事务,许多消息中间件也支持,但它通常会影响性能。

需要注意的是,分布式事务只能保证应用和消息中间件之间(如消息的发布和消费)是原子的。如果还涉及其他系统(比如数据库写入),就需要我们自己保证幂等(比如用 Saga 模式)。

常见的消息去重策略有两类:生产端去重和消费端去重,后文会详细讲解。

2.2.1.1 生产端去重(Producer-side deduplication)

这是消息中间件最常支持的方式。每条消息分配一个唯一序列号(sequence ID)。如果消息因故重发,中间件可以通过序列号识别并去重,防止重复下发到消费者。

这种方式适合应对消息重发和重投递(详见“投递保障”),但无法消除“内容相同但序列号不同”的重复消息。每次发送的消息都有新的序列号,所以被视为不同消息。

为了解决这个问题,许多中间件允许应用自定义如何分配 sequence ID。但这会让我们不得不自己追踪历史序列号,使应用变成有状态的。一般来说不推荐,但某些场景下可以权衡考虑。

2.2.1.2 消费端去重(Consumer-side deduplication)

如果有多个生产者往同一个 topic 写数据,在生产端去重就会变得复杂,因为要全局协调唯一的序列号,难度极高。

这种场景下,消费端去重更简单。做法是在消费者里维护消息唯一标识(比如 message ID)的记录,每收到一条消息就判断是否已经消费过。

一个常见问题是:我们需要保存多久的历史消息记录?其实无论生产端还是消费端,都会遇到这个问题。

常用做法是只保留近 N 分钟内处理过的消息,比如只记录最近 30 分钟的消息。这样可以覆盖绝大多数因崩溃重试导致的重复消息。如果超出时间窗口还有重复,那往往已经是更严重的问题了。业界普遍接受这种做法。

事实上,Kafka Streams 等流处理平台也普遍采用了这种时间窗口去重方法。

2.2.1.3 去重引擎(Deduplication engine)

自己写一个既支持生产端又支持消费端去重的“去重引擎”,是理解分布式系统如何保证无重复消息的好方法。

本书配套 Demo 模块中有这样一个实现:维护一份可配置时间窗口内处理过的消息 ID 集合,然后用当前命令的 ID 检查是否已经处理过,决定是否跳过。

这种去重可以在内存中做,也可以持久化到磁盘以应对重启。选择哪种方式,要根据具体需求来决定。

比如在“独占”或“故障切换”模式下运行服务(详见“订阅类型”),可以用内存去重,依赖下游的 ACK 机制来保证准确性,很多时候甚至不用专门去重。

但如果服务以“共享”或“按 key 共享”的方式订阅消息,去重就变得很关键了,这时往往需要持久化去重结果,确保系统功能正确。

2.3 原子性

ACID 中的 “A” 代表原子性(Atomicity)。在数据库系统中,原子事务指的是一组不可再分的操作,要么全部成功(COMMIT),要么全部失败(ROLLBACK)。

不过,原子事务(或原子操作)的概念也扩展到了并发编程领域,在这里原子性常被称为“线性化”(linearizability)。一些常见的线性化模型包括 CAS(比较并交换,Compare-and-Swap)和锁(lock)等。

Cats Effect 提供的很多并发数据结构就用到了这些机制。例如,cats.effect.kernel.Ref 是基于 Java 的 AtomicReference 及其 compareAndSet 方法实现的;另外,cats.effect.std.Semaphore 则是锁(mutex)的一个实现。

仅仅依靠 Cats Effect,我们就拥有了丰富的内存级原子操作工具箱。但如果需要分布式原子事务,就得借助于代码之外的工具。

大多数关系型数据库都符合 ACID 标准,意味着它们支持原子事务,因此对于需要强一致性的分布式系统来说是理想选择。然而,有时我们也需要在不支持事务的 NoSQL 数据库中实现原子性。

2.3.1 分布式事务

很多 SQL 数据库都支持分布式事务,也就是涉及两个或多个不同服务器,需要协调写操作才能保证事务成功,通常通过“两阶段提交协议”(2PC, Two-Phase Commit)实现。

不过,分布式事务不仅仅限于数据库。像 Kafka、Pulsar 这样的消息中间件同样支持分布式事务(参考 Pulsar 事务相关内容)。

消息中间件的事务支持允许我们在一次原子操作中消费、处理、并生产多条消息,甚至可以涉及多个 topic。分布式事务能够实现强一致性,但会带来一定的延迟(尤其是在大规模场景下)。

在我们的应用中,会使用分布式事务,并且可以体会到,虽然会有一些性能代价,但实现强一致性保障其实非常简单。

2.3.2 变更数据捕获(Change Data Capture, CDC)

CDC(Change Data Capture,变更数据捕获)是《数据密集型应用系统设计》一书中重点讨论的主题。近年来,随着流式系统的兴起,CDC 越来越受欢迎,这背后是有原因的。

CDC 优雅地解决了“原子地写入多个存储系统”的难题。为了更好地理解这一点,我们来看下面这个例子:

假设我们有一个负责注册作者的服务。它需要把作者记录写入 PostgreSQL 表,把作者名字存进 Redis,并在完成后向 Pulsar 发布一个 AuthorRegistered 事件。

image.png

要想对所有这些操作都实现原子性,实际上几乎是不可能的。如果无法保证原子性,我们可以选择让所有操作都具备幂等性,或者为每个操作实现回滚机制,比如采用 Saga 模式。

这些方案虽然可行,但在实际业务中,并非所有操作都容易保证幂等,也不总是能实现回滚机制。而且,随着系统复杂度增加,实现多操作的原子性会变得更加棘手。

CDC 通过直接读取数据库的事务日志,实现了操作的线性化(linearizability),如图 2.6 所示。以 PostgreSQL 为例,CDC 就是利用其逻辑解码(logical decoding)功能来捕获变更。

image.png

此外,我们还可以将写入 Redis 的操作延后,改为监听 AuthorRegistered 事件后再进行,这样就能像图 2.6 展示的那样,把整个流程变成一条线性的分布式操作序列。

image.png

图 2.6 中多次出现的服务可以是一个服务,也可以是多个服务。如果是同一个服务,同时响应自己发布的事件,这种模式也被称为“自监听(listen to yourself)”模式。

像 Debezium 这样的 PostgreSQL 连接器产品,可以让我们方便地将其接入多个数据库和消息中间件,从而极大简化遗留系统的迁移。

需要注意的是,Debezium 连接器需要额外的进程在系统中运行,会增加运维复杂度。不过,一旦部署好它,就可以在类似场景下避免直接用分布式事务。

2.3.2.1 Outbox 模式

CDC 实现的另一个常见模式是 Outbox 模式(也叫事务出箱,Transactional Outbox)。与直接从业务表读取 CDC 事件不同,这种做法是在数据库中单独维护一张 outbox 表,用来存放那些需要在数据库事务提交成功后发布的事件。

继续以作者注册为例,图 2.7 展示了这种模式的实现流程。

image.png

作者实体和 AuthorRegistered 事件会在同一个数据库事务中落库,因此要么两者都成功,要么所有变更都会回滚。一旦事务提交,CDC 连接器会捕捉到 outbox 表的插入操作,并将事件发送到消息中间件。

Outbox 模式依赖单个数据库事务实现了强一致性,但也会让数据库成为瓶颈,而这是我们在流式和事件驱动系统中通常想要避免的。这是该模式需要注意的一个缺点。

在某些场景下,“自监听(listen to yourself)”模式可能是更好的方案。系统设计时需要权衡,不要滥用 outbox 模式。

2.3.3 分布式锁

分布式锁管理器(DLM)是分布式系统中用于同步访问共享资源的重要工具。许多操作系统、集群管理器和分布式数据库都用到了分布式锁。

一种高效轻量的分布式锁可以基于 Redis 实现,其本质上是安全(互斥)、无死锁且具备容错性的。

当我们只有单个 Redis 实例时,思路很简单:客户端通过创建一个带过期时间(TTL)的键来获取锁。

SET my_lock client_uuid NX PX 30000

意思是:只有当 my_lock 这个键不存在时(NX),才用 client_uuid 这个值去设置,并且设置 30000 毫秒的过期时间(PX)。如果键已经存在,就说明锁被别的客户端持有,需要稍后重试(通常等几毫秒)。

使用 redis4cats 库,可以这样写:

redis.set("my_lock", "client_uuid", SetArgs(Nx, Px(30000.millis)))

拿到锁的客户端可以在用完资源后随时通过删除键释放锁。如果客户端崩溃,没来得及释放,等 key 过期后锁会自动释放(无死锁风险)。

可以用一个 Redis 实例和至少三个不同客户端模拟实现分布式锁。

一个更完整的 Scala 例子如下:

type Lock = String

val lockName = "my_lock"
val clientId = "porcupine-ea4190d4-0807-4c90-aea9-41c19e249c84"
val acquireLock: IO[Lock] =
  redis
    .set(lockName, clientId, SetArgs(Nx, Px(30000.millis)))
    .flatMap {
      case true  => IO.pure(clientId)
      case false => IO.sleep(50.millis) >> acquireLock
    }

val deleteLock: Lock => IO[Unit] =
  id =>
    redis.get(lockName).flatMap {
      _.traverse_ { v =>
        redis.del(lockName).whenA(v === id)
      }
    }

val lock: Resource[IO, Lock] =
  Resource.make(acquireLock)(deleteLock)

一定要注意,在释放锁前要校验锁的值是否为自己应用的 ID,否则可能会错误地释放了别的客户端获取的锁。

有了这种“资源化”的分布式锁模型后,我们就可以为任意需要分布式共享资源访问的计算操作加上一层保障:

val program: IO[Unit] =
  lock.surround {
    IO.println("some computation")
  }

如果你想了解其他分布式锁的流行实现,可以研究一下 Google Chubby 和 Apache ZooKeeper。

2.4 小结

通过将计算任务分布到多台机器上,我们获得了强大的能力和可扩展性,但也为此付出了代价:分布式系统很难。

不过,希望你已经对前面讨论的主题有了足够的理解,可以在交易应用的开发中加以应用。当然,我们在第 6 章会从实战角度重新梳理这些概念,并结合每个服务的设计决策进行具体讨论。

声明:本章旨在简要梳理开发事件驱动应用过程中最常用的概念。虽然篇幅简短,你也能体会到每个主题其实都只是点到为止,远未涉及全部细节。

如果你想进一步加深理解,强烈建议阅读一些关于分布式系统的专业书籍(可参考推荐阅读材料)。