【ArchSummit架构师峰会】探讨数据与人工智能相互驱动的关系>>> 了解详情
写点什么

通过 CQRS/ 事件溯源框架 Reveno 实现高负载交易事务处理

  • 2016-06-14
  • 本文字数:8467 字

    阅读完需:约 28 分钟

在当今世界,事务的处理每时每刻都在发生,其范围包括使用关系型数据库进行订购处理的个体零售网站,乃至每秒进行 10 多万次处理的实时交易系统。

Reveno 是一个全新的无锁事务处理框架,它支持 JVM 平台,并基于 CQRS 及事件溯源模式实现。虽然它只是一个简单而强大的工具,但在性能方面也毫不逊色。所有事务都被持久化为只读的日志,并且可以通过按顺序重演事件的方式恢复领域模型的最新状态。所有的运行时操作都是在内存中执行的,从而使其吞吐量可达到每秒种几百万次事务的数量级,平均延迟时间则限制在微秒级。虽然 Reveno 的功能如此强大,但它仍然是一个通用目的的框架,涵盖了大量不同种类的用例,并提供了丰富的引擎配置选项。举例来说,你可以调整它的持久性配置,在极其随意(为了提高系统吞吐量)与高度受限(对于数据丢失的情况具有较低的容忍度)之间进行选择。

对于不同的用例来说,其需求也是千差万别的。作为一个通用框架,它需要考虑所有不同的可能性。在本文中,我将通过示例为读者展现如何使用 Reveno 框架开发一个简单的交易系统。

首先让我们来了解一下 Reveno 如何处理你的领域模型。作为一个基于 CQRS 的框架,Reveno 会将你的领域切分为 _ 事务模型与查询模型 _。对这两个模型的定义没有任何限制,你无需使用任何强制性的注解、基类,甚至不需要实现 Serializable 接口,只需使用最简单的 POJO 就可以完成任务。

没有任何一种单一的途径能够应对所有不同的用例,因此应用程序的设计者需要决定如何处理事务模型的回滚操作。Reveno 为实现模型对象提供了两种主要方式:可变与不可变模型。这两种方式各有不同的底层处理机制,并且各有其优缺点。在 Java 中,不可变对象的开销很低,并且无需进行同步操作,这种方式目前非常流行。Reveno 能够非常高效地处理不可变对象,但每种使用不可变类型的领域都会产生额外的垃圾对象,Reveno 也不例外。因此,如果你能够接受一些额外的GC 开销,那么就应当将这种方式作为默认的选择。反之,如果使用可变模型,那么在进行事务运行时,就要为已使用的对象生成额外的序列化快照,而这将影响到你的性能。幸运的是,如果你坚持使用可变模型,并且仍需要保证性能的最大化及GC 影响的最小化,那么你可以选择一种额外的可变模型特性,“补偿行为”(Compensating Actions)。简单来说,补偿行为是指在实现常规的事务处理函数时,一并实现的一种手动回滚行为。如果读者想了解这方面的更多细节,请参考 Reveno 的官方文档页面

现在,我们已经设计好了一些基本规则,那么让我们进行一些实际的编码工作吧。在我们所设计的交易系统中存在大量的帐户,每个帐户可以有零至多个订单。我们必须提供各种维护性操作,例如帐户的创建和订单的处理等等。在实际应用中,这种类型的系统可能需要应对大量的负载,例如每秒种处理 10 个直至 50 万个事务,甚至更多。更复杂的是,此类系统对于延迟非常敏感,而频繁出现的负载峰值可能会直接造成财务损失。

安装

如果你正在使用一些流行的构建工具,例如 Maven、Gradle、Sbt 等等,那么你可以在 Maven Central 中加入一个 Reveno 的依赖。目前为止,共有 3 个可用的库能够选择:

  • reveno-core —— 包括所有 Reveno 核心功能包,负责引擎的初始化和事务处理等等。
  • reveno-metrics —— 这个库中的包负责从运行中的引擎中收集各种指标,并将这些指标传递给 Graphite、Slf4j 等工具。
  • reveno-cluster —— 支持在主 - 从架构的集群中运行 Reveno,以提供故障转移的能力。

你可以在 Reveno 安装页面中找到完整的安装指南与示例。

定义事务模型

让我们首先从领域模型的定义开始我们的开发过程。正如我们之前所说,领域模型将通过简单的 POJO 进行创建。我个人倾向于使用不可变对象,因为这将大大简化整个工作。它们不仅能够绕开各种并发问题,并且最重要的一点在于,由于不需要保留已访问对象的快照,因此它在 Reveno 中有非常出色的性能表现。Reveno 允许我们直接使用不可变对象(可以说,Reveno 也成为了一个帮助我们学习如何在常规的 Java 应用中处理不可变性的优秀教程)。

让我们首先定义一个表现系统中典型的交易帐户的实体(为了简单起见,我们将实例变量都定义为 public,但在实际应用中并不存在这种限制):

复制代码
public class TradeAccount {
public final long id;
public final long balance;
public final String currency;
private final LongSet orders;
public TradeAccount(long id, String currency) {
this(id, 0, currency, new LongOpenHashSet());
}
private TradeAccount(long id, long balance,
String currency, LongSet orders) {
this.id = id;
this.balance = balance;
this.currency = currency;
this.orders = orders;
}
public LongSet orders() {
return new LongOpenHashSet(orders);
}
}

正如我们所见,这个类是不可变的。但这种值对象并不具备任何功能,往往因此被人称为“贫血”对象。因此,更好的方式是让TradeAccount 类能够实现一些实用的功能,例如处理订单以及进行货币计算:

复制代码
public class TradeAccount {
public final long id;
public final long balance;
public final String currency;
private final LongSet orders;
public TradeAccount(long id, String currency) {
this(id, 0, currency, new LongOpenHashSet());
}
private TradeAccount(long id, long balance,
String currency, LongSet orders) {
this.id = id;
this.balance = balance;
this.currency = currency;
this.orders = orders;
}
public TradeAccount addBalance(long amount) {
return new TradeAccount(id, balance + amount, currency, orders);
}
public TradeAccount addOrder(long orderId) {
LongSet orders = new LongOpenHashSet(this.orders);
orders.add(orderId);
return new TradeAccount(id, balance, currency, orders);
}
public TradeAccount removeOrder(long orderId) {
LongSet orders = new LongOpenHashSet(this.orders);
orders.remove(orderId);
return new TradeAccount(id, balance, currency, orders);
}
public LongCollection orders() {
return new LongOpenHashSet(orders);
}
}

现在,这个类就变得非常实用了。在开始讲述实际的订单处理细节之前,首先要说明一下 Reveno 如何使用它的事务模型。所有的实体都会保存在某个 repository 中,任何类型的处理函数都可以访问该 repository(我们稍后将对此进行详细地讲解)。这些实体相互之前通过 ID 进行引用,并通过 ID 在 repository 中进行访问。由于内部的性能优化机制,所有的 ID 都限制为 long 类型。

Order 类的定义也与之类似,为了简便起见,我们将忽略这部分源代码。不过,你可以在 GitHub 上下载完整的示例代码,并在本文的末尾找到更多的链接。

定义查询模型

我们已经简单地探索了如何创建 Reveno 中的事务模型。从逻辑上说,查询功能的定义也同样关键。在 Reveno 中,查询是通过“视图”的定义而创建的,每个视图都表现了事务模型中的某些实体。除了定义视图类之外,你还应当为每种视图类型提供映射器。我们稍后将对细节进行深入讲解。

当一个事务成功地完成之后,Reveno 将对所改变的实体进行映射操作,以保证视图的更新发生在命令完成之前。在默认情况下,Reveno 中的查询模型是保存在内存中的。让我们为TradingAccount 类定义一个视图:

复制代码
public class TradeAccountView {
public final double balance;
public final Set<OrderView> orders;
public TradeAccountView(double balance, Set<OrderView> orders) {
this.balance = balance;
this.orders = orders;
}
}

TradingAccountView 类中还包括其他种类视图(在这个示例中对应着 OrderView)的一个集合,在进行查询、序列化、JSON 格式转换等操作时,这种方式能够带来很大的便利。Reveno 映射器支持多种实用的方法,以简化将 ID 的集合映射到视图的集合等操作。我们稍后将进行一些实际操作。

定义命令与事务行为

为了在 Reveno 中执行事务,我们必须首先执行一个“命令”对象。命令对象本身可以是一个简单的 POJO,它需要在系统中注册一个特定的处理函数。通常来说,命令将用于执行某些聚合与校验逻辑,以只读方式访问 repository。但最重要的是,命令需要履行它的职责,以发送各种“事务行为”(因此命令也被称为“状态转变器”)。

事务行为是用于在领域模型中进行状态改变的组件,它通过对 repository 的读 - 写访问以执行。事务行为对象本身可以表现为一个 POJO,并在系统中注册对应的处理函数。所有行为组合在一起成为一个单一的原子性事务,它包含在当前所执行命令的范围内。在成功执行完成之后,事务行为将被持久化至底层的存储引擎中,并且在重启或发生任何故障之后重演其状态。

在这个交易系统中,我们需要创建新的交易帐户,并设定初始的余额。与之前的做法一样,我们首先要定义一个事务命令:

复制代码
public class CreateAccount {
public final String currency;
public final double initialBalance;
public CreateAccount(String currency, double initialBalance) {
this.currency = currency;
this.initialBalance = initialBalance;
}
public static class CreateAccountAction {
public final CreateAccount info;
public final long id;
public CreateAccountAction(CreateAccount info, long id) {
this.info = info;
this.id = id;
}
}
}

我们实际上共创建了两个类。CreateAccount 作为命令,CreateAccountAction 则作为事务行为。通常来说,这种切分方式并非强制性的。如果命令与事务行为数据完全匹配,那么你可以放心地重用同一个类。但在这个示例中,我们所获取的货币数值类型为 double(例如来自于某些遗留的终端系统),而在内部引擎中,货币的数值将保存为 long,以确保其精确度能够完全匹配。

现在,我们就可以初始化一个 Reveno 引擎,并定义命令与事务行为的处理函数了:

复制代码
Reveno reveno = new Engine(pathToEngineFolder);
reveno.domain().command(CreateAccount.class, long.class, (c, ctx) -> {
long accountId = ctx.id(TradeAccount.class);
ctx.executeTxAction(new CreateAccount.CreateAccountAction(c, accountId));
if (c.initialBalance > 0) {
ctx.executeTxAction(new ChangeBalance(
accountId, toLong(c.initialBalance)));
}
return accountId;
});
reveno.domain().transactionAction(CreateAccount.CreateAccountAction.class,
(a, ctx) -> ctx.repo().store(a.id,
new TradeAccount(a.id, a.info.currency)));
reveno.domain().transactionAction(ChangeBalance.class,
(a, ctx) -> ctx.repo().
remap(a.accountId, TradeAccount.class,
(id, e) -> e.addBalance(a.amount))
);

这段代码包括的内容很多,让我们仔细分析一下。首先,我们定义了一个 CreateAccount 命令处理函数,它负责生成下一个帐户 ID,并执行了一个事务命令以进行帐户的创建。如果在定义时传入了初始的余额,则还需执行 ChangeBalance 这个事务行为。需要指出的是,ctx.executeTxAction 这个方法调用不会阻塞。当命令处理函数成功完成之后,所有事务行为都会在一个单一的进程中执行。因此,如果需要在任何一个 TxAction 处理函数中进行回滚操作,那么这些事务行为所生成的改动都将被顺利回滚(实际的回滚机制是基于事务模型等实现的)。

将实体映射至查询模型

由于事务模型与查询模型是分离的,因此我们需要定义一些映射器,将实体转换为对应的视图表现。不过,我们并不需要在代码中明确地调用这些映射方法,因为 Reveno 会自动发现 repository 中的“脏”实体,并调用相应的映射方法。让我们看看 TraceAccount 是如何映射到 TradeAccountView 的:

复制代码
reveno.domain().viewMapper(TradeAccount.class,
TradeAccountView.class, (id,e,r) ->
new TradeAccountView(fromLong(e.balance),
r.linkSet(e.orders(), OrderView.class)));

这段代码中的 id 指代实体的标识符,e 指代实体本身,而 r 指代一个特殊的映射上下文,其中包含各种实用的方法。实际完成映射工作的是 r.linkSet(…) 方法,它将以延迟的方式将 ID 指针的集合映射至实际视图的集合。

我们可以通过相同的方式定义 Order 至 OrderView 之间的映射:

复制代码
reveno.domain().viewMapper(Order.class, OrderView.class, (id,e,r) ->
new OrderView(fromLong(e.price), e.size, e.symbol,
r.get(TradeAccountView.class, e.accountId)));

正如你所见,我们的查询模型也是通过不可变对象组成的,这一点与事务模型中的实体一样,它将极大地简化映射逻辑。再次强调,虽然这一点并非强制约束,但如若不然,则我们必须自行负责映射逻辑的正确性。

执行命令

Reveno 中的事务处理操作默认就是异步的。当你在一个运行中的引擎中执行某个命令时,该方法调用将立即返回一个 CompletableFuture 对象,最终将在将来某一时刻返回结果。Reveno 在内部定义了一个具有多个阶段的“管道”,每个阶段将处理各自的线程。在这些管道中如果选择逐个传递对象会产生很大的消耗,因此在这里就可以使用批处理方式。在高负载情况下,Reveno 会在每个阶段处理进行批处理。正因为如此,Reveno 在一开始就为系统提供了高吞吐能力。

在完成了所有定义与业务逻辑实现之后,我们可以开始使用这个引擎了。首先我们需要启动它:

复制代码
reveno.startup();

随后,我们就可以在系统中创建一个新的交易帐户了。你应该留意一点,executeCommand() 方法也存在一个同步的版本,它对于测试以及编写示例非常有用:

复制代码
long accountId = reveno.executeSync(new CreateAccount("USD", 5.15));

在这个示例中,Reveno 内部将调用相应的命令以及事务行为处理函数,后者将为你创建一个新的美元帐户,并将初始余额设为 5.15 美元。我们可以通过以下方式检查它的正确性:

复制代码
System.out.println(reveno.query().find(TradeAccountView.class,
accountId).balance);

这段代码将打印出“5.15”。为了让这个示例看起来更有趣,让我们为这个帐户添加一个新的订单:

复制代码
long orderId = reveno.executeSync(
new MakeOrder(accountId, "EUR/USD", 1, 1.213));

我们在这里创建了一个以 1.213 美元购买 1 欧元的新订单。随后,我们可以再次检查帐户信息,以了解其中的变化:

复制代码
System.out.println(reveno.query().find(TradeAccountView.class,
accountId).orders.size());

这一次所打印的结果是“1”,这表示这个帐户有一个未完成的订单。最后,让我们关闭这个订单,完成这个以美元购买欧元的操作,它将会使帐户中的余额减少 1.213,最终余额为 3.937 美元。

复制代码
reveno.executeSync(new ExecuteOrder(orderId));
// the balance is expected to be 3.937, after order successfully executed
System.out.println(reveno.query().find(TradeAccountView.class,
accountId).balance);

持久化

正如我们在介绍部分所说的一样,Reveno 首先是一个事务处理框架。你可以在同一个目录下重启你的引擎,而仍然能看到模型的最新状态。我们尽可能使该框架的每个部分都具有可配置性,而一点与持久性同样相关。你可以通过调用 reveno.config() 方法查看所有的可选项。

发布事件

Reveno 本身自带一个事件处理子系统。你的任务就是为它定义自己的事件类(通常来说定义为 POJO)以及处理函数,并发布这些事件以处理事务行为。需要指出的重点在于,事件只有在命令执行成功之后才会被发布,并且完全是异步的。所有的视图映射过程都严格地发生于事件处理函数执行之前。

事件的执行结果同样会被持久化到存储引擎中,如果事件成功地完成了,那么在引擎重启之后通常也不会被再次处理。但这种行为并不能得到严格的保障,因此,如果你需要确保处理函数是 100% 幂等的,则应当检查 EventMetadata.isReplay 这个标记,在每个事件处理函数中都可以访问它。

让我们再次扩展一下这个示例,让它对于某个交易帐户中的余额变化事件进行发布与处理。首先,我们将定义这个事件,并添加适当的字段:

复制代码
public class BalanceChangedEvent {
public final long accountId;
public BalanceChangedEvent(long accountId) {
this.accountId = accountId;
}
}

当某个帐户的余额产生变化时,我们只需要了解帐户的 ID,因为我们可以在处理函数中查询相应的视图。我们将通过以下方法定义事件处理函数:

复制代码
reveno.events().eventHandler(BalanceChangedEvent.class, (e, m) -> {
TradeAccountView account = reveno.query().find(TradeAccountView.class,
e.accountId);
System.out.println(String.format(
"New balance of account %s from event is: %s",
e.accountId, account.balance));
});

相应地,我们也需要在 ChangeBalance 事务行为处理函数的定义中添加一行代码:

复制代码
reveno.domain().transactionAction(ChangeBalance.class, (a, ctx) -> {
ctx.repo().remap(a.accountId, TradeAccount.class,
(id, e) -> e.addBalance(a.amount));
// publish an event to all listeners
ctx.eventBus().publishEvent(new BalanceChangedEvent(a.accountId));
});

由于 ChangeBalance 这个事务行为出现在多个命令中,当添加了这段事件发布代码后,我们就会不断收到它的事件。还有一点需要注意,publishEvent 调用会立即返回,而事件的发布则是 _ 最终 _ 某一时刻才会发生的。最终,我们将看到以下输出:

New balance of account 1 from event is: 5.15

New balance of account 1 from event is: 3.937

性能检测

现在,整个示例已经可以运行了,那么让我们来看看这个应用能够处理怎样的负载。Reveno 提供了一个非常实用的reveno-metrics库,能够帮助你追踪某个运行中引擎的性能指标。与其他部分一样,这个指标库也经过了优化,包括堆外内存(off-heap memory)的使用以及无锁的代码,因此它对于整体性能所造成的影响非常小。它还支持与某些流行的监控系统进行集成,例如 Graphite。

(需要指出的是,reveno-metrics 总的来说是一个性能监控工具,而不是一个微基准测试框架。如果要获取准确的基准测试结果,可以考虑使用 JMH 或类似的工具。)

我们选择的环境 MacBook Pro 2.7 GHz i5 CPU,首先要在代码中对指标集合进行初始化,以使用 Reveno Slf4j 这个 sink ,随后重复运行 ChangeBalance 这个命令 4 千 5 百万次(包括用于预热的迭代):

  • reveno.instances.MAC-15_local.default.latency.mean: 68804
  • reveno.instances.MAC-15_local.default.latency.min: 775
  • reveno.instances.MAC-15_local.default.latency.max: 522265
  • reveno.instances.MAC-15_local.default.throughput.hits: 1183396

这些数字表示平均延迟时间约 69 微秒,最小值为 775 纳秒,而最高值则为 522 微秒,总吞量是每秒运行 1183396 次事务。考虑到后台所需完成的各种工作以及持久性级别,这一结果令人印象十分深刻。

结论

Reveno 框架目前才刚刚崭露头角,但它的发展十分迅速。你可以访问我们的官方网站以学习更多的相关知识。我们对于任何建议以及反馈都保持开发的态度。你也可以加入我们的 Google 讨论小组,在 Issues 页面中提交 bug,或是向 mailto:support@reveno.org 提交非公开问题或其他任何问题。

本文中所描述的 Demo 的完整代码可以在 GitHub 上找到,你还能够找到使用 Reveno 的各种示例(或者自行提交一个pull request)。

关于作者

Artem Dmitriev目前在 GetIntent 这家广告科技创业公司担任软件工程师,他的工作是交付大规模实时投标这方面需求的平台。近几年来,Artem 致力于在 JVM 平台上创建高负载的系统。他的工作背景包括为多市场交易平台开发核心引擎,这些平台通常对于延迟与吞吐量有很高的要求。Artem 对于开源软件及其开发充满热情,他热诚欢迎用户的反馈,可以通过 art.dm.ser@gmail.com 向他发送邮件。

查看英文原文 High Load Trading Transaction Processing with Reveno CQRS/Event Sourcing Framework

公众号推荐:

跳进 AI 的奇妙世界,一起探索未来工作的新风貌!想要深入了解 AI 如何成为产业创新的新引擎?好奇哪些城市正成为 AI 人才的新磁场?《中国生成式 AI 开发者洞察 2024》由 InfoQ 研究中心精心打造,为你深度解锁生成式 AI 领域的最新开发者动态。无论你是资深研发者,还是对生成式 AI 充满好奇的新手,这份报告都是你不可错过的知识宝典。欢迎大家扫码关注「AI前线」公众号,回复「开发者洞察」领取。

2016-06-14 19:217482
用户头像

发布了 428 篇内容, 共 172.0 次阅读, 收获喜欢 38 次。

关注

评论

发布
暂无评论
发现更多内容

AirServer2023下载安装教程投屏软件,支持安卓、苹果手机投屏至电脑

茶色酒

AirServer AirServer2023

WSL中使用vcpkg安装pcl库出现编译失败的原因

大伟

【Java应用服务体系】「序章入门」全方位盘点和总结调优技术专题指南

洛神灬殇

Java 技术分析 应用调优 优化指南

水果软件2024FL Studio中文语言版本下载

茶色酒

FL Studio FL Studio 21

Java高手速成 | 多态性实战

TiAmo

编程语言 多态 Java 开发

中原银行对金融行业实时数仓的现状与发展趋势思考

Apache Flink

大数据 flink 实时计算

Best Wishes「兔」You!

阿里云视频云

Kratos微服务框架API工程化指南

喵个咪

golang gRPC microservice Kratos 工程化

架构误区系列11:无聊的架构归一

agnostic

架构治理

2022大数据产业年度“国产化优秀代表厂商”榜单发布,亚信科技AntDB数据库位列其中

亚信AntDB数据库

AntDB 国产数据库 AntDB数据库

复习前端:浏览器缓存策略

devpoint

Service Worker 浏览器缓存 缓存技术

复习前端:前端安全

devpoint

CSRF xss攻击 前端安全

2022下半年盘点:20+主流数据库重大更新及技术要点汇总

亚信AntDB数据库

AntDB 国产数据库 AntDB数据库

MySQL:如果被更新字段的新值与旧值相等,SQL会被真正执行吗?

程序员拾山

MySQL

Studio One2024旗舰级DAW的跨版本升级

茶色酒

Studio One 5 Studio One2023

Portraiture2024汉化版磨皮插件下载

茶色酒

Portraiture2023 Portraiture

工信部电子标准院:龙蜥操作系统获评“优秀”

OpenAnolis小助手

工信部 开源项目 获奖 龙蜥操作系统 生态构建

2022年终总结-无所吊谓

wood

年终总结 稳定 开心 普通的一年

easyrecovery2024非常好用的磁盘恢复工具

茶色酒

EasyRecovery EasyRecovery15 easyrecovery2023

C#/VB.NET 在 Word 表格中插入或提取图像

在下毛毛雨

C# .net 提取图像 word表格 添加图片

亚信科技AntDB数据库荣获2022年度技术卓越奖

亚信AntDB数据库

AntDB 国产数据库 AntDB数据库

FL Studio2024中文完整版电脑编曲软件及配置要求

茶色酒

FL Studio FL Studio 21

2022 年行摄回忆录

穿过生命散发芬芳

摄影 行摄回忆录

2022年的魔力象限领导者,为什么是华为数通?

脑极体

华为

tomcat8和tomcat7性能比较

五毛

tomcat 压测分析

英特尔2022技术创新和产品发布盘点:深耕硬核创新,助推数字未来

科技热闻

“信”创未来 | AntDB数据库2022年度总结,请查收!

亚信AntDB数据库

AntDB 国产数据库 AntDB数据库

MySQL统计总行数:听说count(*)性能更好,是真的吗

程序员拾山

MySQL

从员工批量离职中,认识管理的价值

石云升

极客时间 1月月更 技术领导力实战笔记

二十年,三条路:国产CPU的“饱和式救援”

脑极体

cpu

AntDB数据库助力中国移动结算中心建设

亚信AntDB数据库

AntDB 国产数据库 AntDB数据库

通过CQRS/事件溯源框架Reveno实现高负载交易事务处理_Java_Artem Dmitriev_InfoQ精选文章