将微服务迁移到 Spring WebFlux

至少几个月来,反应式编程一直是许多会议演讲中的热门话题。轻松找到简单的代码示例和教程并将它们应用到新建项目中。当需要从现有解决方案迁移具有数百万用户和每秒数千个请求的生产服务时,事情会变得更加复杂。在本文中,我想 通过其中一个 Allegro 微服务的示例来讨论从Spring Web MVC到 Spring WebFlux的迁移策略。我将展示一些常见的陷阱以及生产中的性能指标如何受到迁移的影响。

变革的动机

在详细探讨迁移策略之前,我们先讨论一下更改的动机。由我的团队开发和维护的其中一项微服务参与了 2018 年 7 月 18 日发生的严重 Allegro 中断事件(请参阅事后分析中的更多详细信息)。虽然我们的微服务不是问题的根本原因,但一些实例也因为线程池饱和而崩溃。临时修复是增加线程池大小并减少外部服务调用的超时;然而,这还不够。临时解决方案仅略微提高了吞吐量和外部服务延迟的恢复能力。我们决定改用非阻塞方法,彻底摆脱线程池作为并发的基础。使用 WebFlux 的另一个动机是新项目使我们的微服务中的外部服务调用流程变得复杂。我们面临的挑战是,无论复杂性如何增加,都要保持代码库的可维护性和可读性。我们发现 WebFlux 比我们之前基于 Java 8 的解决方案更友好,CompletableFuture可以对复杂流程进行建模。

什么是 Spring WebFlux?

让我们从了解 Spring WebFlux 的含义和用途开始。Spring WebFlux 是反应式堆栈 Web 框架,定位为众所周知且广泛使用的 Spring Web MVC 的继承者。创建新框架的主要目的是支持:

  • 一种非阻塞方法,可以处理少量线程的并发并有效扩展,
  • 函数式编程,有助于使用流畅的 API 编写更具声明性的代码。

最重要的新功能是功能端点、事件循环并发模型和 反应式 Netty 服务器。您可能认为通过引入全新的堆栈和范例,WebFlux 和 Web MVC 之间的兼容性已经被打破。事实上,Pivotal 致力于让共存尽可能轻松。

Spring Web MVC 和 Spring WebFlux 功能比较
Spring Web MVC 和 Spring WebFlux 功能比较(来自WebFlux 文档的图表)。

我们不必被迫将代码的各个方面迁移到新方法。我们可以轻松地选择一些响应式的东西(例如 响应式 WebClient)并向前迈出一小步。如果我们认为某些功能没有提供真正的价值但更改成本很高,我们甚至可以省略它们。例如,如果您对@Controller注释感到满意 - 它们可以很好地与反应式堆栈配合使用。另外,如果您熟悉UndertowTomcat配置 - 那完全没问题,您不必使用 Netty 服务器。

什么时候(不)迁移?

每一项新兴技术往往都有其炒作周期。仅仅因为新的解决方案新鲜、闪亮和热闹而使用新的解决方案,尤其是在生产环境中,可能会导致挫败感,有时甚至会导致灾难性的结果。每个软件供应商都希望宣传自己的产品并说服客户使用它。然而,Pivotal 的行为非常负责任,密切关注迁移到 WebFlux 不是最好主意的情况。 官方文档第 1.1.4 部分 对此进行了详细介绍。最重要的一点是:

  • 不要改变工作正常的东西。如果您的服务没有性能或扩展问题 - 找到一个更好的地方来尝试 WebFlux。
  • 阻塞 API 和 WebFlux 并不是最好的朋友。他们可以合作,但迁移到反应式堆栈并没有提高效率。您应该对这个建议持保留态度。当代码中只有某些依赖项发生阻塞时,有一些优雅的方法可以处理它。当它们占多数时(您的代码变得更加复杂且容易出错),一个阻塞调用可能会锁定整个应用程序。
  • 团队的学习曲线可能会很陡峭,特别是如果它没有反应性事物的经验。迁移过程中要高度重视人为因素。

让我们谈谈性能。对此存在很多误解。被动并不意味着性能会自动提升。此外,WebFlux 文档警告我们需要做更多的工作才能以非阻塞方式完成任务。然而,每次调用的延迟越高或调用之间的相互依赖性越高,好处就越显着。反应性在这里表现出色——等待其他服务响应不会阻塞线程。因此,获得相同吞吐量所需的线程更少,而更少的线程意味着使用更少的内存。

始终建议检查独立来源,以避免框架作者的偏见。在选择新技术时, ThoughtWorks 的技术雷达是一个极好的意见来源。他们报告了迁移到 WebFlux 后系统吞吐量和代码可读性的改进。另一方面,他们指出,要成功采用 WebFlux,思维上的重大转变是必要的。

总而言之,有四个指标表明迁移到 WebFlux 对于该项目来说可能是一个好主意:

  1. 当前的技术堆栈并不能解决足够的性能和可扩展性问题。
  2. 有大量对外部服务或数据库的调用,响应可能很慢。
  3. 现有的阻塞依赖可以很容易地用反应性依赖替换。
  4. 开发团队乐于接受新挑战并愿意学习。

迁移策略

根据我们的迁移经验,我想介绍一下三阶段迁移策略。为什么分为3个阶段?当我看到我们的流程时,我觉得它非常熟悉。我们先点了开胃菜,以增加食欲。然后我们开始吃主菜,重写并从错误中吸取教训。甜点就像上面有一颗樱桃的蛋糕,让我们想起我们所做的所有辛勤工作而微笑。要记住的一件事 - 如果我们谈论具有大型代码库、每秒数千个请求和数百万用户的实时服务 - 从头开始​​重写是一个相当大的风险。让我们看看如何通过后续的小步骤将应用程序从 Spring Web MVC 迁移到 Spring WebFlux,从而实现从阻塞世界到非阻塞世界的平滑过渡。

第 1 阶段,入门 — 迁移一小段代码

一般来说,首先在系统的非关键部分尝试新技术是一个很好的做法。被动式也不例外。这一阶段的想法是只找到一个非关键功能,封装在一个阻塞方法调用中,并将其重写为非阻塞风格。RestTemplate让我们尝试在用于从外部服务检索结果的阻塞方法的示例中执行此操作。

Pizza getPizzaBlocking(int id) {
    try {
        return restTemplate.getForObject("http://localhost:8080/pizza/" + id, Pizza.class);
    } catch (RestClientException ex) {
        throw new PizzaException(ex);
    }
}

我们从丰富的 WebFlux 功能中只选择一个东西——反应式 WebClient——并用它以非阻塞的方式重写这个方法。

Mono<Pizza> getPizzaReactive(int id) {
    return webClient
        .get()
        .uri("http://localhost:8080/pizza/" + id)
        .retrieve()
        .bodyToMono(Pizza.class)
        .onErrorMap(PizzaException::new);
}

现在是时候将我们的新方法与应用程序的其余部分连接起来了。非阻塞方法返回Mono,但我们需要一个普通类型。我们可以使用该.block()方法从 中检索值Mono

Pizza getPizzaBlocking(int id) {
    return getPizzaReactive(id).block();
}

最终,我们的方法仍然处于阻塞状态。然而,它内部利用了一个非阻塞库。此阶段的主要目标是熟悉非阻塞 API。此更改对于应用程序的其余部分应该是透明的,易于测试并可部署到生产环境中。

第 2 阶段,主菜 — 将关键路径转换为非阻塞方法

使用 WebClient 转换一小段代码后,我们准备进一步进行。第二阶段的目标是将应用程序的关键路径在所有层中转换为非阻塞——从 HTTP 客户端,通过处理外部服务响应的类,到控制器。此阶段重要的是避免重写所有代码。应用程序的不太关键的部分,例如那些没有外部调用或很少使用的部分,应保留原样。我们需要关注非阻塞方法显示其优势的领域。

//parallel call to two services using Java8 CompletableFuture
Food orderFoodBlocking(int id) {
    try {
        return CompletableFuture.completedFuture(new FoodBuilder())
            .thenCombine(CompletableFuture.supplyAsync(() -> pizzaService.getPizzaBlocking(id), executorService), FoodBuilder::withPizza)
            .thenCombine(CompletableFuture.supplyAsync(() -> hamburgerService.getHamburgerBlocking(id), executorService), FoodBuilder::withHamburger)
            .get()
            .build();
    } catch (ExecutionException | InterruptedException ex) {
        throw new FoodException(ex);
    }
}

//parallel call to two services using Reactor
Mono<Food> orderFoodReactive(int id) {
    return Mono.just(new FoodBuilder())
        .zipWith(pizzaService.getPizzaReactive(id), FoodBuilder::withPizza)
        .zipWith(hamburgerService.getHamburgerReactive(id), FoodBuilder::withHamburger)
        .map(FoodBuilder::build)
        .onErrorMap(FoodException::new);
}

使用方法可以轻松地将系统的阻塞部分与非阻塞代码合并.subscribeOn()。我们可以使用默认的 Reactor 调度程序之一以及我们自己创建并由ExecutorService.

Mono<Pizza> getPizzaReactive(int id) {
    return Mono.fromSupplier(() -> getPizzaBlocking(id))
        .subscribeOn(Schedulers.fromExecutorService(executorService));
}

此外,只需对控制器进行很小的更改就足够了 - 将返回类型从FootoMono<Foo>或更改Flux<Foo>。它甚至可以在 Spring Web MVC 中工作 - 您不需要将整个应用程序的堆栈更改为响应式。第 2 阶段的成功实施为我们提供了非阻塞方法的所有主要好处。现在是衡量并检查我们的问题是否解决的时候了。

第三阶段,甜点——让我们把一切都改为 WebFlux!

在第 2 阶段之后我们可以做更多的事情。我们可以重写代码中不太关键的部分并使用 Netty 服务器而不是 servlet。我们还可以摆脱@Controller注释并将端点重写为函数式风格,尽管这是风格和个人喜好而不是性能的问题。这里的关键问题是:这些优势的成本是多少?代码可以随时重构,并且通常很难定义“足够好”点。我们没有决定进一步处理我们的案例。重写整个代码库需要大量工作。帕累托法则 再次被证明是有效的。我们觉得我们获得了显着的收益,而后续的收益成本相对较高。一般来说,当我们从头开始编写新服务时,获得 WebFlux 的所有好处是件好事。另一方面,当我们重构现有(微)服务时,通常最好做尽可能少的工作。

移民陷阱——经验教训

正如我之前所说,将代码迁移到非阻塞需要思维上的重大转变。我的团队也不例外——我们陷入了一些陷阱,主要是由于植根于阻塞和命令式编码实践的思维造成的。如果您计划将一些代码重写为 WebFlux - 这里有一些现成的具体要点!

问题 1 - 在构建服务器中挂起集成测试

代码的出色测试覆盖率是安全重构的最好朋友。特别是集成测试可以证实我们的感觉,即在重写应用程序的很大一部分后一切都很好。在我们的例子中,他们中的大多数与框架甚至编程语言无关——他们使用 HTTP 请求查询被测服务。不幸的是,我们注意到我们的集成测试有时开始挂起。这是一个令人震惊的信号——迁移到 WebFlux 后,从客户端的角度来看,服务的行为应该是相同的。经过几天的研究,我们终于发现Wiremock(我们测试中使用的模拟库)与WebFlux starter不完全兼容。经过进一步调查,我们了解到 webmvc starter 的测试运行良好。 GitHub 问题 #914对此进行了详细介绍。

经验教训

  • 仔细检查您的测试库是否完全支持 WebFlux。
  • 不要在重构的早期阶段将 spring-boot-starter 依赖项从 webmvc 更改为 webflux。仅当 servlet 应用程序类型一切正常时,才尝试将代码重写为非阻塞并将应用程序类型更改为响应式。

问题 2 - 悬挂单元测试

我们使用Groovy + Spock作为单元测试的基础。尽管 WebFlux 提供了新的令人兴奋的测试可能性,但我们尝试以尽可能少的努力使现有的单元测试适应非阻塞现实。当某个方法转换为 returnMono<Foo>而不是 时Foo,通常在测试中使用 来跟踪此方法调用就足够了.block()。否则,存根和模拟配置为 return foo,现在应该用反应类型包装它,通常返回Mono.just(foo)。这个理论看起来很简单,但我们的测试开始挂起。幸运的是,以一种可重现的方式。问题出在哪里?在经典的阻塞方法中,当我们忘记(或故意省略)在存根或模拟中配置某些方法调用时,它只会返回null. 很多情况下,这并不影响测试。然而,当我们的存根方法返回反应类型时,错误配置可能会导致它挂起,因为预期MonoFlux永远不会解析。

吸取的教训: 返回反应类型的方法的存根或模拟,在测试执行期间调用,以前隐式返回 null,现在必须显式配置为至少返回Mono.empty()Mono.just(some_empty_object)

问题 3 - 缺少订阅

WebFlux 初学者有时会忘记反应式流往往是尽可能惰性的。由于缺少订阅,以下函数永远不会向控制台打印任何内容:

Food orderFood(int id) {
    FoodBuilder builder = new FoodBuilder().withPizza(new Pizza("margherita"));

    hamburgerService.getHamburgerReactive(id).doOnNext(builder::withHamburger);
    //hamburger will never be set, because Mono returned from getHamburgerReactive() is not subscribed to

    return builder.build();
}

吸取的教训: 每个Mono和 都Flux应该订阅。在控制器中返回响应式类型就是这样一种隐式订阅。

问题 4 -.block()在 Reactor 线程中

正如我之前(在第 1 阶段)所示,.block()有时用于将反应函数加入到阻塞代码中。

Food getFoodBlocking(int id) {
    return foodService.orderFoodReactive(id).block();
}

在 Reactor 线程中不可能调用此函数。这种尝试会导致以下错误:

block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-2

.block()只允许在其他线程中显式使用(请参阅 参考资料.subscribeOn())。Reactor 抛出异常并通知我们问题是很有帮助的。不幸的是,许多其他场景允许将阻塞代码插入到 Reactor 线程中,但不会自动检测到。

吸取的教训.block()只能在调度程序下执行的代码中使用。更好的是完全避免使用.block()

问题 5 - Reactor 线程中的阻塞代码

没有什么可以阻止我们向反应流添加阻塞代码。而且,我们不需要使用.block()——我们可以通过使用可以阻塞当前线程的库来无意识地引入阻塞。考虑以下代码示例。第一个类似于适当的“反应性”延迟。

Mono<Food> getFood(int id) {
    return foodService.orderFood(id)
        .delayElement(Duration.ofMillis(1000));
}

另一个示例模拟了危险的延迟,这会阻塞订阅者线程。

Mono<Food> getFood(int id) throws InterruptedException {
    return foodService
      .orderFood(id)
      .doOnNext(food -> Thread.sleep(1000));
}

乍一看,两个版本似乎都有效。当我们在本地主机上运行此应用程序并尝试请求服务时,我们可以看到类似的行为。“你好世界!” 延迟1秒后返回。然而,这种观察具有很大的误导性。在更高的流量下,我们的服务响应会发生巨大变化。让我们使用JMeter 来获取一些性能特征。

具有反应性延迟的服务性能

具有阻塞延迟的服务性能

两个版本均使用 100 个线程进行查询。正如我们所看到的,具有反应性延迟的版本(上)在重负载下工作良好,提供恒定的延迟和高吞吐量。另一方面,具有阻塞延迟(较低)的版本无法服务任何可观的流量。为什么这如此危险?如果延迟与外部服务调用相关,只要其他服务快速响应,一切都会正常。这是一颗定时炸弹。此类代码甚至可以在生产环境中存在几天,并在您最意想不到的时候导致突然中断。

经验教训

  • 始终仔细检查反应环境中使用的库。
  • 对应用程序进行性能测试,特别是考虑到外部调用的延迟。
  • 使用诸如BlockHound之类的特殊库,它可以为检测隐藏的阻塞调用提供宝贵的帮助。

问题 6 -WebClient响应未消耗

WebClient.exchange()方法的文档明确指出: 您必须始终使用响应的主体或实体方法之一以确保释放资源。 WebFlux 官方文档的第 2.3 章 给了我们类似的信息。这个要求很容易被忽略,主要是当我们使用.retrieve()method时,method是.exchange(). 我们偶然发现了这样一个问题。我们正确地将有效响应映射到对象,并在出现错误时完全忽略响应。

Mono<Pizza> getPizzaReactive(int id) {
    return webClient
        .get()
        .uri("http://localhost:8080/pizza/" + id)
        .retrieve()
        .onStatus(HttpStatus::is5xxServerError, clientResponse -> Mono.error(new Pizza5xxException()))
        .bodyToMono(Pizza.class)
        .onErrorMap(PizzaException::new);
}

只要外部服务返回有效响应,上面的代码就可以很好地工作。在前几个错误响应之后不久,我们可以在日志中看到一条令人担忧的消息:

ERROR 3042 --- [ctor-http-nio-5] io.netty.util.ResourceLeakDetector       : LEAK: ByteBuf.release() was not called before it's garbage-collected. See http://netty.io/wiki/reference-counted-objects.html for more information.

资源泄漏意味着我们的服务将崩溃。在几分钟、几小时甚至几天内——这取决于其他服务错误计数。这个问题的解决方案很简单:使用错误响应来生成错误消息。现在已经正常食用了。

吸取的教训: 始终在测试您的应用程序时考虑外部服务错误,尤其是在高流量的情况下。

问题 7 — 意外的代码执行

Reactor 有许多有用的方法,有助于编写表达性和声明性代码。然而,其中一些可能有点棘手。考虑以下代码:

String valueFromCache = "some non-empty value";
return Mono.justOrEmpty(valueFromCache)
    .switchIfEmpty(Mono.just(getValueFromService()));

我们使用类似的代码来检查缓存中是否有特定值,然后如果该值丢失则调用外部服务。作者的意图似乎很明确:getValueFromService() 只在缺少缓存值的情况下才执行。但是,此代码每次都会运行,即使是缓存命中也是如此。此处给出的参数.switchIfEmpty()不是 lambda — 并且Mono.just()会导致直接执行作为参数传递的代码。显而易见的解决方案是使用Mono.fromSupplier()条件代码并将其作为 lambda 传递,如下例所示:

String valueFromCache = "some non-empty value";
return Mono.justOrEmpty(valueFromCache)
    .switchIfEmpty(Mono.fromSupplier(() -> getValueFromService()));

吸取的教训: Reactor API 有许多不同的方法。始终考虑参数是否应按原样传递或用 lambda 包装。

移民的好处

是时候总结并检查迁移到 WebFlux 后我们服务的生产指标了。明显而直接的影响是应用程序使用的线程数量减少。有趣的是,我们没有将应用程序类型更改为响应式(我们仍然使用 servlet,有关详细信息,请参阅第 3 阶段),而且 Undertow 工作线程的利用率也降低了一个数量级。

低级指标如何受到影响?我们观察到垃圾收集次数更少,而且花费的时间也更少。每个图表的上半部分显示阻塞版本,而下半部分显示反应版本。

GC 计数比较 — 反应式与阻塞式

GC 时间比较 — 反应式与阻塞式

此外,响应时间略有缩短,尽管我们没有预料到会出现这样的效果。其他指标,如 CPU 负载、文件描述符使用率和消耗的总内存,没有变化。我们的服务还做了更多与通信无关的工作。将流程迁移到 HTTP 客户端和控制器周围的响应式至关重要,但在资源使用方面并不重要。正如我在一开始所说的,迁移的预期好处是更高的可扩展性和延迟恢复能力。我们确信我们已经实现了这个目标。

结论

您在绿地上工作吗?这是熟悉 WebFlux 或其他响应式框架的绝佳机会。

您正在迁移现有的微服务吗?考虑文章中涵盖的因素,而不仅仅是技术因素 - 检查使用新解决方案的时间和人员能力。有意识地做出决定,不要盲目相信技术炒作。

始终测试您的应用程序。涵盖外部调用延迟和错误的集成和性能测试在迁移过程中至关重要。请记住,反应性思维不同于众所周知的阻塞式、命令式方法。

享受乐趣并构建弹性微服务!

将微服务迁移到 Spring WebFlux · allegro.tech

posted @ 2024-02-28 09:53  CharyGao  阅读(341)  评论(0)    收藏  举报