3.1 无状态服务与有状态中间件
在事件驱动架构中,无状态服务的基本前提是:大部分“状态”都存储在消息中间件上。这样一来,服务只需基于收到的消息进行计算,然后将计算结果再次发布回消息中间件,有点类似于 Actor 模型的做法。
这并不意味着我们完全不需要数据库或缓存。但这种架构让我们可以非常清晰地分离无状态服务和有状态组件。
举例来说,两个关键服务可以基于各自收到的消息进行计算,并将结果发布到消息中间件,实现并行处理与高吞吐。
如果最终结果(或部分结果)需要持久化用于分析或审计,可以由第三个专门的服务消费这些消息,并按需写入数据库。
这是一个简单但极具威力的设计模式,我们会在交易系统中广泛使用。
3.1.1 有状态服务
虽然图 3.1 把其中一个服务标记为“有状态”,但仅仅写数据到数据库并不等于服务就是有状态的。这里需要进一步澄清。
正如本章开头所述,有状态服务是指在启动时(比如重启后),需要“记住”之前会话发生了什么的服务。通常,这种“记忆”会以内部状态的形式存在,并被持久化到外部存储,方便后续读取。
因此,这类服务启动时,依赖于从外部存储加载之前的状态(如果有)。而且大多数情况下,服务还需要能把自己的内部状态写回存储,以便重启后能恢复到之前的位置。
这种方案我们会在后面详细讨论(参见“状态快照”部分)。
虽然“有状态服务”的定义很明确,但实际工作中,人们还是经常会把带有内存状态、或者与数据库交互的无状态服务误认为有状态服务。
所以,图 3.1 里标记为“有状态”的服务其实也可以被归为“无状态”。真正的判断标准只有一个关键问题:
这个服务启动时,是否需要依赖外部存储的初始状态?
如果答案是“需要”,那它就是有状态服务;如果不需要,那就是无状态服务。
再举一个持有状态但本质无状态的例子,比如 Kafka Streams。这类流式服务会做聚合操作,底层依赖 KTable(本地特定的数据存储)。
我们仍然把这类服务看作无状态服务,因为 Kafka Streams 自动管理状态,并在本地 KTable 做好同步和容灾,服务本身无需操心。
3.1.1.1 用户登录状态
下面我们用用户登录功能为例,分析“状态”到底存在哪里。
从这个架构图可以看出,大多数服务其实都是无状态的。让我们具体解释一下原因:
- HTTP 服务 只从数据库读取用户凭证;
- 通知服务 负责接收事件并通知其他设备;
- 时间戳服务 可能只是在追加写入一个日志文件;
- 在线用户服务 既可以是有状态的,也可以是无状态的(取决于具体职责)。在本例中,假设它需要在启动时从缓存中读取当前在线用户数,因此被认为是有状态服务。但我们很快就会学到,如果用“紧凑主题”(见“主题压缩”部分),就可以去除这个限制,让它变成无状态服务。
3.1.2 应用集群(Application clustering)
与“无状态服务 + 有状态中间件”相反的一类架构,是所谓的集群化服务(clustered services)。这类应用需要和其它节点(或服务实例)一起组成网络集群,所有节点都知道集群中的实例数量。
这类系统里,常见有“主节点”(leader/master)和“工作节点”(worker/slave),主节点通常通过一致性算法选举产生(参考“一致性协议”部分)。
Akka Cluster 是目前最流行的 Scala 框架之一,非常适合写这种分布式应用。它让 Actor 模型可以跑在多台机器上(实现分布式),很多人认为这是它最强大的功能。
不过,集群方案会引入很多复杂性,这是无法避免的。如果我们用的是无状态服务,集群状态就交给消息中间件管理——虽然中间件本身也足够“复杂”,但更多属于 DevOps 领域。
如果采用 Akka Cluster 这类方案,应用开发者就需要亲自管理集群节点,应用代码和系统基础设施的边界也会变得模糊。
两种架构各有优缺点,最终还是要看你愿意接受哪些权衡。
3.2 消息驱动架构
事件驱动架构和基于 Actor 的架构其实都属于消息驱动架构的范畴,甚至可以结合使用。
但消息(message)和事件(event)之间有一个关键区别:消息有明确的目标接收方,而事件则没有。事件只是“发生”,其他组件可以“观察并响应”;而消息则是“点对点”地发送给特定收件人。
不过,无论是消息还是事件,都必须通过某种方式进行投递,所以在投递保障方面其实没有本质区别。
3.2.1 投递保障(Delivery guarantees)
消息投递保障有三种类型:最多一次(at-most-once)、至少一次(at-least-once)、精确一次(exactly-once),它们各有权衡。
- 最多一次(at-most-once):消息最多投递一次,可能丢失;
- 至少一次(at-least-once):消息至少投递一次,绝不会丢失,但可能重复;
- 精确一次(exactly-once):消息只投递一次,既不会丢失,也不会重复。
第一种方式最简单,可以直接“发了就不管”,无需等待确认。大多数消息中间件默认使用第二种方式,需要 ACK 和重试机制。
而“精确一次”其实几乎无法保证。发送方必须收到接收方的确认。可万一消息已经送达,但接收方还没来得及 ACK 就挂了,此时发送方只能选择:重发,或者假定消息已送达。不管选哪种,都不能真正保证精确一次。
所以,在绝大多数场景下,我们用的还是“至少一次”保障;而如果业务能容忍消息丢失,则可以选“最多一次”。
有些系统,后来的消息可以覆盖前面的消息(比如物联网场景),确认机制反而是负担,此时“最多一次”就很合适。
3.2.2 Apache Kafka
Apache Kafka 可以说是目前最流行的开源消息中间件,最初由 LinkedIn 开发。它被广泛用于数据管道、分析和其他关键业务系统。
Kafka 本质上是一个分布式提交日志,目标是成为高吞吐、实时数据流的平台。
Kafka 由生产者、消费者组成,topic 会跨多个 broker 分区以提升吞吐量。
在 2.8 版本之前,Kafka 严重依赖 Apache ZooKeeper 作为分区和 broker 的元数据存储。但在 Kafka 3.4 及之后的版本,Zookeeper 会被 KRaft(Kafka 自己的新共识协议)完全替代。
所以,现在大多数企业部署的 Kafka 还是依赖 ZooKeeper 的老版本,因为升级并不简单。ZooKeeper 的引入让 Kafka 变得比实际需要的更复杂,所以 KRaft 是个很好的新方向。
本书不会详细讲 Kafka,有兴趣可以去读《Designing Event-Driven Systems》(可参考推荐阅读资料)。
3.2.3 Apache Pulsar
在消息中间件领域,Apache Pulsar 是“后起之秀”,最初由 Yahoo! 创建。
Pulsar 同样适用于数据管道、分析和低延迟实时数据流。
与 Kafka 不同,Pulsar 的 topic 默认并不分区,一个 topic 通常只由一个 broker 服务。当然,也可以选择分区,这让 topic 管理更加灵活。
我们会在交易应用中大量用到 Pulsar,所以下面会简单介绍 Pulsar 的核心特性,并和 Kafka 做一些比较。
最后我也会给出个人对两者对比的看法。
3.2.3.1 订阅(Subscriptions)
Pulsar 很值得称道的一点,是它允许我们在订阅 topic 时选择不同的订阅类型,从而支持多种设计模式,比如扇出(fan-out)的发布-订阅(exclusive 模式)、消息队列(shared、fail-over、key-shared 模式)等。
Pulsar 官方文档做得非常好,基本所有内容都可以在官网查到。不过,订阅类型对于理解本书后续系统设计非常关键,所以下面我们会重点讲解各种类型。
图 3.3 很好地展示了这些类型的区别,下面我们为每种类型做一个简单定义:
- Exclusive(独占型) :一个订阅只能有一个消费者连接。
- Fail-over(故障切换型) :多个消费者可以连接同一个订阅,但只有主消费者会真正收到消息,其余消费者仅作备份,一旦主消费者出错会自动切换。
- Shared(共享型) :多个消费者连接同一个订阅,消息会以轮询(round-robin)的方式分发,每条消息只会被一个消费者接收。
- Key-Shared(按 Key 共享) :和 Shared 类似,但消息按照用户定义的消息 key 或顺序 key 分发,而不是简单轮询。
理解好这些订阅类型有助于我们把握系统设计上的各种权衡。
3.2.3.2 去重(Deduplication)
另一个非常关键的特性是去重,能让流式应用放心地消费 topic 而不用担心重复数据。Kafka Streams 就广泛使用了这一特性(后文“流式支持”有详细介绍)。
简单来说,Pulsar 通过给每条消息分配唯一的 sequence ID 实现去重,这个 sequence ID 应该由每条消息自行设置。我们在第 5 章介绍 Neutron 库时会看到它是如何自动帮我们做去重的(见“通过 Apache Pulsar 分布式”)。
Pulsar 的去重可以在系统级、命名空间级或 topic 级启用,默认在服务端和客户端都是关闭的,实际使用时请参考官方文档。
为扩展第 2 章“去重”内容,我们再分析下实际系统中可能遇到的一些挑战:
假设我们每条消息都带递增的 sequence ID,服务在某个时刻崩溃。重启后,我们需要确保下一条消息的 sequence ID 一定大于已知的最大值。
如果只有一个生产者,这很简单:只要给每个生产者一个唯一的名字,Pulsar 就能用 Map[ProducerName, LastSequenceId] 这样的结构追踪每个生产者的最后 ID。
真正麻烦的是,有多个服务实例、每个实例都在往同一个 topic 生产消息时:
- 消费消息 A
- 处理消息 A
- 生产消息 B
- 确认(ACK)消息 A
如果服务在第 4 步之前崩溃,另一个实例会重新处理消息 A,导致生产出的消息 B 的 sequence ID 可能和前一个实例本来分配的不一样。
如果所有操作都能封装在事务里,且外部影响仅限于 Pulsar,那么事务机制可以保证正确性。但如果还涉及数据库或 HTTP 调用,则这些外部操作必须具备幂等性才能避免重复执行。需要注意的是,事务的开销并不低,需要在 broker 端手动开启。
只要生产者数量固定且名字唯一,Pulsar 的去重机制就能很好地工作。否则,建议使用 Pulsar 事务,或者无法用事务时在消费端去重。
总之,利用 Pulsar 的去重机制会让实现大大简化。对于多实例生产场景,分布式事务是唯一能在生产端保证“无重复”的方案。
3.2.3.3 主题压缩(Topic compaction)
Pulsar 默认会保留所有未被 ACK 的消息,并保证消息顺序。任何消费者都可以从头开始订阅消息(非常适合事件溯源),但这可能导致应用启动变慢。
在很多场景下,应用其实只关心每个 key 的“最新状态”,不需要整个事件历史。
Pulsar 提供了“主题压缩”功能,能大大加快回溯速度。
其实,压缩并不是消息中间件的独创,很多数据库存储文件分段时也用类似机制。
具体做法是:每条消息设置一个分区 key(partition key)。
比如,我们发送的每条消息包含商品最新价格,服务只需要“当前价格”来计算购物车总价。这时可以把商品 ID 作为分区 key 启用主题压缩。
Item(id = 100, price = 456.12)
Item(id = 263, price = 799.99)
Item(id = 100, price = 510.95)
下次主题压缩被触发(可手动或自动),每个 key 只保留最新值:
Item(id = 263, price = 799.99)
Item(id = 100, price = 510.95)
我们会在第 6 章的实战中用到这项特性。
3.2.3.4 事务(Transactions)
Pulsar 另一个值得一提的特性是“事务”,允许我们以原子操作的方式消费、处理、生产多条消息。
这样我们可以把多条消息(甚至跨多个 topic)打包成一个事务,协调消费与生产。
其实这并非 Pulsar 独有,Kafka 早就有事务机制。
第 5 章介绍 Neutron 时,我们会看到 Scala 代码如何操作事务,以及在交易系统里如何用到事务机制。
3.2.3.5 Pulsar IO
Pulsar IO 提供了与外部系统(如 Apache Cassandra、Aerospike 等)集成的原生 Connector,相当于 Pulsar 版的 Kafka Connect。
IO 组件分为 Source 和 Sink:Source 把外部数据导入 Pulsar,Sink 把 Pulsar 数据写到外部系统。
最有趣的可能还是 CDC 相关的 Connector(参考第 2 章“变更数据捕获”)。
Pulsar CDC Connector 能实时捕获 PostgreSQL 等数据库的变更,并把事件发布到 Pulsar topic,同时保留操作顺序。
本书配套交易系统代码提供了 PostgreSQL CDC 的演示,按 README 指引可以运行 demo 模块下的 PulsarCDC 程序。
第 7 章会介绍如何利用这个特性解决某个具体业务问题(见“预测命令处理器”)。
3.2.4 我该选什么?
其实没有绝对答案,两者特性都很接近,用哪一个都可以实现你的目标。
当然,具体需求下还可以考虑 RabbitMQ、Kinesis(如果用 AWS 的话,不过有供应商锁定)。
但既然我在生产环境都用过 Kafka 和 Pulsar,分享几点实际感受:
3.2.4.1 部署与运维难度
两种消息中间件都需要有经验的运维或开发人员负责配置和调优。
Kafka(尤其依赖 ZooKeeper 的版本)维护成本要高不少。新版 KRaft 方案我没用过,不便评价。
Pulsar 上手更简单,默认配置比较合理,但还是需要做 JVM 调优和一些额外配置。
3.2.4.2 成熟度
Kafka+ZooKeeper 绝对是业界最成熟、最受考验的方案,许多世界 500 强公司都用它,选它绝对不会错。
如果你愿意尝试新技术,追求更简单的上手体验,同时希望有活跃的社区和创新支持,那 Pulsar 也非常值得一试。
Kafka KRaft 也是不错的新方案,不过用得还没老架构多,早期可能会有 bug,需要多关注社区 issue 和 PR。
3.2.4.3 易用性
这里可能我有点主观(毕竟我是 Scala 客户端的维护者),但我认为 Pulsar 更易用。Kafka 概念多,客户端实现复杂(很多操作还是单线程的)。
Kafka 的分区 topic 管理一直是痛点,而 Pulsar 分区完全是可选的,开发体验好不少。
不过多分区带来更高吞吐量,这点也要考虑。
3.2.4.4 客户端支持
如果你的团队涉及多种语言,务必要看消息中间件是否有对应语言的官方或社区客户端。
Kafka 作为最成熟的产品,支持的语言自然更多,Pulsar 还在追赶阶段,这点需注意。
3.2.4.5 流式支持
Kafka Streams 是主流的流处理平台,支持分组、聚合等丰富功能,生态也非常成熟。
Pulsar Functions 是 Pulsar 的轻量级流处理组件,功能大致类似,不过缺乏丰富的 DSL。
我们在第 8 章会专门讲分布式链路追踪等相关内容。
此外,Pulsar 自带一些 Kafka 没有的特性,比如 Geo-Replication(多地域同步)。
3.2.4.6 总结
新技术总有不足,Pulsar 和基于 KRaft 的 Kafka 3 也不例外。虽然两者都有活跃社区和商业支持,但免费开源的特性决定了——偶尔遇到 bug 是常事。
总体而言,Kafka 和 Pulsar 都是很强大的选择,最终选哪个还是要看你的具体场景。Pulsar 在许多方面更具优势,但依然在努力追赶“头部选手”的地位。
你需要自己评估,哪个方案更适合你的业务需求。
3.3 状态快照
事件溯源允许我们通过重放系统自启动以来的所有事件,来恢复应用的当前状态,这一过程被称为“投影(projection)”。
但随着事件数量越来越多,每次重放所有事件(比如在服务重启或部署时)就会变得非常慢,难以应对。
解决这个问题的方法,就是定期保存当前状态的快照。这样,应用启动时只需要加载最近的快照,并重放快照之后的少量事件,而不必从头重放全部事件。
我们前面讲过,主题压缩(topic compaction)能加快数据回溯(参见“主题压缩”部分),但并不是所有场景都能用压缩,尤其是那些要求完整事件历史用于审计的事件溯源主题。这时候,快照就是最佳替代方案。
在交易系统实战中,我们会实现一个轻量级的快照机制:由专门的服务负责写入快照,另外两个服务进行读取。
值得一提的是,Akka Persistence(见“应用集群”部分)就内置了快照特性,是 Akka Cluster 推荐的事件溯源方案。
3.3.1 保留策略(Retention policy)
事件溯源意味着事件要被持久化在可靠的 topic 上,这样服务才能随时重放任意时间点的事件流。
但如果每天处理上百万甚至上亿事件,长期保留全部事件会极大占用磁盘空间。于是“保留策略”就显得很重要。
通常,在事件溯源场景下结合快照机制使用,可以让我们无需永久保存所有事件(除非出于审计等特殊需求)。
即使真的要满足审计需求,也有更好的做法,比如把老旧事件转存到数仓,或者只把关键数据存入专用数据库,而不是依赖千万级别的原始事件流。
有了状态快照,我们可以把 topic 的保留策略设得比较短。大多数消息中间件都提供合理的默认值,也支持自定义调整。
Pulsar 使用 Apache BookKeeper 作为持久化引擎(分布式预写日志 WAL)。默认情况下只保存未被 ACK 的消息,但具体行为可以通过保留策略(retention policy)灵活调整。
总之,系统管理员和应用开发者需要协作,合理配置这些设置,才能最优利用系统资源。
当然,这还不够。对这类系统,监控与告警同样不可或缺——第 8 章会详细讲解相关内容(参见“监控”)。
3.4 模式演进(Schema evolution)
模式演进的核心就是处理跨时间的兼容性问题。每当我们修改数据类型的结构时,都必须确保其他系统能够识别并正确处理这种变化。否则,系统很容易因为模式不兼容而“炸锅”。
因此,模式变更必须在设计阶段就仔细考虑好,事后再补救就太迟了。
应对模式演进主要有两种方式:兼容性保障 和 版本控制。实际中也可以两者结合,但一般选一种即可。
3.4.1 模式兼容性(Schema compatibility)
模式兼容性指的是:当我们发布了带有破坏性变更的新服务版本时,老版本和新版本仍然能互相处理彼此的数据。
常见的兼容性策略有:
- 向后兼容(backward compatibility) :新版本能读取老版本产生的数据;
- 向前兼容(forward compatibility) :老版本能读取新版本产生的数据;
- 完全兼容(full compatibility) :既向前又向后兼容。
大多数消息系统,通常只要求向后兼容就足够了,向前兼容虽然好,但不总是能实现。
举个 JSON 的例子来说明。假设最初我们的数据格式是这样的:
{
"uuid": "171c546a-734c-479e-927e-33ddea086e50",
"value": "foo"
}
后来我们加了一个字段:
{
"uuid": "171c546a-734c-479e-927e-33ddea086e50",
"value": "foo",
"code": 403
}
这会发生什么?——要看情况。如果数据生产方不了解 code 字段,就只能把它硬编码为某个固定值。如果只是数据消费方关注这个 JSON,老的解码器仍然可以正常解析,只不过 code 字段它不会用。
如果我们有办法明确 code 字段是不是可选的,那兼容性就更好把控了。这正是“模式”所做的事情——为系统各方制定一份“协议”,让大家都遵循。
可惜,JSON 本身并不支持模式描述。如果是 Scala 服务之间通信,且共用领域模型,就可以直接用 Option 类型表达新字段的可选性:
case class Event(uuid: UUID, value: String, code: Option[Int])
这样生成的 JSON 解码器就能自动兼容有无 code 字段两种情况。
但如果涉及跨系统通信,就必须借助专门的 schema 协议,比如 Avro、Protocol Buffers、Thrift 等。
用 Avro 表达上述变更,可以这样写:
{
"type" : "record",
"name" : "Event",
"namespace" : "org.arxiv.domain",
"fields" : [ {
"name" : "uuid",
"type" : {
"type" : "string",
"logicalType" : "uuid"
}
}, {
"name" : "value",
"type" : "string"
}, {
"name" : "code",
"type" : [ "null", "int" ],
"default" : null
} ]
}
如前所述,Avro 可以无缝集成到 Kafka,Pulsar 也原生支持 Avro 格式的 schema。
3.4.2 版本控制策略(Versioning strategies)
应对模式演进的另一种方式是:针对破坏性变更直接发布新端点。如果你做的是 Web 服务,API 版本号一定要加到 REST 路径里。
无论设计阶段觉得是否“必要”,我都强烈建议这么做,比如:
GET /api/v1/users/96bf41a4
而不是
GET /api/users/96bf41a4
这样版本号一目了然。也可以放到 HTTP Header 里,但我更推荐直接写在 URL 路径上,最直观。
未来要发破坏性变更,只需新开一个端点:
GET /api/v2/users/96bf41a4
这样做的好处是:不用协调多个团队就能发布大变更。但缺点是,服务要同时维护多套处理逻辑,维护压力大,必须提前规划好。
这不仅适用于 HTTP API,也适用于消息通信。比如,把内部消息发布到 versioned topic:
v1/users-topic
v2/users-topic
比如,我们有两个用户服务(内部代号 Royal 和 Thunder),它们各自生产和消费消息,如图 3.4 所示。
如果我们上线了 Royal 服务的新版本,并且事件 schema 发生了不兼容的破坏性变更,就会导致 Thunder 服务无法解析最新版本的事件,从而发生故障。
当然,我们也可以让新版本发布到一个新的带版本号的 topic,但这样会导致消息中间件上原有 topic 积压大量“无人消费”的消息。
因此,遇到这种情况,最佳做法应该是优先升级消费端,先让消费者适配新 schema,再上线生产端的变更。
策略是:先上线新版 Thunder 服务,让它能同时从两个不同版本的 topic 拉取消息,并且能理解两种 schema——即使此时 v2-events topic 还没有新消息。
这样一来,等消费端部署好了,生产端就可以自由发布新版本了。如图 3.6 所示。
和 HTTP 服务一样,消费端服务也需要维护同时兼容两个版本的解析逻辑。因此,在后续清理旧代码时,必须仔细协调,确保不再需要时可以安全移除。
这还包括移除像 v1-events 这样的旧 topic,并确保所有相关消息都已经被成功处理。
总体来说,这是一种简单高效的方案,适用于大多数场景。不过,如果想获得更强的保障,schema 兼容性校验会更加可靠。
3.4.3 Schema 注册中心
Apache Kafka 支持与独立的 schema 注册中心(schema registry)协同工作。注册中心负责记录每个 topic 使用的数据 schema,应用通常会在启动时从注册中心加载 schema。
前面提到,Avro 是个很棒的选择,业界有很多 schema registry 方案,既有自建的,也有云服务。
而 Apache Pulsar 内置了 schema registry,客户端可以为每个 topic 上传数据 schema。schema 用来定义该 topic 上哪些数据类型是合法的。
实际使用时,生产者和消费者会在启动时检查 schema 兼容策略,如果发现不兼容就会启动失败(抛出运行时错误)。
无论是哪种方式,都强烈建议把所有 schema 纳入版本控制系统,方便团队成员随时查阅和管理。
3.5 小结
在很少的篇幅里,我们已经快速梳理了大量基础主题——就像前两章一样,目的都是尽量减少理论、避免把书写得枯燥乏味。
尽管如此,以上内容已经足够为你今后进一步深入学习指明方向。
既然你已经顺利读完理论部分,先恭喜你!接下来的章节,我们将会大量实操 Scala 3 代码!