重新审视分布式(微服务)体系结构中的全局数据一致性

早在2015年,我就写过几篇文章,介绍了如何搭载标准的Java EE事务管理器来实现分布式服务之间的数据一致性( 这是原始文章
这是一篇有关使用Spring Boot,Tomcat或Jetty做到这一点的文章

去年,我很幸运地从事了一个小项目,我们从头开始质疑数据的一致性。 我们的结论是,还有另一种获取数据一致性保证的方式,这是我在撰写的另一篇文章中所提到的,该文章将资源绑定到事务中 。 另一种解决方案是将架构从同步架构更改为异步架构。 基本思想是在单个数据库事务中将业务数据与“命令”一起保存。 命令只是事实,仍然需要调用其他系统。 通过将并发事务的数量减少到只有一个,可以保证数据永远不会丢失。 然后,已提交的命令将尽快执行,然后是命令执行(在新事务中)对远程系统进行调用。 实际上,它是BASE一致性模型的实现,因为从全局的角度来看,数据仅最终是一致的。

想象一下这样的情况:更新保险案例会导致在工作流系统中创建任务,从而使人得到提醒以做某事,例如写信给客户。 处理更新保险案例的请求的代码可能如下所示:

@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个小时,因为我们无法保证停机时间会同时发生。

上面的服务设计的第二个问题是在调用任务应用程序后将数据提交到数据库时出现问题。 上面的代码使用JPAJPA可以选择在调用之后的某个时间,最晚在提交时,刷新通过调​​用merge方法或对实体的更新生成的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; }
}

命令的execute方法是开发人员实现需要完成的工作的地方。 我没有显示用于调用任务应用程序的代码,因为它在这里并不重要,它只是一个HTTP调用。

这种异步设计的有趣部分不在上面的两个清单中,而是在确保命令被执行的框架代码中。 该算法比您最初想象的要复杂得多,因为它必须能够处理失败,这也导致它也必须处理锁定。 调用命令服务后,将发生以下情况:

  • 该命令将保留到数据库
  • 触发了CDI事件
  • 当应用程序提交事务时,调用框架,因为它观察到事务成功
  • 框架在数据库中“保留”命令,因此应用程序的多个实例不会尝试同时执行同一命令
  • 该框架使用异步EJB调用来执行命令
  • 通过使用容器搜索ExecutableCommand接口的实现并使用名称保存在命令中的任何实现来ExecutableCommand命令
  • 通过调用它们的execute方法来执行所有匹配的命令,并将存储在数据库中的输入传递给它们
  • 成功执行的命令将从数据库中删除
  • 失败的命令会在数据库中更新,因此执行尝试的次数会增加

除了相当复杂的算法外,该框架还需要做一些整理工作:

  • 定期检查是否有需要执行的命令。 条件是:
    • 该命令已失败,但是没有被尝试超过5次
  • 定期检查是否有挂起的命令,然后将其解锁,以便重新尝试使用它们

例如,如果应用程序在执行期间崩溃,则命令可能会挂起。 正如您所看到的,该解决方案并非微不足道,并且属于框架代码,因此该轮子不会被不断发明。 不幸的是,实现很大程度上取决于应该在其上运行的环境,因此这使得编写可移植的库非常困难(这就是为什么我除了在demo应用程序commands 包中发布类之外还做了很多事情)。 有趣的是,它甚至取决于所使用的数据库,例如
与Oracle一起使用时Hibernate不正确支持select for update 。 为了完整起见,应该监视失败5次的命令,以便管理员可以解决问题并更新命令,以便重新尝试使用它们。

在这个阶段正确的问题是,将架构更改为异步架构是否是最佳解决方案? 从表面上看,它看起来似乎可以解决我们所有的数据一致性问题。 但实际上,有一些事情需要详细考虑。 这里有一些例子。

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(而不是跟踪其ID)来查询它们。 通过这样做,您消除了对响应数据的依赖(除了检查是否已创建任务而没有错误),因此,无需根据来自远程应用程序的响应来更新数据。

希望我已经能够证明使用如上所述的命令的异步体系结构可以提供一种合适的方法来替代模式,以保证几年前我写的全局数据一致性。

请注意,在实现框架并将其应用于我们的多个应用程序之后,我们了解到,我们并不是唯一拥有这种想法的人。 虽然我还没有读过
最终电车及其事务命令看起来非常相似。 比较实现会很有趣。

最后,除了命令以外,我们还在命令之上添加了“事件”。 在这种情况下,事件是通过JMS,Kafka发送的消息,以一致且有保证的方式选择您喜欢的消息系统。 双方(即事件的发布和使用)都作为命令来实现,这提供了非常好的至少一次交付保证。 事件通知环境中的1..n应用程序发生了某些事情,而命令则告诉单个远程应用程序执行某件事。 这些与websocket技术以及通知客户端后端异步更改的功能一起,构成了保证全局数据一致性所需的体系结构。 我仍在学习这样的异步体系结构是否比背负事务管理器以确保全局数据一致性更好。 两者都有其挑战,优点和缺点。 最好的解决方案可能取决于混合,就像复杂软件系统通常会出现的情况:-)

翻译自: https://www.javacodegeeks.com/2018/02/revisiting-global-data-consistency-distributed-microservice-architectures.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值