如何用Netty寫一個高效能的分散式服務框架

JavaDog發表於2019-01-21

提綱

  1. 什麼是Netty? 能做什麼?
  2. 貼近日常生活, 先設計一個服務框架
  3. RPC的一些Features&好的實踐
  4. 如何壓榨效能
  5. Why Netty? (延伸: Netty --> NIO --> Linux Epoll一些實現細節)

什麼是Netty? 能做什麼?

  • Netty是一個致力於建立高效能網路應用程式的成熟的IO框架
  • 相比較與直接使用底層的Java IO API, 你不需要先成為網路專家就可以基於Netty去構建複雜的網路應用
  • 業界常見的涉及到網路通訊的相關中介軟體大部分基於Netty實現網路層

設計一個分散式服務框架

  • Architecture

  • 遠端呼叫的流程

    • 啟動服務端(服務提供者)併發布服務到註冊中心
    • 啟動客戶端(服務消費者)並去註冊中心訂閱感興趣的服務
    • 客戶端收到註冊中心推送的服務地址列表
    • 呼叫者發起呼叫, Proxy從服務地址列表中選擇一個地址並將請求資訊<group, providerName, version>, methodName, args[]等資訊序列化為位元組陣列並通過網路傳送到該地址上
    • 服務端收到收到並反序列化請求資訊, 根據<group, providerName, version>從本地服務字典裡查詢到對應providerObject, 再根據<methodName, args[]>通過反射呼叫指定方法, 並將方法返回值序列化為位元組陣列返回給客戶端
    • 客戶端收到響應資訊再反序列化為Java物件後由Proxy返回給方法呼叫者
    以上流程對方法呼叫者是透明的, 一切看起來就像本地呼叫一樣
    重要概念: RPC三元組 <ID, Request, Response>
  • 遠端呼叫客戶端圖解

    若是netty4.x的執行緒模型, IO Thread(worker) —> Map<InvokeId, Future>代替全域性Map能更好的避免執行緒競爭
  • 遠端呼叫服務端圖解

    重要概念: RPC三元組 <ID, Request, Response>
  • 遠端呼叫傳輸層圖解

    左圖為客戶端, 右圖為服務端
  • 設計傳輸層協議棧

    協議頭
    如何用Netty寫一個高效能的分散式服務框架
協議體
  • metadata: <group, providerName, version>
  • methodName
  • parameterTypes[]
    真的需要?
    • 有什麼問題?
      1. 反序列化時ClassLoader.loadClass()潛在鎖競爭
      2. 協議體碼流大小
      3. 泛化呼叫多了引數型別
    • 能解決嗎?
      • Java方法靜態分派規則參考JLS <Java語言規範> $15.12.2.5 Choosing the Most Specific Method 章節
  • args[]
  • 其他: traceId, appName…

一些Features&好的實踐&壓榨效能

  • 建立客戶端代理物件

    • Proxy做什麼?
      • 叢集容錯 —> 負載均衡 —> 網路
    • 有哪些建立Proxy的方式?
      • jdk proxy/javassist/cglib/asm/bytebuddy
    • 要注意的:
      • 注意攔截toString, equals, hashCode等方法避免遠端呼叫
    • 推薦的(bytebuddy):
      如何用Netty寫一個高效能的分散式服務框架
  • 優雅的同步/非同步呼叫

    • 先往上翻再看看'遠端呼叫客戶端圖解'
    • 再往下翻翻看看Failover如何處理更好
    • 思考下如何拿到future?
  • 單播/組播

    • 訊息派發器
    • FutureGroup
  • 泛化呼叫

    • Object $invoke(String methodName, Object... args)
    • parameterTypes[]
  • 序列化/反序列化(協議header標記serializer type, 同時支援多種)

  • 可擴充套件性

    • Java SPI
      • -java.util.ServiceLoader
      • -META-INF/services/com.xxx.Xxx
  • 服務級別執行緒池隔離

    • 要掛你先掛, 別拉著我
  • 責任鏈模式的攔截器

    • 太多擴充套件需要從這裡起步
  • 指標度量(Metrics)

  • 鏈路追蹤

  • 註冊中心

  • 流控(應用級別/服務級別)

    • 要有能方便接入第三方流控中介軟體的擴充套件能力
  • Provider執行緒池滿了怎麼辦?

  • 軟負載均衡

    • 加權隨機 (二分法, 不要遍歷)
      如何用Netty寫一個高效能的分散式服務框架
    • 加權輪訓(最大公約數)
      如何用Netty寫一個高效能的分散式服務框架
    • 最小負載
    • 一致性hash(有狀態服務場景)
    • 其他
    要有預熱邏輯
  • 叢集容錯

    • Fail-fast
    • Failover
      • 非同步呼叫怎麼處理?
      • Bad ?
        如何用Netty寫一個高效能的分散式服務框架
      • Better ?
        如何用Netty寫一個高效能的分散式服務框架
    • Fail-safe
    • Fail-back
    • Forking
    • 其他

如何壓榨效能(Don’t trust it, Test it)

  • ASM寫個FastMethodAccessor來代替服務端那個反射呼叫
    如何用Netty寫一個高效能的分散式服務框架
  • 序列化/反序列化
    • 在業務執行緒中序列化/反序列化, 避免佔用IO執行緒
      • 序列化/反序列化佔用數量極少的IO執行緒時間片
      • 反序列化常常會涉及到Class的載入, loadClass有一把鎖競爭嚴重(可通過JMC觀察一下)
    • 選擇高效的序列化/反序列化框架(kryo/protobuf/protostuff/hessian/fastjson/…)
    • 選擇只是第一步, 它(序列化框架)做的不好的, 去擴充套件和優化之
      • 傳統的序列化/反序列化+寫入/讀取網路的流程
        • java物件--> byte[] -->堆外記憶體 / 堆外記憶體--> byte[] -->java物件
      • 新社會主義優化
        • 省去byte[]環節, 直接讀/寫 堆外記憶體, 這需要擴充套件對應的序列化框架
    • String編碼/解碼優化
    • Varint優化
      • 多次writeByte合併為writeShort/writeInt/writeLong
    • Protostuff優化舉例
  • IO執行緒繫結CPU
  • 同步阻塞呼叫的客戶端和容易成為瓶頸, 客戶端協程?
    • Java層面可選的並不多, 暫時也都不完美 ?
      kilim
      編譯期間位元組碼增強
      quasar
      agent動態位元組碼增強
      ali_wisp
      ali_jvm在底層直接實現
  • Netty Native Transport & PooledByteBufAllocator
    • 減小GC帶來的波動
  • 儘快釋放IO執行緒去做他該做的事情, 儘量減少執行緒上下文切換

Why Netty?

  • BIO vs NIO

  • Java原生NIO API從入門到放棄

    • 複雜度高
      • API複雜難懂, 入門困難
      • 粘包/半包問題費神
      • 需超強的併發/非同步程式設計功底, 否則很難寫出高效穩定的實現
    • 穩定性差, 坑多且深
      • 除錯困難, 偶爾遭遇匪夷所思極難重現的bug, 邊哭邊查是常有的事兒
      • linux下EPollArrayWrapper.epollWait直接返回導致空輪訓進而導致100% cpu的bug一直也沒解決利索, Netty幫你work around(通過rebuilding selector)
    • NIO程式碼實現方面的一些缺點
      • Selector.selectedKeys() 產生太多垃圾
        • Netty修改了sun.nio.ch.SelectorImpl的實現, 使用雙陣列代替HashSet儲存來selectedKeys
          • 相比
            HashSet(
            迭代器
            ,
            包裝物件等
            )
            少了一些垃圾的產生
            (help GC)
          • 輕微的效能收益
            (1~2%)
      • Nio的程式碼到處是synchronized (比如allocate direct buffer和Selector.wakeup())
        • 對於allocate direct buffer, Netty的pooledBytebuf有前置TLAB(Thread-local allocation buffer)可有效的減少去競爭鎖
        • wakeup呼叫多了鎖競爭嚴重並且開銷非常大(開銷大原因: 為了在select執行緒外跟select執行緒通訊, linux下用一對pipe, windows下由於pipe控制程式碼不能放入fd_set, 只能委曲求全用兩個tcp連線模擬), wakeup呼叫少了容易導致select時不必要的阻塞(如果懵逼了就直接用Netty吧, Netty中有對應的優化邏輯)
        • Netty Native Transport中鎖少了很多
      • fdToKey對映
        • EPollSelectorImpl#fdToKey維持著所有連線的fd(描述符)對應SelectionKey的對映, 是個HashMap
        • 每個worker執行緒有一個selector, 也就是每個worker有一個fdToKey, 這些fdToKey大致均分了所有連線
        • 想象一下單機hold幾十萬的連線的場景, HashMap從預設size=16, 一步一步rehash...
      • Selector在linux平臺是Epoll LT實現
        • Netty Native Transport支援Epoll ET
      • Direct Buffers事實上還是由GC管理
        • DirectByteBuffer.cleaner這個虛引用負責free direct memory, DirectByteBuffer只是個殼子, 這個殼子如果堅強的活下去熬過新生代的年齡限制最終晉升到老年代將是一件讓人傷心的事情…
        • 無法申請到足夠的direct memory會顯式觸發GC, Bits.reserveMemory() -> { System.gc() }, 首先因為GC中斷整個程式不說, 程式碼中還sleep 100毫秒, 醒了要是發現還不行就OOM
        • 更糟的是如果你聽信了個別<XX優化寶典>讒言設定了-XX:+DisableExplicitGC引數, 悲劇會靜悄悄的發生...
        • Netty的UnpooledUnsafeNoCleanerDirectByteBuf去掉了cleaner, 由Netty框架維護引用計數來實時的去釋放

Netty的真實面目

Netty中幾個重要概念及其關係

  • EventLoop
    • 一個Selector
    • 一個任務佇列(mpsc_queue: 多生產者單消費者 lock-free)
    • 一個延遲任務佇列(delay_queue: 一個二叉堆結構的優先順序佇列, 複雜度為O(log n))
    • EventLoop繫結了一個Thread, 這直接避免了pipeline中的執行緒競爭
  • Boss: mainReactor角色, Worker: subReactor角色
    • Boss和Worker共用EventLoop的程式碼邏輯, Boss處理accept事件, Worker處理read, write等事件
    • Boss監聽並accept連線(channel)後以輪訓的方式將channel交給Worker, Worker負責處理此channel後續的read/write等IO事件
    • 在不bind多埠的情況下BossEventLoopGroup中只需要包含一個EventLoop, 也只能用上一個, 多了沒用
    • WorkerEventLoopGroup中一般包含多個EventLoop, 經驗值一般為 cpu cores * 2(根據場景測試找出最佳值才是王道)
    • Channel分兩大類ServerChannel和Channel, ServerChannel對應著監聽套接字(ServerSocketChannel), Channel對應著一個網路連線

Netty4 Thread Model

ChannelPipeline

Pooling & reuse

  • PooledByteBufAllocator
    • 基於 jemalloc paper (3.x)
    • ThreadLocal caches for lock free
      • 這個做法導致曾經有坑: 申請(Bytebuf)執行緒與歸還(Bytebuf)執行緒不是同一個導致記憶體洩漏, 後來用一個mpsc_queue解決, 代價就是犧牲了一點點效能
    • Different size classes
  • Recycler
    • ThreadLocal + Stack
    • 曾經有坑, 申請(元素)執行緒與歸還(元素)執行緒不是同一個導致記憶體洩漏
    • 後來改進為不同執行緒歸還元素的時候放入一個WeakOrderQueue中並關聯到stack上, 下次pop時如果stack為空則先掃描所有關聯到當前stack上的weakOrderQueue
    • WeakOrderQueue是多個陣列的連結串列, 每個陣列預設size=16
    • 問題: 老年代物件引用新生代物件對GC的影響

Netty Native Transport

  • 相比Nio建立更少的物件, 更小的GC壓力
  • 針對linux平臺優化, 一些specific features
    • SO_REUSEPORT - 埠複用(允許多個socket監聽同一個IP+埠, 與RPS/RFS協作, 可進一步提升效能)
      • 可把RPS/RFS模糊的理解為在軟體層面模擬多佇列網路卡, 並提供負載均衡能力, 避免網路卡收包發包的中斷集中的一個CPU core上而影響效能
    • TCP_FASTOPEN - 3次握手時也用來交換資料
    • EDGE_TRIGGERED (支援Epoll ET是重點)
    • Unix域套接字(同一臺機器上的程式間通訊, 比如Service Mesh)

多路複用簡介

  • select/poll
    • 本身的實現機制上的限制(採用輪詢方式檢測就緒事件, 時間複雜度: O(n), 每次還要將臃腫的fd_set在使用者空間和核心空間拷貝來拷貝去), 併發連線越大, 效能越差
    • poll相比select沒有很大差異, 只是取消了最大檔案描述符個數的限制
    • select/poll都是LT模式
  • epoll
    • 採用回撥方式檢測就緒事件, 時間複雜度: O(1), 每次epoll_wait呼叫只返回已就緒的檔案描述符
    • epoll支援LT和ET模式

稍微深入瞭解一點Epoll

  • LT vs ET
    • 概念
      • LT: level-triggered 水平觸發
      • ET: edge-triggered 邊沿觸發
    • 可讀
      • buffer不為空的時候fd的events中對應的可讀狀態就被置為1, 否則為0
    • 可寫
      • buffer中有空間可寫的時候fd的events中對應的可寫狀態就被置為1, 否則為0
    • 圖解
      如何用Netty寫一個高效能的分散式服務框架
    • epoll三個方法簡介
      • 主要程式碼: linux-2.6.11.12/fs/eventpoll.c
      • int epoll_create(int size)
        • 建立rb-tree(紅黑樹)和ready-list(就緒連結串列)
          • 紅黑樹O(logN), 平衡效率和記憶體佔用, 在容量需求不能確定並可能量很大的情況下紅黑樹是最佳選擇
          • size引數已經沒什麼意義, 早期epoll實現是hash表, 所以需要size引數
      • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
        • 把epitem放入rb-tree並向核心中斷處理程式註冊ep_poll_callback, callback觸發時把該epitem放進ready-list
      • int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
        • ready-list —> events[]
    • epoll的資料結構
      如何用Netty寫一個高效能的分散式服務框架
    • epoll_wait工作流程概述 (對照程式碼: linux-2.6.11.12/fs/eventpoll.c)
      • epoll_wait呼叫ep_poll
        • 當rdlist(ready-list)為空(無就緒fd)時掛起當前執行緒, 直到rdlist不為空時執行緒才被喚醒
      • 檔案描述符fd的events狀態改變
        • buffer由不可讀變為可讀或由不可寫變為可寫, 導致相應fd上的回撥函式ep_poll_callback被觸發
      • ep_poll_callback被觸發
        • 將相應fd對應epitem加入rdlist, 導致rdlist不空, 執行緒被喚醒, epoll_wait得以繼續執行
      • 執行ep_events_transfer函式
        • 將rdlist中的epitem拷貝到txlist中, 並將rdlist清空
        • 如果是epoll LT, 並且fd.events狀態沒有改變(比如buffer中資料沒讀完並不會改變狀態), 會再重新將epitem放回rdlist
      • 執行ep_send_events函式
        • 掃描txlist中的每個epitem, 呼叫其關聯fd對應的poll方法取得較新的events
        • 將取得的events和相應的fd傳送到使用者空間

Netty的最佳實踐

  • 業務執行緒池必要性
    • 業務邏輯尤其是阻塞時間較長的邏輯, 不要佔用netty的IO執行緒, dispatch到業務執行緒池中去
  • WriteBufferWaterMark, 注意預設的高低水位線設定(32K~64K), 根據場景適當調整(可以思考一下如何利用它)
  • 重寫MessageSizeEstimator來反應真實的高低水位線
    • 預設實現不能計算物件size, 由於write時還沒路過任何一個outboundHandler就已經開始計算message size, 此時物件還沒有被encode成Bytebuf, 所以size計算肯定是不準確的(偏低)
  • 注意EventLoop#ioRatio的設定(預設50), 這是EventLoop執行IO任務和非IO任務的一個時間比例上的控制
  • 空閒鏈路檢測用誰排程?
    • Netty4.x預設使用IO執行緒排程, 使用eventLoop的delayQueue, 一個二叉堆實現的優先順序佇列, 複雜度為O(log N), 每個worker處理自己的鏈路監測, 有助於減少上下文切換, 但是網路IO操作與idle會相互影響
    • 如果總的連線數小, 比如幾萬以內, 上面的實現並沒什麼問題, 連線數大建議用HashedWheelTimer實現一個IdleStateHandler, HashedWheelTimer複雜度為 O(1), 同時可以讓網路IO操作和idle互不影響, 但有上下文切換開銷
  • 使用ctx.writeAndFlush還是channel.writeAndFlush?
    • ctx.write直接走到下一個outbound handler, 注意別讓它違揹你的初衷繞過了空閒鏈路檢測
    • channel.write從末尾開始倒著向前挨個路過pipeline中的所有outbound handlers
  • 使用Bytebuf.forEachByte() 來代替迴圈 ByteBuf.readByte()的遍歷操作, 避免rangeCheck()
  • 使用CompositeByteBuf來避免不必要的記憶體拷貝
    • 缺點是索引計算時間複雜度高, 請根據自己場景衡量
  • 如果要讀一個int, 用Bytebuf.readInt(), 不要Bytebuf.readBytes(buf, 0, 4)
    • 這能避免一次memory copy (long, short等同理)
  • 配置UnpooledUnsafeNoCleanerDirectByteBuf來代替jdk的DirectByteBuf, 讓netty框架基於引用計數來釋放堆外記憶體
    • io.netty.maxDirectMemory
      • < 0: 不使用cleaner, netty方面直接繼承jdk設定的最大direct memory size, (jdk的direct memory size是獨立的, 這將導致總的direct memory size將是jdk配置的2倍)
      • == 0: 使用cleaner, netty方面不設定最大direct memory size
      • > 0: 不使用cleaner, 並且這個引數將直接限制netty的最大direct memory size, (jdk的direct memory size是獨立的, 不受此引數限制)
  • 最佳連線數
    • 一條連線有瓶頸, 無法有效利用cpu, 連線太多也白扯, 最佳實踐是根據自己場景測試
  • 使用PooledBytebuf時要善於利用 -Dio.netty.leakDetection.level 引數
    • 四種級別: DISABLED(禁用), SIMPLE(簡單), ADVANCED(高階), PARANOID(偏執)
    • SIMPLE, ADVANCED取樣率相同, 不到1%(按位與操作 mask ==128 - 1)
    • 預設是SIMPLE級別, 開銷不大
    • 出現洩漏時日誌會出現”LEAK: ”字樣, 請時不時grep下日誌, 一旦出現”LEAK: ”立刻改為ADVANCED級別再跑, 可以報告洩漏物件在哪被訪問的
    • PARANOID: 測試的時候建議使用這個級別, 100%取樣
  • Channel.attr(), 將自己的物件attach到channel上
    • 拉鍊法實現的執行緒安全的hash表, 也是分段鎖(只鎖連結串列頭), 只有hash衝突的情況下才有鎖競爭(類似ConcurrentHashMapV8版本)
    • 預設hash表只有4個桶, 使用不要太任性

從Netty原始碼中學到的程式碼技巧

  • 海量物件場景中 AtomicIntegerFieldUpdater --> AtomicInteger
    • Java中物件頭12 bytes(開啟壓縮指標的情況下), 又因為Java物件按照8位元組對齊, 所以物件最小16 bytes, AtomicInteger大小為16 bytes, AtomicLong大小為 24 bytes
    • AtomicIntegerFieldUpdater作為static field去操作volatile int
  • FastThreadLocal, 相比jdk的實現更快
    • 線性探測的Hash表 —> index原子自增的裸陣列儲存
  • IntObjectHashMap / LongObjectHashMap …
    • Integer—> int
    • Node[] —> 裸陣列
    • 雜湊衝突: 拉鍊法 —> 線性探測
  • RecyclableArrayList, 基於前面說的Recycler, 頻繁new ArrayList的場景可考慮
  • JCTools: 一些jdk沒有的 SPSC/MPSC/SPMC/MPMC 無鎖併發隊以及NonblockingHashMap(可以對比ConcurrentHashMapV6/V8)

參考資料

如何用Netty寫一個高效能的分散式服務框架


相關文章