RxJava的局限性和响应式流引入(响应式新标准)

1 API不一致性问题

大量的同类型响应式库(如RxJava,以及CompletableStage这样的Java核心库功能)使我们可以选择不同的代码的编写方式。例如,我们可能依赖于使用RxJava的API来编写正在处理的数据项的流程。如果要构建一个简单的异步请求-响应交互,依赖CompletableStage就足够了。也可以使用特定于框架的类(如org.springframework.util.concurrent.ListenableFuture)来构建组件之间的异步交互,并基于该框架简化开发工作。

丰富的选择很容易使系统过于复杂。例如,若存在两个依赖于同一个异步非阻塞通信概念但具有不同API的库,会导致我们需要提供额外的工具类,以便将一个回调转换为另一个回调;简单来说就是需要我们进行开发一层适配代码来兼容各种。

比如我们使用jdk提供的CompletionshiStage类也可以实现异步任务链

CompletionStage可以简单地认为,该接口是同步或者异步任务完成的某个阶段,它可以是整个任务管道中的最后一个阶段,甚至可以是管道中的某一个阶段,这就意味着可以将多个CompletionStage链接在一起形成一个异步任务链,前置任务执行结束之后会自动触发下一个阶段任务的执行。CompletionshiStage使用参考

这里就使用CompletionshiStage来定义一个异步数据库存储接口

public interface AsyncDatabaseClient{
    <T> CompletionStage<T> store(CompletionStage<T> stage); 
}

提供一个假实现:

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
public class FakeAsyncDatabaseClient implements AsyncDatabaseClient{
    @Override
    public <T> CompletionStage<T> store(CompletionStage<T> stage) {
        return stage.thenCompose(e -> CompletableFuture.supplyAsync(() -> e));
    }
}

jdk中提供了CompletableFuture这个类实现了CompletionStage,但是spring中的ListenableFuture并不支持CompletionStage,所以在使用过程中,难免需要对这种API差异进行适配,比如这里就简单提供一个适配类,将CompletionStage转为ListenableFuture,ListenableFuture转为CompletionStage

/**
 * 使用final修饰,这种类不能被继承,不允许重写
 */
public final class AsyncAdapters {

    public static <T> CompletionStage<T> toCompletion(ListenableFuture<T> future){
        CompletableFuture<T> completableFuture = new CompletableFuture<>();
        // 添加回调
        // 参数一:成功回调函数
        // 参数二:失败的回调函数
        future.addCallback(completableFuture::complete,
                completableFuture::completeExceptionally);

        return completableFuture;
    }

    public static <T> ListenableFuture<T> toListenable(CompletionStage<T> stage){
        SettableListenableFuture<T> future = new SettableListenableFuture<>();
        stage.whenComplete((v, t) -> {
            if (t == null) {
                // 没有异常,就设置结果
                future.set(v);
            }
            else {
                // 否则就设置异常信息
                future.setException(t);
            }
        });
        return future;
    }
}

测试:

import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.util.concurrent.ListenableFuture;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.AsyncRestTemplate;
import org.springframework.web.client.HttpMessageConverterExtractor;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletionStage;

@RestController
public class MyController {
	private final List<HttpMessageConverter<?>> messageConverters = new ArrayList<>();

	{
		this.messageConverters.add(new ByteArrayHttpMessageConverter());
		this.messageConverters.add(new StringHttpMessageConverter());
		this.messageConverters.add(new MappingJackson2HttpMessageConverter());
	}

	@RequestMapping(produces = MediaType.TEXT_PLAIN_VALUE)
	public ListenableFuture<?> requestData() {
		AsyncRestTemplate httpClient = new AsyncRestTemplate();
		AsyncDatabaseClient databaseClient = new FakeAsyncDatabaseClient();

		CompletionStage<String> completionStage = AsyncAdapters.toCompletion(httpClient.execute(
			"http://localhost:8080/hello",
			HttpMethod.GET,
			null,
			new HttpMessageConverterExtractor<>(String.class, messageConverters)
		));

		return AsyncAdapters.toListenable(databaseClient.store(completionStage));
	}
	@RequestMapping(value = "/hello", produces = MediaType.TEXT_PLAIN_VALUE)
	public String hello() {
		return "Hello World";
	}
	
}

声明了请求处理器方法,它以异步方式执行并返回ListenableFuture,以便基于非阻塞方式处理执行结果。接下来,为了存储AsyncRestTemplate的执行结果,我们必须将它与CompletionStage 进行适配。最后,为了满足所支持的API,我们必须再次使用为ListenableFuture返回。

Spring 4.框架中的ListenableFuture和CompletionStage之间没有直接集成。此外,该示例并没有脱离响应式编程的常见用法。许多库和框架为组件之间的异步通信提供了自己的接口和类,其中包括简单的请求-响应通信以及流处理框架。在许多情况下,为了解决这个问题并使几个独立的库兼容,我们必须提供自己的适配并在几个地方重用它。此外,我们自己的适配可能有bug,需要额外的维护。

Spring 5.框架扩展了ListenableFuture的API并且提供了一个名为Completable的方法来解决不兼容的问题。

当我们同时使用几个RxJava 1.兼容库时,第一个出现的问题通常是版本不兼容。由于RxJava 1.随着时间的推移而迅速发展,许多库提供商没有机会更新他们对新版本的依赖。有时候,版本更新带来了许多内部更改,最终导致某些版本不兼容。因此,依赖于不同RxJava 1版本的不同库和不同框架可能导致一些意料之外的问题。

另外一个问题,开发者可以提供适配又或者对其进行扩展,但是这个没有相关的标准,所以也会出现API混乱的情况

2 数据交互方式:拉与推的问题

在整个响应式环境演变的早期阶段,所有库的设计思想都是把数据从源头推送到订阅者。做出这个决定是因为纯粹的拉模型在某些场景下效率不够高。这种场景的一个例子是在具有网络边界的系统中进行网络通信。假设我们要过滤一大堆数据,但只取其中前10个元素。

2.1 使用拉取的方式实现

  1. AsyncDatabaseClient接口声明。使用该接口将异步、非阻塞通信与外部数据库连接起来
/**
 * 将异步、非阻塞通信与外部数据库连接起来的客户端
 */
public interface AsyncDatabaseClient {

    /**
     * 根据商品id获取商品,
     * 返回的是一个CompletableFuture异步编程模型
     */
    CompletableFuture<Item> getNextAfterId(Long id);
}

其中item:

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;

@Getter
@AllArgsConstructor
@ToString
public class Item {

    private final Long id;

}

提供一个mock实现


import io.reactivex.Flowable;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

/**
 模拟实现,不会真正的去查询数据库
 */
public class FakeAsyncDatabaseClient implements AsyncDatabaseClient{
    @Override
    public CompletableFuture<Item> getNextAfterId(Long id) {
        CompletableFuture<Item> completableFuture = new CompletableFuture<>();
        // 使用的是Rxjava2的API
        Flowable.just(new Item(id))
                // 延迟500ms,模拟耗时
                .delay(500,TimeUnit.MILLISECONDS)
                .subscribe(completableFuture::complete);
                // 没有简化lambda如下:
                //.subscribe((Item item)->completableFuture.complete(item));
        return completableFuture;
    }
}
  1. 拉取数据模拟
import java.util.Queue;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;

public class ItemPuller {

    private final AsyncDatabaseClient asyncDatabaseClient = new FakeAsyncDatabaseClient();


    public CompletableFuture<Queue<Item>> list(int count) {
        BlockingQueue<Item> storage = new ArrayBlockingQueue<>(count);
        CompletableFuture<Queue<Item>> result = new CompletableFuture<>();
        // 用当前时间模拟id
        pull(1L, storage, result,count);

        return result;
    }

    private void pull(Long id, BlockingQueue<Item> queue,
                      CompletableFuture<Queue<Item>> resultFuture, int count) {
        asyncDatabaseClient.getNextAfterId(id)
                .thenAccept(item -> {
                    if(isValid(item)){
                        // 数据校验通过,就放到Queue中
                        queue.offer(item);
                        if(queue.size() == count){
                            // 队列中的数据已经达到了count了
                            // 就设置结果,并跳出
                            resultFuture.complete(queue);
                        }
                    }
                    // 否则就再次拉取数据
                    pull(item.getId() +1 ,queue,resultFuture,count);
                });
    }

    boolean isValid(Item item) {
        return item.getId() % 2 == 0;
    }
}
  1. 测试
public class PullerTest {

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch l = new CountDownLatch(1);
        ItemPuller puller = new ItemPuller();

        CompletionStage<Queue<Item>> list = puller.list(10);

        list.thenAccept(queue ->{
            queue.forEach(System.out::println);
            l.countDown();
        });
        l.await();
    }
}

输出结果:

Item(id=2)
Item(id=4)
Item(id=6)
Item(id=8)
Item(id=10)
Item(id=12)
Item(id=14)
Item(id=16)
Item(id=18)
Item(id=20)

代码可以看出,我们在服务和数据库之间使用了异步、非阻塞交互。乍一看,这里没有任何问题,但是我们看下整个过程的时序图:

在这里插入图片描述

整体处理时间大部分浪费在空闲状态上。即使没有使用资源,由于额外的网络活动,整体处理时间也会是原来的两倍甚至三倍。此外,数据库不知道未来请求的数量,这意味着数据库不能提前生成数据,并因此处于空闲状态。这意味着数据库正在等待新请求。在响应被传递给服务、服务处理传入响应然后请求新数据的过程中,其效率会比较低下

我们可以将拉取操作与批处理结合起来,减少空闲时间,一次多请求几个数据

接口增加一个批量支持方法

CompletionStage<List<Item>> getNextBatchAfterId(Long id, int count);

模拟实现:

@Override
 public CompletionStage<List<Item>> getNextBatchAfterId(Long id, int count) {
     CompletableFuture<List<Item>> completableFuture = new CompletableFuture<>();
     Flowable.range(id.intValue(),count +1)
             .map(i -> new Item(i.longValue()))
             .collectInto(new ArrayList<Item>(),ArrayList::add)
             .delay(500, TimeUnit.MILLISECONDS)
             .subscribe(completableFuture::complete);
     return completableFuture;
 }

修改pull方法

private void pull(Long id, BlockingQueue<Item> queue,
                   CompletableFuture<Queue<Item>> resultFuture, int count) {
     asyncDatabaseClient.getNextBatchAfterId(id,10)
             .thenAccept(items -> {
                 items.forEach(item -> {
                     if (isValid(item)){
                         // 数据校验通过,就放到Queue中
                         queue.offer(item);
                         if(queue.size() == count){
                             // 队列中的数据已经达到了count了
                             // 就设置结果,并跳出
                             resultFuture.complete(queue);
                         }
                     }
                 });
                 pull(items.get(items.size() - 1)
                         .getId(), queue, resultFuture, count);
             });
 }

和之前相比,就是降低了请求次数而已,但并没有实质解决问题。
当数据库查询数据时,客户端仍处于空闲状态。同时,发送一批元素比发送一个元素需要更多的时间。最后,对整批元素的额外请求实际上可能是多余的。例如,如果只剩下一个元素就能完成处理,并且下一批中的第一个元素就满足验证条件,那么其余的数据项就完全是多余的且会被跳过。

为了提供最终的优化,我们只会请求一次数据,之后当数据变为可用时,该数据源会异步推送数据。

2.2 推送数据

使用的是rxjava的Observable实现的

  1. AsyncDatabaseClient接口增加一个对数据库完成一次订阅的方法
Observable<Item> getStreamOfItems();
@Override
 public Observable<Item> getStreamOfItems() {
     return Observable.range(1, Integer.MAX_VALUE)
             .map(i -> new Item(i.longValue()))
             .delay(500, TimeUnit.MILLISECONDS);
 }
 public Observable<Item> listPush(int count) {
     return asyncDatabaseClient.getStreamOfItems()
             .filter(this::isValid)
             .take(count);
 }

测试

 public static void main(String[] args) throws InterruptedException {

     ItemPuller puller = new ItemPuller();

     puller.listPush(10).subscribe(System.out::println);

     Thread.currentThread().join();
 }

在这里插入图片描述
发现数据是推送来的,不像之前直接获取一下打印,而是逐个打印,现在的时序图:

在这里插入图片描述
在交互过程中,只有当服务等待第一个响应时会有一大段空闲时间。当第一个元素到达后,数据库会在数据到来时开始发送后续元素。反过来,即使处理一个元素的过程可能比查询下一个元素快一点,服务的整体空闲时间也会很短。但是,在服务已经收集到所需数量的元素后,数据库仍可能生成多余元素,此时数据库会忽略它们。

采用推模型的主要原因是它可以通过将请求量减少到最小值来优化整体处理时间。这就是为什么RxJava 1.及类似的开发库以推送数据为目的进行设计,这也是为什么流技术能成为分布式系统中组件之间重要的通信技术。

2.3 流量控制问题

上述说明告诉我们,采用推模型的主要原因是它可以通过将请求量减少到最小值来优化整体处理时间。这就是为什么RxJava 1.及类似的开发库以推送数据为目的进行设计,这也是为什么流技术能成为分布式系统中组件之间重要的通信技术。

另一方面,如果仅仅与推模型进行组合,那么该技术有其局限性。消息驱动通信的本质是假设每个请求都会有一个响应,因此服务可能收到异步的、潜在的无限消息流。而这里存在陷阱,因为如果生产者不关注消费者的吞吐能力,它可能会以下面描述的方式影响系统的整体稳定性。

  1. 慢生产者和快消费者
    假设我们有一个慢生产者和一个快消费者。这种情况是可能发生的,因为生产者端可能对未知消费者有一些偏好假设。
    一方面,这种配置是一种特定的业务假设。另一方面,不仅实际运行情况可能不同,消费者也可能动态变化。例如,我们可以利用伸缩性来增加生产者的数量,从而增加消费者的负担。
    为了解决这个问题,很重要的一点是要明确真实需求。遗憾的是,纯推模型不能给我们这样的指标,因此动态增加系统的吞吐量是不可能的。

  2. 快生产者和慢消费者
    假设我们有一个快生产者和一个慢消费者。这里的问题是生产者所发送的数据可能远远超出消费者的处理能力,而这可能导致组件在压力下发生灾难性故障。
    针对这种情况的一个直观解决方案是将未处理的元素收集到队列中,该队列不仅可以构建在生产者和消费者之间,甚至还可以驻留在消费者端。即使消费者非常繁忙,这种技术也可以通过处理前一个元素或一部分数据使其能够应对新数据。
    使用队列处理所推送数据的关键要素之一是选择具有合适特性的队列。

3 队列选择

前面说了选择具有合适特性的队列。通常,有3种常见的队列类型

3.1 无界队列

提供一个无限大小的队列,所有生产的元素都将放到这个队列中,订阅者从队列中获取数据,但这样也有个问题,就是可能出现内存溢出

3.2 有界丢弃队列

就是在溢出时,丢弃元素,当消息的重要性很低的时候可以采取这种队列,我们可以根据需求实现不同的丢弃策略。

3.3 有界阻塞队列

如果每个消息都很重要,不能丢弃的时候,就不能采取有界丢弃队列,就可以在队列满了之后,阻塞生产者。但这样就否定异步行为,也降低了有效资源利用率。

所以在纯推模型可能出现很多不希望出现的情况,所谓的背压机制就是使系统巧妙的响应负载机制。

RXjava1这样的类库并没有提供这样的标准化功能,也没有明确的开箱即用的背压机制API

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值