关于 Spring-WebFlux 的一些想法 - 干货满满张哈希 - 博客园
本文是本人在知乎提问 spring webflux现在看来是否成功?下的回答,其他回答也很精彩,如果感兴趣可以查看
现在基于 spring web 的同步微服务有一个非常大的缺陷就是:相对于基于 spring-webflux 的异步微服务,基于 spring-web 的同步微服务没有很好的处理客户端有请求超时配置的情况。当客户端请求超时时,客户端会直接返回超时异常,但是调用的服务端任务,在基于 spring-web 的同步微服务并没有被取消,基于 spring-webflux 的异步微服务是会被取消的。目前,还没有很好的办法在同步环境中可以取消这些已经超时的任务。
Spring-weflux 目前最大的问题,在于很多框架,包括 JDK 本身,有很多 基于 Thread 的 Context,例如 Thread local 这种。还有就是 Java Log 框架的 MDC 的实现,一般都是基于 ThreadLocal 的 Map.还有就是像 redisson 的分布式锁,它让每个线程生成唯一id并和线程绑定,解锁的时候会检查。 但是这种设计,与 Spring-Webflux 的 Context 很难兼容。可以看看 Spring cloud sleuth 在 Spring-Webflux 中加入链路信息上下文,并保持,有多麻烦,而且,还有不少的 bug 和漏掉的点,参考:
- Spring Cloud Gateway 没有链路信息,我 TM 人傻了(上)
- Spring Cloud Gateway 没有链路信息,我 TM 人傻了(中)
- Spring Cloud Gateway 没有链路信息,我 TM 人傻了(下)
还有一点比较麻烦,就是和 现有的各种阻塞锁的设计,不兼容,因为响应式编程需要非阻塞。这需要重构成队列排序消费来解决并发竞争,工作量也很大。
然后就是 官方数据库 IO 库,不是 NIO这个问题。不论是Java自带的Future框架,还是 Spring WebFlux,还是 Vert.x,他们都是一种非阻塞的基于Ractor模型的框架(后两个框架都是利用netty实现)。在阻塞编程模式里,任何一个请求,都需要一个线程去处理,如果io阻塞了,那么这个线程也会阻塞在那。但是在非阻塞编程里面,基于响应式的编程,线程不会被阻塞,还可以处理其他请求。举一个简单例子:假设只有一个线程池,请求来的时候,线程池处理,需要读取数据库 IO,这个 IO 是 NIO 非阻塞 IO,那么就将请求数据写入数据库连接,直接返回。之后数据库返回数据,这个链接的 Selector 会有 Read 事件准备就绪,这时候,再通过这个线程池去读取数据处理(相当于回调),这时候用的线程和之前不一定是同一个线程。这样的话,线程就不用等待数据库返回,而是直接处理其他请求。这样情况下,即使某个业务 SQL 的执行时间长,也不会影响其他业务的执行。但是,这一切的基础,是 IO 必须是非阻塞 IO,也就是 NIO(或者 AIO)。官方JDBC没有 NIO,只有 BIO 实现。这样无法让线程将请求写入链接之后直接返回,必须等待响应。但是也就解决方案,就是通过其他线程池,专门处理数据库请求并等待返回进行回调,也就是业务线程池 A 将数据库 BIO 请求交给线程池B处理,读取完数据之后,再交给 A 执行剩下的业务逻辑。这样A也不用阻塞,可以处理其他请求。但是,这样还是有因为某个业务 SQL 的执行时间长,导致B所有线程被阻塞住队列也满了从而A的请求也被阻塞的情况,这是不完美的实现。真正完美的,需要 JDBC 实现 NIO。当然,也可以使用其他异步响应式的三方库,但是非官方的,兼容性以及是否更新及时,还有使用限制什么的也很麻烦。
最后,提一下 Java 本身的 Project Loom,我简单研究过他的实现原理:
简单总结即:在虚拟线程中运行的 Java 同步网络 API 会将底层原生 Socket 切换到非阻塞模式。当 Java 代码启用一个 I/O 请求并且这个请求没有立即完成(原生 socket 返回 EAGAIN - 代表"未就绪"/"会阻塞")的时候,这个底层 socket 会被注册到一个 JVM 内部事件通知机制(Poller),并且虚拟线程会被 parked。当底层 I/O 操作就绪的时候(有相关事件会到达 Poller),虚拟线程会 unparked 并且底层的 Socket 操作会被重试处理。同步 Java 网络 API 已经被重新实现,相关的 JEP 包括 JEP 353 和 JEP 373. 在虚拟线程中运行时,不能立即完成的 I/O 操作将导致虚拟线程被 parked 。当 I/O 就绪时,虚拟线程将被 unparked。这个实现相对于当前的异步非阻塞 I/O 实现代码来看,更加简单易用,隐藏了很多业务不关心的实现细节。
Project Loom 解决了主要的网络 IO 阻塞问题,并且基本不用改现有代码就能实现纤程,用阻塞的代码风格实现非阻塞的代码(而且和现在的基于 Thread 的上下文框架兼容)。是目前的 Java 架构师 Goetz 最看重的特性之一,目前 Java 17 的很多新特性其实就是为这个 Project Loom 的发布铺路,可以看看 Nicolai 和 Goetz 大神的这个视频,从 19:17 秒开始:
Brian Goetz: "I think Project Loom is going to kill Reactive Programming"(哈哈,有点过于乐观带节奏了,不过值得观望)
不过,虽然期望中是仅需要下面这种代码就可以把现有的线程和线程池替换成虚拟线程:
Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
ThreadFactory factory = Thread.ofVirtual().factory();
ExecutorService b = Executors.newVirtualThreadPool();
但是还有很多问题需要解决:
- ThreadLocal 相关的类,由于虚拟线程会无限制的生成,ThreadLocal 的生成也需要重新设计:首先是很多 JDK 中的框架基于 ThreadLocal 的 Probe 实现,例如 ThreadLocalRandom 的初始 Seed。第二是 ThreadLocal 的使用可能会导致 GC 压力增大,因为虚拟线程可以无限制的生成。
- 依然阻塞实际线程的地方:在同步锁的阻塞还是会阻塞实际的线程,还有文件 IO 等。
- 修改以上带来的 bug 以及安全问题,由于这些修改动了 JDK 的一些框架的根本,没有经过实际线上应用之前,仅凭单元测试和压测可能很难发现一些细节问题。
不过,现在的 Java 已经在为 Project loom 铺路了,例如:
- Java 13 中的 Reimplement the Legacy Socket API以及 Java 15 中的 Reimplement the Legacy DatagramSocket API也是为了优化 Project Loom 对于 网络 IO 的兼容
- Java 18 中的 JEP 416: Reimplement Core Reflection with Method Handles使用句柄重构反射,减少 Loom 虚拟线程对于 native 栈帧的调用(因为虚拟线程会非常大量,如果每个都访问 native 线程栈则性能有严重问题)
- Java 18 中的 JEP 418: Internet-Address Resolution SPI为了解决 DNS 解析时阻塞虚拟线程的实际负载线程的问题
- 其他还有 JEP draft: Scope Locals为了归一化区域本地变量(例如 ThreadLocal),这样一部分目标也是为了 Loom 的实现