宜人貸蜂巢API閘道器技術解密之Netty使用實踐

宜信技術發表於2019-06-06

宜人貸蜂巢團隊,由Michael創立於2013年,通過使用網際網路科技手段助力金融生態和諧健康發展。自成立起一直致力於多維度資料閉環平臺建設。目前團隊規模超過百人,涵蓋徵信、電商、金融、社交、五險一金和保險等使用者授信資料的抓取解析業務,輔以先進的資料分析、挖掘和機器學習等技術對使用者信用級別、欺詐風險進行預測評定,全面對外輸出金融反欺詐、社交圖譜、自動化模型定製等服務或產品。

目前宜人貸蜂巢基於使用者授權資料實時抓取解析技術,並結合頂尖大資料技術,快速迭代和自主的創新,已形成了強大而領先的聚合和輸出能力。

為了適應完成宜人貸蜂巢強大的服務輸出能力,蜂巢設計開發了自己的API閘道器係統,集中實現了鑑權、加解密、路由、限流等功能,使各業務抓取團隊關注其核心抓取和分析工作,而API閘道器係統更專注於安全、流量、路由等問題,從而更好的保障蜂巢服務系統的質量。今天帶著大家解密API閘道器的Netty執行緒池技術實踐細節。

API閘道器作為宜人貸蜂巢資料開放平臺的統一入口,所有的客戶端及消費端通過統一的API來使用各類抓取服務。從物件導向設計的角度看,它與外觀模式類似,包裝各類不同的實現細節,對外表現出統一的呼叫形式。

本文首先,簡要地介紹API閘道器的專案框架,其次對比BIO和NIO的特點,再引入Netty作為專案的基礎框架,然後介紹Netty執行緒池的原理,最後深入Netty執行緒池的初始化、ServerBootstrap的初始化與啟動及channel與執行緒池的繫結過程,讓讀者瞭解Netty在承載高併發訪問的設計路思。

專案框架

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

圖1 - API閘道器專案框架

圖中描繪了API閘道器係統的處理流程,以及與服務註冊發現、日誌分析、報警系統、各類爬蟲的關係。其中API閘道器係統接收請求,對請求進行編解碼、鑑權、限流、加解密,再基於Eureka服務註冊發現模組,將請求傳送到有效的服務節點上;閘道器及抓取系統的日誌,會被收集到elk平臺中,做業務分析及報警處理。

BIO vs NIO

API閘道器承載數倍於爬蟲的流量,提升伺服器的併發處理能力、縮短系統的響應時間,通訊模型的選擇是至關重要的,是選擇BIO,還是NIO?

Streamvs Buffer & 阻塞 vs 非阻塞

BIO是面向流的,io的讀寫,每次只能處理一個或者多個bytes,如果資料沒有讀寫完成,執行緒將一直等待於此,而不能暫時跳過io或者等待io讀寫完成非同步通知,執行緒滯留在io讀寫上,不能充分利用機器有限的執行緒資源,造成server的吞吐量較低,見圖2。而NIO與此不同,面向Buffer,執行緒不需要滯留在io讀寫上,採用作業系統的epoll模式,在io資料準備好了,才由執行緒來處理,見圖3。

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

圖2 – BIO 從流中讀取資料

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

圖3 – NIO 從Buffer中讀取資料

Selectors

NIO的selector使一個執行緒可以監控多個channel的讀寫,多個channel註冊到一個selector上,這個selector可以監測到各個channel的資料準備情況,從而使用有限的執行緒資源處理更多的連線,見圖4。所以可以這樣說,NIO極大的提升了伺服器接受併發請求的能力,而伺服器效能還是要取決於業務處理時間和業務執行緒池模型。

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

圖4 – NIO 單一執行緒管理多個連線

而BIO採用的是request-per-thread模式,用一個執行緒負責接收TCP連線請求,並建立鏈路,然後將請求dispatch給負責業務邏輯處理的執行緒,見圖5。一旦訪問量過多,就會造成機器的執行緒資源緊張,造成請求延遲,甚至服務當機。

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

圖5 – BIO 一連線一執行緒

對比JDK NIO與諸多NIO框架後,鑑於Netty優雅的設計、易用的API、優越的效能、安全性支援、API閘道器使用Netty作為通訊模型,實現了基礎框架的搭建。

Netty執行緒池

考慮到API閘道器的高併發訪問需求,執行緒池設計,見圖6。

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

圖6 – API閘道器執行緒池設計

Netty的執行緒池理念有點像ForkJoinPool,不是一個執行緒大池子併發等待一條任務佇列,而是每條執行緒都有一個任務佇列。而且Netty的執行緒,並不只是簡單的阻塞地拉取任務,而是在每個迴圈中做三件事情:

  • 先SelectKeys()處理NIO的事件
  • 然後獲取本執行緒的定時任務,放到本執行緒的任務佇列裡
  • 最後執行其他執行緒提交給本執行緒的任務

每個迴圈裡處理NIO事件與其他任務的時間消耗比例,還能通過ioRatio變數來控制,預設是各佔50%。可見,Netty的執行緒根本沒有阻塞等待任務的清閒日子,所以也不使用有鎖的BlockingQueue來做任務佇列了,而是使用無鎖的MpscLinkedQueue(Mpsc 是Multiple Producer, Single Consumer的縮寫)

NioEventLoopGroup初始化

下面分析下Netty執行緒池NioEventLoopGroup的設計與實現細節,NioEventLoopGroup的類層次關係見圖7

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

圖7 –NioEvenrLoopGroup類層次關係

其建立過程——方法呼叫,見下圖

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

圖8 –NioEvenrLoopGroup建立呼叫關係

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

NioEvenrLoopGroup的建立,具體執行過程是執行類MultithreadEventExecutorGroup的構造方法

/** * Create a new instance. * * @param nThreads the number of threads that will be used by this instance. * @param executor the Executor to use, or {@code null} if the default should be used. * @param chooserFactory the {@link EventExecutorChooserFactory} to use. * @param args arguments which will passed to each {@link #newChild(Executor, Object...)} call */protected MultithreadEventExecutorGroup(int nThreads, Executor executor, EventExecutorChooserFactory chooserFactory, Object... args) { if (nThreads <= 0) { throw new IllegalArgumentException(String.format("nThreads: %d (expected: > 0)", nThreads)); } if (executor == null) { executor = new ThreadPerTaskExecutor(newDefaultThreadFactory()); } children = new EventExecutor[nThreads]; for (int i = 0; i < nThreads; i ++) { boolean success = false; try { children[i] = newChild(executor, args); success = true; } catch (Exception e) {  throw new IllegalStateException("failed to create a child event loop", e); } finally { if (!success) { for (int j = 0; j < i; j ++) { children[j].shutdownGracefully(); } for (int j = 0; j < i; j ++) { EventExecutor e = children[j]; try { while (!e.isTerminated()) { e.awaitTermination(Integer.MAX_VALUE, TimeUnit.SECONDS); } } catch (InterruptedException interrupted) { // Let the caller handle the interruption. Thread.currentThread().interrupt(); break; } } } } } chooser = chooserFactory.newChooser(children); final FutureListener<Object> terminationListener = new FutureListener<Object>() { @Override public void operationComplete(Future<Object> future) throws Exception { if (terminatedChildren.incrementAndGet() == children.length) { terminationFuture.setSuccess(null); } } }; for (EventExecutor e: children) { e.terminationFuture().addListener(terminationListener); } Set<EventExecutor> childrenSet = new LinkedHashSet<EventExecutor>(children.length); Collections.addAll(childrenSet, children); readonlyChildren = Collections.unmodifiableSet(childrenSet);}複製程式碼

其中,建立細節見下:

  • 執行緒池中的執行緒數nThreads必須大於0;
  • 如果executor為null,建立預設executor,executor用於建立執行緒(newChild方法使用executor物件);
  • 依次建立執行緒池中的每一個執行緒即NioEventLoop,如果其中有一個建立失敗,將關閉之前建立的所有執行緒;
  • chooser為執行緒池選擇器,用來選擇下一個EventExecutor,可以理解為,用來選擇一個執行緒來執行task;

chooser的建立細節,見下

DefaultEventExecutorChooserFactory根據執行緒數建立具體的EventExecutorChooser,執行緒數如果等於2^n,可使用按位與替代取模運算,節省cpu的計算資源,見原始碼

@SuppressWarnings("unchecked")@Overridepublic EventExecutorChooser newChooser(EventExecutor[] executors) { if (isPowerOfTwo(executors.length)) { return new PowerOfTowEventExecutorChooser(executors); } else { return new GenericEventExecutorChooser(executors); }}  private static final class PowerOfTowEventExecutorChooser implements EventExecutorChooser { private final AtomicInteger idx = new AtomicInteger(); private final EventExecutor[] executors; PowerOfTowEventExecutorChooser(EventExecutor[] executors) { this.executors = executors; } @Override public EventExecutor next() { return executors[idx.getAndIncrement() & executors.length - 1]; } } private static final class GenericEventExecutorChooser implements EventExecutorChooser { private final AtomicInteger idx = new AtomicInteger(); private final EventExecutor[] executors; GenericEventExecutorChooser(EventExecutor[] executors) { this.executors = executors; } @Override public EventExecutor next() { return executors[Math.abs(idx.getAndIncrement() % executors.length)]; } }複製程式碼

newChild(executor, args)的建立細節,見下

MultithreadEventExecutorGroup的newChild方法是一個抽象方法,故使用NioEventLoopGroup的newChild方法,即呼叫NioEventLoop的建構函式

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

 @Override protected EventLoop newChild(Executor executor, Object... args) throws Exception { return new NioEventLoop(this, executor, (SelectorProvider) args[0], ((SelectStrategyFactory) args[1]).newSelectStrategy(), (RejectedExecutionHandler) args[2]); }複製程式碼

在這裡先看下NioEventLoop的類層次關係

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

NioEventLoop的繼承關係比較複雜,在AbstractScheduledEventExecutor 中, Netty 實現了 NioEventLoop 的 schedule 功能, 即我們可以通過呼叫一個 NioEventLoop 例項的 schedule 方法來執行一些定時任務. 而在 SingleThreadEventLoop 中, 又實現了任務佇列的功能, 通過它, 我們可以呼叫一個NioEventLoop 例項的 execute 方法來向任務佇列中新增一個 task, 並由 NioEventLoop 進行排程執行.

通常來說, NioEventLoop 肩負著兩種任務, 第一個是作為 IO 執行緒, 執行與 Channel 相關的 IO 操作, 包括呼叫 select 等待就緒的 IO 事件、讀寫資料與資料的處理等; 而第二個任務是作為任務佇列, 執行 taskQueue 中的任務, 例如使用者呼叫 eventLoop.schedule 提交的定時任務也是這個執行緒執行的.

具體的構造過程,見下

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

建立任務佇列tailTasks(內部為有界的LinkedBlockingQueue)

宜人貸蜂巢API閘道器技術解密之Netty使用實踐

建立執行緒的任務佇列taskQueue(內部為有界的LinkedBlockingQueue),以及任務過多防止系統當機的拒絕策略rejectedHandler

其中tailTasks和taskQueue均是任務佇列,而優先順序不同,taskQueue的優先順序高於tailTasks,定時任務的優先順序高於taskQueue。

ServerBootstrap初始化及啟動

瞭解了Netty執行緒池NioEvenrLoopGroup的建立過程後,下面看下API閘道器服務ServerBootstrap的是如何使用執行緒池引入服務中,為高併發訪問服務的。

API閘道器ServerBootstrap初始化及啟動程式碼,見下

serverBootstrap = new ServerBootstrap();bossGroup = new NioEventLoopGroup(config.getBossGroupThreads());workerGroup = new NioEventLoopGroup(config.getWorkerGroupThreads());serverBootstrap.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class) .option(ChannelOption.TCP_NODELAY, config.isTcpNoDelay()) .option(ChannelOption.SO_BACKLOG, config.getBacklogSize()) .option(ChannelOption.SO_KEEPALIVE, config.isSoKeepAlive()) // Memory pooled .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) .childHandler(channelInitializer); ChannelFuture future = serverBootstrap.bind(config.getPort()).sync();log.info("API-gateway started on port: {}", config.getPort());future.channel().closeFuture().sync();複製程式碼

API閘道器係統使用netty自帶的執行緒池,共有三組執行緒池,分別為bossGroup、workerGroup和executorGroup(使用在channelInitializer中,本文暫不作介紹)。其中,bossGroup用於接收客戶端的TCP連線,workerGroup用於處理I/O、執行系統task和定時任務,executorGroup用於處理閘道器業務加解密、限流、路由,及將請求轉發給後端的抓取服務等業務操作。

Channel與執行緒池的繫結

ServerBootstrap初始化後,通過呼叫bind(port)方法啟動Server,bind的呼叫鏈如下

AbstractBootstrap.bind ->AbstractBootstrap.doBind -> AbstractBootstrap.initAndRegister複製程式碼

其中,ChannelFuture regFuture = config().group().register(channel);中的group()方法返回bossGroup,而channel在serverBootstrap的初始化過程指定channel為NioServerSocketChannel.class,至此將NioServerSocketChannel與bossGroup繫結到一起,bossGroup負責客戶端連線的建立。那麼NioSocketChannel是如何與workerGroup繫結到一起的?

呼叫鏈AbstractBootstrap.initAndRegister -> AbstractBootstrap. init-> ServerBootstrap.init ->ServerBootstrapAcceptor.ServerBootstrapAcceptor ->ServerBootstrapAcceptor.channelRead

public void channelRead(ChannelHandlerContext ctx, Object msg) { final Channel child = (Channel) msg; child.pipeline().addLast(childHandler); for (Entry<ChannelOption<?>, Object> e: childOptions) { try { if (!child.config().setOption((ChannelOption<Object>) e.getKey(), e.getValue())) { logger.warn("Unknown channel option: " + e); } } catch (Throwable t) { logger.warn("Failed to set a channel option: " + child, t); } } for (Entry<AttributeKey<?>, Object> e: childAttrs) { child.attr((AttributeKey<Object>) e.getKey()).set(e.getValue()); } try { childGroup.register(child).addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { if (!future.isSuccess()) { forceClose(child, future.cause()); } } }); } catch (Throwable t) { forceClose(child, t); }}複製程式碼

其中,childGroup.register(child)就是將NioSocketChannel與workderGroup繫結到一起,那又是什麼觸發了ServerBootstrapAcceptor的channelRead方法?

其實當一個 client 連線到 server 時, Java 底層的 NIO ServerSocketChannel 會有一個SelectionKey.OP_ACCEPT 就緒事件, 接著就會呼叫到 NioServerSocketChannel.doReadMessages方法

@Overrideprotected int doReadMessages(List<Object> buf) throws Exception { SocketChannel ch = javaChannel().accept(); try { if (ch != null) { buf.add(new NioSocketChannel(this, ch)); return 1; } } catch (Throwable t) { … } return 0;}複製程式碼

javaChannel().accept() 會獲取到客戶端新連線的SocketChannel,例項化為一個 NioSocketChannel, 並且傳入 NioServerSocketChannel 物件(即 this), 由此可知, 我們建立的這個NioSocketChannel 的父 Channel 就是 NioServerSocketChannel 例項 .

接下來就經由 Netty 的 ChannelPipeline 機制, 將讀取事件逐級傳送到各個 handler 中, 於是就會觸發前面我們提到的 ServerBootstrapAcceptor.channelRead 方法啦。

至此,分析了Netty執行緒池的初始化、ServerBootstrap的啟動及channel與執行緒池的繫結過程,能夠看出Netty中執行緒池的優雅設計,使用不同的執行緒池負責連線的建立、IO讀寫等,為API閘道器專案的高併發訪問提供了技術基礎。

總結

至此,對API閘道器技術的Netty實踐分享就到這裡,各位如果對中間的各個環節有什麼疑問和建議,歡迎大家指正,我們一起討論,共同學習提高。

參考

http://tutorials.jenkov.com/java-nio/nio-vs-io.html

http://netty.io/wiki/user-guide-for-4.x.html

http://netty.io/

http://www.tuicool.com/articles/mUFnqeM

https://segmentfault.com/a/1190000007403873

https://segmentfault.com/a/1190000007283053

作者:蜂巢團隊  宜信技術學院


相關文章