早在2015年的时候,我写了几篇文章,介绍如何通过搭载标准Java EE事务管理器以获得跨分布式服务的数据一致性(查看原文请点击这里,基于Spring Boot、Tomcat 或Jetty的实现请点击这里) 。
去年,我有幸在一个小型项目上工作,从头开始,我们就在争论数据的一致性。我们的结论是,还有另一种获得数据一致性保证的方式,这是我在另一篇文章《将资源绑定到事务中的模式》中没有考虑到的。另一种解决方案是将架构从同步架构改为异步架构,其基本思想是将业务数据与“指令”一起保存在单个数据库事务中,指令仍然由其他系统调用,通过将并发事务的数量减少到一个,从而保证数据不会丢失,所有已提交的指令会立即执行,并在新的事务中执行调用远程系统的指令。实际上,这是基本一致性模型的实现,因为从全局的角度来看,数据只是最终一致。
试想一下,更新保险案例的情况应该会导致在工作流系统中创建一项任务,以便提醒某人做某事,例如写信给客户。处理更新保险案例请求的代码可能如下所示:
@Inject EntityManager em;
@PUT
@Path("case")
@Produces("application/json")
public void updateCase(Case case) {
case = em.merge(case);
if(anEmployeeShouldWriteToTheCustomer(case)){
long taskId = taskService.createTask(case.getNr(),"Write to customer...");
case.addTask(taskId);
}
}
对任务服务的调用会导致对任务应用程序的远程调用,任务应用程序是负责工作流程和人员任务(需要由人员完成的工作)的微服务。
如上所述,我们的服务存在两个问题。首先,假如任务应用程序在调用时处于脱机状态,这会降低我们应用程序的可用性。每当我们的应用程序连接到的远程应用程序数量增加,系统的可用性都会有所下降。如果其中一个远程应用每个月有4小时的允许停机时间,第二个远程应用有8个小时的停机时间,这会导致我们的应用程序每月离线12个小时,还要加上我们自己应用程序的停机时间,因为从来就不能保证停机时间会重叠。
上面的服务设计的第二个问题是,在调用任务应用之后,在将数据提交给数据库时会出现问题。上面的代码使用了JPA,可以选择在调用之后和提交之前的某个时间,将合并方法或更新实体调用生成的SQL语句清除,这意味着在调用任务应用之后可能会发生数据库错误。数据库调用甚至可能因其他原因(例如网络不可用)而失败,因此,从概念上讲,我们有一个问题,即我们可能创建了一项任务,要求员工向客户发送信函,但无法更新案例,导致员工可能甚至没有写信所需的信息。
如果任务应用程序是事务感知的,即能够绑定到事务中,以便应用程序中的事务管理器可以处理远程提交/回滚,那么肯定有助于避免上述第二个问题(数据一致性),但不能解决停机时间的增加的问题。
然而,改变体系结构使得对任务应用程序的调用异步发生将同时解决这两个问题。请注意,我不是在谈论简单的异步方法调用,而是讨论我们的应用程序提交数据库事务之后对任务应用的调用。只有在这一点上,我们才能保证不会丢失任何数据。然后,我们可以根据需要尝试重复远程调用,直到任务成功创建,那时,全局数据是一致的。失败重试的机制意味着整个系统变得更加可靠,我们的停机时间减少了。请注意,我也没有谈到通常被称为异步的非阻塞方法。
为了做到这一点,我创建了一个简单的库,需要开发人员做两件事。有关演示应用程序中使用的基本实现的更多信息,请参阅此处。首先,开发人员需要调用CommandService,传递在执行实际指令时所需的数据。其次,开发人员需要提供框架将执行的指令的实现。第一部分看起来像这样:
public class TaskService {
@Inject
CommandService commandService;
/** will create a command which causes a task to be
* created in the task app, asynchronously, but robustly. */
public void createTask(long caseNr, String textForTask) {
String context = createContext(caseNr, textForTask);
Command command = new Command(CreateTaskCommand.NAME, context);
commandService.persistCommand(command);
}
private String createContext(long nr, String textForTask) {
//TODO use object mapper rather than build string ourselves...
return "{\"caseNr\": " + nr + ", \"textForTask\": \"" + textForTask + "\"}";
}
此处显示的指令服务采用一个指令对象,其中包含两条信息:指令的名称和包含该指令将需要的数据的JSON字符串。我为客户编写的更成熟的实现将对象作为输入而不是JSON字符串,并且API使用泛型。
开发人员提供的指令实现如下所示:
public class CreateTaskCommand implements ExecutableCommand {
public static final String NAME = "CreateTask";
@Override
public void execute(String idempotencyId, JsonNode context) {
long caseNr = context.get("caseNr").longValue();
CALL THE TASK MICROSERVICE HERE
}
@Override
public String getName() { return NAME; }
}
开发人员需要完成的是该指令的执行方法的具体实现。我没有给出用于调用任务应用的代码,因为它在这里并不真正相关,它只是一个HTTP调用。
这种异步设计的有趣之处不在于上面的两个列表,而是在确保指令执行的框架代码中。算法比你想象的要复杂得多,因为它必须能够处理失败,这也导致它也必须处理锁定问题。当对指令服务进行调用时,会发生以下情况:
ExecutableCommand
接口的实现并使用指令中保存的名称来执行该指令除了相当复杂的算法外,框架还需要做一些维护:
如果例如应用程序在执行期间崩溃,指令可能会挂起。正如你所看到的,解决方案并不琐碎,因此它们属于框架代码,所以轮子不会被重复发明出来。不幸的是,这个实现很大程度上取决于它应该运行的环境,所以这使得编写一个可移植的库变得非常困难(这是我除了在演示应用程序的指令包中发布类之外, 没有做更多的工作的原因)。有趣的是,它甚至依赖于正在使用的数据库,例如与Oracle一起使用时,Hibernate没有可用的更新支持。
在这个阶段的关键问题是,将体系结构更改为异步体系是否是最佳解决方案。
从表面看,好像解决了我们所有的数据一致性问题。但实际上有几件事情需要详细考虑。这里有一些例子。
A)假设在更新保险案例之后,用户想要关闭它,并且决定是否关闭一个案例的业务规则的一部分包括检查是否有任何任务不完整。检查任务是否不完整的最佳位置是任务应用程序!因此开发人员添加了几行代码来调用它。在这个阶段,它已经变得复杂了,开发者应该同步调用任务应用程序,还是使用指令?下面给出我的建议在,为了简单起见,让我们假设该呼叫是同步进行的。但是如果三秒钟之前,任务应用程序关闭,所以一个不完整的指令仍然在我们的数据库中,当它执行时会创建一个任务。如果我们只依靠任务应用程序,当我们关闭案例,并在下一次尝试执行不完整的指令时,即使案件已关闭,我们也会保存任务。这将导致混乱,因为当用户点击任务来处理它时,我们必须构建额外的逻辑来重新打开案例。更合适的方案是首先询问任务应用程序,然后检查数据库中的指令。即便如此,因为指令是异步执行的,所以我们最终可能会遇到计时问题,我们会错过某些东西。在这块我们面对的问题通常是次序问题,众所周知,最终一致的系统会遭遇次序问题,并且可能需要额外的补偿机制,如上述重新打开案例的情况。这些东西可能会对整体设计产生相当复杂的影响,所以要小心!
B)假设在系统环境中发生了一个事件,导致案例应用程序被调用以创建一个保险案例。想象一下,第二个事件会导致该案件被更新。想象一下,希望创建和更新案例的应用程序是使用指令框架异步实现的。最后,想象在第一个事件期间案例应用程序不可用,导致创建案例的指令停留在未完成状态的数据库中。如果第二个指令在第一个指令之前执行,会发生什么情况,即该情况在它存在之前是否已更新?当然,我们可以将案例应用程序设计得很聪明,如果案例不存在,就以更新的状态创建它。但是,当执行创建案例的指令时,我们会做什么?将其更新至原始状态?那会很糟糕。忽略了第二条指令?如果某些业务逻辑依赖于增量,即情况发生变化,那可能会很糟糕。我听说像Elastic Search这样的系统在请求中使用时间戳来决定它们是否在当前状态之前被发送,并决定是否忽略这些调用。是否创建第二个案例?如果我们没有控制权力,这可能会发生,这也会是不好的。可以实现某种复杂的状态机来跟踪指令,例如只允许在创建指令之后执行更新指令。但是这需要一个额外的地方来存储更新指令,直到创建指令执行完毕。正如你所看到的,次序问题再次发生!
C)我们什么时候需要使用指令,什么时候可以远程调用远程应用程序?一般规则似乎是,只要我们需要访问多个资源以写入它,我们就应该使用指令,如果全局数据一致性对我们很重要的话。因此,如果某个调用需要从多个远程应用程序读取大量数据,以便我们可以更新数据库,则不必使用指令,尽管可能需要实现幂等性或调用者实现某种类型的重试机制,或者确实使用指令来调用我们的系统。另一方面,如果我们想以一致的方式写入远程应用程序和数据库,那么我们需要使用一个指令来调用远程应用程序。
D)如果我们想调用多个远程应用程序,我们该怎么做?如果他们都提供幂等API,从一个指令调用它们都不会出现问题。否则,可能需要为每个远程应用程序调用使用一个指令。如果需要按照某个顺序调用它们,则有必要让一个指令实现创建应该在链中下一个被调用的指令,一连串的指令让我想起了舞蹈编排,将业务流程实现为编排可能更容易或更易于维护。详情请看这里。
E)线程本地存储(TLS)可能会导致问题,因为指令不会在创建该指令的同一线程上执行。因此,像注入@RequestScoped CDI bean这样的机制也不会像你所期望的那样工作。适用于@Asynchronous EJB调用的常规Java EE规则也适用于此,这正是因为框架代码在其实现中使用了该机制。如果您需要TLS或范围的bean,那么您应该考虑将这些地方的数据添加到与数据库中的指令一起保存的输入中,并且一旦执行指令,就依靠它在调用任何本地服务/ bean之前恢复状态。
F)如果需要远程应用程序的响应,我们该怎么做?大多数情况下,我们调用远程系统并需要响应数据以继续处理。有时可以分开读取和写入,例如使用CQRS。一种解决方案是将进程分解为更小的步骤,以便每次需要调用远程系统时都会由新指令来处理,并且该指令不仅可以进行远程调用,还可以在响应到达时更新本地数据。然而,我们注意到,如果采用乐观锁定策略,当用户想要保留对数据所做的更改时,可能会导致错误,与数据库中的版本相比,这种更改现在已经“陈旧”了,即使它们可能只想改变指令没有改变的某些属性。解决此问题的一个办法是将事件从后端通过Web套接字传播到客户端,以便它可以对受该指令影响的属性进行部分更新,以便用户仍然可以稍后保存其数据。另一种解决方案则质疑为什么你需要响应数据。在上面的例子中,我将任务ID放入案例中,这可能是跟踪与案件有关的任务的一种方式。更好的方法是将案例ID传递给任务应用程序,并将案例ID存储在任务中。如果您需要与案例相关的任务列表,您可以使用* your * ID查询他们,而不是跟踪他们的ID,通过这样做,您可以消除对响应数据的依赖(除了检查是否创建了没有错误的任务),因此不需要根据远程应用程序的响应来更新数据。
希望我已经能够证明,使用上面描述的指令异步架构为我几年前写的保证全局数据一致性的模式提供了一种合适的替代方法。
请注意,在实施框架并将其应用到我们的几个应用程序之后,我们了解到我们不是唯一拥有此类想法的人,尽管我没有阅读关于Eventuate Tram及其交易指令,但它看起来非常相似,比较其实现应该会很有趣。
最后,除了指令之外,我们还在指令之上添加了“事件”。这种情况下的事件是通过JMS,Kafka发送的消息,以一致和有保证的方式选择您最喜欢的消息系统。事件的发布和消费都是作为一种指令来实施的,它提供了非常好的至少一次交付保证。事件通知1..n应用程序在场景中发生了某些事情,而指令告诉单个远程应用程序执行某些操作。这些与websocket技术以及在后台通知客户异步更改的能力构成了保证全局数据一致性所需的体系结构。这种异步架构是否比支持事务管理器以保证全局数据一致性更好,是我仍在学习的东西。两者都有其挑战,优点和缺点,可能在通常情况下和复杂软件系统一样,最好的解决方案是两者的混合。