本文結合自身后臺開發經驗,從高可用、高性能、易維護和低風險(安全)角度出發,嘗試總結業界常見微服務介面設計原則,幫助大家設計出優秀的微服務,
1.前言
微服務是一種系統架構風格,是 SOA(面向服務架構)的一種實踐,微服務架構通過業務拆分實作服務組件化,通過組件組合快速開發系統,業務單一的服務組件又可以獨立部署,使得整個系統變得清晰靈活:
- 原子服務
- 獨立行程
- 隔離部署
- 去中心化服務治理
一個大型復雜的軟體應用,都可以拆分成多個微服務,各個微服務可被獨立部署,各個微服務之間是松耦合的,現如今后臺服務大部分以微服務的形式存在,每個微服務負責實作應用的一個功能模塊,而微服務由一個個介面組成,每個介面實作某個功能模塊下的子功能,

以一個 IM 應用為例,它的功能架構可能是下面這樣的:

所以如果是后臺開發的同學,經常需要實作一個后臺微服務來提供相應的能力,完成業務功能,
服務以介面形式提供服務,在實作服務時,我們要將一個大的功能拆分成一個個獨立的子功能來實作,每一個子功能就是我們要在服務中實作的一個介面,
有時一個服務會有很多介面,每個介面所要實作的功能可能會有關聯,那么這就非常考驗設計服務介面的功底,讓服務變得簡單可靠,
業界已經有很多比較成熟的實踐原則,可以幫助我們設計實作出一個可靠易維護的服務,
微服務設計原則并沒有嚴格的規范,下面結合業界成熟的方法和個人多年后臺開發經驗,介紹高可用,高性能,易維護,低風險服務常用的設計原則,
2.高可用
2.1 降級兜底
大部分服務是如下的結構,既要給使用方使用,又依賴于他人提供的第三方服務,中間又穿插了各種業務邏輯,這里每一塊都可能是故障的來源,

如果第三方服務掛掉怎么辦?我們業務也跟著掛掉?顯然這不是我們希望看到的結果,如果能制定好降級兜底的方案,那將大大提高服務的可靠性,
比如我們做個性化推薦服務時,需要從用戶中心獲取用戶的個性化資料,以便代入到模型里進行打分排序,但如果用戶中心服務掛掉,我們獲取不到資料了,那么就不推薦了?顯然不行,我們可以在本地 cache 里放置一份熱門商品以便兜底,
又比如做一個資料同步的服務,這個服務需要從第三方獲取最新的資料并更新到 MySQL 中,恰好第三方提供了兩種方式:
- 一種是訊息通知服務,只發送變更后的資料;
- 一種是 HTTP 服務,需要我們自己主動呼叫獲取資料,
我們一開始選擇訊息同步的方式,因為實時性更高,但是之后就遭遇到訊息遲遲發送不過來的問題,而且也沒什么例外,等我們發現一天時間已過去,問題已然升級為故障,合理的方式應該兩個同步方案都使用,訊息方式用于實時更新,HTTP 主動同步方式定時觸發(比如 1 小時)用于兜底,即使訊息出了問題,通過主動同步也能保證一小時一更新,
2.2 過載保護(保護自己)
如果是高并發場景使用的介面,那么需要做過載保護,防止服務過載引發雪崩,
相信很多做過高并發服務的同學都碰到類似事件:某天 A 君突然發現自己的介面請求量突然漲到之前的 10 倍,沒多久該介面幾乎不可使用,并引發連鎖反應導致整個系統崩潰,
如何應對這種情況?生活給了我們答案:比如老式電閘都安裝了保險絲,一旦有人使用超大功率的設備,保險絲就會燒斷以保護各個電器不被強電流給啥訓,同理我們的介面也需要安裝上“保險絲”,以防止非預期的請求對系統壓力過大而引起的系統癱瘓,當流量過大時,可以采取拒絕或者引流等機制,
過載保護的做法:
- 請求等待時間超時
比如把接收到的請求放在指定的佇列中排隊處理,如果請求等待時間超時了(假設是 100ms),這個時候直接拒絕超時請求;再比如佇列滿了之后,就清除佇列中一定數量的排隊請求,保護服務不過載,保障服務高可用,
- 服務過載及早拒絕
根據服務當前指標(如 CPU、記憶體使用率、平均耗時等)判斷服務是否處于過載,過載則及早拒絕請求并帶上特殊錯誤碼,告知上游下游已經過載,應做限流處理,
2.3 流量控制(保護下游)
流量控制,或者叫限流,一般用戶保護下游不被大流量壓垮,
常見的場景有:
(1)下游有嚴格的請求限制;比如銀行轉賬介面,微信支付介面等都有嚴格的介面限頻;
(2)呼叫的下游不是為高并發場景設計;比如提供異步計算結果拉取的服務,并不需要考慮各種復雜的高并發業務場景,提供高并發流量場景的支持,每個業務場景應該在拉取資料時快取下來,而不是每次業務請求都過來拉取,將業務流量壓垮下游,
(3)失敗重試,呼叫下游失敗了,一定要重試嗎?如果不管三七二十一直接重試,這樣是不對的,比如有些業務回傳的例外表示業務邏輯出錯,那么你怎么重試結果都是例外;又如有些例外是介面處理超時例外,這個時候就需要結合業務來判斷了,有些時候重試往往會給后方服務造成更大壓力,造成雪上加霜的效果,所有失敗重試要有收斂策略,必要時才重試,做好限流處理,
控制流量,常用的限流演算法有漏桶演算法和令牌桶演算法,必要的情況下,需要實作分布式限流,
2.4 快速失敗
遵循快速失敗原則,一定要設定超時時間,
某服務呼叫的一個第三方介面正常回應時間是 50ms,某天該第三方介面出現問題,大約有 15%的請求回應時間超過 2s,沒過多久服務 load 飆高到 10 倍以上,回應時間也非常緩慢,即第三方服務將我們服務拖垮了,
為什么會被拖垮?沒設定超時!我們采用的是同步呼叫方式,使用了一個執行緒池,該執行緒池里最大執行緒數設定了 50,如果所有執行緒都在忙,多余的請求就放置在佇列里中,如果第三方介面回應時間都是 50ms 左右,那么執行緒都能很快處理完自己手中的活,并接著處理下一個請求,但是不幸的是如果有一定比例的第三方介面回應時間為 2s,那么最后這 50 個執行緒都將被拖住,佇列將會堆積大量的請求,從而導致整體服務能力極大下降,
正確的做法是和第三方商量確定個較短的超時時間比如 200ms,這樣即使他們服務出現問題也不會對我們服務產生很大影響,
2.5 無狀態服務
盡可能地使微服務無狀態,
無狀態服務,可以橫向擴展,從而不會成為性能瓶頸,
狀態即資料,如果某一呼叫方的請求一定要落到某一后臺節點,使用服務在本地快取的資料(狀態),那么這個服務就是有狀態的服務,
我們以前在本地記憶體中建立的資料快取、Session 快取,到現在的微服務架構中就應該把這些資料遷移到分布式快取中存盤,讓業務服務變成一個無狀態的計算節點,遷移后,就可以做到按需動態伸縮,微服務應用在運行時動態增刪節點,就不再需要考慮快取資料如何同步的問題,
2.6 最少依賴
能不依賴的,盡可能不依賴,越少越好,
減少依賴,便可以減少故障發生的可能性,提高服務可靠性,
任何依賴都有可能發生故障,即使其如何保證,我們在設計上應盡可能地減少對第三方的依賴,如果無法避免,則需要對第三方依賴在發生故障時做好相應處理,避免因第三方依賴的抖動或不可用導致我們自身服務不可用,比如降級兜底,
2.7 簡單可靠
可靠性只有靠不斷追求最大程度的簡化而得到,
乏味是一種美德,與生活中的其他東西不同,對于軟體而言,“乏味”實際上是非常正面的態度,我們不想要自發性的和有趣的程式;我們希望這些程式按設計執行,可以預見性地完成目標,與偵探小說不同,缺少刺激、懸念和困惑是源代碼的理想特征,
因為工程師也是人,他們經常對于自己撰寫的代碼形成一種情感依附,這些沖突在大規模清理源代碼的時候并不少見,一些人可能會提出抗議,“如果我們以后需要這個代碼怎么辦?”,“我們為什么不只是把這些代碼注釋掉,這樣稍后再使用它的時候會更容易,”,“為什么不增加一個功能開關?”,這些都是糟糕的建議,源代碼控制系統中的更改反轉很容易,數百行的注釋代碼則會造成干擾和混亂;那些由于功能開關沒有啟用而沒有被執行的代碼,就像一個定時炸彈等待爆炸,極端地說,當你指望一個 Web 服務 7*24 可以用時,某種程度上,每一行新代碼都是負擔,
法國詩人 Antoine de Saint-Exupéry 曾寫道:“不是在不能添加更多的時候,而是沒有什么可以去掉的時候,才能達到完美”,這個原則同樣適用于軟體設計,API 設計是這個規則應該被遵循的一個清晰的例子,書寫一個明確的、簡單的 API 是介面可靠的保證,我們向 API 消費者提供的方法和引數越少,這些 API 就越容易理解,在軟體工程上,少就是多!一個很小的,很簡單的 API 通常也是一個對問題深刻理解的標志,
軟體的簡單性是可靠性的前提條件,當我們考慮如何簡化一個給定的任務的每一步時,我們并不是在偷懶,相反,我們是在明確實際上要完成的任務是什么,以及如何容易地做到,我們對新功能說“不”的時候,不是在限制創新,而是在保持環境整潔,以免分心,這樣我們可以持續關注創新,并且可以進行真正的工程作業,
2.8 分散原則
雞蛋不要放一個籃子,分散風險,
比如一個模塊的所有介面不應該放到同一個服務中,如果服務不可用,那么該模塊的所有介面都不可用了,我們可以基于主次進行服務拆分,將重要介面放到一個服務中,次要介面放到另外一個服務中,避免相互影響,
再如所有交易資料都放在同一個庫同一張表里面,萬一這個庫掛了,此時影響所有交易,我們可以對資料庫水平切分,分庫分表,
2.9 隔離原則
控制風險不擴散,不放大,
不同模塊之間要相互隔離,避免單個模塊有問題影響其他模塊,傳播擴散了影響范圍,
比如部署隔離:每個模塊的服務部署在不同物理機上;
再如 DB 隔離:每個模塊單獨使用自身的存盤實體,
古代赤壁之戰就是一個典型的反面例子,鐵鎖連船導致隔離性被破壞,一把大火燒了 80W 大軍,
隔離是有級別的,隔離級別越高,風險傳播擴散的難度就越大,容災能力越強,
例如:一個應用集群由 N 臺服務器組成,部署在同一臺物理機上,或同一個機房的不同物理機上,或同一個城市的不同機房里,或不同城市里,不同的部署代表不同的容災能力,
例如:人類由無數人組成,生活在同一個地球的不同洲上,這意味著人類不具備星球級別的隔離能力,當地球出現毀滅性影響時,人類是不具備容災的,
2.10 冪等設計(可重入)
所謂冪等,簡單地說,就是對介面的多次呼叫所產生的結果和呼叫一次是一致的,資料發生改變才需要做冪等,有些介面是天然保證冪等性的,
比如查詢介面,有些對資料的修改是一個常量,并且無其他記錄和操作,那也可以說是具有冪等性的,其他情況下,所有涉及對資料的修改、狀態的變更就都有必要防止重復性操作的發生,實作介面的冪等性可防止重復操作所帶來的影響,
重復請求很容易發生,比如用戶誤觸,超時重試等,舉個最簡單的例子,那就是支付,用戶購買商品后支付,支付扣款成功,但是回傳結果時網路例外(超時成功),此時錢已經扣了,用戶再次點擊按鈕,此時會進行第二次扣款,回傳結果成功,用戶查詢余額返發現多扣錢了,流水記錄也變成了兩條,就沒有保證介面的冪等性,
2.11 故障自愈
沒有 100% 可靠的系統,故障不可避免,但要有自愈能力,
人體擁有強大的自愈能力,比如手指劃破流血,會自動止血,結痂,再到皮膚再生,微服務應該像人體一樣,當面對非毀滅性傷害(故障)時,在不借助外力的情況下,自行修復故障,比如訊息處理或異步邏輯等非關鍵操作失敗引發的資料不一致,需要有最終一致的修復操作,如兜底的定時任務,失敗重試佇列,或由用戶在下次請求時觸發修復邏輯,
2.12 CAP 定理
2000 年,加州大學伯克利分校的計算機科學家 Eric Brewer 在分布式計算原理研討會(PODC)上提出了一個猜想,分布式系統有三個指標:
一致性(Consistency)
可用性(Availability)
磁區容錯性(Partition tolerance)
它們的第一個字母分別是 C、A、P,

Eric Brewer 說,這三個指標最多只能同時實作兩點,不可能三者兼顧,這便是著名的布魯爾猜想,
在隨后的 2002 年,麻省理工學院(MIT)的 Seth Gilbert 和 Nancy Lynch 發表了布魯爾猜想的證明,使之成為一個定理,即 CAP 定理,
CAP 定理告訴我們,如果服務是分布式服務,那么不同節點間通信必然存在失敗可能性,即我們必須接受磁區容錯性(P),那么我們必須在一致性(C)和可用性(A)之間做出取舍,即要么 CP,要么 AP,
如果你的服務偏業務邏輯,對接用戶,那么可用性顯得更加重要,應該選擇 AP,遵守 BASE 理論,這是大部分業務服務的選擇,
如果你的服務偏系統控制,對接服務,那么一致性顯得更加重要,應該選擇 CP,遵守 ACID 理論,經典的比如 Zookeeper,
總體來說 BASE 理論面向的是大型高可用、可擴展的分布式系統,與傳統 ACID 特性相反,不同于 ACID 的強一致性模型,BASE 提出通過犧牲強一致性來獲得可用性,并允許資料段時間內的不一致,但是最終達到一致狀態,同時,在實際分布式場景中,不同業務對資料的一致性要求不一樣,因此在設計中,ACID 和 BASE 應做好權衡和選擇,
2.13 BASE 理論
在 CAP 定理的背景下,大部分分布式系統都偏向業務邏輯,面向用戶,那么可用性相對一致性顯得更加重要,如何構建一個高可用的分布式系統,BASE 理論給出了答案,
2008 年,eBay 公司選則把資料庫事務的 ACID 原則放寬,于計算機協會(Association for Computing Machinery,ACM)上發表了一篇文章Base: An Acid Alternative,正式提出了一套 BASE 原則,
BASE 基于 CAP 定理逐步演化而來,其來源于對大型分布式系統實踐的總結,是對 CAP 中一致性和可用性權衡的結果,其核心思想是即使無法做到強一致性,但每個業務根據自身的特點,采用適當的方式來使系統達到最終一致性,BASE 可以看作是 CAP 定理的延伸,
BASE 理論指:
- Basically Available(基本可用)
基本可用就是假設系統出現故障,要保證系統基本可用,而不是完全不能使用,比如采用降級兜底的策略,假設我們在做個性化推薦服務時,需要從用戶中心獲取用戶的個性化資料,以便代入到模型里進行打分排序,但如果用戶中心服務掛掉,我們獲取不到資料了,那么就不推薦了?顯然不行,我們可以在本地 cache 里放置一份熱門商品以便兜底,
- Soft state( 軟狀態)
軟狀態指的是允許系統中的資料存在中間狀態,并認為該狀態不影響系統的整體可用性,即允許系統在多個不同節點的資料副本存在資料延時,
- Eventual consistency(最終一致性)
上面講到的軟狀態不可能一直是軟狀態,必須有時間期限,在期限過后,應當保證所有副本保持資料一致性,從而達到資料的最終一致性,因此所有客戶端對系統的資料訪問最終都能夠獲取到最新的值,而這個時間期限取決于網路延時,系統負載,資料復制方案等因素,
3.高性能
3.1 無鎖
3.1.1 鎖的問題
高性能系統中使用鎖,往往帶來的壞處要大于好處,
并發編程中,鎖帶解決了安全問題,同時也帶來了性能問題,因為鎖讓并發處理變成了串行操作,所以如無必要,盡量不要顯式使用鎖,
鎖和并發,貌似有一種相克相生的關系,

為了避免嚴重的鎖競爭導致性能的下降,有些場景采用了無鎖化設計,特別是在底層框架上,無鎖化主要有兩種實作,無鎖佇列和無鎖資料結構,
3.1.2 串行無鎖
串行無鎖最簡單的實作方式可能就是單執行緒模型了,如 Redis/Nginx 都采用了這種方式,在網路編程模型中,常規的方式是主執行緒負責處理 I/O 事件,并將讀到的資料壓入佇列,作業執行緒則從佇列中取出資料進行處理,這種單 Reactor 多執行緒模型需要對佇列進行加鎖,這種模型叫單 Reactor 多執行緒模型,如下圖所示:

上圖的模式可以改成串行無鎖的形式,當 MainReactor accept 一個新連接之后從眾多的 SubReactor 選取一個進行注冊,通過創建一個 Queue 與 I/O 執行緒進行系結,此后該連接的讀寫都在同一個佇列和執行緒中執行,無需進行佇列的加鎖,這種模型叫主從 Reactor 多執行緒模型,

3.1.3 無鎖資料結構
利用硬體支持的原子操作可以實作無鎖的資料結構,很多語言都提供 CAS 原子操作(如 Go 中的 atomic 包和 C++11 中的 atomic 庫),可以用于實作無鎖資料結構,如無鎖鏈表,
我們以一個簡單的執行緒安全單鏈表的插入操作來看下無鎖編程和普通加鎖的區別,
template<typename T> struct Node { Node(const T &value) : data(value) {} T data; Node *next = nullptr; };
有鎖鏈表 WithLockList:
template<typename T> class WithLockList { mutex mtx; Node<T> *head; public: void pushFront(const T &value) { auto *node = new Node<T>(value); lock_guard<mutex> lock(mtx); // (1) node->next = head; head = node; } };
無鎖鏈表 LockFreeList:
template<typename T> class LockFreeList { atomic<Node<T> *> head; public: void pushFront(const T &value) { auto *node = new Node<T>(value); node->next = head.load(); while(!head.compare_exchange_weak(node->next, node)); // (2) } };
從代碼可以看出,在有鎖版本中 (1) 進行了加鎖,在無鎖版本中,(2) 使用了原子 CAS 操作 compare_exchange_weak,該函式如果存盤成功則回傳 true,同時為了防止偽失敗(即原始值等于期望值時也不一定存盤成功,主要發生在缺少單條比較交換指令的硬體機器上),通常將 CAS 放在回圈中,
下面對有鎖和無鎖版本進行簡單的性能比較,分別執行 1000,000 次 push 操作,測驗代碼如下:
int main() { const int SIZE = 1000000; //有鎖測驗 auto start = chrono::steady_clock::now(); WithLockList<int> wlList; for(int i = 0; i < SIZE; ++i) { wlList.pushFront(i); } auto end = chrono::steady_clock::now(); chrono::duration<double, std::micro> micro = end - start; cout << "with lock list costs micro:" << micro.count() << endl; //無鎖測驗 start = chrono::steady_clock::now(); LockFreeList<int> lfList; for(int i = 0; i < SIZE; ++i) { lfList.pushFront(i); } end = chrono::steady_clock::now(); micro = end - start; cout << "free lock list costs micro:" << micro.count() << endl; return 0; }
三次輸出如下,可以看出無鎖版本有鎖版本性能高一些,
with lock list costs micro:548118 free lock list costs micro:491570 with lock list costs micro:556037 free lock list costs micro:476045 with lock list costs micro:557451 free lock list costs micro:481470
3.1.4 減少鎖競爭
如果加鎖無法避免,則可以采用分片的形式,減少對資源加鎖的次數,這樣也可以提高整體的性能,
比如 Golang 優秀的本地快取組件 bigcache 、go-cache、freecache 都實作了分片功能,每個分片一把鎖,采用分片存盤的方式減少加鎖的次數從而提高整體性能,
以一個簡單的示例,通過對map[uint64]struct{}分片前后并發寫入的對比,來看下減少鎖競爭帶來的性能提升,
var ( num = 1000000 m0 = make(map[int]struct{}, num) mu0 = sync.RWMutex{} m1 = make(map[int]struct{}, num) mu1 = sync.RWMutex{} ) // ConWriteMapNoShard 不分片寫入一個 map, func ConWriteMapNoShard() { g := errgroup.Group{} for i := 0; i < num; i++ { g.Go(func() error { mu0.Lock() defer mu0.Unlock() m0[i] = struct{}{} return nil }) } _ = g.Wait() } // ConWriteMapTwoShard 分片寫入兩個 map, func ConWriteMapTwoShard() { g := errgroup.Group{} for i := 0; i < num; i++ { g.Go(func() error { if i&1 == 0 { mu0.Lock() defer mu0.Unlock() m0[i] = struct{}{} return nil } mu1.Lock() defer mu1.Unlock() m1[i] = struct{}{} return nil }) } _ = g.Wait() }
看下二者的性能差異:
func BenchmarkConWriteMapNoShard(b *testing.B) { for i := 0; i < b.N; i++ { ConWriteMapNoShard() } } BenchmarkConWriteMapNoShard-12 3 472063245 ns/op func BenchmarkConWriteMapTwoShard(b *testing.B) { for i := 0; i < b.N; i++ { ConWriteMapTwoShard() } } BenchmarkConWriteMapTwoShard-12 4 310588155 ns/op
可以看到,通過對分共享資源的分片處理,減少了鎖競爭,能明顯地提高程式的并發性能,可以預見的是,隨著分片粒度地變小,性能差距會越來越大,當然,分片粒度不是越小越好,因為每一個分片都要配一把鎖,那么會帶來很多額外的不必要的開銷,可以選擇一個不太大的值,在性能和花銷上尋找一個平衡,
3.2 快取
3.2.1 為什么要有快取?
資料的訪問具有區域性,符合二八定律:80% 的資料訪問是集中在 20% 的資料上,這部分資料也被稱作熱點資料,
不同層級的存盤訪問速率不同,記憶體讀寫速度快于磁盤,磁盤快于遠端存盤,基于記憶體的存盤系統(如 Redis)高于基于磁盤的存盤系統(如 MySQL),
因為存在熱點資料和存盤訪問速率的不同,我們可以考慮采用快取,
快取快取一般使用記憶體作為本地快取,
必要情況下,可以考慮多級快取,如一級快取采用本地快取,二級快取采用基于記憶體的存盤系統(如 Redis、Memcache 等),
快取是原始資料的一個復制集,其本質就是空間換時間,主要是為了解決高并發讀,
3.2.2 快取的使用場景
快取是空間換時間的藝術,使用快取能提高系統的性能,“勁酒雖好,可不要貪杯”,使用快取的目的是為了提高性價比,而不是一上來就為了所謂的提高性能不計成本的使用快取,而是要看場景,
適合使用快取的場景,以之前參與過的專案企鵝電競為例:(1)一旦生成后基本不會變化的資料:如企鵝電競的游戲串列,在后臺創建一個游戲之后基本很少變化,可直接快取整個游戲串列;
(2)讀密集型或存在熱點的資料:典型的就是各種 App 的首頁,如企鵝電競首頁直播串列;
(3)計算代價大的資料:如企鵝電競的 Top 熱榜視頻,如 7 天榜在每天凌晨根據各種指標計算好之后快取排序串列;
(4)千人一面的資料:同樣是企鵝電競的 Top 熱榜視頻,除了快取的整個排序串列,同時直接在行程內按頁快取了前 N 頁資料組裝后的最侄訓包結果;
不適合使用快取的場景:
(1)寫多讀少,更新頻繁;
(2)對資料一致性要求嚴格,
3.2.3 快取的分類?
(1)行程級快取
快取的資料直接在行程地址空間內,這可能是訪問速度最快使用最簡單的快取方式了,主要缺點是受制于行程空間大小,能快取的資料量有限,行程重啟快取資料會丟失,一般通常用于快取資料量不大的場景,
(2)集中式快取
快取的資料集中在一臺機器上,如共享記憶體,這類快取容量主要受制于機器記憶體大小,而且行程重啟后資料不丟失,常用的集中式快取中間件有單機版 redis、memcache 等,
(3)分布式快取
快取的資料分布在多臺機器上,通常需要采用特定演算法(如 Hash)進行資料分片,將海量的快取資料均勻的分布在每個機器節點上,常用的組件有:Memcache(客戶端分片)、Codis(代理分片)、Redis Cluster(集群分片),
(4)多級快取
指在系統中的不同層級進行資料快取,以提高訪問效率和減少對后端存盤系統的沖擊,
3.2.4 快取的使用模式
關于快取的使用,已經有人總結出了一些模式,主要分為 Cache-Aside 和 Cache-As-SoR 兩類,其中 SoR(System-of-Record)表示記錄系統,即資料源,而 Cache 正是 SoR 的拷貝,
- Cache-Aside:旁路快取
這應該是最常見的快取模式了,對于讀,首先從快取讀取資料,如果沒有命中則回源 SoR 讀取并更新快取,對于寫操作,先寫 SoR,再寫快取,這種模式架構圖如下:

這種模式用起來簡單,但對應用層不透明,需要業務代碼完成讀寫邏輯,同時對于寫來說,寫資料源和寫快取不是一個原子操作,可能出現以下情況導致兩者資料不一致,
(1)在并發寫時,可能出現資料不一致,
如下圖所示,user1 和 user2 幾乎同時進行讀寫,在 t1 時刻 user1 寫 db,t2 時刻 user2 寫 db,緊接著在 t3 時刻 user2 寫快取,t4 時刻 user1 寫快取,這種情況導致 db 是 user2 的資料,快取是 user1 的資料,兩者不一致,
(2)先寫資料源成功,但是接著寫快取失敗,兩者資料不一致,
對于這兩種情況如果業務不能忍受,可簡單的通過先 delete 快取然后再寫 db 解決,其代價就是下一次讀請求的 cache miss,
- Cache-as-SoR:快取即資料源
該模式把 Cache 當作 SoR,所以讀寫操作都是針對 Cache,然后 Cache 再將讀寫操作委托給 SoR,即 Cache 是一個代理,如下圖所示:
有三種實作方式:
(1)Read-Through:稱為穿透讀模式,首先查詢 Cache,如果不命中則再由 Cache 回源到 SoR 即存盤端實作 Cache-Aside 而不是業務),
(2)Write-Through:稱為穿透寫模式,由業務先呼叫寫操作,然后由 Cache 負責寫快取和 SoR,
(3)Write-Behind:稱為回寫模式,發生寫操作時業務只更新快取并立即回傳,然后異步寫 SoR,這樣可以利用合并寫/批量寫提高性能,
3.2.5 快取淘汰策略
在空間有限、低頻熱點訪問或者無主動更新通知的情況下,需要對快取資料進行回收,常用的回收策略有以下幾種:
(1)基于時間:基于時間的策略主要可以分兩種,
- TTL(Time To Live):即存活期,從快取資料創建開始到指定的過期時間段,不管有沒有訪問快取都會過期,如 Redis 的 EXPIRE,
- TTI(Time To Idle):即空閑期,快取在指定的時間沒有被訪問將會被回收,
(2)基于空間:快取設定了存盤空間上限,當達到上限時按照一定的策略移除資料,
(3)基于容量:快取設定了存盤條目上限,當達到上限時按照一定的策略移除資料,
(4)基于參考:基于參考計數或者強弱參考的一些策略進行回收,
快取常見淘汰演算法如下:
- FIFO(First In First Out):先進選出原則,先進入快取的資料先被移除,
- LRU(Least Recently Used):最基于區域性原理,即如果資料最近被使用,那么它在未來也極有可能被使用,反之,如果資料很久未使用,那么未來被使用的概率也較,
- LFU:(Least Frequently Used):最近最少被使用的資料最先被淘汰,即統計每個物件的使用次數,當需要淘汰時,選擇被使用次數最少的淘汰,
3.2.6 快取的崩潰與修復
由于在設計不足、請求攻擊(并不一定是惡意攻擊)等會造成一些快取問題,下面列出了常見的快取問題和解決方案,
- 快取穿透
大量使用不存在的 Key 進行查詢時,快取沒有命中,這些請求都穿透到后端的存盤,最終導致后端存盤壓力過大甚至被壓垮,這種情況原因一般是存盤中資料不存在,主要有三個解決辦法,
(1)設定空置或默認值:如果存盤中沒有資料,則設定一個空置或者默認值快取起來,這樣下次請求時就不會穿透到后端存盤,但這種情況如果遇到惡意攻擊,不斷的偽造不同的 Key 來查詢時并不能很好的應對,這時候需要引入一些安全策略對請求進行過濾,
(2)布隆過濾器:采用布隆過濾器將,將所有可能存在的資料哈希到一個足夠大的 Bitmap 中,一個一定不存在的資料會被這個 Bitmap 攔截掉,從而避免了對底層資料庫的查詢壓力,
(3)singleflight 多個并發請求對一個失效的 Key 進行源資料獲取時,只讓其中一個得到執行,其余阻塞等待到執行的那個請求完成后,將結果傳遞給阻塞的其他請求達到防止擊穿的效果,
- 快取雪崩
指大量的快取在某一段時間內集體失效,導致后端存盤負載瞬間升高甚至被壓垮,通常是以下原因造成:
(1)快取失效時間集中在某段時間,對于這種情況可以采取對不同的 Key 使用不同的過期時間,在原來基礎失效時間的基礎上再加上不同的隨機時間;
(2)采用取模機制的某快取實體宕機,這種情況移除故障實體后會導致大量的快取不命中,有兩種解決方案:(a)采取主從備份,主節點故障時直接將從實體替換主;(b)使用一致性哈希替代取模,這樣即使有實體崩潰也只是少部分快取不命中,
- 快取熱點
雖然快取系統本身性能很高,但也架不住某些熱點資料的高并發訪問從而造成快取服務本身過載,假設一下微博以用戶 ID 作為哈希 Key,突然有一天亦菲姐姐宣布婚了,如果她的微博內容按照用戶 ID 快取在某個節點上,當她的萬千粉絲查看她的微博時必然會壓垮這個快取節點,因為這個 Key 太熱了,這種情況可以通過生成多份快取到不同節點上,每份快取的內容一樣,減輕單個節點訪問的壓力,
3.2.6 快取的一些好實踐
- 動靜分離
對于一個快取物件,可能分為很多種屬性,這些屬性中有的是靜態的,有的是動態的,在快取的時候最好采用動靜分離的方式,以免因經常變動的資料發生更新而要把經常不變的資料也更新至快取,成本很高,
- 慎用大物件
如果快取物件過大,每次讀寫開銷非常大并且可能會卡住其他請求,特別是在 redis 這種單執行緒的架構中,典型的情況是將一堆串列掛在某個 value 的欄位上或者存盤一個沒有邊界的串列,這種情況下需要重新設計資料結構或者分割 value 再由客戶端聚合,
- 過期設定
盡量設定過期時間減少臟資料和存盤占用,但要注意過期時間不能集中在某個時間段,
- 超時設定
快取作為加速資料訪問的手段,通常需要設定超時時間而且超時時間不能過長(如 100ms 左右),否則會導致整個請求超時連回源訪問的機會都沒有,
- 快取隔離
首先,不同的業務使用不同的 Key,防止出現沖突或者互相覆寫,其次,核心和非核心業務進行通過不同的快取實體進行物理上的隔離,
- 失敗降級
使用快取需要有一定的降級預案,快取通常不是關鍵邏輯,特別是對于核心服務,如果快取部分失效或者失敗,應該繼續回源處理,不應該直接中斷回傳,
- 容量控制
使用快取要進行容量控制,特別是本地快取,快取數量太多記憶體緊張時會頻繁的 swap 存盤空間或 GC 操作,從而降低回應速度,
- 業務導向
以業務為導向,不要為了快取而快取,對性能要求不高或請求量不大,分布式快取甚至資料庫都足以應對時,就不需要增加本地快取,否則可能因為引入資料節點復制和冪等處理邏輯反而得不償失,
- 監控告警
對大物件、慢查詢、記憶體占用等進行監控,做到快取可觀測,用得放心,
3.3 異步
3.3.1 呼叫異步
呼叫異步發生在使用異步編程模型來提高代碼效率的時候,實作方式主要有:
- Callback
異步回呼通過注冊一個回呼函式,然后發起異步任務,當任務執行完畢時會回呼叫戶注冊的回呼函式,從而減少呼叫端等待時間,這種方式會造成代碼分散難以維護,定位問題也相對困難;
- Future
當用戶提交一個任務時會立刻先回傳一個 Future,然后任務異步執行,后續可以通過 Future 獲取執行結果;
- CPS(Continuation-passing style)
可以對多個異步編程進行編排,組成更復雜的異步處理,并以同步的代碼呼叫形式實作異步效果,CPS 將后續的處理邏輯當作引數傳遞給 Then 并可以最終捕獲例外,解決了異步回呼代碼散亂和例外跟蹤難的問題,Java 中的 CompletableFuture 和 C++ PPL 基本支持這一特性,典型的呼叫形式如下:
void handleRequest(const Request &req) { return req.Read().Then([](Buffer &inbuf){ return handleData(inbuf); }).Then([](Buffer &outbuf){ return handleWrite(outbuf); }).Finally(){ return cleanUp(); }); }
關于 CPS 更多資訊推薦閱讀:2018 中國 C++ 大會的吳銳_C++服務器開發實踐部分,
3.3.2 流程異步
同步改異步,可以降低主鏈路的處理耗時,
舉個例子,比如我們去 KFC 點餐,遇到排隊的人很多,當點完餐后,大多情況下我們會隔幾分鐘就去問好了沒,反復去問了好幾次才拿到,在這期間我們也沒法干活了,
這個就叫同步輪訓,這樣效率顯然太低了,
服務員被問煩了,就在點完餐后給我們一個號碼牌,每次準備好了就會在服務臺叫號,這樣我們就可以在被叫到的時候再去取餐,中途可以繼續干自己的事,這就叫異步,
3.4 池化
3.4.1 為什么要池化
池化的目的是完成資源復用,避免資源重復創建、洗掉來提高性能,
常見的池子有記憶體池、連接池、執行緒池、物件池...
記憶體、連接、執行緒、物件等都是資源,創建和銷毀這些資源都有一個特征, 那就是會涉及到很多系統呼叫或者網路 IO,每次都在請求中去創建這些資源,會增加處理耗時,但是如果我們用一個 容器(池) 把它們保存起來,下次需要的時候,直接拿出來使用,避免重復創建和銷毀浪費的時間,
3.4.1 記憶體池
我們都知道,在 C/C++ 中分別使用 malloc/free 和 new/delete 進行記憶體的分配,其底層呼叫系統呼叫 sbrk/brk,頻繁的呼叫系統呼叫分配釋放記憶體不但影響性能還容易造成記憶體碎片,記憶體池技術旨在解決這些問題,正是這些原因,C/C++ 中的記憶體操作并不是直接呼叫系統呼叫,而是已經實作了自己的一套記憶體管理,malloc 的實作主要有三大實作,
- ptmalloc:glibc 的實作,
- tcmalloc:Google 的實作,
- jemalloc:Facebook 的實作,
雖然標準庫的實作在作業系統記憶體管理的基礎上再加了一層記憶體管理,但應用程式通常也會實作自己特定的記憶體池,如為了參考計數或者專門用于小物件分配,所以看起來記憶體管理一般分為三個層次,
3.4.2 執行緒池
執行緒創建是需要分配資源的,這存在一定的開銷,如果我們一個任務就創建一個執行緒去處理,這必然會影響系統的性能,執行緒池的可以限制執行緒的創建數量并重復使用,從而提高系統的性能,
執行緒池可以分類或者分組,不同的任務可以使用不同的執行緒組,可以進行隔離以免互相影響,對于分類,可以分為核心和非核心,核心執行緒池一直存在不會被回收,非核心可能對空閑一段時間后的執行緒進行回收,從而節省系統資源,等到需要時在按需創建放入池子中,
3.4.3 連接池
常用的連接池有資料庫連接池、redis 連接池、TCP 連接池等等,其主要目的是通過復用來減少創建和釋放連接的開銷,連接池實作通常需要考慮以下幾個問題:
-
初始化:啟動即初始化和惰性初始化,啟動初始化可以減少一些加鎖操作和需要時可直接使用,缺點是可能造成服務啟動緩慢或者啟動后沒有任務處理,造成資源浪費,惰性初始化是真正有需要的時候再去創建,這種方式可能有助于減少資源占用,但是如果面對突發的任務請求,然后瞬間去創建一堆連接,可能會造成系統回應慢或者回應失敗,通常我們會采用啟動即初始化的方式,
-
連接數目:權衡所需的連接數,連接數太少則可能造成任務處理緩慢,太多不但使任務處理慢還會過度消耗系統資源,
-
連接取出:當連接池已經無可用連接時,是一直等待直到有可用連接還是分配一個新的臨時連接,
-
連接放入:當連接使用完畢且連接池未滿時,將連接放入連接池(包括連接池已經無可用連接時創建的臨時連接),否則關閉,
-
連接檢測:長時間空閑連接和失效連接需要關閉并從連接池移除,常用的檢測方法有:使用時檢測和定期檢測,
3.4.4 物件池
嚴格來說,各種池都是物件池的的具體應用,包括前面介紹的三種池,
物件池跟各種池一樣,也是快取一些物件從而避免大量創建同一個型別的物件,同時限制了實體的個數,如 Redis 中 0-9999 整數物件就通過物件池進行共享,在游戲開發中物件池經常使用,如進入地圖時怪物和 NPC 的出現并不是每次都是重新創建,而是從物件池中取出,
3.5 批量
能批量就不要并發,
如果呼叫方需要呼叫我們介面多次才能進行一個完整的操作,那么這個介面設計就可能有問題,
比如獲取資料的介面,如果僅僅提供getData(int id)介面,那么使用方如果要一次性獲取 20 個資料,它就需要回圈遍歷呼叫我們介面 20 次,不僅使用方性能很差,也無端增加了我們服務的壓力,這時提供一個批量拉取的介面getDataBatch(List<Integer> idList)顯然是必要的,
對于批量介面,我們也要注意介面的吞吐能力,避免長時間執行,
還是以獲取資料的介面為例:getDataList(List<Integer> idList),假設一個用戶一次傳 1w 個 id 進來,那么介面可能需要很長的時間才能處理完,這往往會導致超時,用戶怎么呼叫結果都是超時例外,那怎么辦?限制長度,比如限制長度為 100,即每次最多只能傳 100 個 id,這樣就能避免長時間執行,如果用戶傳的 id 串列長度超過 100 就報例外,
加了這樣限制后,必須要讓使用方清晰地知道這個方法有此限制,盡可能地避免用戶誤用,
有三種方法:
- 改變方法名,比如
getDataListWithLimitLength(List<Integer> idList); - 在介面說明檔案中增加必要的注釋說明;
- 介面明確拋出超長例外,直白告知主調,
3.6 并發
3.6.1 請求并發
如果一個任務需要處理多個子任務,可以將沒有依賴關系的子任務并發化,這種場景在后臺開發很常見,如一個請求需要查詢 3 個資料,分別耗時 T1、T2、T3,如果串行呼叫總耗時 T=T1+T2+T3,對三個任務執行并發,總耗時 T=max(T1,T 2,T3),同理,寫操作也如此,對于同種請求,還可以同時進行批量合并,減少 RPC 呼叫次數,
3.6.2 冗余請求
冗余請求指的是同時向后端服務發送多個同樣的請求,誰回應快就是使用誰,其他的則丟棄,這種策略縮短了主調方的等待時間,但也使整個系統呼叫量猛增,一般適用于初始化或者請求少的場景,比如騰訊公司 WNS 的跑馬模塊其實就是這種機制,跑馬模塊為了快速建立長連接同時向后臺多個 IP/Port 發起請求,誰快就用誰,這在弱網的移動設備上特別有用,如果使用等待超時再重試的機制,無疑將大大增加用戶的等待時間,
這種方式較少使用,知道即可,
3.7 存盤設計
任何一個系統,從單機到分布式,從前端到后臺,功能和邏輯各不相同,但干的只有兩件事:讀和寫,而每個系統的業務特性可能都不一樣,有的側重讀、有的側重寫,有的兩者兼備,本節主要探討在不同業務場景下存盤讀寫的一些方法論,
3.7.1 讀寫分離
大多數業務都是讀多寫少,為了提高系統處理能力,可以采用讀寫分離的方式將主節點用于寫,從節點用于讀,如下圖所示,

讀寫分離架構有以下幾個特點:(1)資料庫服務為主從架構;(2)主節點負責寫操作,從節點負責讀操作;(3)主節點將資料復制到從節點;
基于讀寫分離思想,可以設計出多種主從架構,如主-主-從、主-從-從等,主從節點也可以是不同的存盤,如 MySQL+Redis,
讀寫分離的主從架構一般采用異步復制,會存在資料復制延遲的問題,適用于對資料一致性要求不高的業務,可采用以下幾個方式盡量避免復制滯后帶來的問題,
- 寫后讀一致
即讀自己的寫,適用于用戶寫操作后要求實時看到更新,典型的場景是,用戶注冊賬號或者修改賬戶密碼后,緊接著登錄,此時如果讀請求發送到從節點,由于資料可能還沒同步完成,用戶登錄失敗,這是不可接受的,針對這種情況,可以將自己的讀請求發送到主節點上,查看其他用戶資訊的請求依然發送到從節點,
- 二次讀取
優先讀取從節點,如果讀取失敗或者跟蹤的更新時間小于某個閥值,則再從主節點讀取,
- 區分場景
關鍵業務讀寫主節點,非關鍵業務讀寫分離,
- 單調讀
保證用戶的讀請求都發到同一個從節點,避免出現回滾的現象,如用戶在 M 主節點更新資訊后,資料很快同步到了從節點 S1,用戶查詢時請求發往 S1,看到了更新的資訊,接著用戶再一次查詢,此時請求發到資料同步沒有完成的從節點 S2,用戶看到的現象是剛才的更新的資訊又消失了,即以為資料回滾了,
3.7.2 分庫分表
讀寫分離雖然可以明顯的提示查詢的效率,但是無法解決更高的并發寫入請求的場景,這時候就需要進行分庫分表,提高并發寫入的能力,
通常,在以下情況下需要進行分庫分表:
(1)單表的資料量達到了一定的量級(如 mysql 一般為千萬級),讀寫的性能會下降,這時索引也會很大,性能不佳,需要分解單表,
(2)資料庫吞吐量達到瓶頸,需要增加更多資料庫實體來分擔資料讀寫壓力,
分庫分表按照特定的條件將資料分散到多個資料庫和表中,分為垂直切分和水平切分兩種模式,
- 垂直切分
按照一定規則,如業務或模塊型別,將一個資料庫中的多個表分布到不同的資料庫上,以電商平臺為例,將商品資料、訂單資料、用戶資料分別存盤在不同的資料庫上,如下圖所示:

優點:(1)切分規則清晰,業務劃分明確;(2)可以按照業務的型別、重要程度進行成本管理,擴展也方便;(3)資料維護簡單,
缺點:(1)不同表分到了不同的庫中,無法使用表連接 Join,不過在實際的業務設計中,也基本不會用到 Join 操作,一般都會建立映射表通過兩次查詢或者寫時構造好資料存到性能更高的存盤系統中,(2)事務處理復雜,原本在事務中操作同一個庫的不同表不再支持,這時可以采用柔性事務或者其他分布式事物方案,
- 水平切分
按照一定規則,如哈希或取模,將同一個表中的資料拆分到多個資料庫上,可以簡單理解為按行拆分,拆分后的表結構是一樣的,如用戶資訊記錄,榷訓月累,表會越來越大,可以按照用戶 ID 或者用戶注冊日期進行水平切分,存盤到不同的資料庫實體中,
優點:(1)切分后表結構一樣,業務代碼不需要改動;(2)能控制單表資料量,有利于性能提升,
缺點:(1)Join、count、記錄合并、排序、分頁等問題需要跨節點處理;(2)相對復雜,需要實作路由策略;
綜上所述,垂直切分和水平切分各有優缺點,通常情況下這兩種模式會一起使用,
3.7.3 動靜分離
動靜分離將經常更新的資料和更新頻率低的資料進行分離,最常見于 CDN,一個網頁通常分為靜態資源(圖片/JS/CSS 等)和動態資源(JSP、PHP 等),采取動靜分離的方式將靜態資源快取在 CDN 邊緣節點上,只需請求動態資源即可,減少網路傳輸和服務負載,
在資料庫和 KV 存盤上也可以采取動態分離的方式,動靜分離更像是一種垂直切分,將動態和靜態的欄位分別存盤在不同的庫表中,減小資料庫鎖的粒度,同時可以分配不同的資料庫資源來合理提升利用率,
3.7.4 冷熱分離
冷熱分離可以說是每個存盤產品和海量業務的必備功能,MySQL、ElasticSearch 等都直接或間接支持冷熱分離,將熱資料放到性能更好的存盤設備上,冷資料下沉到廉價的磁盤,從而節約成本,
3.7.5 重寫輕讀
基本思路就是寫入資料時多寫點(冗余寫),降低讀的壓力,
社交平臺中用戶可以互相關注,查看關注用戶的最新訊息,形成 Feed 流,
用戶查看 Feed 流時,系統需要查出此用戶關注了哪些用戶,再查詢這些用戶所發的訊息,按時間排序,
為了滿足高并發的查詢請求,可以采用重寫輕讀,提前為每個用戶準備一個收件箱,
每個用戶都有一個收件箱和一個發件箱,比如一個用戶有 1000 個粉絲,他發布一條訊息時,寫入自己的發件箱即可,后臺異步的把這條訊息放到那 1000 個粉絲的收件箱中,
這樣,用戶讀取 Feed 流時就不需要實時查詢聚合了,直接讀自己的收件箱就行了,把計算邏輯從”讀”移到了”寫”一端,因為讀的壓力要遠遠大于寫的壓力,所以可以讓”寫”幫忙干點活兒,提升整體效率,
上圖展示了一個重寫輕度的一個例子,在實際應用中可能會遇到一些問題,如:
(1)寫擴散:這是個寫擴散的行為,如果一個大 V 的粉絲很多,這寫擴散的代價也是很大的,而且可能有些人萬年不看朋友圈甚至屏蔽了朋友,需要采取一些其他的策略,如粉絲數在某個范圍內是才采取這種方式,數量太多采取推拉結合和分析一些活躍指標等,
(2)信箱容量:一般來說查看 Feed 流(如微信朋友圈)不會不斷的往下翻頁查看,這時候應該限制信箱存盤條目數,超出的條目從其他存盤查詢,
3.7.6 資料異構
資料異構顧名思義就是存盤不同結構的資料,有很多種含義:
- 資料格式的異構
資料的存盤格式不同,可以是關系型(如 MySQL、SQL Server、DB2 等),也可以是 KV 格式(如 Redis、Memcache 等),還可以是檔案行二維資料(如 txt、CSV、XLS 等),
- 資料存盤地點的異構
據存盤在分散的物理位置上,此類情況大多出現在大型機構中,如銷售資料分別存盤在北京、上海、日本、韓國等多個分支機構的本地銷售系統中,
- 資料存盤邏輯的異構
相同的資料按照不同的邏輯來存盤,比如按照不同索引維度來存盤同一份資料,
這里主要說的是按照不同的維度建立索引關系以加速查詢,如京東、天貓等網上商城,一般按照訂單號進行了分庫分表,由于訂單號不在同一個表中,要查詢一個買家或者商家的訂單串列,就需要查詢所有分庫然后進行資料聚合,可以采取構建異構索引,在生成訂單的時同時創建買家和商家到訂單的索引表,這個表可以按照用戶 ID 進行分庫分表,
3.8 零拷貝
3.8.1 為什么要實作零拷貝?
這里的拷貝指的是資料在內核緩沖區和應用程式緩沖區直接的傳輸,并非指行程空間中的記憶體拷貝(當然這方面也可以實作零拷貝,如傳參考和 C++ 中 move 操作),現在假設我們有個服務,提供用戶下載某個檔案,當請求到來時,我們把服務器磁盤上的資料發送到網路中,這個流程偽代碼如下:
filefd = open(...); //打開檔案 sockfd = socket(...); //打開socket buffer = new buffer(...); //創建buffer read(filefd, buffer); //從檔案內容讀到buffer中 write(sockfd, buffer); //將buffer中的內容發送到網路
資料拷貝流程如下圖:

上圖中綠色箭頭表示 DMA copy,DMA(Direct Memory Access)即直接存盤器存取,是一種快速傳送資料的機制,指外部設備不通過 CPU 而直接與系統記憶體交換資料的介面技術,紅色箭頭表示 CPU copy,即使在有 DMA 技術的情況下還是存在 4 次拷貝,DMA copy 和 CPU copy 各 2 次,
3.8.2 記憶體映射
記憶體映射將用戶空間的一段記憶體區域映射到內核空間,用戶對這段記憶體區域的修改可以直接反映到內核空間,同樣,內核空間對這段區域的修改也直接反映用戶空間,簡單來說就是用戶空間共享這個內核緩沖區,
使用記憶體映射來改寫后的偽代碼如下:
filefd = open(...); //打開檔案 sockfd = socket(...); //打開socket buffer = mmap(filefd); //將檔案映射到行程空間 write(sockfd, buffer); //將buffer中的內容發送到網路
使用記憶體映射后資料拷貝流如下圖所示:

從圖中可以看出,采用記憶體映射后資料拷貝減少為 3 次,不再經過應用程式直接將內核緩沖區中的資料拷貝到 Socket 緩沖區中,RocketMQ 為了訊息存盤高性能,就使用了記憶體映射機制,將存盤檔案分割成多個大小固定的檔案,基于記憶體映射執行順序寫,
3.8.3 零拷貝
零拷貝就是一種避免 CPU 將資料從一塊存盤拷貝到另外一塊存盤,從而有效地提高資料傳輸效率的技術,Linux 內核 2.4 以后,支持帶有 DMA 收集拷貝功能的傳輸,將內核頁快取中的資料直接打包發到網路上,偽代碼如下:
filefd = open(...); //打開檔案 sockfd = socket(...); //打開socket sendfile(sockfd, filefd); //將檔案內容發送到網路
使用零拷貝后流程如下圖:

零拷貝的步驟為:
(1)DMA 將資料拷貝到 DMA 引擎的內核緩沖區中,(2)將資料的位置和長度的資訊的描述符加到套接字緩沖區,(3)DMA 引擎直接將資料從內核緩沖區傳遞到協議引擎,
可以看出,零拷貝并非真正的沒有拷貝,還是有 2 次內核緩沖區的 DMA 拷貝,只是消除了內核緩沖區和用戶緩沖區之間的 CPU 拷貝,Linux 中主要的零拷貝系統函式有 sendfile、splice、tee 等,零拷貝比普通傳輸會快很多,如 Kafka 也使用零拷貝技術,
下圖是來住 IBM 官網上普通傳輸和零拷貝傳輸的性能對比,可以看出零拷貝比普通傳輸快了 3 倍左右,

4.易維護
4.1 充分必要
不是隨便一個功能就要有個介面,
雖然一個介面應該只專注一件事,但并不是每一個功能都要新建一個介面,要有充分的理由和考慮,即這個介面的存在是十分有意義和價值的,無意義的介面不僅浪費開發人力,更增加了服務的維護難度,服務將會十分臃腫,
相關功能我們應該考慮合為一個介面來實作,
4.2 單一職責
每個 API 應該只專注做一件事情,
就像我們開發人員一樣,要么從事后臺開發,要么從事前端開發,要么從事服務器運維開發,公司一般不會讓一個人包攬所有的開發作業,因為這讓員工的職責不夠單一,不利于員工在專業領域的深耕,很容易成為萬金油,對公司的影響是因員工對專業知識掌握的不夠深,導致開發出的軟體質量得不到保證,
讓介面的功能保持單一,實作起來不僅簡單,維護起來也會容易很多,不會因為大而全的冗雜功能導致介面經常出錯,
比如讀寫分離和動靜分離的做法都是單一職責原則的具體體現,如果一個介面干了兩件事情,就應該把它分開,因為修改一個功能可能會影響到另一個功能,
4.3 內聚解耦
一個介面要包含完整的業務功能,而不同介面之間的關聯要盡可能的小,
這樣便降低了對其他介面的依賴程度,如此其他介面的變動對當前介面的影響也會降低,一般都是通過訊息中間件 MQ 來完成介面之間的耦合,
4.4 開閉原則
對擴展開放,對修改關閉,
這句話怎么理解呢,也就是說,我們在設計一個介面的時候,應當使這個介面可以在不被修改的前提下被擴展其功能,換句話說,應當可以在不修改源代碼的情況下改變介面的行為,
比如 IM 應用中,當用戶輸入簡介時有個長度限制,我們不應該將長度限制寫死在代碼,可以通過組態檔的方式來動態擴展,這就做到了對擴展開放(用戶簡介長度可以變更),對修改關閉(不需要修改代碼),
此外,在設計模式中模板方法模式和觀察者模式都是開閉原則的極好體現,
4.5 統一原則
介面要具備統一的命名規范、統一的出入參風格、統一的例外處理流程、統一的錯誤碼定義、統一的版本規范等,
統一規范的介面有很多優點,自解釋、易學習,難誤用,易維護等,
4.6 用戶重試
介面失敗時,應該盡可能地由用戶重試,
失敗不可避免,因為介面無法保證 100%成功,一個簡單可靠的例外處理策略便是由用戶重試,而不是由后臺服務進行處理,
還是 IM 應用為例,有這樣的需求場景,群管理員需要拉黑用戶,被拉黑的用戶要先剔出群,且后續不允許加入群,那么拉黑由一個獨立的介面來完成,需要兩個操作,一是將用戶剔出群,二是將用戶寫入群的黑名單存盤,此時兩個操作無法做到事務,也就是我們無法保證兩個操作要么同時成功,要么同時失敗,這種情況下我們該怎么做,既讓介面實作起來簡單,要能滿足需求呢?
我們如果將用戶剔出群放到第一步,那么可能會存在踢出群成功,但是寫入群的黑名單存盤失敗,這種情況下提示用戶拉黑失敗,但卻把用戶給踢出了群,對用戶來說,體驗上是個功能 bug,
秉著用戶盡可能地由用戶重試的原則,我們應該將寫入群的黑名單存盤放到第一步,踢出群放到第二步,并且踢出群作為非關鍵邏輯,允許失敗,因為者可以讓用戶手動將該用戶踢出群,這就給了用戶重試的機會,并且我們的介面在實作上也變得簡單,
如果要引入訊息佇列存盤踢出群的失敗日志,讓后由后臺服務消費重試來保證一定成功,那么實作上將變得復雜且難以維護,不是非常重要的操作,一定不要這么做,
4.7 最小驚訝
代碼應該盡可能避免讓讀者蒙圈,
只需根據需求來設計實作即可,切勿刻意去設計一個復雜無用、華而不實的 API,以免弄巧成拙,一個通俗易懂易維護的 API 比一個炫技復雜難理解的 API 更容易讓人接受,
4.8 避免無效請求
不要傳遞無效請求至下游,
無效請求下游應及早檢測發現并拒絕,可能會引發相關入參無效的告警,混淆視聽且騷擾,我們應避免傳遞無效請求至下游,避免浪費帶寬和計算資源,
換位思考,誰都不想浪費力氣做無用功,
4.9 入參校驗
自己收到的請求要做好入參校驗,及早發現無效請求并拒絕,然后告警,發現垃圾請求后推動上游不要傳遞無效請求至下游,
此時,我們是上游的下游,做好入參校驗,避免做無用功,
4.10 設計模式
適當的使用設計模式,讓我們的代碼更加簡潔、易讀、可擴展,
設計模式(Design Pattern)是一套被反復使用、多人知曉、分類編目、代碼設計經驗的總結,使用設計模式可以帶來如下益處,
- 簡潔,比如單例模式,減少多實體創建維護的成本,獲取實體只需要一個 Get 函式,
- 易讀,業界經驗,多人知曉,如果告知他人自己使用了相應的設計模式實作某個功能,那么他人便大概知曉了你的實作細節,更加容易讀懂你的代碼,
- 可擴展,設計模式不僅能簡潔我們的代碼,還可以增加代碼的可擴展性,比如 Go 推崇的 Option 模式,既避免了書寫不同引數版本的函式,又達到了無限擴增函式引數的效果,增加了函式擴展性,
4.11 禁用 flag 標識
為什么介面不要使用 flag 標識,因為這會使介面變得臃腫,違背單一職責,最終難以維護,
這里說下,我們為什么會使用 flag 標識,
有時,我們需要提供一個讀介面供上游呼叫查詢相關資訊,如主調 A 需要資訊 a,主調 B 需要資訊 b,主調 C 需要資訊 c,主調 D 需要資訊 a 和 b,如果為每個主調獲取資訊都提供單獨的介面,那么介面會變得很多,為了減少介面的數量,我們很容易想到給介面增加多個 flag 引數,每個主調在呼叫介面時攜帶不同的 flag,表明需要獲取哪些資訊,然后介面根據入參 flag 獲取對應的資訊,比如主調 A 呼叫時將 flag_a 置為 true,主調 B 將 flag_b 置為 true,主調 C 將 flag_c 置為 true,主調 D 將 flag_a 和 flag_c 置為 true,
在專案前期或者 flag 數量較少的情況下,介面功能不是很多時,一般不會暴露出問題,一但開了這個口子,隨著需要不同資訊主調的增多,介面會不停的增加 flag,最終導致介面變得龐大臃腫,不僅難以閱讀維護,還會使介面性能低下,
所以,我們應該禁用 flag 標識,盡可能地保證介面功能單一,
回到上面提到的場景,不適用 flag 標識,我們改如何是好呢?
我們應該堅持單一職責的原則,將資訊進行原子分割,每個原子資訊作為一個獨立的介面對外提供服務,如果需要多個原子資訊,我們可以增加一個 proxy 層,以獨立介面將需要的相關原子資訊匯聚組合,這么做你可能會問,介面變多了,會導致服務難以維護,不用擔心,如果服務介面數量過多,我們應該對服務進行拆分,
還是以上面提及的例子為例,介面禁用 flag 前后組織形式對比如下:

4.12 頁宜小不宜大
對于設計和實作 API 來說,當結果集包含成千上萬條記錄時,回傳一個查詢的所有結果可能是一個挑戰,它給服務器、客戶端和網路帶來了不必要的壓力,于是就有了分頁的功能,
通常我們通過一個 offset 偏移量或者頁碼來進行分頁,然后通過 API 一頁一頁的查詢,
那么頁大小設為多少合適呢?
常見的頁大小有 50,100,200 和 500,如何選擇頁大小,我們應該在滿足特定業務場景需求下,宜少不宜多,
太大的頁,主要有以下幾個問題:
- 影響用戶體驗,頁太大,加載會比較慢,用戶等待時間會比較長;
- 影響介面性能,頁太大,會增加資料的拉取編解碼耗時,降低介面性能;
- 浪費帶寬,很多場景下,用戶在瀏覽的程序中,不會看完一頁中的所有資料,回傳太大的頁是一種浪費;
- 擴展性差,隨著業務的發展,介面在頁大小不變的情況下,回傳的頁資料可能會越來越大,導致介面性能越來越差,最終拖垮介面,
頁大小多少合適,沒有標準答案,需要根據具體的業務場景來定,但是要堅持一點,頁宜小不宜大,如果介面的頁大小,能用 50 便可滿足業務需求,就不要用 100 和 200,更不要用 500,
5.低風險
道路千萬條,安全第一條,雖然很多時候感覺網路攻擊和安全事故離我們很遠,但一旦發生,后面不堪設想,所以服務介面的安全問題是設計實作程序中不得不考慮的一環,
下面將列舉常見的服務介面面臨的安全問題與應對策略,來加固我們的服務,降低安全風險,
5.1 防 XSS
5.1.1 簡介
XSS(Cross Site Scripting)名為跨站腳本攻擊,因其縮寫會與層疊樣式表(Cascading Style Sheets,CSS)混淆,故將其縮寫為 XSS,
XSS 漏洞是 Web 安全中最為常見的漏洞,通常指的是通過利用網頁開發時留下的漏洞,通過巧妙的方法注入惡意指令代碼到網頁中,使用戶加載并執行攻擊者惡意制造的網頁程式,這些惡意網頁程式通常是 JavaScript,但實際上也可以包括 Java、 VBScript、ActiveX、 Flash,甚至是普通的 HTML,攻擊成功后,攻擊者可能得到包括但不限于更高的權限(如執行一些操作)、私密網頁內容、會話和 Cookie 等各種內容,
XSS 本質是 HTML 注入,
5.1.2 分類
XSS 攻擊通常可以分為 3 類:存盤型(持久型)、反射型(非持久型)、DOM 型,
-
存盤型 XSS 危害直接,跨站代碼存盤在服務器,如在個人資訊或發表文章的地方加入代碼,如果沒有過濾或過濾不嚴,那么這些代碼將儲存到服務器中,每當有用戶訪問該頁面的時候都會觸發代碼執行,
-
反射型 XSS 最為普遍,反射型跨站腳本漏洞,需要欺騙用戶去點擊鏈接才能觸發 XSS 代碼,一般容易出現在搜索頁面,用戶打開帶有惡意代碼的 URL 時,網站服務端將惡意代碼從 URL 中取出,拼接在 HTML 中回傳給瀏覽器,用戶瀏覽器接收到回應后決議執行,混在其中的惡意代碼也被執行,
反射型和存盤型 XSS 的區別是:存盤型 XSS 的惡意代碼存在資料庫里,反射型 XSS 的惡意代碼存在 URL 里,
- 基于 DOM 的 XSS 通過修改原始的客戶端代碼,受害者瀏覽器的 DOM 環境改變,導致惡意腳本的執行,也就是說,頁面本身并沒有變化,但由于 DOM 環境被惡意修改,有客戶端代碼被包含進了頁面,并且意外執行,DOM 型 XSS 攻擊,實際上就是網站前端 JavaScript 代碼本身不夠嚴謹,把不可信的資料當作代碼執行了,
DOM 型 XSS 跟前兩種 XSS 的區別:DOM 型 XSS 攻擊中,取出和執行惡意代碼由瀏覽器端完成,屬于前端 JavaScript 自身的安全漏洞,而其他兩種 XSS 都屬于服務端的安全漏洞,
5.1.3 防御措施
通過前面的介紹可以得知,XSS 攻擊有兩大要素:
- 攻擊者提交惡意代碼,
- 瀏覽器執行惡意代碼,
XSS 攻擊主要是由程式漏洞造成的,要完全防止 XSS 安全漏洞主要依靠程式員較高的編程能力和安全意識,當然安全的軟體開發流程及其他一些編程安全原則也可以大大減少 XSS 安全漏洞的發生,這些防范 XSS 漏洞原則包括:
預防存盤型和反射型 XSS 攻擊
存盤型和反射型 XSS 都是在服務端取出惡意代碼后,插入到回應 HTML 里的,攻擊者刻意撰寫的“資料”被內嵌到“代碼”中,被瀏覽器所執行,
預防這兩種漏洞,常見做法:
- 輸入校驗,
不信任 UGC(用戶提交的任何內容),對所有用戶提交內容進行驗證,包括對 URL、查詢關鍵字、HTTP 頭、REFER、POST 資料等,僅接受指定長度范圍內、采用適當格式、采用所預期的字符的內容提交,對其他的一律過濾,
- 改成純前端渲染,把代碼和資料分隔開,
純前端渲染的程序:
(1)瀏覽器先加載一個靜態 HTML,此 HTML 中不包含任何跟業務相關的資料,
(2)然后瀏覽器執行 HTML 中的 JavaScript,
(3)JavaScript 通過 Ajax 加載業務資料,呼叫 DOM API 更新到頁面上,
在純前端渲染中,我們會明確的告訴瀏覽器:下面要設定的內容是文本(.innerText),還是屬性(.setAttribute),還是樣式(.style)等等,瀏覽器不會被輕易的被欺騙,執行預期外的代碼了,
在很多內部、管理系統中,采用純前端渲染是非常合適的,但對于性能要求高,或有 SEO 需求的頁面,我們仍然要面對拼接 HTML 的問題,
- 拼接 HTML 時轉義
如果拼接 HTML 是必要的,就需要采用合適的轉義庫,對 HTML 模板各處插入點進行充分的轉義,常用的模板引擎,如 doT.js、ejs、FreeMarker 等,對于 HTML 轉義通常只有一個規則,就是把& < > " ' /這幾個字符轉義掉,確實能起到一定的 XSS 防護作用,但并不完善,
| XSS 安全漏洞 | 簡單轉義是否有防護作用 |
|---|---|
| HTML 標簽文字內容 | 有 |
| HTML 屬性值 | 有 |
| CSS 行內樣式 | 無 |
| 行內 JavaScript | 無 |
| 行內 JSON | 無 |
| 跳轉鏈接 | 無 |
所以要完善 XSS 防護措施,我們要使用更完善更細致的轉義策略,
預防 DOM 型 XSS 攻擊
DOM 型 XSS 攻擊,實際上就是網站前端 JavaScript 代碼本身不夠嚴謹,把不可信的資料當作代碼執行了,
在使用 .innerHTML、.outerHTML、document.write() 時要特別小心,不要把不可信的資料作為 HTML 插到頁面上,而應盡量使用 .textContent、.setAttribute() 等,
如果用 Vue/React 技術堆疊,并且不使用 v-html/dangerouslySetInnerHTML 功能,就在前端 render 階段避免 innerHTML、outerHTML 的 XSS 隱患,
DOM 中的行內事件監聽器,如 location、onclick、onerror、onload、onmouseover 等,<a>標簽的 href 屬性,JavaScript 的 eval()、setTimeout()、setInterval() 等,都能把字串作為代碼運行,如果不可信的資料拼接到字串中傳遞給這些 API,很容易產生安全隱患,請務必避免,
<!-- 行內事件監聽器中包含惡意代碼 --> <img onclick="UNTRUSTED" one rror="UNTRUSTED" src="data:image/png,"> <!-- 鏈接內包含惡意代碼 --> <a href="UNTRUSTED">1</a> <script> // setTimeout()/setInterval() 中呼叫惡意代碼 setTimeout("UNTRUSTED") setInterval("UNTRUSTED") // location 呼叫惡意代碼 location.href = 'UNTRUSTED' // eval() 中呼叫惡意代碼 eval("UNTRUSTED") </script>
如果專案中有用到這些的話,一定要避免在字串中拼接不可信資料,
其他手段
- Content Security Policy
嚴格的 CSP 在 XSS 的防范中可以起到以下的作用:(1)禁止加載外域代碼,防止復雜的攻擊邏輯,(2)禁止外域提交,網站被攻擊后,用戶的資料不會泄露到外域,(3)禁止行內腳本執行(規則較嚴格,目前發現 GitHub 使用),(4)禁止未授權的腳本執行(新特性,Google Map 移動版在使用),(5)合理使用上報可以及時發現 XSS,利于盡快修復問題,
- HTTP-only Cookie
禁止 JavaScript 讀取某些敏感 Cookie,攻擊者完成 XSS 注入后也無法竊取此 Cookie,
- 驗證碼
防止腳本冒充用戶提交危險操作
- 主動檢測和發現
(1)使用通用 XSS 攻擊字串手動檢測 XSS 漏洞,(2)使用掃描工具自動檢測 XSS 漏洞,例如 Arachni、Mozilla HTTP Observatory、w3af 等,
5.1.4 小結
防范 XSS 是不只是服務端的任務,需要后端和前端共同參與的系統工程,雖然很難通過技術手段完全避免 XSS,但通過上面的做法可以有效減少漏洞的產生和 XSS 攻擊帶來的影響,
5.2 防 CSRF
5.2.1 簡介
CSRF(Cross Site Request Forgery)名為跨站請求偽造,是一種挾制用戶在當前已登錄的 Web 應用程式上執行非本意的操作的攻擊方法,
攻擊者盜用了你的身份,以你的名義發送惡意請求,對服務器來說這個請求是完全合法的,但是卻完成了攻擊者所期望的一個操作,比如以你的名義發送郵件、發訊息,盜取你的賬號,添加系統管理員,甚至于購買商品、虛擬貨幣轉賬等,
一個典型的 CSRF 攻擊有著如下的流程:
- 受害者登錄 a.com,并保留了登錄憑證(Cookie),
- 攻擊者引誘受害者訪問了 b.com,
- b.com 向 a.com 發送了一個請求:a.com/act,瀏覽器會默認攜帶 a.com 的 Cookie,
- a.com 接收到請求后,對請求進行驗證,并確認是受害者的憑證,誤以為是受害者自己發送的請求,
- a.com 以受害者的名義執行了 act,
攻擊完成,攻擊者在受害者不知情的情況下,冒充受害者,讓 a.com 執行了自己定義的操作,
5.2.2 示例
假如一家銀行用以運行轉賬操作的 URL 地址如下:
https://www.examplebank.com/withdraw?account=AccoutName&amount=1000&for=PayeeName
那么,一個惡意攻擊者可以在另一個網站上放置如下代碼:
<img src="https://www.examplebank.com/withdraw?account=Alice&amount=1000&for=Badman">
如果有賬戶名為 Alice 的用戶訪問了惡意站點,當圖片被加載時,圖片鏈接將被觸發,而她之前剛訪問過銀行不久,登錄資訊尚未過期,那么她就會損失 1000 資金,
這種惡意的網址可以有很多種形式,藏身于網頁中的許多地方,此外,攻擊者也不需要控制放置惡意網址的網站,例如他可以將這種地址藏在論壇,博客等任何用戶生成內容的網站中,這意味著如果服務端沒有合適的防御措施的話,用戶即使訪問熟悉的可信網站也有受攻擊的危險,
透過例子能夠看出,攻擊者并不能通過 CSRF 攻擊來直接獲取用戶的賬戶控制權,也不能直接竊取用戶的任何資訊,他們能做到的,是欺騙用戶瀏覽器,讓其以用戶的名義運行操作,
5.2.3 防御措施
CSRF 通常從第三方網站發起,被攻擊的網站無法防止攻擊發生,只能通過增強自己網站針對 CSRF 的防護能力來提升安全性,
上文中講了 CSRF 的兩個特點:
- CSRF(通常)發生在第三方域名,
- CSRF 攻擊者不能獲取到 Cookie 等資訊,只是使用,
針對這兩點,我們可以專門制定防護策略,如下:
- 阻止不明外域的訪問 (1)同源檢測 (2)Samesite Cookie
- 提交時要求附加本域才能獲取的資訊 (1)CSRF Token (2)雙重 Cookie 驗證
以下我們對各種防護方法做詳細說明,
(1)同源檢測:驗證 HTTP Referer 欄位,
根據 HTTP 協議,在 HTTP 頭中有一個欄位叫 Referer,它記錄了該 HTTP 請求的來源地址,
以上文銀行操作為例,Referer 欄位地址通常應該是轉賬按鈕所在的網頁地址,應該也位于 www.examplebank.com 之下,而如果是 CSRF 攻擊傳來的請求,Referer 欄位會包含惡意網址的地址,不會位于 www.examplebank.com 之下,這時候服務器就能識別出惡意的訪問,
這種辦法簡單易行,作業量低,僅需要在關鍵訪問處增加一步校驗,但這種辦法也有其局限性,因其完全依賴瀏覽器發送正確的 Referer 欄位,雖然 HTTP 協議對此欄位的內容有明確的規定,但并無法保證來訪的瀏覽器的具體實作,亦無法保證瀏覽器沒有安全漏洞影響到此欄位,并且也存在攻擊者攻擊某些瀏覽器,篡改其 Referer 欄位的可能,
(2)Samesite Cookie,為了從源頭上解決這個問題,Google 起草了一份草案來改進 HTTP 協議,那就是為 Set-Cookie 回應頭新增 Samesite 屬性,它用來標明這個 Cookie 是個“同站 Cookie”,同站 Cookie 只能作為第一方 Cookie,不能作為第三方 Cookie,Samesite 有兩個屬性值,分別是 Strict 和 Lax,
Samesite=Strict 這種稱為嚴格模式,表明這個 Cookie 在任何情況下都不可能作為第三方 Cookie,比如說 a.com 設定了如下 Cookie:
Set-Cookie: foo=1; Samesite=Strict
Set-Cookie: bar=2; Samesite=Lax
Set-Cookie: baz=3
我們在 b.com 下發起對 a.com 的任意請求,foo 這個 Cookie 都不會被包含在 Cookie 請求頭中,但 bar 會,
Samesite=Lax 這種稱為寬松模式,比 Strict 放寬了點限制:假如這個請求是這種請求(改變了當前頁面或者打開了新頁面)且同時是個 GET 請求,則這個 Cookie 可以作為第三方 Cookie,比如說 a.com 設定了如下 Cookie:
Set-Cookie: foo=1; Samesite=Strict
Set-Cookie: bar=2; Samesite=Lax
Set-Cookie: baz=3
當用戶從 b.com 點擊鏈接進入 a.com 時,foo 這個 Cookie 不會被包含在 Cookie 請求頭中,但 bar 和 baz 會,也就是說用戶在不同網站之間通過鏈接跳轉是不受影響了,但假如這個請求是從 b.com 發起的對 a.com 的異步請求,或者頁面跳轉是通過表單的 post 提交觸發的,則 bar 也不會發送,
(3)CSRF Token,CSRF 攻擊之所以能夠成功,是因為黑客可以完全偽造用戶的請求,該請求中所有的用戶驗證資訊都是存在于 Cookie 中,因此黑客可以在不知道這些驗證資訊的情況下直接利用用戶自己的 Cookie 來通過安全驗證,
要抵御 CSRF,關鍵在于在請求中放入黑客所不能偽造的資訊,并且該資訊不存在于 Cookie 之中,可以在 HTTP 請求中以引數的形式加入一個隨機產生的 Token,并在服務器端建立一個攔截器來驗證這個 Token,如果請求中沒有 Token 或者 Token 內容不正確,則認為可能是 CSRF 攻擊而拒絕該請求,
Token 一般由服務端生成(也可以由前端生成),一般 Token 由隨機字串和時間戳組合后通過哈希運算獲得,用戶首次加載頁面時由服務端回傳給前端,顯然在提交時 Token 不能再放在 Cookie 中了,否則又會被攻擊者冒用,因此,為了安全起見 ,前端在訪問后臺介面時,可以把 Token 放到如下三個地方:
- query
- header
- request body
(4)雙重 Cookie 驗證,在會話中存盤 CSRF Token 比較繁瑣,而且不能在通用的攔截上統一處理所有的介面,
那么另一種防御措施是使用雙重提交 Cookie,利用 CSRF 攻擊不能獲取到用戶 Cookie 的特點,我們可以要求 Ajax 和表單請求攜帶一個 Cookie 中的值,
雙重 Cookie 采用以下流程:
- 在用戶訪問網站頁面時,向請求域名注入一個 Cookie,內容為隨機字串(例如 csrfcookie=v8g9e4ksfhw),
- 在前端向后端發起請求時,取出 Cookie,并添加到 URL 的引數中(接上例 POST https://www.a.com/comment?csrfcookie=v8g9e4ksfhw),
- 后端介面驗證 Cookie 中的欄位與 URL 引數中的欄位是否一致,不一致則拒絕,
此方法相對于 CSRF Token 就簡單了許多,可以直接通過前后端攔截的的方法自動化實作,后端校驗也更加方便,只需進行請求中欄位的對比,而不需要再進行查詢和存盤 Token,
5.2.4 小結
CSRF 和 XSS 完全是兩種不同的 Web 攻擊手段,所以有著不同的應對方法,二者的主要區別有:(1)XSS 本質是 HTML 注入,和 SQL 注入差不多,而 CSRF 則是冒充用戶發起非法請求;(2)CSRF 需要用戶登錄后完成攻擊,XSS 不需要,
5.3 防 SQL 注入
什么是 SQL 注入?SQL 注入攻擊是通過將惡意的 SQL 陳述句插入到應用的輸入引數中,再在后臺 SQL 服務器上決議執行進行的攻擊,它目前黑客對資料庫進行攻擊的最常用手段之一,
為什么要防 SQL 注入?如果用戶輸入的資料被構造成惡意 SQL 代碼,程式又未對動態構造的 SQL 陳述句使用的引數進行審查,則會帶來意想不到的危險,
- 篡改后臺資料
- 盜取敏感資訊
如何防 SQL 注入?這是開發人員應該思考的問題,作為測驗人員,了解如何預防 SQL 注入,可以在發現注入攻擊 Bug 時,對 Bug 產生原因進行定位,
- 嚴格檢查輸入變數的型別和格式,對于整數引數,加判斷條件:不能為空、引數型別必須為數字,對于字串引數,可以使用正則運算式進行過濾:如 [0-9a-zA-Z] 范圍內的字串,
- 過濾和轉義特殊字符,對用戶輸入的 SQL 引數進行轉義,如
' " / * #等特殊字符, - 使用引數化查詢(Parameterized Query)而非手動拼接 SQL,不僅可以防止 SQL 注入,還可以避免重復編譯 SQL 帶來性能提升,具體是怎樣防止 SQL 注入的呢?實際上當將系結的引數傳到 MySQL 服務器,MySQL 服務器對引數進行編譯,即填充到相應的占位符的程序中,做了轉義操作,
5.4 防刷
為什么要防刷?
后臺服務介面都應該有一個合理的請求速度,尤其對于來自真人請求的介面,如果單個用戶短時間內對某個介面的請求量很大,很有可能介面被惡意強刷或客戶端請求邏輯有問題,
比如 IM 應用中的加好友請求,正常用戶請求頻次不會超過 1/s,如果每秒鐘有 10+ 次加好友的請求,那么說明介面很有可能被刷了,
介面被刷,不管是讀還是寫介面,都會對后臺服務造成巨大壓力,嚴重的可能會導致服務不可用,
所以,我們應該對介面做適當的限頻,提早拒絕非法請求,
如何防刷?
可以通過介面限頻來應對被刷,介面請求頻次的統計一般有如下維度:
- 基于用戶 ID
- 基于 IP
- 基于設備 ID
每個介面應該有不同的合理閾值,這個需要結合具體的業務場景來定,
這個功能為服務介面的公共功能,建議做在網關層或單獨的安全層,
5.5 防篡改
什么是篡改?在一次客戶端與服務端的請求程序中,從請求方到接收方中間要經過很多路由器和交換機,黑客可以在中途截獲請求的資料,篡改請求內容后再發往服務端,比如中間人攻擊,假設在一個網上存款系統中,一條訊息表示用戶的一筆轉賬,攻擊者完全可以多次將收款賬號改為自己的賬號后再將請求發到服務端,
為什么要防篡改?假如客戶端與服務端采用的是 HTTPS 協議,雖然 HTTPS 協議可以將傳輸的明文進行加密,但是黑客仍然可以截獲傳輸的資料包,進一步偽造請求進行重放攻擊,如果黑客使用特殊手段讓請求方設備使用了偽造的證書進行通信,那么 HTTPS 加密的內容也會被解密,
在 API 介面中我們除了使用 HTTPS 協議進行通信外,還需要有自己的一套加解密機制,對請求的引數進行保護,防止被篡改,
如何防篡改?對請求包進行簽名可以有效的防篡改,
具體程序如下:
- 客戶端使用約定好的秘鑰對傳輸的引數進行加密,得到簽名值 signature1,一般使用 HMAC
- 客戶端將簽名值也放入請求的引數中,發送請求給服務端,
- 服務端接收到客戶端的請求,然后使用約定好的秘鑰對請求的引數再次進行簽名,得到簽名值 signature2,
- 服務端比對 signature1 和 signature2 的值,如果對比一致,認定為合法請求,如果對比不一致,說明引數被篡改,為非法請求,
因為黑客不知道簽名的密鑰,所以即使截取到請求資料,對請求引數進行篡改,但是卻無法對引數進行簽名,無法得到修改后引數的簽名值 signature,
5.6 防重放
什么是重放?如果惡意用戶抓取真實的介面請求包,不停地發起重復請求,這就是對介面的重放,
為什么要重放?介面重放一般是針對寫介面的惡意請求,讀介面不會有什么影響,比如發帖,發訊息這種寫介面,如果不防重放,會出現很多垃圾內容和騷擾訊息,
如何防重放?防重放的目的是不允許讓相同內容的請求重復發起,對于一個具體的請求,我們可以限制某個請求的生命周期,如果超過其生命周期,認定為非法請求,這樣便起到了防重放的效果,
具體做法是:
- 客戶端基于"請求內容+時間戳+密鑰"計算一個簽名 signature1,一般使用 HMAC,
- 客戶端請求后臺介面時帶上簽名 signature1,
- 后臺拿到簽名后,會使用相同的演算法計算出一個簽名與前端帶來的簽名做比較,如果不一致,說明請求非法,直接拒絕,
因為黑客不知道簽名秘鑰,沒有辦法生成新的簽名,
以上做法需要注意幾個問題:
- 簽名計算使用的演算法可能會被壞人破解,因為對于 APP 或桌面應用,壞人可以反匯編獲取,
- 簽名計算時使用密鑰需要保存在客戶端本地,可能會有泄露的風險,因為對于 APP 或桌面應用,壞人可以反匯編獲取,
- 終端使用的時間戳是由后臺回傳的,這樣防止前后端的本地時間不一致導致生成的簽名,
- 不適用于 Web 應用,壞人是可以直接查看網頁原始碼獲取簽名計算使用的演算法和密鑰,
如果要嚴格做到一段時間內某個請求只能被請求一次,需要對請求進行次數的統計,會用到后臺存盤,實作起來會復雜一點,不過一般不需要這么做,
這個功能為服務介面的公共功能,建議做在網關層或單獨的安全層,
5.7 防 DDoS
什么是 DDoS 攻擊?DDoS(Distributed Denial of Service)是分布式拒絕服務攻擊,攻擊者利用分散在各地的設備發出海量實際上并不需要的互聯網流量,耗盡目標的資源,造成正常流量無法到達其預定目的地或目標服務被壓垮無法提供正常服務,
可能我舉個例子會更加形象點,
我開了一家有五十個座位的重慶火鍋店,由于用料上等,童叟無欺,平時門庭若市,生意特別紅火,而對面二狗家的火鍋店卻無人問津,二狗為了對付我,想了一個辦法,叫了五十個人來我的火鍋店坐著卻不點菜,讓別的客人無法吃飯,上面這個例子講的就是典型的 DDoS 攻擊,一般來說是指攻擊者利用“肉雞”對目標網站在較短的時間內發起大量請求,大規模消耗目標網站的主機資源,讓它無法正常服務,因為“肉雞”分散在各地,有分布式的特性,所以叫分布式拒絕服務攻擊,
在線游戲、互聯網金融等領域是 DDoS 攻擊的高發行業,
為什么要防 DDoS?DDoS 攻擊帶來的危害輕微的會降低目標服務的質量,增加回應延遲,嚴重的直接導致目標服務崩潰,無法提供服務,所以必須要防 DDoS 攻擊,
常見的 DDoS 攻擊有哪些?
- 網路層攻擊
(1)ICMP Flood 攻擊,ICMP Flood 攻擊屬于流量型的攻擊方式,是利用大的流量給服務器帶來較大的負載,影響服務器的正常服務,由于目前很多防火墻直接過濾 ICMP 報文,因此 ICMP Flood 出現的頻度較低,
(2)UDP 反射攻擊 DNS 反射攻擊是一種常見的攻擊媒介,網路犯罪分子通過偽裝其目標的 IP 地址,向開放的 DNS 服務器發送大量請求,作為回應,這些 DNS 服務器通過偽造的 IP 地址回應惡意請求,大量的 DNS 答復形成洪流,從而構成預定目標的攻擊,很快,通過 DNS 答復產生的大量流量就會造成受害企業的服務不堪重負、無法使用,并造成合法流量無法到達其預定目的地,
如 NTP Flood 攻擊,這類攻擊主要利用大流量擁塞被攻擊者的網路帶寬,導致被攻擊者的業務無法正常回應客戶訪問,
- 傳輸層攻擊
(1)SYN Flood 攻擊,SYN Flood 攻擊是當前網路上最為常見的 DDoS 攻擊,它利用了 TCP 協議實作上的一個缺陷,通過向網路服務所在埠發送大量的偽造源地址的攻擊報文,就可能造成目標服務器中的半連接佇列被占滿,從而阻止其他合法用戶進行訪問,
(2)Connection Flood 攻擊,Connection Flood 是典型的利用小流量沖擊大帶寬網路服務的攻擊方式,這種攻擊的原理是利用真實的 IP 地址向服務器發起大量的連接,并且建立連接之后很長時間不釋放,占用服務器的資源,造成服務器上殘余連接(WAIT 狀態)過多,效率降低,甚至資源耗盡,無法回應其他客戶所發起的鏈接,
(3)UDP Flood 攻擊,UDP Flood 是日漸猖厥的流量型 DDoS 攻擊,原理也很簡單,常見的情況是利用大量 UDP 小包沖擊 DNS 服務器或 Radius 認證服務器、流媒體視頻服務器,由于 UDP 協議是一種無連接的服務,在 UDP Flood 攻擊中,攻擊者可發送大量偽造源 IP 地址的小 UDP 包,
- 會話層攻擊
(1)SSL 連接攻擊,比較典型的攻擊型別是 SSL 連接攻擊,這類攻擊占用服務器的 SSL 會話資源從而達到拒絕服務的目的,
- 應用層攻擊
(1)HTTP Get 攻擊,和服務器建立正常的 TCP 連接之后,不斷地向后端服務介面發起 Get 請求,壓垮后臺服務,這種攻擊的特點是可以繞過普通的防火墻防護,可通過 Proxy 代理實施攻擊,
(2)UDP DNS Query Flood 攻擊 UDP DNS Query Flood 攻擊采用的方法是向被攻擊的服務器發送大量的域名決議請求,通常請求決議的域名是隨機生成或者是網路世界上根本不存在的域名,域名決議的程序給服務器帶來了很大的負載,每秒鐘域名決議請求超過一定的數量就會造成 DNS 服務器決議域名超時,
如何防 DDoS?DDoS 防御是保障系統安全運行的必要舉措,雖然不屬于服務介面層面需要考慮的事情,但是知道相關的防御措施還是很有必要的,
防御 DDoS 攻擊的策略方法,包括但不限于:
(1)定期檢查服務器漏洞,定期檢查服務器軟體安全漏洞,是確保服務器安全的最基本措施,無論是作業系統(Windows 或 linux),還是網站常用應用軟體(mysql、Apache、nginx、FTP 等),服務器運維人員要特別關注這些軟體的最新漏洞動態,出現高危漏洞要及時打補丁修補,
(2)隱藏服務器真實 IP,通過 CDN 節點中轉加速服務,可以有效的隱藏網站服務器的真實 IP 地址,CDN 服務根據網站具體情況進行選擇,對于普通的中小企業站點或個人站點可以先使用免費的 CDN 服務,比如百度云加速、七牛 CDN 等,待網站流量提升了,需求高了之后,再考慮付費的 CDN 服務,
其次,防止服務器對外傳送資訊泄漏 IP 地址,最常見的情況是,服務器不要使用發送郵件功能,因為郵件頭會泄漏服務器的 IP 地址,如果非要發送郵件,可以通過第三方代理(例如 sendcloud)發送,這樣對外顯示的 IP 是代理的 IP 地址,
(3)關閉不必要的服務或埠,這也是服務器運維人員最常用的做法,在服務器防火墻中,只開啟使用的埠,比如網站 Web 服務的 80 埠、資料庫的 3306 埠、SSH 服務的 22 埠等,關閉不必要的服務或埠,在路由器上過濾假 IP,
(4)購買高防服務器提高承受能力,該措施是通過購買高防的盾機,提高服務器的帶寬等資源,來提升自身的承受攻擊能力,一些知名 IDC 服務商都有相應的服務提供,比如阿里云、騰訊云等,但該方案成本預算較高,對于普通中小企業甚至個人站長并不合適,且不被攻擊時造成服務器資源閑置,所以這里不過多闡述,
(5)限制 SYN/ICMP 流量,用戶應在路由器上配置 SYN/ICMP 的最大流量來限制 SYN/ICMP 封包所能占有的最高頻寬,這樣,當出現大量的超過所限定的 SYN/ICMP 流量時,說明不是正常的網路訪問,而是有黑客入侵,早期通過限制 SYN/ICMP 流量是最好的防范 DOS 的方法,雖然目前該方法對于 DDoS 效果不太明顯了,不過仍然能夠起到一定的作用,
(6)黑名單,對于惡意流量,將 IP 或 IP 段拉黑,
(7)DDoS 清洗,DDoS 清洗會對用戶請求資料進行實時監控,及時發現 DOS 攻擊等例外流量,在不影響正常業務開展的情況下清洗掉這些例外流量,
(8)CDN 加速,CDN 指的是網站的靜態內容分發到多個服務器,用戶就近訪問,提高速度,因此,CDN 也是帶寬擴容的一種方法,可以用來防御 DDoS 攻擊,
5.8 小結
道高一尺,魔高一丈,沒有絕對的安全,我們能做的就是盡可能地提高壞人作惡的門檻,讓我們的系統變得更加安全可靠,
6.小結
好的服務是設計出來的,而不是維護出來的,
優秀的設計原則告訴我們如何寫出好的服務來應對千變萬化的業務場景,
所有事物都不是 100% 可靠的,服務亦是如此,但遵守優秀的設計原則讓我們的服務距離 100% 可靠更近一步,
參考文獻
Google Cloud API Desgin Guide
知乎.怎么理解軟體設計中的開閉原則?
微服務的 4 個設計原則和 19 個解決方案
博客園.如何健壯你的后端服務?
高可用的本質
一文搞懂后臺高性能服務器設計的常見套路, BAT 高頻面試系列
【架構】高可用高并發系統設計原則
CAP 定理的含義 - 阮一峰的網路日志
CAP 理論該怎么理解?為什么是三選二?為什么是 CP 或者 AP?面試題有哪些?
mmap 詳解
Base: An Acid Alternative
Cache Usage Patterns - Ehcache
Securing APIs: 10 Ways to Keep Your Data and Infrastructure Safe
前端安全系列(一):如何防止 XSS 攻擊?
前端安全系列(二):如何防止 CSRF 攻擊?
SQL 注入攻擊常見方式及測驗方法 | CSDN 博客
API 介面設計:防引數篡改+防二次請求 | 騰訊云
什么是 DDoS 攻擊?| 知乎
DDoS 攻擊是什么? 如何防止 DDos 攻擊?| SegmentFault
本文來自博客園,作者:古道輕風,轉載請注明原文鏈接:https://www.cnblogs.com/88223100/p/Microservice-Interface-Design-Principles.html
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/501541.html
標籤:架構設計
上一篇:單例模式
下一篇:設計模式之裝飾器模式
