性能優化原則
問題導向
不要過早進行優化,避免增加系統復雜度,同時也浪費研發人力
遵循二八原則
要抓住主要矛盾,優先優化主要的性能瓶頸
優化需要有數據支撐
要時刻了解你的優化讓響應時間減少了多少,提升了多少的吞吐量。可以使用平均值、極值(最大/最小值)、分位值等作為統計的特征值
高可用性設計
系統設計
遵循"design for failure"的設計原則,未雨綢繆,具體優化方法有故障轉移、超時控制、降級、限流
故障轉移
對等節點直接可直接轉移
節點分主節點和備用節點,轉移時需要進行主備切換
什么時候進行主備切換?
一般采用某種故障檢測機制,比如心跳機制,備份節點定期發送心跳包,當多數節點未收到主節點的心跳包,表示主節點故障,需要進行切換。
如何進行切換?
一般采用paxos、raft等分布式一致性算法,在多個備份節點中選出新主節點。
超時控制
在分布式環境下,服務響應慢可能比宕機危害更大,失敗只是瞬時的,但調用延遲會導致占用的資源得不到釋放,在高并發情況下會造成整個系統奔潰。
如何合理設置超時時間?
收集系統之間的調用日志,統計比如說 99% 的響應時間是怎樣的,然后依據這個時間來指定超時時間。
降級
關閉整個流程中非核心部分,保證主流程能穩定執行(詳細見后文)
限流
限制單位時間內的請求量,超過的部分直接返回錯誤 (詳細見后文)
系統運維
灰度發布
通過線上流量觀察代碼變更帶來的影響
故障演練
對系統中的部分節點/組件人為破壞,模擬故障,觀察系統的表現。為了避免對生產系統造成影響,可以先部署另外一套與線上環境一摸一樣的系統,在這上面進行故障演練
系統可用性度量指標
MTBF:兩次故障之間的間隔,這個時間越長,系統越穩定。
MTTR:故障平均恢復時間,時間越短,故障對用戶影響越小
可用性=MTBF / (MTBF+MTTR)
高擴展性設計
存儲層
分庫分表,按業務和數據緯度對庫表進行水平/垂直拆分,突破單機限制。有以下兩點需要注意:
最好一次性確定好節點/分表數量,避免頻繁遷移數據
拆分后盡量避免使用事務,分布式事務需要協調各個模塊的資源,容易出問題
業務層
按業務緯度,接口重要性緯度和請求來源等多個維度對服務進行拆分和隔離
數據庫高可用設計
數據庫有兩個大方面的優化方向:
提升讀寫性能;
增強存儲擴展能力,應對大數據量的存儲需求
池化技術
池化是一種空間換時間的思路。預先創建好多個對象,重復使用,避免頻繁創建銷毀對象造成的開銷
如何設計一個數據庫連接池?
維護池中連接數量和保證連接可用性是連接池管理的兩個關鍵點。
請求獲取連接流程
初始化連接池時,需要指定最大連接數和最小連接數
連接池當前連接數 < 最小連接數: 創建新鏈接處理數據庫請求
最小連接數 < 連接池當前連接數 < 最大連接數: 優先復用空閑連接,否則創建新連接處理請求
連接池連接數 > 最大連接數: 等待一段時間(自旋/線程休眠),超時還沒有連接可以直接拋錯
保證連接可用性
心跳機制,定期檢查連接是否可用
每次使用連接前,先檢驗下連接是否可用,再進行SQL請求
如何設計一個線程池?
指定一個最大線程數量,并利用一個有限大小的任務隊列,當池中線程數量較少時,直接創建新線程去處理任務,當池中線程達到設置的最大線程數量后,可以將任務放入任務隊列中,等待空閑線程執行。
合理設置最大線程數量
CPU密集型任務,保持與CPU核數相當的線程就可以了,避免過多的上下文切換,降低執行效率
IO密集型,可以適當放開數量,因為在執行IO時線程阻塞,CPU空閑下來可以去執行其他線程的任務
等待隊列必須有界,若不限制大小可能會導致隊列任務數量過多,觸發Full GC,直接導致服務不可用
必須監控等待隊列中的任務數,避免最大線程數設置不合理導致大量任務留在等待隊列中得不到執行
主從讀寫分離
分離后,從庫可以用作數據備份,也可用于處理讀請求,減少單機壓力;
注意從庫數量,從庫越多,主庫需要越多的資源用于數據復制,同時還占用主庫網絡帶寬,一般最多掛3-5個從庫
主從之間存在延遲,在某些場景下從庫可能讀不到最新的數據會導致錯誤。
處理方法:
1. 使用緩存,在更新數據后同時更新緩存,讀的時候直接讀緩存
2. 寫主庫后發送可以發送完整數據記錄到消息隊列,避免后面讀庫操作
3. 需要強一致的讀請求直接讀主庫
需要對主從延遲進行監控
最好屏蔽分離后導致訪問數據庫方式的改變
1. 以基礎庫中間件的方式直接引進項目代碼中,訪問時直接訪問該中間件,主流方案有TDDL、DDB等
2. 單獨部署數據庫代理層,業務代碼使用時訪問代理層,代理層轉發到指定的數據源,有Cobar、Mycat、Atlas、DBProxy等,這種方案多了一次轉發,性能上有一些損耗
分庫分表
隨著存儲量變大,單機寫入性能和查詢性能會降低,分庫分表能提高讀寫性能;按模塊分庫,實現不同模塊的故障隔離
拆分方式
垂直拆分
將數據庫的表拆到不同數據庫中,一般可以按業務來拆分,專庫專用,將業務耦合度較高的表放到同一個庫中
水平拆分
將單一表的數據按一定規則拆分到多個表中,需要選一個字段作為分區鍵。一般通過對某個字段hash進行分區或按某個字段(比如時間字段)的區間進行分區
如何保證ID全局唯一?
可以開發一個單獨的分布式發號器
使用發號器而不是UUID的原因?發號器的好處?
同一個發號器生成的id能保證有序
能在id中某一部分定義業務含義,有利于問題排查
常見的發號算法
snowFlake:64bit 的二進制數字分成若干部分,每一部分都存儲有特定含義的數據,比如說時間戳、機器 ID、序列號等等,最終生成全局唯一的有序 ID。
發號器的實現
服務啟動時,先向etcd注冊中心獲取當前機器號列表,計算得到未使用的機器號A,向etcd注冊當前地址為機器號A的服務地址,該注冊信息有ttl,需要定期進行續租操作,保證ttl不過期
服務向etcd獲取該機器號最后的上報時間戳,若本地當前時間戳< 最后的上報時間戳,暫時拒絕發號請求,直到當前時間戳 > 上報時間戳。避免時間回撥問題
發號器依賴服務節點本地時間戳,各節點時間戳可能沒法準確同步,當節點重啟時可能出現時間回撥現象
服務可使用單進程處理id生成邏輯,避免加鎖,線程模型可參考redis實現
每次生成ID后,本地會記錄一個last_time(最后發號時間戳), 定期會上報etcd這個last time
發號器實現tips
ID中有幾位是序列號,表示在單個時間戳內最多可以創建多少個ID,當發號器的QPS不高時,單個時間戳只發一個ID,會導致ID的末位永遠是1;這個時候分庫分表使用ID作為分區健會導致數據不均勻
變大時間戳單位,比如記錄秒而不是毫秒
序列號的起始號設置為隨機數
其他注意事項
最好屏蔽分離后導致訪問數據庫方式的改變(同上)
水平拆分后,為了避免全分區查詢,盡量帶上分區鍵;若查詢條件中沒有分區鍵,可創建查詢條件字段與分區鍵的映射表,查詢時先通過映射表找到分區鍵,再通過分區鍵去數據表中查詢
水平拆分后,對于多表join的需求可直接把多個表的數據分別先查出來后在業務代碼中進行關聯
水平拆分后,對于一些聚合操作,比如count、sum,可以直接將聚合后的數據單獨存儲在一張表中或記錄到redis中
關系型數據庫和NoSQL
關系型數據庫能提供強大的查詢功能、事務和索引等功能;NoSQL可在某些場景下作為關系型數據庫的補充:
提升寫入性能,比如某些NoSQL使用LSM作為存儲結構。寫入時完全不需要訪問磁盤,可提高寫入性能,但這是在犧牲讀性能的前提下。
LSM相關介紹:
//blog.csdn.net/jinking01/article/details/105377370
//blog.csdn.net/SweeNeil/article/details/86482781
倒排索引,通過「分詞-記錄ID」的映射,避免關系型數據庫在模糊查詢場景下掃描全表
某些NoSQL,比如mongodb,設計之初就考慮到了分布式和大數據存儲的場景,具備了副本集、數據分片和負載均衡(當分片未均勻分布在各節點上時,會啟動rebalance)的特性
緩存
緩存與數據庫一致性保證
先操作緩存,再操作數據庫
只更新緩存,不直接更新數據庫,通過批量異步的方式來更新數據庫。
主要用于底層存儲性能與緩存相差較大,比如操作系統的page cache就使用這種方式避免磁盤的隨機IO
先操作數據庫,再操作緩存
Cache-Aside
Read-Through/Write-Through 與Cache-Aside相比,多了一層Cache-Provider,程序代碼變的更簡潔,一般在設計本地緩存可采用這個方式
操作緩存時,要刪除而不是更新緩存
由于操作數據庫和操作緩存之間沒有原子性,所以如果采用更新緩存的方式可能導致最終緩存不是最新數據
若緩存值需要大量計算得到,更新頻率高時,可能計算的緩存還沒被讀過又被更新了,浪費性能。
刪除緩存失敗會影響一致性
刪除失敗時,將失敗的key存到消息隊列中,異步重試刪除
通過canal等工具監聽binlog日志,將更新日志發送到消息隊列中,異步刪除相關的key
緩存設置過期時間,過期后重新加載到最新數據
緩存的高可用設計
客戶端方案
數據分片,將數據分散到多個緩存節點,一般有hash取模和一致性hash兩種分片算法。
hash取模:讀寫時,客戶端對key進行hash計算,并對緩存節點數取余,計算出數據所在的節點。該算法實現簡單;但當緩存節點個數變化時,容易導致大批量緩存失效。
一致性hash算法:一個有2^32個槽的hash環,使用一定的hash函數,以服務器的IP或主機名作為鍵進行哈希,這樣每臺服務器就能確定其在哈希環上的位置;讀寫時,使用相同的hash函數對key進行hash,得到哈希環上的一個位置,順時針查找到的第一個服務器,就是該key所在的緩存節點。
1. 節點數量變化時,只有少量的key會漂移到其他節點上,不會導致大批量失效
2. 某個節點故障時,該節點上的緩存會全部壓到后一個節點上,如果后一個節點承受不了,會繼續引發故障,如此下去,最后造成整體系統的雪崩。可通過虛擬節點解決
3. 在集群中有兩個節點 A 和 B,客戶端初始寫入一個 Key 為 k,值為 3 的緩存數據到 Cache A 中。這時如果要更新 k 的值為 4,但是緩存 A 恰好和客戶端連接出現了問題,那這次寫入請求會寫入到 Cache B 中。接下來緩存 A 和客戶端的連接恢復,當客戶端要獲取 k 的值時,就會獲取到存在 Cache A 中的臟數據 3,而不是 Cache B 中的 4。所以必須設置緩存的過期時間
緩存節點設置主從機制,在主節點故障時客戶端能自動切換
在客戶端本地緩存少量熱點數據,減少對緩存節點的壓力
中間代理層方案
對緩存的所有讀寫請求都通過代理層完成,代理層提供路由功能,內置了高可用相關邏輯,保證底層緩存節點的可用
服務端方案
參考redis的哨兵+cluster實現
消息隊列
異步處理
將請求先放入隊列中,快速響應用戶,之后異步通知用戶處理結果
削峰填谷
避免高峰寫時導致請求處理的延遲
解耦系統模塊
多個模塊之間解耦開來,通過發布訂閱消息隊列通信。各自系統的變更不會影響到另外一個
使用時注意事項
避免消息隊列數據堆積
添加對應監控
啟動一個監控程序,定時將監控消息寫入消息隊列中,在消費端檢查消費時與生產時間的時間間隔,達到閾值后發告警
通過消息隊列提供的工具對隊列內數據量進行監控
減少消息延遲
優化消費代碼
增加消費并發度
避免消息丟失(以kafka舉例)
生產端
失敗重試
ack設置為all,保證所有的ISR都寫入成功
消息隊列服務端
保證副本數量和ISR數量
消費端
確保消費后再提交消費進度
避免消息生產/消費重復(以kafka舉例)
生產端
更新kafka版本,利用kafka的冪等機制和事務機制保證消息不重復
消費端
消息id+業務冪等判斷
其他tips
使用poll方式消費時需注意當無新消息時消費進程空轉占用cpu,拉取不到消息可以等待一段時間再來拉取,等待的時間不宜過長,否則會增加消息的延遲。
一般建議固定的 10ms~100ms,也可以按照一定步長遞增,比如第一次拉取不到消息等待 10ms,第二次 20ms,最長可以到100ms,直到拉取到消息再回到 10ms。
分布式服務
一體化架構的痛點
數據庫會成為性能瓶頸,MySQL客戶端并發數量有限制
增加研發溝通的成本,抑制了研發效率的提升。
降低系統運維效率,隨著系統代碼量增多,一次構建的過程,花費的時間可能達到十幾分鐘;而且一次小改動可能會影響系統的其他模塊
服務拆分原則
高內聚,低耦合,每個服務只負責自己的任務
關注拆分的粒度,在拆分初期,可先粗粒度拆分,隨著團隊對業務和微服務理解的加深,再逐漸細化
拆分不能影響日常功能迭代,可以先剝離獨立的邊界服務,減少對現有業務的影響,同時也能作為一個練習、試錯的機會。
優先拆分被依賴的服務
接口要注意可擴展,兼容舊的請求方式,避免接口更新后,其他服務調用時報錯
微服務化后引入了額外的復雜度
需要引入服務注冊中心,并管理監控各個服務的運行狀態
引入服務治理體系。對其他服務調用時,需要通過熔斷、降級、限流、超時控制等方法,避免被調服務故障時,影響整個調用鏈。
引入監控系統。
分布式追蹤工具,分析請求中的性能瓶頸
服務端監控報表,分析服務和資源的宏觀性能表現
rpc選型考慮
采用合適的io模型,提升網絡傳輸性能,一般采用io多路復用
選擇合適的序列化方式
序列化選型考慮:
1. 是否跨語言、跨平臺
2. 考慮時間和空間上的開銷
3. 是否有足夠的擴展性,避免稍微改動一個字段就會導致傳輸協議的不兼容,服務調用失敗
常見的序列化方案
json/xml
優點:簡單,方便,無需關注要序列化的對象格式;可讀性強
缺點:序列化和放序列化速度較慢;占用空間大
protobuf
優點:性能好,效率高;支持多種語言,配合IDL能自動生成對應語言的代碼
缺點:二進制格式傳輸,可讀性差
服務注冊與發現
假設調用者直接存儲服務地址列表,當服務節點變更時,需要調用者配合,所以需要一個服務注冊中心,用于存儲服務節點列表,并且當服務端地址發生變化時,可以推送給客戶端。常用的注冊中心有zookeeper、etcd、nacos、eureka...
服務狀態管理
rpc服務到注冊中心完成注冊,注冊有時效限制
rpc服務每隔一定時間間隔需要向注冊中心發送心跳包,注冊中心收到后更新服務節點的過期時間
到了過期時間還未收到rpc服務節點的更新心跳包,認定該節點不可用,會將這個消息通知客戶端
注意事項
客戶端與服務端之間也需要維護一個心跳包活機制
因為有可能服務端與注冊中心網絡正常,但客戶端與服務端之間網絡不通,這種時候需要把該服務節點從客戶端的節點列表中剔除。
需要采取一定的保護策略避免注冊中心故障影響整個集群
客戶端在收到「節點不可用」消息后,可以先主動ping下服務端,確認不可用后再剔除
自研注冊中心時,當下線的節點數量超過一定數量時,可停止繼續摘除服務節點,并發送相關告警
注冊中心管理服務數量越多,訂閱的客戶端數量也越多,一個服務發生變更時,注冊中心需要推送大量消息,嚴重占用集群帶寬
控制一組注冊中心管理的服務數量
擴容注冊中心集群
規范注冊中心推送消息的使用,比如服務變更時只推送變更的節點,而不是把整個最新可用列表推送出去,減少推送數據量
注冊中心做削峰處理,避免并發流量過高
全鏈路追蹤
哪些地方需要打日志?
一個請求的處理過程中,比較耗時的基本都是在IO部分,包括網絡IO和磁盤IO,所以一般針對 數據庫、磁盤、依賴的第三方服務這些地方的耗時即可
如何打日志?
同一個服務中,為請求添加一個日志標示符requestID,之后的日志中都帶上requestID
采用切面編程的方法,在IO操作前后記錄下時間,并計算出耗時
當一個請求處理需要跨多個服務時,可以用同一個requestId將多個服務的日志串起來,同時每個服務注冊一個spanId,串起請求過程中經過的spanId,表示服務之間的調用關系
如何查看日志?
將日志統一上傳到集中存儲中,比如es,查看時直接帶著requestId即可以把整條調用鏈查詢出來(存儲參考ELK)
全量打日志時,會對磁盤IO造成較大壓力,所以需要進行采樣打印,比如只打印“requestId%10=0”的日志
另外,由于打日志會影響接口響應耗時,可以提供一個開關,正常時關閉打印采集,當發生異常時再打開收集日志
負載均衡
服務端負載均衡
四層負載均衡(LVS)
工作在傳輸層,性能較高,LVS-DR模式甚至可以在服務端回包時直接發送到客戶端而不需要經過負載均衡服務器
七層負載均衡(nginx)
工作在應用層,會對請求URL進行解析,進行更細維度的請求分發。并且提供探測后端服務存活機制(nginx_upstream_check_module模塊),nginx配合consul還可以實現新增節點自動感知;配置比四層負載均衡更加靈活
在高并發場景下,可以在入口處部署LVS,將流量分發到多個nginx服務器上,再由nginx服務器轉發到應用服務器上
客戶端負載均衡
客戶端中通過注冊中心獲取到全量的服務節點列表,發送請求前使用一定的負載均衡策略選擇一個合適的節點
負載均衡策略
靜態策略
選擇時不會考慮后端服務的實際運行狀態
輪訓
加權輪訓
隨機
源地址hash:自于同一個源IP的請求將始終被定向至同一個后端服務
動態策略
客戶端上監控各后端服務節點狀態。根據后端服務的負載特性,選擇一個較好的服務節點
最少連接
加權最少連接
最短延遲
基于本地的最小連接
API網關
入口網關
協議轉換。為客戶端提供統一的接入地址和協議,屏蔽掉后端服務不同的協議細節
植入服務熔斷、服務降級、流量控制、分流控制等服務治理相關的策略
認證和授權。統一處理不同端的認證和授權,為后端服務屏蔽掉認證細節
黑白名單限制
出口網關
部署在應用服務和第三方系統之間,對調用外部的api做統一的認證、授權、審計以及訪問控制
API網關實現/選型考慮
性能
使用IO多路復用提高性能
采用多線程池避免多個服務之間相互影響(不同服務使用不同的線程池,在同一個服務中針對不同接口設置不同的配額)
擴展性
可以方便在網關的執行鏈路上增加/刪除一些邏輯
服務降級
在分布式系統中,由于某個服務響應緩慢,導致服務調用方等待時間過長,容易耗盡調用方資源,產生級聯反應,發生服務雪崩。
所以在分布式環境下,系統最怕的反而不是某一個服務或者組件宕機,而是最怕它響應緩慢,因為,某一個服務或者組件宕機也許只會影響系統的部分功能,但它響應一慢,就會出現雪崩拖垮整個系統。
放棄部分非核心服務或部分請求,保證整體系統的可用性,是一種有損的系統容錯方式,有熔斷降級、開關降級等
熔斷降級
服務調用方為調用的服務維護一個有限狀態機,分別有關閉(調用遠程服務)、半打開(嘗試調用遠程服務)、打開(不調用遠程服務,直接返回降級數據)
***關閉->打開:當調用失敗的次數累積到一定的閾值時,熔斷狀態從關閉態切換到打開態。一般在實現時,如果調用成功一次,就會重置調用失敗次數。
打開->半打開:打開狀態時,啟動一個計時器,計時器超時后,切換成半打開狀態;也可以設置一個定時器,定期探測服務是否恢復
半打開->打開:半打開狀態下,如果出現調用失敗的情況,切換回打開狀態
半打開->關閉*:在半打開狀態下,累計一定的成功調用次數后,會切換回關閉狀態
開關降級
在代碼中預先埋設一些開關,控制時調用遠程服務還是應用降級策略。開關可以通過配置中心控制,當系統出現問題需要降級時,修改配置中心變更開關的值即可
代碼埋入開關后,需要驗證演練,保證開關的可用性。避免線上出了問題需要降級時才發現開關不生效
流量控制
為什么要限流?
在高負載時,核心服務不能直接降級處理,為了保證服務的可用性,可以限制系統的并發流量,保證系統能正常響應部分用戶的請求,對于超過限制的流量,直接拒絕服務。
在哪進行限流?
API網關,可以對系統整體流量做塑形
在RPC服務中引入限流策略,避免單個服務被過大流量壓垮
從哪些緯度進行限流?
對系統單位時間請求量做限制
對單接口單位時間請求量做限制
對單個客戶端單位時間內請求量做限制
如何進行限流?
時間窗口算法
*固定窗口*
限制單位時間的流量,比如限制1秒1000次請求,超出部分拒絕服務。下一個1秒時重置請求量計數
在前后兩個窗口的邊界區如果有大流量可能不會觸發限流策略
*滑動窗口*
將窗口細化分為多個小窗口,比如要限制1秒1000的請求,將1秒的窗口劃為5個大小為200ms的小窗口,每個小窗口有單獨的計數,請求來時,通過判斷最近5個小窗口的請求總量是否觸發限流。
時間窗口算法可能會出現短時間的集中流量,為了使流量更加平滑,一般可采用漏桶算法和令牌桶算法
漏桶算法
漏桶算法其實非常形象,如下圖所示可以理解為一個漏水的桶,當有突發流量來臨的時候,會先到桶里面,桶下有一個洞,可以以固定的速率向外流水,如果水的從桶中外溢了出來,那么這個請求就會被拒絕掉。具體的表現就會向下圖右側的圖表一樣,突發流量就被整形成了一個平滑的流量。
實現可參考ratelimit
令牌桶算法
請求處理前需要到桶中獲取一個令牌,如果桶中沒有令牌就觸發限流策略
桶中按一定速率放入新令牌,比如限制1s訪問次數1000次,那每隔(1/1000)s=1ms的時間往桶中加入新令牌
同時注意桶中的令牌總數要有一個限制。
漏桶算法在突發流量時,流量先緩存到漏桶中,然后勻速漏出處理,這樣流量的處理時間會變長;而令牌桶在一段空閑期后,會暫存一定量的令牌,能夠應對一定的突發流量。
過載保護
以上的限流方案,都是設置一個限流閾值,當流量超過該閾值就阻止或減少流量就繼續進行。但合理設置限流閾值并不容易,同時也很被動,比如設置限流閾值的依據是什么?當服務擴容或代碼優化后閾值是否需要重新設置?
因此我們需要一種自適應的限流算法,能根據系統當前的負載自動決定是否丟棄流量。我們可以計算系統臨近過載時的吞吐作為限流的閾值,進行流量控制
*如何計算系統的吞吐量?*
根據利科爾法則,系統的吞吐量 = 系統請求新增速率 x 請求平均耗時。
我們可以每500ms為一個bucket,Pass為每個bucket成功請求的數量,rt為bucket中的平均響應時間;維護一個大小為10bucket的滑動窗口,及統計最近5s的請求情況,觸發過載保護時,獲取滑動窗口內Pass最大的bucket,該bucket的pass * rt就是系統最大吞吐
*如何計算系統當前吞吐?
服務進程維護一個變量inflight,新請求進來時加一,處理完成時減一
*如何判斷系統是否過載?
使用CPU使用率/內存使用率作為過載信號;使用一個獨立的進程采樣,每隔100ms觸發一次采樣,計算峰值時,可采用滑動平均值,避免毛刺現象。
*過載保護流程
設置當CPU使用率超過80%時,觸發過載保護,請求進來時,判斷passrt < inflight, 否則拒絕該請求
過載保護觸發后,需要設置一個持續時間,不能CPU一降立即接觸過載保護。否則一個短時間的CPU下降可能導致大量的請求被放行,嚴重時會打滿CPU。
持續時間過后,重新根據CPU利用率決定是否繼續過載保護