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

提綱

  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>, methodName, args[]等信息序列化為字節數組並通過網絡發送到該地址上/<group>
  • 服務端收到收到並反序列化請求信息, 根據<group>從本地服務字典裡查找到對應providerObject, 再根據<methodname>通過反射調用指定方法, 並將方法返回值序列化為字節數組返回給客戶端/<methodname>/<group>
  • 客戶端收到響應信息再反序列化為Java對象後由Proxy返回給方法調用者
  • 以上流程對方法調用者是透明的, 一切看起來就像本地調用一樣
  • 重要概念: RPC三元組
  • 遠程調用客戶端圖解
  • 若是netty4.x的線程模型, IO Thread(worker) —> Map<invokeid>代替全局Map能更好的避免線程競爭/<invokeid>
  • 遠程調用服務端圖解
  • 重要概念: RPC三元組
  • 遠程調用傳輸層圖解
  • 左圖為客戶端, 右圖為服務端
  • 設計傳輸層協議棧
  • 協議頭
如何用Netty寫一個高性能的分佈式服務框架

協議體

  • metadata: <group>
  • methodName
  • parameterTypes[]
  • 真的需要?
  • 有什麼問題?
  1. 反序列化時ClassLoader.loadClass()潛在鎖競爭
  2. 協議體碼流大小
  3. 泛化調用多了參數類型
  • 能解決嗎?
  • Java方法靜態分派規則參考JLS <java> $15.12.2.5 Choosing the Most Specific Method 章節/<java>
  • 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)
  • 鏈路追蹤
  • OpenTracing
  • 註冊中心
  • 流控(應用級別/服務級別)
  • 要有能方便接入第三方流控中間件的擴展能力
  • 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/…)
  • github.com/eishay/jvm-…
  • 選擇只是第一步, 它(序列化框架)做的不好的, 去擴展和優化之
  • 傳統的序列化/反序列化+寫入/讀取網絡的流程
  • java對象--> byte[] -->堆外內存 / 堆外內存--> byte[] -->java對象
  • 新社會主義優化
  • 省去byte[]環節, 直接讀/寫 堆外內存, 這需要擴展對應的序列化框架
  • String編碼/解碼優化
  • Varint優化
  • 多次writeByte合併為writeShort/writeInt/writeLong
  • Protostuff優化舉例
  • UnsafeNioBufInput 直接讀堆外內存
  • UnsafeNioBufOutput 直接寫堆外內存
  • IO線程綁定CPU
  • github.com/OpenHFT/Jav…
  • 同步阻塞調用的客戶端和容易成為瓶頸, 客戶端協程?
  • Java層面可選的並不多, 暫時也都不完美
  • kilim編譯期間字節碼增強quasaragent動態字節碼增強ali_wispali_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:+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)

關注我:私信回覆“555”獲取往期Java高級架構資料、源碼、筆記、視頻Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式、高併發等架構技術往期架構視頻截圖


分享到:


相關文章: