华泰证券:事件驱动型微服务架构的实践与探索

科技   2024-09-25 15:00   辽宁  

作者 | 毕成功
策划 | FCon 全球金融科技大会
审校 | 罗燕珊

在金融交易系统的设计中,微服务架构为应对复杂的业务需求提供坚实支持。华泰证券在构建“大象交易平台”时,深入探索了事件驱动型架构的实践,尤其是在固定收益交易场景中,实现了多资产实时定价、风险控制和对冲等复杂业务的高效处理。

2024 FCon 全球金融科技大会上,华泰证券 FICC 平台架构团队负责人毕成功围绕《事件驱动型微服务架构的实践》进行分享,聚焦事件驱动型架构的设计与实施,结合平台的实践经验,探讨如何在金融交易系统中实现服务解耦、提升系统灵活性,并有效应对异步通讯带来的技术挑战。

以下是演讲实录(经 InfoQ 进行不改变原意的编辑整理):

今天我将分享华泰证券在微服务架构实践的一些心得,特别是在事件驱动型微服务架构方面的应用。我们开发的系统名为“大象交易平台”,这是一个专注于固定收益领域,能够处理多资产实时定价、风险控制和对冲等复杂交易的综合交易平台。我们的系统在业内获得了广泛的认可,最近更是荣获了中国人民银行颁发的金融科技发展一等奖,这是对我们工作的极大肯定。借此机会,我想与大家分享我们在构建大象交易平台过程中积累的经验,以及我们对于微服务架构建设的深入思考

交易系统的微服务架构如何选择

在探讨微服务架构时,我们不可避免地会面对一个问题:为什么我们在构建交易系统时不采用经典的微服务框架,比如 Dubbo 等,这些在互联网行业中已经被广泛使用并充分理解其优缺点的技术。今天,我将尽力解释这个问题,并分享一些我们在实践中的经验和思考。

耦合和系统稳定性

在券商类系统中涉及很多交易链路,如果我们使用像 Dubbo 这样的 RPC 框架,它会导致上游服务对下游服务产生强依赖。这种依赖关系意味着,如果下游服务不稳定,上游服务也会受到影响,导致整个系统的稳定性下降。虽然我们可以通过引入降级机制来应对这种情况,但如果每个环节都需要降级处理,这将使得系统变得异常复杂。

从下单到完成交易,整个交易链路的调用链非常长,这进一步降低了系统的稳定性。此外,由于每次调用都需要进行 RPC,这会导致性能问题,如 TPS(每秒事务处理数)表现不佳。RPC 架构虽然在扩展性方面表现良好,但这种扩展性很大程度上是因为服务本身是无状态的。然而,这种无状态实际上是一个伪概念,因为服务的状态实际上是集中在数据库中。数据库成为了系统的中心节点,其稳定性对整个系统的稳定性有着极大的影响。众所周知,有状态的数据库的扩展性也并不理想。

接⼝膨胀

在微服务架构中,接口膨胀是一个不容忽视的问题。以用户资源(user)为例,随着业务的发展,我们可能需要对这个单一资源执行多种操作,这就要求我们提供各种各样的 API。这些 API 的数量会随着时间的推移而不断增加,呈现出一种几乎无限增长的趋势。对于单个资源的操作是这样,但如果我们从整个系统的全局角度来看,这个问题可能会变得更加严重。

随着 API 数量的不断增加,我们需要进行服务治理,比如绘制服务拓扑关系图。即使我们绘制出了这样的拓扑图,实际上我们也会发现很难对其进行有效的管理和优化。这种现状与架构本身是共生的,绘制出的拓扑图在实际应用中的价值并不大,除非是在寻找单个调用出错时,用来追踪调用链可能会有一些帮助。

不同场景下的技术选型差异

在技术选型时,我们不能一概而论地说某种架构好或不好,因为这实际上取决于不同的应用场景。以互联网场景为例,之所以采用经典的 RPC 架构,是因为这类场景通常需要处理高吞吐量的任务,如秒杀活动,面向的是庞大的互联网用户群体。在这种场景下,系统可能需要动态扩缩容以节约成本,同时在特定时期扩充容量以支持业务发展。此外,同步开发方式也便于编写代码,适应互联网业务快速发展的需求。

金融交易场景与互联网场景有着显著的不同。金融交易系统首先追求的是极速交易链路,即低延迟,通常要求达到微秒级。在这种情况下,传统的同步调用 RPC 架构就无法满足需求。其次,金融交易系统对稳定性的要求极高,我们希望尽可能减少外部依赖,以确保系统的稳定性。例如,如果数据库这样的第三方中间件出现问题导致系统不稳定,我们依然可能会面临监管部门的处罚。

金融交易场景还有一个特点是,它不像互联网那样向不同的业务扩展,而是在一个业务领域内不断增加新的场景,如新的交易所、新的交易品种。这意味着业务会变得日益复杂,但我们仍需保持系统的简洁,避免臃肿,同时保持开发的可维护性。

基于这些考虑,业内已经形成了一种共识:几乎所有新的交易系统都采用了事件驱动型的开发模式。这种模式经过实践的沉淀和筛选,被认为是较好的解决方案。归根结底,无论是哪种业务场景,我们都在寻求解决问题的最佳方案,以使系统更容易开发,实现高内聚、低耦合,让服务开发更简单、更可靠。

事件驱动型架构的实践与实现⽅案

在今天的演讲中,我想重点探讨事件驱动型架构方案。尽管这个概念被广泛讨论,但目前并没有一个公认的开源框架作为标准,这导致许多团队都在自行开发适合自己需求的解决方案。今天我想分享我们对事件驱动型架构的理解以及实施方法。

我认为事件驱动型架构的核心优势在于它改变了服务之间的关系,与传统 RPC 架构相比,这是一个显著的区别。

在传统的 RPC 模式中,上游服务是数据的持有者,它决定了下游服务的行为,而下游服务则是被动响应的。但在事件驱动型架构中,上游和下游服务之间实现了解耦。上游服务不再直接控制下游服务,而是通过发布(pub)事件来通知,下游服务则根据需要订阅(sub)这些事件。这种模式不仅实现了异步化,还支持一对多的场景,即上游服务无需感知下游服务的增加或退出。

这种异步化的模式解决了上下游服务之间的依赖问题,同时也避免了长调用链的问题,转而形成了一种多级流水线的关系。我们的理解是,事件驱动型架构不仅仅是服务之间的解耦,更重要的是它解决了数据之间的关系。下游服务需要对自己的数据负责,将关心的数据订阅到本地,然后对本地数据进行处理。

这种方法有效地避免了之前提到的两个问题:首先,下游服务不再依赖数据库,因为它可以将数据缓存在本地内存中,即使数据库出现问题,也不会影响到服务的运行。其次,由于服务只需订阅数据而不需要暴露所有 API,接口膨胀问题也得到了解决。因此,服务之间的交互变得更加简单,只需要关注数据的订阅和处理。

我们认为 事件驱动型架构不仅仅是改变了服务之间的交互方式,更重要的是它优化了数据处理的方式,使得系统更加灵活、高效和稳定。

事件的类型与特征

在探讨事件驱动型架构时,我们需要明确一个概念:并非所有事件都是相同的。实际上,事件可以分为几类,每类事件都有其独特的处理方式。我们通常将事件分为三大类:数据类(Data)、指令类(Command)和查询类(Query)。

  1. 数据类事件:这类事件通常与业务数据相关,例如参考数据或成交信息。这些数据是下游服务关心的业务数据本身,通常需要被存储下来。在传统架构中,这些数据可能会通过消息队列(MQ)进行传递。

  2. 指令类事件:这类事件更像是一些不需要保存的过程性指令,它们与 RPC 框架中的方法调用类似,但区别在于它们是异步的,不追求返回结果。指令类事件主要关注执行过程,而不是数据的持久化。

  3. 查询类事件:这类事件与指令类事件不同,它们需要有返回结果。查询类事件的目的在于获取信息,如果没有返回结果,那么查询就失去了意义。所以说查询类事件在某种程度上与同步请求相似。

为什么要区分这三类事件呢?这主要是基于它们被驱动的方式。数据类事件是由数据驱动的,其特征是需要将数据存储下来。而指令类和查询类事件则属于过程性事件,它们不需要被保存,而是关注于请求的过程。这里有个问题需要考虑,即查询类事件虽然与指令类事件不同,但它们都是过程性的。在技术实现上,使用混合的协议来处理事件也是可行的。例如,可以使用 RPC 框架来处理查询类事件,因为它们需要同步返回结果。同时,也可以使用异步的总线能力来模拟同步查询,这只是一个技术选择问题,并不是最关键的因素。事件驱动型架构强调的是事件本身,而不是事件系统中的通信中间件。

在事件驱动型架构中,一个关键的考量是事件是否会引起状态变更。对于数据类事件,例如交易成交信息,通常会涉及到状态的变化。在业务逻辑中,我们可能会使用状态机来管理这些状态的变更,这类事件会不断发布最新的交易状态。

对于查询类事件,情况则相对简单,因为查询操作本身并不会影响数据的状态,它只是检索现有的数据。

指令类(command)事件则处于这两者之间,它们的作用和影响需要更细致的解释。理论上,指令类事件是用来驱动数据变更的,但它们本身并不是变更。它们将变更的指令发送到上游的数据源。如下图,当服务 A 接收到一个下单的指令,它可能会触发一系列动作,最终导致成交。然后,服务 A 会将成交结果发布(pub)出去。对于下游服务而言,它们不关心这个状态变更是由指令类事件直接触发的,还是由定时任务或其他任何情况引起的。下游服务只关心当数据发生变化时,它们需要订阅并响应这些变化。这种机制实际上在上游和下游服务之间产生了一定程度的解耦。

数据:存哪里

在事件驱动型架构中,数据存储的位置是一个重要的考虑因素。可能有人认为,如果使用了像 Kafka 这样的总线,数据自然就会存储在 Kafka 中,但实际上,数据存储的策略远比这更为复杂和多样化。

首先,我们有本地缓存的策略。如上图所示,服务 B 会将自己的状态缓存到本地内存中。这样做的好处是,服务 B 可以直接读取本地数据,这不仅加快了访问速度,避免了跨进程通信,还减少了对上游服务 A 的依赖。因为服务 B 关心的数据已经存储在本地,它可以直接从本地读取,而不需要每次都向服务 A 请求数据。

其次,我们有旁路集中存储的策略。我们的设计中有一个非传统的观点:总线的持久化并不影响整个系统架构。我们不依赖总线的持久化,而是使用旁路持久化来替代。在实际使用场景中,如果服务 C 没有缓存它关心的数据,或者数据使用频率很低,那么旁路存储就可以作为一个回查的逻辑,帮助服务 C 进行查询。旁路存储的另一个作用是作为服务 B 状态的冗余副本。如果服务 B 崩溃,它可以从旁路存储中恢复状态。此外,我们使用类似数据库的结构来存储旁路数据,以便于数据的使用,同时对现有结构没有任何冲击。

最后是可选快照文件,将数据文件化以加速查询。例如,对于交易时段内变化不大的数据,我们可以将它们文件化,这样在查询时可以显著提高性能。这种方法适用于数据变化频率低的场景。

另外,还有一个特别有趣的地方,有一个技术概念叫 CQRS(Command Query Responsibility Segregation),即命令和查询责任分离。在传统的 RPC 设计中,要实现 CQRS,通常需要准备一套独立的读服务。但在我们的事件驱动型架构中,读服务已经天然独立出来,恰巧就符合上了 CQRS 的理念。

数据:存储结构

在探讨数据存储方式时,我们经常被问到一个问题:为什么需要使用数据库来存储数据?这个问题的答案与我们选择的数据存储结构密切相关。我们主要使用两种数据结构:一种是类似于 Kafka 的 append-only 模式,另一种是业内称为物化表的 upsert 模式。

Append-only 模式非常简单,它允许数据持续追加,类似于日志系统。这种模式非常适合历史回放的场景,因为你可以通过调整游标位置来回溯到过去的数据状态。

物化表的 upsert 模式则更为复杂。这种模式的核心在于,如果数据已经存在,就更新它;如果不存在,就追加它。这种方式确保了每个数据项始终保持在最新状态。这种结构特别适合那些只关心最新状态而不需要了解历史变更的查询场景。例如,在服务缓存恢复时,通常只需要知道数据的最后状态,而不需要进行完整的历史回放。

使用数据库的原因在于它能够支持更多样化的查询方式。除了基本的游标查询,数据库还能够支持基于时间或其他参数的复杂查询,这是日志型存储如 Kafka 所无法提供的。此外,物化表这种结构在 Kafka 等日志型存储系统中难以实现,因为它们通常不支持 upsert 操作,也无法提供丰富的查询功能。

数据:怎么获取

在讨论数据如何获取时,我们关注的是服务间的数据查询,而非用户查询。用户查询通常涉及 OLAP 操作,如求和、平均等分析性操作,而服务查询则更侧重于数据的恢复和实时性。服务查询通常涉及两种操作:query(查询)和 sub(订阅)。我们强调一个重要原则,即希望以统一的方式获取数据,使得 query 和 sub 的操作方式一致。

首先,对于 query 操作,我们通常使用 SQL 来查询数据库,这是非常直观的。但我们也希望 sub 操作能够使用 SQL。但像 Kafka 这样的消息系统通常不支持 SQL,只提供各种 SDK,但是我们可以通过抽象,将 sub 操作模拟成类似 SQL 的操作,实现统一的使用模式。为什么要这样做呢?我们认为,在这种场景下,query 操作可以被理解为在时间轴上对历史数据的 sub 操作。本质上,它就是 sub 操作。如果 query 和 sub 是同一种操作,那么它们应该使用相同的查询模式。

基于这个思考,我们进一步提出了一个实现上的策略:既然 query 是对某个时间点开始的历史数据的 sub,那么 sub 和 query 操作能否使用相同的 SQL 语句呢?显然,它们之间存在一个差异,即 query 操作需要指定一个时间范围,比如从今天开盘时间开始。这个时间范围条件在 sub 操作中是始终成立的,只是 query 时需要额外指定。因此,除了这个时间戳范围条件外,sub 和 query 的 SQL 应该是一致的。

在实现上,我们希望 query 和 sub 的能力是对等的,即以 sub 的能力为基准,对 query 的能力进行适当的限制,使其与 sub 的能力相匹配,只是多了一个时间戳范围条件。这样,我们就可以实现一个统一的数据获取方式,无论是通过 query 还是 sub,都能以类似 SQL 的方式进行,简化了服务间的数据交互和查询操作。

可用性:内存状态的异常恢复

当服务发生故障并需要重新启动时,如何确保服务能够准确地恢复其内存状态。这涉及到两个主要动作:对历史数据的查询(query)和对未来新数据的订阅(sub)。这两个动作必须确保数据的完整性,即不丢失任何数据,也不重复任何数据。

为了实现这一目标,我们引入了 q&s 的概念,即将查询和订阅封装成一个原子语义操作。这样做的目的是确保在服务重启后,能够无缝地恢复到故障前的状态。实现 q&s 的方法之一是先进行订阅,然后进行查询,确保查询的数据范围比订阅的数据范围稍大,以覆盖可能的重叠部分,并通过去重确保数据的一致性。

我们选择使用统一的 SQL 来实现 q&s,这样做的好处是减少了 query 和 sub 操作不一致导致的问题,因为它们都是基于相同的 SQL 逻辑。这样的一致性有助于避免数据恢复时出现意外。

在数据源的选择上,我们有两种主要方式:

  1. 从流水恢复:这种方式类似于回放,它基于时间轴,通过查询历史数据来恢复状态。这种方式对于业务逻辑处理来说非常直观,因为查询的数据本质上就是订阅的数据,处理逻辑相同,易于实现。

  2. 从快照恢复:这种方式利用物化表,即在特定时间点对数据进行快照。从某个快照点开始恢复,可以显著提高恢复速度。这种方式适用于大多数场景,尤其是当服务在交易时段发生故障时,从快照恢复比从流水回放要快得多。

可用性:有状态服务的高可用

在讨论有状态服务的高可用性时,我们的目标是确保服务在面临故障时能够迅速恢复,并且尽可能减少停机时间。虽然单个服务的快速恢复很重要,但我们更希望服务本身就具备高可用性,以避免长时间的服务中断。

有状态服务实现高可用性比无状态服务要复杂得多。通常,多活部署对于有状态的服务来说比较困难,因此,业界普遍采取的策略是实现热备。在金融交易等业务场景中,由于交易量突增的情况较少,且服务的内存化处理使得吞吐量非常高,因此热备通常已经足够满足需求。此外,通过业务分片,将不同的交易品种和交易所拆分为独立的服务,也能提高整体的吞吐量,从而减少对单个服务高可用性的需求。实现热备的策略主要有两种:

  1. 同步消费模式:这种模式下,热备服务与主服务同步消费总线上的消息,但热备服务不发布处理结果,类似于一个“哑巴”服务。在需要切换时,通过 Monitor 服务协调完成主备切换。这种方式的优点是主备服务互不干扰,但缺点是可能会限制业务逻辑处理的性能,并需要对代码进行较大改动。此外,验证主备服务的状态一致性也比较困难,通常需要在特定时间点进行对账。在主备切换时,还需要总线支持协调,这对总线提出了额外的要求。

  2. 内存状态同步模式:这种模式借鉴了数据库的主从同步思路,将主服务的内存状态变更同步到热备机器,并确保热备服务的消费进度与主服务保持一致。这种方式的优点是对业务逻辑的侵入性较小,不依赖于特定的总线。但缺点是需要额外的机制来支持内存状态的同步,可能会对主服务的性能产生一定影响,尽管可以通过事后同步和幂等操作来最小化这种影响。

目前,还没有看到特别好的开源解决方案来实现有状态服务的高可用性,虽然有一些商业解决方案,但可能成本较高。如果选择自己开发,需要投入大量的开发工作。

事件驱动架构的
实践挑战与应对策略

在介绍事件驱动型架构的设计之后,我们确实在实际应用中遇到了一些问题。正如查理·芒格所说:“如果我不能比这世界上最聪明的人更能反驳这个观点,我就不配拥有这个观点”。这句话提醒我们,任何观点都需要经过严格的检验和反思。在事件驱动型架构的实践中,我们遇到了如下一些问题。

事务难以支持

当系统采用事件驱动型架构时,它变成了一个异步且分布式的系统。在传统的 RPC 微服务架构中,分布式事务的处理已经相当复杂,而在事件驱动型架构中,这个问题变得更加棘手。为了解决这一问题,我们通常依赖 BASE 原则,但依据数据库 ACID(原子性、一致性、隔离性、持久性)的原则来审视,其原子性的诉求是存在且合理的,所以需要支持 Batch 的使用模式。

在实现方式上,采用了增加 Batch 相关的数据包头进行标识,发送端采用逐条发送,接收端一批全部收到后再进行回调。这就是类似 TCP 拆包、组包的思想。此外,通过 Batch 模式与数据库写入事务的绑定,我们也能确保数据库在旁路存储中数据写入的原子性,以避免写入一半时发生故障,导致数据不一致。

异步通讯的管理难题

在事件驱动型架构中,异步通讯带来了许多优势,尤其是在增加系统架构的灵活性和运行时的灵活性方面。然而,这种灵活性也带来了新的挑战,尤其是在开发阶段,异步通讯可能导致系统更容易出现混乱。与同步微服务框架相比,异步通讯需要更严格的管理来平衡其带来的问题。

我们的管理策略主要集中在两个关键的通讯媒介上:Topic(主题)和传输的数据传输对象(DTO)。在开发阶段,我们会实施统一的管控,包括对 Topic 和 DTO 的定义。例如,我们会设定哪些 Topic 是敏感的,以及哪些服务有权限订阅这些 Topic,通过白名单等约束来严格控制这些规则。这样的管控相当于将框架内的耦合和约束转移到了开发过程中的工具上,以此达到一种平衡。

在运行时,我们也不会完全放任不管。总线作为中央节点,其稳定性和扩展性是关键考虑因素。如果总线出现问题,影响将是巨大的。过去 SOA 架构逐渐被淘汰,很大程度上就是因为中央节点难以扩容。因此,我们在使用总线时,也必须关注其潜在的瓶颈问题。为了应对这些挑战,我们采取了一种非强制性的控制策略。我们在观测性方面做了很多工作,比如依赖管理、DTO 版本的监控等,通过上报机制和控制面板来进行管理。同时,我们还实施了一些保护措施,如流量控制,以防止某个服务出现问题时对整个系统造成影响。

总线的集中式风险

总线天生的集中式风险,这种风险是不可忽视的,因为它关系到整个系统的稳定性和可靠性。我们不能对这种风险视而不见,而应该积极思考如何避免或减轻它的影响。

一个理想的解决方案是按照业务逻辑进行隔离,将系统划分为多个独立的子系统,每个子系统有自己的总线。这样,每个子系统可以独立运作,互不干扰。然而,在实际操作中,这种理想状态往往难以实现。

为了应对这一挑战,我们在内部实现了一个称为“域”的概念,或者可以理解为集群。每个业务体系使用一个集群,并在该集群内共享一个总线。这样,不同的集群之间通过各自的总线进行通信,减少了集群间的直接依赖。

当然,即使在这种架构下,集群之间的交互仍然不可避免。为了管理这种交互,我们在框架层面实现了多接入的能力,并通过白名单控制来限制哪些服务可以跨集群通信。这种白名单机制有助于我们精确控制服务间的调用关系,避免出现混乱的调用图。这种架构的好处在于,它没有引入额外的通信跳数,通信仍然在框架层直接进行,本质上还是点对点的直连。

在某些特定场景下,例如与上下游系统的对接,我们可能希望进一步隔离系统间的直接交互。这时,我们可以引入一个称为“桥接器”的组件,将流量引导到另一个地方。通过桥接器,我们可以将重要性高的数据流向重要性低的系统,而很少进行反向流动。这样做有助于保持系统边界的清晰和整洁。但是,引入桥接器也会带来新的风险点,因为它本身是一个额外的服务组件。因此,我们通常会将桥接器用于与重要性较低的系统或旁路系统的交互,并且最好将其设计为单向的,以减少系统间的复杂依赖。

总结与建议

虽然我们讨论了许多关于事件驱动型架构的设计细节,但这种架构的复杂性意味着它并非适用于所有场景。我们认为,只有在业务逻辑非常复杂的情况下,比如金融交易领域才适合采用这种架构。

首先,使用这种架构的一个核心前提是接受最终一致性。由于异步架构本质上无法解决传统意义上的事务问题,我们依赖于最终一致性来确保系统的正确性。这意味着业务逻辑必须能够容忍在某些情况下数据的暂时不一致。这是一个重要的先决条件。

其次,仅仅对单个服务进行改造,而不考虑其上下游的服务,通常效果甚微。事件驱动型架构的一个主要优势是能够实现服务间的解耦,但如果只有单个服务进行改造,这种解耦的优势就无法体现。因此,建议在考虑采用这种架构时,应该将相关的上下游服务一起纳入 改造范围。这可能会对现有的架构体系产生较大冲击,因此,选择一个独立的业务系统或场景进行尝试,可能是一个更为有效的策略。

最后,对于异步系统的管理至关重要。随着系统规模的扩大,如果没有强力的中台支持,系统很容易失控。例如,在使用 Kafka 等消息队列时,如果没有适当的管理,系统很快就会变得混乱,新增的 Topic 或订阅可能无人管理,导致资源浪费和维护困难。因此,建立一个有效的中台管理体系,对于确保异步系统的有序运行和健康发展是非常必要的。

嘉宾介绍

毕成功,华泰证券 FICC 平台架构团队负责人。2021 年加入华泰证券,带领 FICC 平台架构团队,负责大象交易系统的平台架构工作。目前主要着力于建设具有“超低延时、内存计算、事件驱动”的金融型架构体系。哈尔滨工业大学计算机硕士。

今日好文推荐

平安证券现象级应用:复用率高达 191.44% 的微卡片平台是如何构建的

工业制造的智能化转型:从传统决策到运筹优化

不要掉入“AI 工程就是一切”的陷阱

滴滴分布式数据库选型技术实践

会议推荐

InfoQ 将于 10 月 18-19 日在上海举办 QCon 全球软件开发大会 ,覆盖前后端 / 算法工程师、技术管理者、创业者、投资人等泛开发者群体,内容涵盖当下热点(AI Agent、AI Infra、RAG 等)和传统经典(架构、稳定性、云原生等),侧重实操性和可借鉴性。现在大会已开始正式报名,可以享受 9 折优惠,单张门票立省 480 元(原价 4800 元),详情可联系票务经理  17310043226 咨询。

InfoQ 架构头条
InfoQ旗下,专注于软件开发基础技术的专业公众号。 在这里,你可以看到涵盖架构、云计算、运维、数据库、安全、编程语言、程序员周边等全领域的干货内容。 帮助广大开发者更好地把握技术脉搏,找准技术方向,了解前沿技术落地实践。
 最新文章