RPC原理介紹

面向服務架構SOA

任何大型網站的發展都伴隨着網站架構的演進。網站架構一般最初是單應用設計,然後逐漸經歷面向對象設計和模塊化設計的架構,最終發展到面向服務的服務化架構。在單應用設計架構體系當中,我們關注的是方法和實體;而在面向服務的服務化架構中,我們則關注的是服務和API。網站架構演進圖如下圖所示:
傳統應用開發中會面臨研發成本高,運維效率低等挑戰。
研發成本高主要體現在:
  • 代碼重複率高:在實際項目分工時,開發都是各種負責幾個功能,即使開發之間存在功能重疊,往往也是選擇自己實現,而不是類庫共享。
  • 需求變更困難:代碼重複率高後,已有功能變更和新需求加入時都會非常困難。所有重複開發的功能都需要重新修改和測試,很容易出現修改不一致或者被遺漏。
  • 無法滿足新業務的快速迭代和交互等問題。
運維效率低主要體現在:
  • 測試、部署成本高:業務運行在一個進程中,因此係統中任何程序的改變,都需要對整個系統重新測試並部署
  • 可伸縮性差:水平擴展只能基於整個系統進行擴展,無法針對某一功能模塊按需擴展
  • 可靠性差:某個應用BUG,會導致整個進程宕機,影響其他應用

爲了解決單體架構面臨的挑戰,會對系統進行拆分、解耦、獨立、分層。將核心業務抽取出來,作爲獨立的服務,逐漸形成穩定的服務中心;同時將公共API抽取出來,作爲獨立的公共服務供其他調用者消費,以實現服務的共享和重用,降低開發和運維成本。

既然系統都是由成千上萬大大小小的服務組成,各服務部署在不同的機器上,由不同的團隊負責。這時就會遇到兩個問題:
  1. 要搭建一個新服務,免不了需要依賴他人的服務,而現在他人的服務都在遠端,怎麼調用?
  2. 其他團隊要使用我們的新服務,我們的服務該怎麼發佈以便他人調用?

服務調用

由於各服務部署在不同機器,服務間的調用免不了網絡通信過程,服務消費方每調用一個服務都要寫一坨網絡通信相關的代碼,不僅複雜而且極易出錯。
如果有一種方式能讓我們像調用本地服務一樣調用遠程服務,並且讓調用者對網絡通信這些細節透明,那麼將大大提高生產力,比如服務消費方在調用本地 的一個接口時,實質上調用的是遠端的服務。這種方式其實就是RPC(Remote Procedure Call Protocol),如今rpc在各大互聯網公司中被廣泛使用,如阿里巴巴的hsf、dubbo(開源)、Facebook的thrift(開源)、Google grpc(開源)、Twitter的finagle(開源)等。當然,也可以通過HTTP + JSON的方式來進行服務間的調用,本質上HTTP的方式也是屬於RPC的一種實現,但是HTTP的方式並不會像調用本地服務一樣那麼直觀,同時也有一定的性能問題。

服務調用方式

服務的調用方式,主要有三種:
  • 同步服務調用:最常見、簡單的服務調用,即同步等待服務方返回。爲了防止服務端長時間不返回應答消息導致客戶端線程被掛死,用戶線程等待的時候需要設置超時時間。
  • 異步服務調用:異步服務調用有兩種實現方式:一種是隻通過Future來實現,還有一種是通過構造Listener對象並將其添加到Future中,用於服務端應答的異步回調。通過Future方式時,線程會阻塞在get結果的操作上;而使用Listener的方式是監聽器異步的獲取執行結果。
  • 並行服務調用:提升服務調用的並行度,降低時延。比如在結算時,會分別調用短信通知服務,訂單詳細服務,經驗增長服務等等。這些服務即可通過並行服務調用來降低端到端的時延,最後只需對執行結果進行匯聚即可。並行服務最常見的技術實現方案是使用Fork/Join框架實現子任務的並行執行和結果匯聚。

RPC通信細節

要讓網絡通信細節對使用者透明,我們需要對通信細節進行封裝,我們先看下一個RPC調用的流程涉及到哪些通信細節:
  1. 服務消費方(client)調用以本地調用方式調用服務;
  2. client stub接收到調用後負責將方法、參數等組裝成能夠進行網絡傳輸的消息體;
  3. client stub找到服務地址,並將消息發送到服務端;
  4. server stub收到消息後進行解碼;
  5. server stub根據解碼結果調用本地的服務;
  6. 本地服務執行並將結果返回給server stub;
  7. server stub將返回結果打包成消息併發送至消費方;
  8. client stub接收到消息,並進行解碼;
  9. 服務消費方得到最終結果。
RPC的目標就是要2~8這些步驟都封裝起來,讓用戶對這些細節透明。有以下的一些技術細節:
  • 如何做到透明化遠程過程調用:使用代理,代理分爲兩種:1. jdk 動態代理 2.字節碼生成。jdk代理的方式是對接口做代理,所以必須先定義接口;字節碼生成方式一般使用的是cglib代理,cglib代理使用的是asm字節碼框架,可以直接對類生成代理對象;雖然字節碼生成的方式更加方便和高效,但是由於代碼維護不易,一般還是採取jdk動態代理的方式。
  • 消息的數據結構:通信的第一步就是要確認客戶端和服務端相互通信的消息結構
     客戶端的請求消息一般需要包括以下內容:
    • 接口名稱:用於確定調用哪個接口
    • 方法名:確定調用接口中哪個方法
    • 參數類型&參數值:
    • 超時時間
    • requestID:請求唯一標識
     服務器返回消息一般需要包括:
    • 返回值
    • 狀態code
    • requestID
  • 序列化:序列化類型包括基於文本和基於二進制方式。在分佈式服務通信框架中,序列化方式應該包含以下特性:
    • 通用性:比如能否支持Map等複雜數據結構
    • 性能:包括時間複雜度和空間複雜度,通信框架被會公司全部服務使用,即使性能提升一點也會引起質變
    • 可擴展性:比如支持自動增加新的業務字段
    • 多語言支持:通過定義idl,生成方式爲靜態編譯和動態編譯
  • 通信:通信框架需要支持同步(BIO)和異步(NIO)方式,一般底層使用Netty
  • RequestID:如果請求時異步的,對於客戶端來說請求發出後線程即可向下執行。服務端處理完成後再以消息的形式發送給客戶端。於是這裏會出現以下兩個問題:
    • 如何讓當前線程“暫停”,等待到結果後,再向後執行
    • 如果多個線程同時進行遠程方法調用,這時建立在client server之間的socket連接上會有很多雙方發送的消息傳遞,前後順序也可能是隨機的,server處理完結果後將結果發送給client,client收到很多的消息,怎麼知道哪個消息是原先哪個線程調用的?
     這時即可通過唯一自增的一個RequestID來解決這兩個問題:
    • 在調用callback的get方法時,在get內部獲取callback的鎖,如果沒有獲取就等待
    • 以RequestID爲Key將callback對象存放在全局ConcurrentHashMap中,先通過RequestID獲取callback對象,然後再獲取callback的鎖,獲取之後再調用notify

服務發佈

如何讓別人使用我們的服務呢?有同學說很簡單嘛,告訴使用者服務的IP以及端口就可以了啊。確實是這樣,這裏問題的關鍵在於是自動告知還是人肉告知。
人肉告知的方式:如果你發現你的服務一臺機器不夠,要再添加一臺,這個時候就要告訴調用者我現在有兩個ip了,你們要輪詢調用來實現負載均衡;調用者咬咬牙改了,結果某天一臺機器掛了,調用者發現服務有一半不可用,他又只能手動修改代碼來刪除掛掉那臺機器的ip。現實生產環境當然不會使用人肉方式。
有沒有一種方法能實現自動告知,即機器的增添、剔除對調用方透明,調用者不再需要寫死服務提供方地址?當然可以,現如今zookeeper被廣泛用於實現服務自動註冊與發現功能!
簡單來講,zookeeper可以充當一個服務註冊表(Service Registry),讓多個服務提供者形成一個集羣,讓服務消費者通過服務註冊表獲取具體的服務訪問地址(ip+端口)去訪問具體的服務提供者。如下圖所示:
消費者在調用服務提供者的時候,不需要每次都去服務註冊中心查詢服務提供者的地址列表,消費者客戶端直接從本地緩存的服務提供者路由表中查詢地址信息。當服務提供者發生變更時,註冊中心主動將變更內容推送給消費者,由後者動態刷新本地緩存的服務路由表,保證服務路由信息的實時準確性。採用客戶端緩存服務提供者地址的方案不僅僅能提升服務調用性能,還能保證系統的可靠性。當註冊中心全部宕機後,服務提供者和服務消費者仍能通過本地緩存的地址信息進行通信,只是影響新服務的註冊和老服務的下線。

下面列出了兩個典型的場景:
  • 服務擴容場景:如下圖所示,服務B擁有者無需維護上游調用者的列表,擴容後,無需逐一通知。服務A擁有者在下游服務提供者擴容後,無需更改配置或重新發布程序代碼
  • 故障結點故障場景:服務A維護一個最新獲取的服務B的可用列表。當調用服務 B3不可用時,服務A會實時屏蔽,並使用下一個節點地址進行重試。當ZK發現服務 B3不可用時,也會通知服務A,並設置相應服務節點狀態爲DEAD。故障節點摘除時,對上游調用服務透明,無感知。

服務路由

分佈式服務框架通常會提供多種服務路由策略,同時支持用戶擴展負載均衡策略。常見服務路由策略爲:
  • 隨機:採用隨機算法進行路由,消費者基於地址列表隨機生成服務提供者地址進行遠程調用。缺點是在一個截面上碰撞概率較高,同時如果服務提供者硬件配置差異較大,會導致各節點負載不均勻
  • 基於權重:爲每個服務提供者分配一個流量佔比權重。缺點是存在慢的服務提供者累積請求問題。
  • 基於負載:消費者緩存所有服務提供者的服務調用時延,週期性的計算服務調用平均時延,然後計算每個服務提供者調用時延和平均時延的差值,根據差值大小動態調整權重,保證服務時延大的服務提供者接收更少的消息,防止消息堆積。
  • 一致性hash:相同參數的請求總是發送到同一服務提供者,當某一臺提供者宕機時,原本發往改提供者的請求,基於虛擬結點,平攤到其他提供者,不會引起劇烈變動。
常見的服務路由策略用於負載均衡一般能滿足大部分業務的線上需求,但是在一些場景中需要對路由策略設置一些過濾條件:
  • 根據IP地址段做路由
  • 讀寫分離:根據不同的方法名匹配,再做相應路由
  • 灰度升級:將流量路由到新服務版本上
  • 連接綁定:據一些業務特徵,把流量路由到固定到某一服務器上
如果服務是跨機房部署,那麼一般服務路由遵循多機房策略,即先本地路由優先,本地服務宕機再同機房優先,其次纔是跨機房訪問。具體路由場景如下所示:
  • 單機房場景
    • 全部列表結點,按照權重分配流量
  • 多機房場景
    • 同機房優先,同機房內的節點,按權重分配流量
    • 同機房節點不可用時,跨機房分配流量
  • 異地多機房場景
    • 同機房優先,同機房內的節點,按權重分配流量
    • 同機房節點不可用時,優先在本地域內,避免跨機房分配流量
    • 本地域無可用節點時,跨地域分配流量

爲了保障業務的擴展性,服務通信框架在默認的路由策略之外,還需要支持業務擴展路由算法,實現業務自定義路由。

服務治理

單體應用服務化之後,通常採用分佈式集羣的部署模式,這會帶來如下問題:
  • 遠端服務訪問失敗後,如何進行容錯
  • 服務宕機後,如何進行隔離降級
  • 如何對服務質量進行監控
  • 如何對鏈路進行跟蹤

服務容錯

經過服務路由之後,選定某個服務提供者進行遠程服務調用,但是服務調用可能會出錯。服務調用失敗後,服務調用框架需要能夠在底層自行容錯,容錯方面最主要的一點就是故障轉移。在實現容錯時,要考慮服務是否是冪等的,如果一個服務不是冪等的,做故障轉移時可能會出現我們本身不期望的結果。常見的容錯策略有:
  • failover:失敗自動切換,當出現失敗時,重試其他服務器。通常用於讀操作等冪等性服務
  • failback:失敗自動恢復,後臺記錄失敗請求,定時重發。通常用於通知操作,不可靠,重啓丟失。
  • failfast:快速失敗,只發起一次,失敗立即報錯,通常用於非冪等性的寫操作。(如果有機器正在重啓,可能會出現調用失敗)
在做重試時需要注意一下是否需要重試,在高峯期出現抖動時,不適當重試會導致雪崩。某個調用方邏輯有問題或者服務掛掉,這時不應該是重試調用服務,而是需要降級。同時高峯期壓力過大時,需要執行過載保護。在執行重試時,需要有一個延時,我們不可能無限制的做重試。下圖列出了導致調用失敗的幾種場景:
  • 服務查找階段failure:無服務提供者,直接拋出;失敗結點快速降級淘汰
  • 請求階段failure:請求確認丟失,可以重試;如果難以確認server是否收到請求,需考慮調用是否冪等來決定是否重試
  • server側failure:分執行前、執行後失敗兩種情況,從client端難以區分這兩種情況,需考慮調用是否冪等來決定是否重試
  • client等待response超時:需考慮調用冪等來決定是否重試          

服務限流

當資源成爲瓶頸時,服務通信框架需要對消費者做限流,啓動流控保護機制。比較常見的流控策略有:
  • 靜態流量分配製:靜態流控通常估算出服務QPS來設置域值。一般靜態分配採用預分配方案,服務框架啓動時就會將該域值加載到內存。靜態分部策略忽略了服務端的動態變化,雲端服務的彈性伸縮特性使得服務節點處理動態變化過程中,服務節點數一旦出現變化,就會使得流控不準。
  • 動態流量分配製:由服務註冊中心以流控窗口T爲時間單位,動態推送每個節點分配的流控域值QPS。當服務節點數發送變更時,會觸發服務註冊中心重新計算每個節點的配額,然後進行推送。動態流量分配製的問題在於在生產環境中,每臺主機的配置可能都不同,如果採用 流控總域值/服務節點數 這種平均計算方式,那麼就會導致性能高的結點配額很快用完,而性能差的結點還剩餘。其中的解決方法有對考慮機器性能對分配做加權來降低偏差,還有一種就是配額指標返還和重新申請,每個服務節點根據自己分配的指標值和處理速率做預測,如果計算結果又剩餘,則把多餘的返還給服務註冊中心,配額用完的結點再去服務中心申請配額。同時該方案缺點在於時間窗口T的值難以確定,如果T值過大,如果各節點負載情況變化太快,情況反饋到註冊中心再計算會引起較大誤差;如果T值過小,經過一系列的上報計算之後,會有一定的時延。
  • 動態流量申請制:系統部署時,根據服務節點數和靜態流控QPS閾值,拿出一定比例的配額做初始分配,剩餘的配額放在配額資源池中,如果哪個服務節點使用完了配額,就主動向服務註冊中心申請配額。配額申請策略爲流控窗口 未分配配額 / M(機器數量) * T(時間窗口) / N(經驗值,默認爲10) 
上述所描述的流控策略都是基於需要預估最大QPS值,本質上都屬於靜態流控的一種。而動態流控是以資源來作爲流控因子,資源又分爲系統資源和應用資源兩大類。系統資源包括CPU使用率、內存使用率等,應用資源包括JVM堆內存使用率、消息隊列積壓率等等。根據不同的資源負載情況,動態流控又分爲多個級別,每個級別流控係數也都不同,也就是拒絕掉的消息比例不同。並且流控係數支持在線動態調整。需要指出爲了防止系統波動導致的偶發性流控,無論是進入流控狀態還是從流控狀態恢復,都需要連續採集N次並計算平均值以此來判斷是否進行流控或恢復。
除了對請求數進行控制之外,還可以對併發數和連接數進行控制。併發控制針對線程的併發執行數,本質是限制對某個服務或者服務的方法過度消費而影響其他服務正常運行。連接控制是針對消費方和提供方採用的是長連接方式進行通信,爲了防止因爲消費者連接數過多導致服務端負載過大。服務通信框架應該提供設置最大併發數和最大連接數的參數,且無論是在服務消費方和服務提供方都可以設置服務的最大併發數和最大連接數。

服務降級

在業務高峯時期,爲了保證核心業務的正常運行,通常會停掉一些不太重要的業務,比如商品評論,用戶經驗等。同時在某個服務依賴方阻塞或者服務不可用時,會導致調往該服務的請求被阻塞,阻塞的請求會消耗佔用掉系統的線程、io等資源,當該類請求越來越多,佔用的資源越來越多時,會導致系統瓶頸出現,最終導致業務系統崩潰。上述兩種場景都使用到了服務降級,服務降級主要包括容錯降級和屏蔽降級兩種模式:
  • 屏蔽降級:屏蔽降級的核心在於將原屬於降級業務的資源調配出來供核心業務使用。在業務高峯時期,對非核心的服務做強制降級,不發起遠程服務調用,直接返回空、異常或者執行特定的本地邏輯,減少自身對公有資源的消費,把資源釋放出來供核心服務使用。一般來說屏蔽降級是由運維人員手動開啓,服務註冊中心收到屏蔽降級消息後,分發給各服務消費集羣。消費者集羣獲取相關內容更新本地緩存,當發起遠程服務調用時,需要與屏蔽降級策略做匹配,如果匹配成功,則只需屏蔽降級邏輯,不發起遠程服務調用。
  • 容錯降級:容錯降級的核心在於服務提供者不可用時,讓消費者執行業務放通。其是根據服務調用結果,自動匹配觸發的。容錯降級的策略有兩種:第一種是將異常轉義,第二種是將異常屏蔽,直接執行模擬接口實現類。
無論是屏蔽降級還是容錯降級,都支持從消費者或服務提供者兩個維度去配置。從消費端配置策略更靈活,可以實現差異化降級策略。服務降級是可逆的,當系統恢復正常或者故障排除之後,可以對已經降級的服務進行恢復操作,恢復之後消費者將正常調用服務提供者,不再執行降級邏輯。

服務隔離

服務的故障隔離分爲四個層次:
  • 進程隔離:進程內部隔離主要有兩種方式:
    • 線程池隔離:爲每個核心服務都分別獨立部署到一個線程池,與其他服務做線程調度隔離,某個線程池資源耗盡,不影響其他服務。對於非核心服務,可以共享一個線程池,防止因爲服務數過多導致線程數過度膨脹。線程池的方式有一定的資源消耗,好處是線程池可以堆積請求,所以可以應對突發流量。
    • 信號量隔離:使用一個原子計數器(或者信號量)來記錄當前有多少個線程在運行,請求來先判斷計數器的數值,若超過設置的最大線程個數則丟棄該類型的新請求,若不超過則執行計數操作請求來計數器+1,請求返回計數器-1。這種方式是嚴格的控制線程且立即返回模式,但是缺點是無法應對突發流量。
          儘管這兩種方式能實現服務的故障鼓勵,但是這種隔離並不充分,例如某個故障服務發生了內存泄露異常,它會導致整個進程不可用,即使實現了          資源調度層的隔離,仍然無法保證其他服務不受影響。
  • VM隔離:通過將基礎設施層虛擬化,將應用部署到不同的VM中,利用VM對資源層的隔離,可以實現更高層次的服務故障隔離。
           
          將核心服務和非核心分別部署到不同的VM中,利用VM的CPU調度、網絡I/O、磁盤和內存等資源限制,實現物理資源層的隔離。
          當非核心服務3發生故障時,無論是線程死循環、OOM還是CPU佔用高,由於VM對資源的限制,這些故障並不會影響其他VM的正常運行,與線程級故障隔離配合使用可以實現邏輯+物理層的故障隔離
  • 物理機隔離
          當物理機足夠多的時候,硬件的故障就會由小概率事件轉變成普通事件。利用分佈式服務框架的集羣容錯能力,可以實現位置無關的自動容錯  
             
          要保證服務器宕機後服務扔能正常訪問,那麼首先必須採用分佈式集羣部署,同時服務實例需要部署到不同的物理機上。通常來說至少需要3臺物理機,假如單臺物理機的故障發生概率爲0.1%,那麼三臺同時發生故障的概率爲0.001%,服務可靠性將會達到5 9標準。
  • 機房隔離
          當應用規模非常大或者做容災時,都需要使用多個機房來部署應用。路由時,優先訪問同一個機房的服務提供者,當本機房的服務提供者大面積不可用或者全部不可用時,根據跨機房路由策略,訪問另一個機房的服務提供者,待本機房服務提供者集羣恢復到正常狀態之後,重新切換到本機房訪問模式。在多機房部署時,也可以選擇每個機房部署一個服務註冊中心,同一個服務實例,可以同時註冊到多個服務註冊中心中,實現跨機房的服務調用。多機房同時共用一個服務註冊中心也可以,但是如果部署服務註冊中心的機房宕掉,那麼會導致新的依賴服務註冊中心的操作不可用,例如服務治理,運行時參數調整,服務擴容等等。

服務鏈路跟蹤

隨着系統內部的服務增多,一次業務調用可能會通過系統內部多個服務協同調用來完成,這些服務可能有不同的團隊開發,並且分佈在不同的VM節點,甚至可能在多個地域不同的機房內。如果無法有效的梳理後端的分佈式調用和依賴關係,故障定界將會非常困難。
分佈式消息跟蹤系統的核心就是調用鏈,每次業務請求都會生成一個全局的TraceID,通過跟蹤ID將不同節點間的日誌串接起來,形成一個完整的日誌調用鏈。
調用鏈路跟蹤能夠用於以下場景:
  • 故障的快速定界定位:分佈式服務化之後,一次業務調用可能會涉及後臺上百次服務調用,傳統人工到各節點人肉搜索的方式效率很低。通過調用鏈跟蹤,將一次業務調用的完整軌跡以調用鍊形式展示出來,通過圖形化界面查看每次服務調用結果,以及故障信息,提升故障的定位效率。
  • 調用路徑分析:對調用鏈的調用路徑分析,可以識別應用的關鍵路徑:找出服務的熱點、耗時瓶頸和易故障點。同時也爲性能優化、容量規劃等提供數據支撐。
  • 調用來源和去向分析:可對依賴關係進行有效梳理,通過對調用來源進行Top排序,可以識別當前服務的消費來源,以及獲取各消費者的QPS、平均時延、出錯率等。針對特定的消費者,可以做針對性治理,例如針對某個消費者的限流降級、路由策略修改等。

服務監控

分佈式系統需要有一種方式來直觀地瞭解系統的調用及運行狀況,測量系統的運行性能,對線上故障進行及時報警。方便準確地指導系統的優化及服務化改進。通過對日誌做實時採集、聚合和數據挖掘,提取各種維度的價值數據,爲系統和運營提供大數據支撐,下圖是日誌監控系統架構:
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章