來自95後的天池中間件大賽總結

來自95後的天池中間件大賽總結

kiritomoe 程序猿DD 7月31日

做者:老徐java

來源:kirito的技術分享node

 

第一部分:Dubbo Mesh優化python

 

天池中間件大賽的初賽在今早終於正式結束了,公衆號停更了一個月,主要緣由就是博主的空餘時間幾乎全花在這個比賽上,第一賽季結束,作下參賽總結,總的來講,收穫不小。react

 

 

先說結果,最終榜單排名是第 15 名(除去前排大佬的兩個小號,加上做弊的第一名,勉強能算是第 12 名),說實話是挺滿意的成績。這篇文章主要是分享給如下讀者:比賽中使用了 netty 卻沒有達到理想 qps 的朋友,netty 剛入門的朋友,對 dubbo mesh 感興趣的朋友。linux

 

在比賽以前我我的對 netty 的認識也僅僅停留在瞭解的層面,在以前解讀 RPC 原理的系列文章中涉及到 netty 傳輸時曾瞭解過一二,基本能夠算零基礎使用 netty 參賽,因此我會更多地站在一個小白的視角來闡述本身的優化歷程,一步步地提升 qps,也不會繞開那些本身踩過的坑以及負優化。另外一方面,因爲本身對 netty 的理解並非很深,因此文中若是出現錯誤,敬請諒解,歡迎指正。c++

 

Dubbo Mesh 是什麼?

 

爲了照顧那些不太瞭解此次比賽內容的讀者,我先花少許的篇幅介紹下此次阿里舉辦的天池中間件大賽到底比的是個什麼東西,那就不得不先介紹下 Dubbo Mesh 這個概念。git

 

若是你用過 dubbo,而且對 service mesh 有所瞭解,那麼必定能夠秒懂 Dubbo Mesh 是爲了解決什麼問題。說白了,dubbo 原先是爲了 java 語言而準備的,沒有考慮到跨語言的問題,這意味着 nodejs,python,go 要想無縫使用 dubbo 服務,要麼藉助於各自語言的 dubbo 客戶端,例如:node-dubbo-client,python-dubbo-client,go-dubbo-client;要麼就是藉助於 service mesh 的解決方案,讓 dubbo 本身提供跨語言的解決方案,來屏蔽不一樣語言的處理細節,因而乎,dubbo 生態的跨語言 service mesh 解決方案就被命名爲了 dubbo mesh。算法

 

一圖勝千言:spring

 

在原先的 dubbo 生態下,只有 consumer,provider,註冊中心的概念。dubbo mesh 生態下爲每一個服務(每一個 consumer,provider 實例)啓動一個 agent,服務間再也不進行直接的通訊,而是經由各自的 agent 完成交互,而且服務的註冊發現也由 agent 完成。圖中紅色的 agent 即是此次比賽的核心,選手們能夠選擇合適的語言來實現 agent,最終比拼高併發下各自 agent 實現的 qps,qps 即最終排名的依據。apache

 

賽題剖析

 

此次比賽的主要考察點在於高併發下網絡通訊模型的實現,能夠涵蓋如下幾個關鍵點:reactor 模型,負載均衡,線程,鎖,io 通訊,阻塞與非阻塞,零拷貝,序列化,http/tcp/udp與自定義協議,批處理,垃圾回收,服務註冊發現等。它們對最終程序的 qps 起着或大或小的影響,對它們的理解越深,越可以編寫出高性能的 dubbo mesh 方案。

 

語言的選擇,初賽結束後的感覺,你們主要仍是在 java,c++,go 中進行了抉擇。語言的選擇考慮到了諸多的因素,通用性,輕量級,性能,代碼量和qps的性價比,選手的習慣等等。雖然前幾名貌似都是 c++,但整體來講,排名 top 10 以外,毫不會是由於語言特性在從中阻撓。c++ 選手高性能的背後,多是犧牲了 600 多行代碼在本身維護一個 etcd-lib(比賽限制使用 etcd,但據使用 c++ 的選手說,c++ 沒有提供 etcd 的 lib);且此次比賽提供了預熱環節,java 黨也露出了欣慰的笑容。

 

java 的主流框架仍是在 nio,akka,netty 之間的抉擇,netty 應該是衆多 java 選手中較爲青睞的,博主也選擇了 netty 做爲 dubbo mesh 的實現;go 的協程和網絡庫也是兩把利器,並不比 java 弱,加上其進程輕量級的特性,也做爲了一個選擇。

 

官方提供了一個 qps 並非很高的 demo,來方便選手們理解題意,能夠說是很是貼心了,來回顧一下最簡易的 dubbo mesh 實現:

 

如上圖所示,是整個初始 dubbo mesh 的架構圖,其中 consumer 和 provider 以灰色表示,由於選手是不能修改其實現的,綠色部分的 agent 是能夠由選手們自由發揮的部分。比賽中 consumer,consumer-agent 爲 單個實例,provider、provider-agent 分別啓動了三個性能不一的實例:small,medium,large,這點我沒有在圖中表示出來,你們自行腦補。

 

因此全部選手都須要完成如下幾件事:

  1. consumer-agent 須要啓動一個 http 服務器,接收來自 consumer 的 http 請求

  2. consumer-agent 須要轉發該 http 請求給 provider-agent,而且因爲 provider-agent 有多個實例,因此須要作負載均衡。consumer-agent 與 provider-agent 之間如何通訊能夠自由發揮。

  3. provider-agent 拿到 consumer-agent 的請求以後,須要組裝成 dubbo 協議, 使用 tcp 與 provider 完成通訊。

 

這樣一個跨語言的簡易 dubbo mesh 便呈如今你們面前了,從 consumer 發出的 http 協議,最終成功調用到了使用 java 語言編寫的 dubbo 服務。這中間如何優化,如何使用各類黑科技成就了一場很是有趣的比賽。博主全部的優化都不是一蹴而就的,都是一每天的提交試出來的,因此剛好可使用時間線順序敘述本身的改造歷程。

 

優化歷程

 

Qps 1000 到 2500 (CA 與 PA 使用異步 http 通訊)

 

官方提供的 demo 直接跑通了整個通訊流程,省去了咱們大量的時間,初始版本評測能夠達到 1000+ 的 qps,因此 1000 能夠做爲 baseline 給你們提供參考。demo 中 consumer 使用 asyncHttpClient 發送異步的 http 請求, consumer-agent 使用了 springmvc 支持的 servlet3.0 特性;而 consumer-agent 到 provider-agent 之間的通訊卻使用了同步 http,因此 C 到 CA 這一環節相比 CA 到 PA 這一環節性能是要強不少的。改造起來也很簡單,參照 C 到 CA 的設計,直接將 CA 到 PA 也替換成異步 http,qps 能夠直接到達 2500。

 

主要得益於 async-http-client 提供的異步 http-client,以及 servlet3.0 提供的非阻塞 api。

  1. <dependency>

  2.    <groupId>org.asynchttpclient</groupId>

  3.    <artifactId>async-http-client</artifactId>

  4.    <version>2.4.7</version>

  5. </dependency>

  1. // 非阻塞發送 http 請求

  2. ListenableFuture<org.asynchttpclient.Response> responseFuture = asyncHttpClient.executeRequest(request);

  3.  

  4. // 非阻塞返回 http 響應

  5. @RequestMapping(value = "/invoke")

  6. public DeferredResult<ResponseEntity> invoke(){}

 

Qps 2500 到 2800 (負載均衡優化爲加權輪詢)

 

demo 中提供的負載均衡算法是隨機算法,在 small-pa,medium-pa,large-pa 中隨機選擇一個訪問,每一個服務的性能不同,響應時間天然也不一樣,隨機負載均衡算法存在嚴重的不穩定性,沒法按需分配請求,因此成了天然而然的第二個改造點。

 

優化爲加權輪詢算法,這一塊的實現參考了 motan(weibo 開源的 rpc 框架)的實現,詳見 com.alibaba.dubbo.performance.demo.agent.cluster.loadbalance.WeightRoundRobinLoadBalance(文末貼 git 地址)。

 

在啓動腳本中配置權重信息,伴隨 pa 啓動註冊服務地址到 etcd 時,順帶將權重信息一併註冊到 etcd 中,ca 拉取服務列表時便可獲取到負載比例。

  1. large:

  2. -Dlb.weight=3

  3. medium:

  4. -Dlb.weight=2

  5. small:

  6. -Dlb.weight=1

 

預熱賽時最高併發爲 256 鏈接,這樣的比例能夠充分發揮每一個 pa 的性能。

Qps 2800 到 3500 (future->callback)

 

c 到 ca 以及 ca 到 pa 此時儘管是 http 通訊,但已經實現了非阻塞的特性(請求不會阻塞 io 線程),但 dubbo mesh 的 demo 中 pa 到 p 的這一通訊環節仍是使用的 future.get + countDownLatch 的阻塞方式,一旦整個環節出現了鎖和阻塞,qps 必然上不去。

 

關於幾種獲取結果的方式,也是老生常談的話題:

 

future 方式在調用過程當中不會阻塞線程,但獲取結果是會阻塞線程,provider 固定 sleep 了 50 ms,因此獲取 future 結果依舊是一個耗時的過程,加上這種模型通常會使用鎖來等待,性能會形成明顯的降低。替換成 callback 的好處是,io 線程專一於 io 事件,下降了線程數,這和 netty 的 io 模型也是很是契合的。

  1. Promise<Integer> agentResponsePromise = new DefaultPromise<>(ctx.executor());

  2. agentResponsePromise.addListener();

 

netty 爲此提供了默認的 Promise 的抽象,以及 DefaultPromise 的默認實現,咱們能夠 out-of-box 的使用 callback 特性。在 netty 的入站 handler 的 channelRead 事件中建立 promise,拿到 requestId,創建 requestId 和 promise 的映射;在出站 handler 的channelRead 事件中拿到返回的 requestId,查到 promise,調用 done 方法,便完成了非阻塞的請求響應。可參考: 入站 handler ConsumerAgentHttpServerHandler 和 和出站 handlerConsumerAgentClientHandler 的實現。

 

Qps 3500 到 4200 (http通訊替換爲tcp通訊)

 

ca 到 pa 的通訊本來是異步 http 的通訊方式,徹底能夠參考 pa 到 p 的異步 tcp 通訊進行改造。自定義 agent 之間的通訊協議也很是容易,考慮到 tcp 粘包的問題,使用定長頭+字節數組來做爲自定義協議是一個較爲經常使用的作法。這裏踩過一個坑,本來想使用 protoBuffer 來做爲自定義協議,netty 也很友好的提供了基於 protoBuffer 協議的編解碼器,只須要編寫好 DubboMeshProto.proto 文件便可:

  1. message AgentRequest {

  2.    int64 requestId = 1;

  3.    string interfaceName = 2;

  4.    string method = 3;

  5.    string parameterTypesString = 4;

  6.    string parameter = 5;

  7. }

  8.  

  9. message AgentResponse {

  10.    int64 requestId = 1;

  11.    bytes hash = 2;

  12. }

 

protoBuffer 在實際使用中的優點是毋庸置疑的,其能夠儘量的壓縮字節,減小 io 碼流。在正式賽以前一直用的好好的,但後來的 512 併發下經過 jprofile 發現,DubboMeshProto 的 getSerializedSize ,getDescriptorForType 等方法存在沒必要要的耗時,對於此次比賽中如此簡單的數據結構而言 protoBuffer 並非那麼優秀。最終仍是採起了定長頭+字節數組的自定義協議。

參考: com.alibaba.dubbo.performance.demo.agent.protocol.simple.SimpleDecoder

http 通訊既然換了,乾脆一換到底,ca 的 springmvc 服務器也可使用 netty 實現,這樣更加有利於實現 ca 總體的 reactive。使用 netty 實現 http 服務器很簡單,使用 netty 提供的默認編碼解碼器便可。

  1. public class ConsumerAgentHttpServerInitializer extends ChannelInitializer<SocketChannel> {

  2.    @Override

  3.    public void initChannel(SocketChannel ch) {

  4.        ChannelPipeline p = ch.pipeline();

  5.        p.addLast("encoder", new HttpResponseEncoder());

  6.        p.addLast("decoder", new HttpRequestDecoder());

  7.        p.addLast("aggregator", new HttpObjectAggregator(10 * 1024 * 1024));

  8.        p.addLast(new ConsumerAgentHttpServerHandler());

  9.    }

  10. }

 

http 服務器的實現也踩了一個坑,解碼 http request 請求時沒注意好 ByteBuf 的釋放,致使 qps 跌倒了 2000+,反而不如 springmvc 的實現。在隊友@閃電俠的幫助下成功定位到了內存泄露的問題。

  1. public static Map<String, String> parse(FullHttpRequest req) {

  2.    Map<String, String> params = new HashMap<>();

  3.    // 是POST請求

  4.    HttpPostRequestDecoder decoder = new HttpPostRequestDecoder(new DefaultHttpDataFactory(false), req);

  5.    List<InterfaceHttpData> postList = decoder.getBodyHttpDatas();

  6.    for (InterfaceHttpData data : postList) {

  7.        if (data.getHttpDataType() == InterfaceHttpData.HttpDataType.Attribute) {

  8.            MemoryAttribute attribute = (MemoryAttribute) data;

  9.            params.put(attribute.getName(), attribute.getValue());

  10.        }

  11.    }

  12.    // resolve memory leak

  13.    decoder.destroy();

  14.    return params;

  15. }

 

在正式賽後發現還有更快的 decode 方式,不須要藉助於上述的 HttpPostRequestDecoder,而是改用 QueryStringDecoder:

  1. public static Map<String, String> fastParse(FullHttpRequest httpRequest) {

  2.    String content = httpRequest.content().toString(StandardCharsets.UTF_8);

  3.    QueryStringDecoder qs = new QueryStringDecoder(content, StandardCharsets.UTF_8, false);

  4.    Map<String, List<String>> parameters = qs.parameters();

  5.    String interfaceName = parameters.get("interface").get(0);

  6.    String method = parameters.get("method").get(0);

  7.    String parameterTypesString = parameters.get("parameterTypesString").get(0);

  8.    String parameter = parameters.get("parameter").get(0);

  9.    Map<String, String> params = new HashMap<>();

  10.    params.put("interface", interfaceName);

  11.    params.put("method", method);

  12.    params.put("parameterTypesString", parameterTypesString);

  13.    params.put("parameter", parameter);

  14.    return params;

  15. }

 

節省篇幅,直接在這兒將以後的優化貼出來,後續再也不對這個優化贅述了。

 

Qps 4200 到 4400 (netty複用eventLoop)

 

這個優化點來自於比賽認識的一位好友@半杯水,因爲沒有使用過 netty,比賽期間惡補了一下 netty 的線程模型,得知了 netty 能夠從客戶端引導 channel,從而複用 eventLoop。不瞭解 netty 的朋友能夠把 eventLoop 理解爲 io 線程,若是入站的 io 線程和 出站的 io 線程使用相同的線程,能夠減小沒必要要的上下文切換,這一點在 256 併發下可能還不明顯,只有 200 多 qps 的差距,但在 512 下尤其明顯。複用 eventLoop 在《netty實戰》中是一個專門的章節,篇幅雖然很少,但很是清晰地向讀者闡釋瞭如何複用 eventLoop(注意複用同時存在於 ca 和 pa 中)。

  1. // 入站服務端的 eventLoopGroup

  2. private EventLoopGroup workerGroup;

  3.  

  4. // 爲出站客戶端預先建立好的 channel

  5. private void initThreadBoundClient(EventLoopGroup workerGroup) {

  6.    for (EventExecutor eventExecutor : eventLoopGroup) {

  7.        if (eventExecutor instanceof EventLoop) {

  8.            ConsumerAgentClient consumerAgentClient = new ConsumerAgentClient((EventLoop) eventExecutor);

  9.            consumerAgentClient.init();

  10.            ConsumerAgentClient.put(eventExecutor, consumerAgentClient);

  11.        }

  12.  

  13.    }

  14. }

 

使用入站服務端的 eventLoopGroup 爲出站客戶端預先建立好 channel,這樣能夠達到複用 eventLoop 的目的。而且此時還有一個伴隨的優化點,就是將存儲 Map<requestid,promise> 的數據結構,從 concurrentHashMap 替換爲了 ThreadLocal ,由於入站線程和出站線程都是相同的線程,省去一個 concurrentHashMap 能夠進一步下降鎖的競爭。

<requestid,promise>

到了這一步,總體架構已經清晰了,c->ca,ca->pa,pa->p 都實現了異步非阻塞的 reactor 模型,qps 在 256 併發下,也達到了 4400 qps。

 

 

正式賽 512 鏈接帶來的新格局

 

上述這份代碼在預熱賽 256 併發下表現尚可,但正式賽爲了體現出你們的差距,將最高併發數直接提高了一倍,但 qps 卻並無獲得很好的提高,卡在了 5400 qps。和 256 鏈接下一樣 4400 的朋友交流事後,發現咱們之間的差距主要體如今 ca 和 pa 的 io 線程數,以及 pa 到 p 的鏈接數上。5400 qps 顯然低於個人預期,爲了下降鏈接數,我修改了原來 provider-agent 的設計。從如下優化開始,是正式賽 512 鏈接下的優化,預熱賽只有 256 鏈接。

 

Qps 5400 到 5800 (下降鏈接數)

 

對 netty 中 channel 的優化搜了不少文章,依舊不是很肯定鏈接數究竟是不是影響我代碼的關鍵因素,在和小夥伴溝通以後實在找不到 qps 卡在 5400 的緣由,因而乎抱着試試的心態修改了下 provider-agent 的設計,採用了和 consumer-agent 同樣的設計,預先拿到 provder-agent 入站服務器的 woker 線程組,建立出站請求的 channel,將原來的 4 個線程,4 個 channel 下降到了 1 個線程,一個 channel。其餘方面未作任何改動,qps 順利達到了 5800。

 

理論上來講,channel 數應該不至於成爲性能的瓶頸,可能和 provider dubbo 的線程池策略有關,最終得出的經驗就是:在 server 中合理的在 io 事件處理能力的承受範圍內,使用盡量少的鏈接數和線程數,能夠提高 qps,減小沒必要要的線程切換。順帶一提(此時 ca 的線程數爲 4,入站鏈接爲 http 鏈接,最高爲 512 鏈接,出站鏈接因爲和線程綁定,又須要作負載均衡,因此爲 $$ 線程數pa數=43=12 $$ 這個階段,還存在另外一個問題,因爲 provider 線程數固定爲 200 個線程,若是 large-pa 繼續分配 3/1+2+3=0.5 即 50% 的請求,很容易出現 provider 線程池飽滿的異常,因此調整了加權值爲 1:2:2。限制加權負載均衡的再也不僅僅是機器性能,還要考慮到 provider 的鏈接處理能力。

 

Qps 5800 到 6100 (Epoll替換Nio)

 

依舊感謝@半杯水的提醒,因爲評測環境使用了 linux 做爲評測環境,因此可使用 netty 本身封裝的 EpollSocketChannel 來代替 NioSocketChannel,這個提高遠超個人想象,直接幫助我突破了 6000 的關卡。

  1. private EventLoopGroup bossGroup = Epoll.isAvailable() ? new EpollEventLoopGroup(1) : new NioEventLoopGroup(1);

  2. private EventLoopGroup workerGroup = Epoll.isAvailable() ? new EpollEventLoopGroup(2) : new NioEventLoopGroup(2);

  3. bootstrap = new ServerBootstrap();

  4.            bootstrap.group(bossGroup, workerGroup)

  5.                    .channel(Epoll.isAvailable() ? EpollServerSocketChannel.class : NioServerSocketChannel.class)

 

本地調試因爲我是 mac 環境,無法使用 Epoll,因此加了如上的判斷。

 

NioServerSocketChannel 使用了 jdk 的 nio,其會根據操做系統選擇使用不一樣的 io 模型,在 linux 下一樣是 epoll,但默認是 level-triggered ,而 netty 本身封裝的 EpollSocketChannel 默認是 edge-triggered。 我原先覺得是 et 和 lt 的差距致使了 qps 如此大的懸殊,但後續優化 Epoll 參數時發現 EpollSocketChannel 也能夠配置爲 level-triggered,qps 並無降低,在比賽的特殊條件下,我的猜測並非這兩種觸發方式帶來的差距,而僅僅是 netty 本身封裝 epoll 帶來的優化。

 

  1. //默認

  2. bootstrap.option(EpollChannelOption.EPOLL_MODE, EpollMode.EDGE_TRIGGERED);

  3. //可修改觸發方式

  4. bootstrap.option(EpollChannelOption.EPOLL_MODE, EpollMode.LEVEL_TRIGGERED);

 

Qps 6100 到 6300 (agent自定義協議優化)

 

agent 之間的自定義協議我以前已經介紹過了,因爲一開始我使用了 protoBuf,發現了性能問題,就是在這兒發現的。在 512 下 protoBuf 的問題尤其明顯,最終爲了保險起見,以及爲了和我後面的一個優化兼容,最終替換爲了自定義協議—Simple 協議,這一點優化以前提到了,不在過多介紹。

 

Qps 6300 到 6500 (參數調優與zero-copy)

 

這一段優化來自於和 @折袖-許華建 的交流,很是感謝。又是一個對 netty 不太瞭解而沒注意的優化點:

 

  • 關閉 netty 的內存泄露檢測:

  1. -Dio.netty.leakDetectionLevel=disabled

 

netty 會在運行期按期抽取 1% 的 ByteBuf 進行內存泄露的檢測,關閉這個參數後,能夠得到性能的提高。

 

  • 開啓 quick_ack:

  1. bootstrap.option(EpollChannelOption.TCP_QUICKACK, java.lang.Boolean.TRUE)

 

tcp 相比 udp ,一個區別即是爲了可靠傳輸而進行的 ack,netty 爲 Epoll 提供了這個參數,能夠進行 quick ack,具體原理沒來及研究。

 

  • 開啓 TCP_NODELAY

  1. serverBootstrap.childOption(ChannelOption.TCP_NODELAY, true)

 

這個優化可能大多數人都知道,放在這兒一塊兒羅列出來。網上搜到了一篇阿里畢玄的 rpc 優化文章,提到高併發下 ChannelOption.TCP_NODELAY=false 可能更好,但實測以後發現並不會。

 

其餘調優的參數可能都是玄學了,對最終的 qps 影響微乎其微。參數調優並不能體現太多的技巧,但對結果產生的影響倒是很可觀的。

 

在這個階段還同時進行了一個優化,和參數調優一塊兒進行的,因此不知道哪一個影響更大一些。demo 中 dubbo 協議編碼沒有作到 zero-copy,這無形中增長了一份數據從內核態到用戶態的拷貝;自定義協議之間一樣存在這個問題,在 dubbo mesh 的實踐過程當中應該儘量作到:能用 ByteBuf 的地方就不要用其餘對象,ByteBuf 提供的 slice 和 CompositeByteBuf 均可以很方便的實現 zero-copy。

 

Qps 6500 到 6600 (自定義http協議編解碼)

 

看着榜單上的人 qps 逐漸上升,而本身依舊停留在 6500,因而乎動了歪心思,GTMD 的通用性,本身解析 http 協議得了,不要 netty 提供的 http 編解碼器,不須要比 HttpPostRequestDecoder 更快的 QueryStringDecoder,就一個偏向於固定的 http 請求,實現自定義解析很是簡單。

  1. POST / HTTP/1.1\r\n

  2. content-length: 560\r\n

  3. content-type: application/x-www-form-urlencoded\r\n

  4. host: 127.0.0.1:20000\r\n

  5. \r\n

  6. interface=com.alibaba.dubbo.performance.demo.provider.IHelloService&method=hash&parameterTypesString=Ljava%32lang%32String;&parameter=xxxxx

 

http 文本協議自己仍是稍微有點複雜的,因此 netty 的實現考慮到通用性,必然不如咱們本身解析來得快,具體的粘包過程就不敘述了,有點 hack 的傾向。

 

同理,response 也本身解析:

  1. HTTP/1.1 200 OK\r\n

  2. Connection: keep-alive\r\n

  3. Content-Type: text/plain;charset=UTF-8\r\n

  4. Content-Length: 6\r\n

  5. \r\n

  6. 123456

 

Qps 6600 到 6700 (去除對象)

 

繼續喪心病狂,不考慮通用性,把以前全部的中間對象都省略,encode 和 decode 盡一切可能壓縮到 handler 中去處理,這樣的代碼看起來很是難受,存在很多地方的 hardcoding。但效果是存在的,ygc 的次數下降了很多,全程使用 ByteBuf 和 byte[] 來進行數據交互。這個優化點一樣存在存在 hack 傾向,不過多贅述。

 

Qps 6700 到 6850 (批量flush,批量decode)

 

事實上到了 6700 有時候仍是須要看運氣的,從羣裏的吐槽現象就能夠發現,512 下的網路 io 很是抖,不清楚是機器的問題仍是高併發下的固有現象,6700的代碼都能抖到 5000 分。因此 6700 升 6850 的過程比較曲折,並且很不穩定,提交 20 次一共就上過兩次 6800+。

 

所作的優化是來自隊友@閃電俠的批量flush類,一次傳輸的字節數能夠提高,使得網絡 io 次數能夠下降,原理能夠簡單理解爲:netty 中 write 10 次,flush 1 次。一共實現了兩個版本的批量 flush。一個版本是根據同一個 channel write 的次數積累,最終觸發 flush;另外一個版本是根據一次 eventLoop 結束才強制flush。通過不少測試,因爲環境抖動太厲害,這二者沒測出多少差距。

  1. handler(new ChannelInitializer<SocketChannel>() {

  2.    @Override

  3.    protected void initChannel(SocketChannel ch) {

  4.    ch.pipeline()

  5.        .addLast(new SimpleDecoder())

  6.        .addLast(new BatchFlushHandler(false))

  7.        .addLast(new ConsumerAgentClientHandler());

  8.    }

  9. });

 

批量 decode 的思想來自於螞蟻金服的 rpc 框架 sofa-bolt 中提供的一個抽象類:AbstractBatchDecoder

Netty 提供了一個方便的解碼工具類 ByteToMessageDecoder ,如圖上半部分所示,這個類具有 accumulate 批量解包能力,能夠儘量的從 socket 裏讀取字節,而後同步調用 decode 方法,解碼出業務對象,並組成一個 List 。

 

最後再循環遍歷該 List ,依次提交到 ChannelPipeline 進行處理。此處咱們作了一個細小的改動,如圖下半部分所示,即將提交的內容從單個 command ,改成整個 List 一塊兒提交,如此能減小 pipeline 的執行次數,同時提高吞吐量。這個模式在低併發場景,並無什麼優點,而在高併發場景下對提高吞吐量有不小的性能提高。

 

值得指出的一點:這個對於 dubbo mesh 複用 eventLoop 的特殊場景下的優化效果實際上是存疑的,但個人最好成績的確是使用了 AbstractBatchDecoder 以後跑出來的。我曾經單獨將 ByteToMessageDecoder 和 AbstractBatchDecoder 拉出跑了一次分,的確是後者 qps 更高。

 

總結

 

其實在 qps 6500 時,總體代碼仍是挺漂亮的,至少感受能拿的出手給別人看。但最後爲了性能,加上時間比較趕,很多地方都進行了 HardCoding,而實際能投入生產使用的代碼必然要求通用性和擴展性,賽後有空會整理出兩個分支:一個 highest-qps 追求性能,另外一個分支保留下通用性。此次比賽從一個 netty 小白,最終學到了很多的知識點,仍是收穫很大的,最後感謝一下比賽中給過我指導的各位老哥。

 

最高 qps 分支:highest-qps

考慮通用性的分支(適合 netty 入門):master

https://code.aliyun.com/250577914/agent-demo.git

 


 

第二部分:百萬隊列存儲設計

 

維持了 20 天的複賽終於告一段落了,國際慣例先說結果,複賽結果不太理想,一度從第 10 名掉到了最後的第 36 名,主要是寫入的優化卡了 5 天,一直沒有進展,最終排名也是定格在了排行榜的第二頁。痛定思痛,這篇文章將本身複賽中學習的知識,成功的優化,未成功的優化都羅列一下。

 

賽題介紹

 

題面描述很簡單:使用 Java 或者 C++ 實現一個進程內的隊列引擎,單機可支持 100 萬隊列以上。

 

  1. public abstract class QueueStore {

  2.    abstract void put(String queueName, byte[] message);

  3.    abstract Collection<byte[]> get(String queueName, long offset, long num);

  4. }

 

編寫如上接口的實現。

 

put 方法將一條消息寫入一個隊列,這個接口須要是線程安全的,評測程序會併發調用該接口進行 put,每一個queue 中的內容按發送順序存儲消息(能夠理解爲 Java 中的 List),同時每一個消息會有一個索引,索引從 0 開始,不一樣 queue 中的內容,相互獨立,互不影響,queueName 表明隊列的名稱,message 表明消息的內容,評測時內容會隨機產生,大部分長度在 58 字節左右,會有少許消息在 1k 左右。

 

get 方法從一個隊列中讀出一批消息,讀出的消息要按照發送順序來,這個接口須要是線程安全的,也即評測程序會併發調用該接口進行 get,返回的 Collection 會被併發讀,但不涉及寫,所以只須要是線程讀安全就能夠了,queueName 表明隊列的名字,offset 表明消息的在這個隊列中的起始索引,num 表明讀取的消息的條數,若是消息足夠,則返回 num 條,不然只返回已有的消息便可,若消息不足,則返回一個空的集合。

 

評測程序介紹

  1. 發送階段:消息大小在 58 字節左右,消息條數在 20 億條左右,即發送總數據在 100G 左右,總隊列數 100w

  2. 索引校驗階段:會對全部隊列的索引進行隨機校驗;平均每一個隊列會校驗1~2次;(隨機消費)

  3. 順序消費階段:挑選 20% 的隊列進行所有讀取和校驗; (順序消費)

  4. 發送階段最大耗時不能超過 1800s;索引校驗階段和順序消費階段加在一塊兒,最大耗時也不能超過 1800s;超時會被判斷爲評測失敗。

  5. 各個階段線程數在 20~30 左右

 

測試環境爲 4c8g 的 ECS,限定使用的最大 JVM 大小爲 4GB(-Xmx 4g)。帶一塊 300G 左右大小的 SSD 磁盤。對於 Java 選手而言,可以使用的內存能夠理解爲:堆外 4g 堆內 4g。

 

賽題剖析

 

首先解析題面,接口描述是很是簡單的,只有一個 put 和一個 get 方法。須要注意特別注意下評測程序,發送階段須要對 100w 隊列,每一次發送的量只有 58 字節,最後總數據量是 100g;索引校驗和順序消費階段都是調用的 get 接口,不一樣之處在於前者索引校驗是隨機消費,後者是對 20% 的隊列從 0 號索引開始進行全量的順序消費,評測程序的特性對最終存儲設計的影響是相當重要的。

 

複賽題目的難點之一在於單機百萬隊列的設計,據查閱的資料顯示

  • Kafka 單機超過 64 個隊列/分區,Kafka 分區數不宜過多

  • RocketMQ 單機支持最高 5 萬個隊列

 

至於百萬隊列的使用場景,只能想到 IOT 場景有這樣的需求。相較於初賽,複賽的設計更加地具備不肯定性,排名靠前的選手可能會選擇截然不同的設計方案。

 

複賽的考察點主要有如下幾個方面:磁盤塊讀寫,讀寫緩衝,順序讀寫與隨機讀寫,pageCache,稀疏索引,隊列存儲設計等。

 

因爲複賽成績並非很理想,優化 put 接口的失敗是致使失利的罪魁禍首,最終成績是 126w TPS,而第一梯隊的 TPS 則是到達了 200 w+ 的 TPS。鑑於此,不太想像初賽總結那樣,按照優化歷程羅列,而是將本身作的方案預研,以及設計思路分享給你們,對文件 IO 不甚瞭解的讀者也能夠將此文當作一篇科普向的文章來閱讀。

 

思路詳解

 

肯定文件讀寫方式

 

做爲忠實的 Java 粉絲,天然選擇使用 Java 來做爲參賽語言,雖然最終的排名是被 Cpp 大佬所壟斷,但着實無奈,畢業後就把 Cpp 丟到一邊去了。Java 中的文件讀寫接口大體能夠分爲三類:

  1. 標準 IO 讀寫,位於 java.io 包下,相關類:FileInputStream,FileOuputStream

  2. NIO 讀寫,位於 java.nio 包下,相關類:FileChannel,ByteBuffer

  3. Mmap 內存映射,位於 java.nio 包下,相關類:FileChannel,MappedByteBuffer

 

標準 IO 讀寫不具有調研價值,直接 pass,因此 NIO 和 Mmap 的抉擇,成了第一步調研對象。

 

第一階段調研了 Mmap。

 

搜索一圈下來發現,幾乎全部的文章都一致認爲:Mmap 這樣的內存映射技術是最快的。不少沒有接觸過內存映射技術的人可能還不太清楚這是一種什麼樣的技術,簡而言之,Mmap 可以將文件直接映射到用戶態的內存地址,使得對文件的操做再也不是 write/read,而轉化爲直接對內存地址的操做。

 

  1. public void test1() throws Exception {

  2.    String dir = "/Users/kirito/data/";

  3.    ensureDirOK(dir);

  4.    RandomAccessFile memoryMappedFile;

  5.    int size = 1 * 1024 * 1024;

  6.    try {

  7.        memoryMappedFile = new RandomAccessFile(dir + "testMmap.txt", "rw");

  8.        MappedByteBuffer mappedByteBuffer = memoryMappedFile.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, size);

  9.        for (int i = 0; i < 100000; i++) {

  10.            mappedByteBuffer.position(i * 4);

  11.            mappedByteBuffer.putInt(i);

  12.        }

  13.        memoryMappedFile.close();

  14.    } catch (Exception e) {

  15.        e.printStackTrace();

  16.    }

  17. }

 

如上的代碼呈現了一個最簡單的 Mmap 使用方式,速度也是沒話說,一個字:快!我懷着將信將疑的態度去找了更多的佐證,優秀的源碼老是第一參考對象,觀察下 RocketMQ 的設計,能夠發現 NIO 和 Mmap 都出如今了源碼中,但更多的讀寫操做彷佛更加青睞 Mmap。RocketMQ 源碼 org.apache.rocketmq.store.MappedFile 中兩種寫方法同時存在,請教 @匠心零度 後大概得出結論:RocketMQ 主要的寫是經過 Mmap 來完成。

 

可是在實際使用 Mmap 來做爲寫方案時遇到了兩大難題,單純從使用角度來看,暴露出了 Mmap 的侷限性:

  • Mmap 在 Java 中一次只能映射 1.5~2G 的文件內存,但實際上咱們的數據文件大於 100g,這帶來了第一個問題:要麼須要對文件作物理拆分,切分紅多文件;要麼須要對文件映射作邏輯拆分,大文件分段映射。

 

RocketMQ 中限制了單文件大小來避免這個問題。

 

 

  • Mmap 之因此快,是由於藉助了內存來加速,mappedByteBuffer 的 put 行爲實際是對內存進行的操做,實際的刷盤行爲依賴於操做系統的定時刷盤或者手動調用 mappedByteBuffer.force() 接口來刷盤,不然將會致使機器卡死(實測後的結論)。

 

因爲複賽的環境下內存十分有限,因此使用 Mmap 存在較難的控制問題。

 

 

通過這麼一折騰,再加上資料的蒐集,最終肯定,Mmap 在內存較爲富足而且數據量小的場景下存在優點(大多數文章的結論認爲 Mmap 適合大文件的讀寫,私覺得是不嚴謹的結論)。

 

第二階段調研 Nio 的 FileChannel,這也是我最終肯定的讀寫方案。

 

因爲每一個消息只有 58 字節左右,直接經過 FileChannel 寫入必定會遇到瓶頸,事實上,若是你這麼作,複賽連成績估計都跑不出來。另外一個說法是 ssd 最小的寫入單位是 4k,若是一次寫入低於 4k,實際上耗時和 4k 同樣。這裏涉及到了賽題的一個重要考點:塊讀寫。

 

 

根據阿里雲的 ssd 雲盤介紹,只有一次寫入 16kb ~ 64kb 才能得到理想的 IOPS。文件系統塊存儲的特性,啓發咱們須要設置一個內存的寫入緩衝區,單個消息寫入內存緩衝區,緩衝區滿,使用 FileChannel 進行刷盤。通過實踐,使用 FileChannel 搭配緩衝區發揮的寫入性能和內存充足狀況下的 Mmap 並沒有區別,而且 FileChannel 對文件大小並沒有限制,控制也相對簡單,因此最終肯定使用 FileChannel 進行讀寫。

 

肯定存儲結構和索引結構

 

因爲賽題的背景是消息隊列,評測 2 階段的隨機檢測以及 3 階段的順序消費一次會讀取多條連續的消息,而且 3 階段的順序消費是從隊列的 0 號索引一直消費到最後一條消息,這些因素都啓發咱們:應當將同一個隊列的消息儘量的存到一塊兒。前面一節提到了寫緩衝區,便和這裏的設計很是契合,例如咱們能夠一個隊列設置一個寫緩衝區(比賽中 Java 擁有 4g 的堆外內存,100w 隊列,一個隊列使用 DirectByteBuffer 分配 4k 堆外內存 ,能夠保證緩衝區不會爆內存),這樣同一個緩衝區的消息一塊兒落盤,就保證了塊內消息的順序性,即作到了」同一個隊列的消息儘量的存到一塊兒「。

 

按塊存取消息目前看來有兩個優點:

  1. 按條讀取消息=>按塊讀取消息,發揮塊讀的優點,減小了 IO 次數

  2. 全量索引=>稀疏索引。塊內數據是連續的,因此只須要記錄塊的物理文件偏移量+塊內消息數便可計算出某一條消息的物理位置。這樣大大下降了索引的數量,稍微計算一下能夠發現,徹底可使用一個 Map 數據結構,Key 爲 queueName,Value 爲 List 在內存維護隊列塊的索引。若是按照傳統的設計方案:一個 queue 一個索引文件,百萬文件必然會超過默認的系統文件句柄上限。索引存儲在內存中既規避了文件句柄數的問題,速度也沒必要多數,文件 IO 和 內存 IO 不是一個量級。

 

因爲賽題規定消息體是非定長的,大多數消息 58 字節,少許消息 1k 字節的數據特性,因此存儲消息體時使用 short+byte[] 的結構便可,short 記錄消息的實際長度,byte[] 記錄完整的消息體。short 比 int 少了 2 個字節,2*20億消息,能夠減小 4g 的數據量。

 

 

稠密索引是對全量的消息進行索引,適用於無序消息,索引量大,數據能夠按條存取。

 

 

稀疏索引適用於按塊存儲的消息,塊內有序,適用於有序消息,索引量小,數據按照塊進行存取。

 

因爲消息隊列順序存儲,順序消費的特性,加上 ssd 雲盤最小存取單位爲 4k(遠大於單條消息)的限制,因此稀疏索引很是適用於這種場景。至於數據文件,能夠作成參數,根據實際測試來判斷究竟是多文件效果好,仍是單文件,此方案支持 100g 的單文件。

 

內存讀寫緩衝區

 

在稀疏索引的設計中,咱們提到了寫入緩衝區的概念,根據計算能夠發現,100w 隊列若是一個隊列分配一個寫入緩衝區,最多隻能分配 4k,這剛好是最小的 ssd 寫入塊大小(但根據以前 ssd 雲盤給出的數據來看,一次寫入 64k 才能打滿 io)。

 

一次寫入 4k,這致使物理文件中的塊大小是 4k,在讀取時一次一樣讀取出 4k。

 

  1. // 寫緩衝區

  2. private ByteBuffer writeBuffer = ByteBuffer.allocateDirect(4 * 1024);

  3. // 用 short 記錄消息長度

  4. private final static int SINGLE_MESSAGE_SIZE = 2;

  5.  

  6. public void put(String queueName,byte[] message){

  7.    // 緩衝區滿,先落盤

  8.    if (SINGLE_MESSAGE_SIZE + message.length  > writeBuffer.remaining()) {

  9.        // 落盤

  10.        flush();

  11.    }

  12.    writeBuffer.putInt(SINGLE_MESSAGE_SIZE);

  13.    writeBuffer.put(message);

  14.    this.blockLength++;

  15. }

 

不足 4k 的部分能夠選擇補 0,也能夠跳過。評測程序保證了在 queue 級別的寫入是同步的,因此對於同一個隊列,咱們沒法擔憂同步問題。寫入搞定以後,一樣的邏輯搞定讀取,因爲 get 操做是併發的,2階段和3階段會有 10~30 個線程併發消費同一個隊列,因此 get 操做的讀緩衝區能夠設計成 ThreadLocal<ByteBuffer> ,每次使用時 clear 便可,保證了緩衝區每次讀取時都是嶄新的,同時減小了讀緩衝區的建立,不然會致使頻繁的 full gc。讀取的僞代碼暫時不貼,由於這樣的 get 方案不是最終方案。

 

到這裏總體的設計架構已經出來了,寫入流程和讀取流程的主要邏輯以下:

寫入流程:

 

讀取流程:

 

內存讀緩存優化

 

方案設計通過好幾回的推翻重來,纔算是肯定了上述的架構,這樣的架構優點在於很是簡單明瞭,實際上個人初版設計方案的代碼量是上述方案代碼量的 2~3 倍,但實際效果卻不理想。上述架構的跑分紅績大概能夠達到 70~80w TPS,只能算做是第三梯隊的成績,在此基礎上,進行了讀取緩存的優化才達到了 126w 的 TPS。在介紹讀取緩存優化以前,先容我介紹下 PageCache 的概念。

 

Linux 內核會將它最近訪問過的文件頁面緩存在內存中一段時間,這個文件緩存被稱爲 PageCache。如上圖所示。通常的 read() 操做發生在應用程序提供的緩衝區與 PageCache 之間。而預讀算法則負責填充這個PageCache。應用程序的讀緩存通常都比較小,好比文件拷貝命令 cp 的讀寫粒度就是 4KB;內核的預讀算法則會以它認爲更合適的大小進行預讀  I/O,好比 16-128KB。

 

因此通常狀況下咱們認爲順序讀比隨機讀是要快的,PageCache 即是最大的功臣。

 

回到題目,這簡直 nice 啊,由於在磁盤中同一個隊列的數據是部分連續(同一個塊則連續),實際上一個 4KB 塊中大概能夠存儲 70 多個數據,而在順序消費階段,一次的 offset 通常爲 10,有了 PageCache 的預讀機制,7 次文件 IO 能夠減小爲 1 次!這但是不得了的優化,可是上述的架構僅僅只有 70~80w 的 TPS,這讓我產生了疑惑,通過多番查找資料,最終在 @江學磊 的提醒下,才定位到了問題。

 

 

兩種可能致使比賽中沒法使用 pageCache 來作緩存

  1. 因爲我使用 FIleChannel 進行讀寫,NIO 的讀寫可能走的正是 Direct IO,因此根本不會通過 PageCache 層。

  2. 測評環境中內存有限,在 IO 密集的狀況下 PageCache 效果微乎其微。

 

雖說不肯定究竟是何種緣由致使 PageCache 沒法使用,可是個人存儲方案仍然知足順序讀取的特性,徹底能夠本身使用堆外內存本身模擬一個「PageCache」,這樣在 3 階段順序消費時,TPS 會有很是高的提高。

 

一個隊列一個讀緩衝區用於順序讀,又要使得 get 階段不存在併發問題,因此我選擇了複用讀緩衝區,而且給 get 操做加上了隊列級別的鎖,這算是一個小的犧牲,由於 2 階段不會發生衝突,3 階段衝突機率也並不大。

 

改造後的讀取緩存方案以下:

 

通過緩存改造以後,使用 Direct IO 也能夠實現相似於 PageCache 的優化,而且會更加的可控,不至於形成頻繁的缺頁中斷。通過這個優化,加上一些 gc 的優化,能夠達到 126w TPS。總體方案算是介紹完畢。

 

其餘優化

 

還有一些優化對總體流程影響不大,拎出來單獨介紹。

 

2 階段的隨機索引檢測和 3 階段的順序消費能夠採起不一樣的策略,2 階段能夠直接讀取所須要的數據,而不須要進行緩存(由於是隨機檢測,因此讀緩存確定不會命中)。

 

將文件數作成參數,調整參數來判斷究竟是多文件 TPS 高仍是單文件,實際上測試後發現,差距並非很大,單文件效果略好,因爲是 ssd 雲盤,又不存在磁頭,因此真的不太懂原理。

 

gc 優化,能用數組的地方不要用 List。儘可能減小小對象的出現,能夠用數組管理基本數據類型,小對象對 gc 很是不友好,不管是初賽仍是複賽,Java 比 Cpp 始終差距一個垃圾回收機制。必須保證全程不出現 full gc。

 

失敗的優化與反思

 

本次比賽算是留下了不小的遺憾,由於寫入的優化一直沒有作好,讀取緩存作好以後我 2 階段和 3階段的總耗時相加是 400+s,算是不錯的成績,可是寫入耗時在 1300+s。我上述的方案採用的是多線程同步刷盤,但也嘗試過以下的寫入方案:

  1. 異步提交寫緩衝區,單線程直接刷盤

  2. 異步提交寫緩衝區,設置二級緩衝區 64k~64M,單線程使用二級緩衝區刷盤

  3. 同步將寫緩衝區的數據拷貝至一個 LockFreeQueue,單線程平滑消費,以打滿 IOPS

  4. 每 16 個隊列共享一個寫入緩衝區,這樣控制寫入緩衝區能夠達到 64k,在刷盤時進行排序,將同一個 queue 的數據放置在一塊兒。

 

但都以失敗了結,沒有 get 到寫入優化的要領,算是本次比賽最大的遺憾了。

 

還有一個失誤在於,評測環境使用的雲盤 ssd 和個人本地 Mac 下的 ssd 存儲結構差距太大,加上 mac os 和 Linux 的一些差距,致使本地成功的優化在線上徹底體現不出來,仍是租個阿里雲環境比較靠譜。

 

另外一方面的反思,則是對存儲和 MQ 架構設計的不熟悉,對於 Kafka 和 RocketMQ 所作的一些優化也都是現學現用,不太肯定用的對不對,致使走了一些彎路,而比賽中認識的一個 96 年的小夥子王亞普,相比之下對中間件知識理解的深度和廣度實在令我欽佩,實在還有不少知識須要學習。

 

參賽感悟

 

第一感覺是累,第二感覺是爽。

 

相信不少選手和我同樣是工做黨,白天工做,只能騰出晚上的時間去搞比賽,對於966 的我真是太不友好了,初賽時間延長了一次還算給緩了一口氣,複賽一眨眼就過去了,想翻盤都沒機會,實在是遺憾。爽在於此次比賽真的是汗快淋漓地實踐了很多中間件相關的技術,初賽的 Netty,複賽的存儲設計,都是難以忘懷的回憶,比賽中也認識了很多朋友,有學生黨,有工做黨,感謝大家不厭其煩的教導與發人深省的討論,從不一樣的人身上是真的能夠學到不少本身缺失的知識。

 

據消息說,阿里中間件大賽頗有多是最後一屆,不管是由於什麼緣由,做爲參賽者,我都感到深深的可惜,但願還能有機會參加下一屆的中間件大賽,也期待能看到更多的相同類型的賽事被各大互聯網公司舉辦,和大佬們同臺競技,一邊認識更多新朋友的感受真棒。

 

雖然最終無緣決賽,但仍是期待進入決賽的 11 位選手能帶來一場精彩的答辯,也好解答我始終優化失敗的寫入方案。後續會考慮吸取下前幾名 JAVA 的優化思路,整理成最終完善的方案。 目前方案的 git 地址,倉庫已公開:https://code.aliyun.com/250577914/queuerace2018.git

- END -

相關文章
相關標籤/搜索