以http接收上传为例的vertx被压实现

603 阅读5分钟

以http接收上传为例的vertx被压实现

前言

背压(Back Pressure)后端的压力。通常是指运动流体在密闭容器中沿其路径(譬如管路或风通路)流动时,由于受到障碍物或急转弯道的阻碍而被施加的与运动方向相反的压力。这是一个经常在管道输送中常见的现象。

由于我们使用的很多协议都是流式的,而且有时也存在上下游处理速率不同的情况,被压在流控(Flow Control)中也很常见。其根本是处理当下游处理速率低于上游产生速率时的积压问题。

举个例子:当用户通过http协议上传文件,服务端把它写入到磁盘时,由于tcp接收的速率高于磁盘写入速率,若文件过大则会导致内存积压,进而导致OOM

Vertx的抽象

vertx对于流给出了两个抽象,可被读取的流——io.vertx.core.streans.ReadStream,可写入的流——io.vertx.core.streans.WriteStream

实际情况中,ReadStream作为上游,WriteStream作为下游,所以WriteStream存在一个缓冲的写入“队列”(不一定真的存在队列,也可能是某个水位标识),同时也提供了判断队列是否满和设置队列允许继续写入的回调的方法,ReadStream存在一个暂停流的方法,这样我们在编程中就可以方便地进行流控了

先来看一个vertx文档中的例子

server.connectHandler(sock -> {
  sock.handler(buffer -> {
    sock.write(buffer);
    if (sock.writeQueueFull()) {
      sock.pause();
      sock.drainHandler(done -> {
        sock.resume();
      });
    }
  });
})

这是一个简单的echo服务的实现,当积压到无法写出时,就暂停接收新的信息,等可以写出时再恢复。如果读者对netty足够熟悉,那么可以不用看下面的内容了

暂停接收新的消息其原理就是关闭自动读 可以写出的回调就是写状态变换到低水位的回调

上面这种其实我们并不常用,常用的方式是readStream.pipeTo(writeStream),这种自动实现了被压特性的传输

而下方这种我们常用的保存上传文件也是利用pipeTo方法实现的

rc.request().uploadHandler(hsf -> {
            hsf.streamToFileSystem("filename");
        });

泵(pipe)

上面提到的pipeTo实现实际上是通过io.vertx.core.streams.Pipe的实现类来实现的

核心代码:

Handler<Void> drainHandler = v -> src.resume();
    src.handler(item -> {
      ws.write(item, this::handleWriteResult);
      if (ws.writeQueueFull()) {
        src.pause();
        ws.drainHandler(drainHandler);
      }
    });

看的出来很简单,当上游获取到一个元素的时候写入到下游,若下游满了则暂停上游,下游恢复后再恢复上游产生元素

Vertx-web对pause的实现

以处理http1.1的类io.vertx.core.http.impl.Http1xServerRequest为例子

 public HttpServerRequest pause() {
    synchronized (conn) {
      pendingQueue().pause();
      return this;
    }
  }

public synchronized InboundBuffer<E> pause() {
    demand = 0L;
    return this;
  }

只是设置内部队列的大小为0(这个队列就是netty信息写入到vertx层的中转)

详情可以查看这一篇Vertx tcpserver启动再次探秘 - 掘金 (juejin.cn)

既然是写入的中转我们来看一下这个InboudBuffer的write方法

public boolean write(E element) {
    checkThread();
    Handler<E> handler;
    synchronized (this) {
      if (demand == 0L || emitting) {
        pending.add(element);
        return checkWritable();
         //省略不必要的部分
 }
private boolean checkWritable() {
    if (demand == Long.MAX_VALUE) {
      return true;
    } else {
      long actual = pending.size() - demand;
      boolean writable = actual < highWaterMark;
      overflow |= !writable;
      return writable;
    }
  }
          

当我们pause的时候,实际上就会触发demand==0的分支,下层netty传来的元素就会在这个inboundbuffer内部堆积直到堆积到设定好的高水位(这个实现中是8个元素),一旦高水位,即这个buffer不再可以接收元素

我们再来反查这个write方法的使用

void handleContent(Buffer buffer) {
    InboundBuffer<Object> queue;
    synchronized (conn) {
      queue = pending;
    }
    if (queue != null) {
      // We queue requests if paused or a request is in progress to prevent responses being written in the wrong order
      if (!queue.write(buffer)) {
        // We only pause when we are actively called by the connection
        conn.doPause();
      }
    } else {
      context.execute(buffer, this::onData);
    }
  }

这个方法就是实际处理传来的buffer的地方

注意 conn.doPause() 方法,这里就是暂停流的具体实现

 public void doPause() {
    chctx.channel().config().setAutoRead(false);
  }

很简单就是关闭自动读取

同理resume就是开启自动读。

总结一下:

如果我们从tcp内核缓冲区为0开始,netty层的channel默认开启自动读,netty从内核缓冲区读取一个buffer出来塞到vertx这边的inboundbuffer里面,由于下游慢,inboundbuffer很快就差一个塞满了,下一次netty层来write到inboundbuffer时返回了false,此时channel就被关闭自动读了,netty的eventloop即使发现可读事件也不会去读,这就意味着对应的内核缓冲区暂时不被读取,如果满了会怎么样呢?

设想一个极端情况,我的下游长时间都给我说不可再写入,那么我的服务器的可用窗口变小直到0,上游发送窗口也到0,就不会有数据传送了,这样就通知到了上游生产方

AsyncFile的writeQueueFull的判断实现

正如之前提到的 “WriteStream存在一个缓冲的写入“队列”(不一定真的存在队列,也可能是某个水位标识)”

其实AsyncFile就是一种记录写入的字节数作为水位判定的实现

@Override
  public synchronized boolean writeQueueFull() {
    check();
    return overflow;
  }

 private void doWrite(ByteBuffer buff, long position, long toWrite, Handler<AsyncResult<Void>> handler) {
    if (toWrite > 0) {
      synchronized (this) {
        writesOutstanding += toWrite;
        overflow |= writesOutstanding >= maxWrites;
      }
      writeInternal(buff, position, handler);
    } else {
      handler.handle(Future.succeededFuture());
    }
  }
 private void writeInternal(ByteBuffer buff, long position, Handler<AsyncResult<Void>> handler) {

    ch.write(buff, position, null, new java.nio.channels.CompletionHandler<Integer, Object>() {

      public void completed(Integer bytesWritten, Object attachment) {

        long pos = position;

        if (buff.hasRemaining()) {
          // partial write
          pos += bytesWritten;
          // resubmit
          writeInternal(buff, pos, handler);
        } else {
          // It's been fully written
          context.runOnContext((v) -> {
            synchronized (AsyncFileImpl.this) {
              writesOutstanding -= buff.limit();
            }
            handler.handle(Future.succeededFuture());
          });
        }
      }
      //省略
   }

这几个方法放在一起就能看出怎么实现的高低水位控制了

写入前将本次要写入的字节数加到正在写入的计数中,写完后减去本次写入的字节数,正在写入的计数和以预设的高水位相比得出是否“队列满”

默认的“满”的标识给了个经验值

private int maxWrites = 128 * 1024

这里推荐根据自己情况通过io.vertx.core.file#setsetWriteQueueMaxSize方法设置

默认的低水位时“满的一半”