天天看點

微服務接口設計原則

作者:閃念基因
本文結合自身背景開發經驗,從高可用、高性能、易維護和低風險(安全)角度出發,嘗試總結業界常見微服務接口設計原則,幫助大家設計出優秀的微服務。

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" onerror="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 攻擊有着如下的流程:

  1. 受害者登入 a.com,并保留了登入憑證(Cookie)。
  2. 攻擊者引誘受害者通路了 b.com。
  3. b.com 向 a.com 發送了一個請求:a.com/act。浏覽器會預設攜帶 a.com 的 Cookie。
  4. a.com 接收到請求後,對請求進行驗證,并确認是受害者的憑證,誤以為是受害者自己發送的請求。
  5. 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 采用以下流程:

  1. 在使用者通路網站頁面時,向請求域名注入一個 Cookie,内容為随機字元串(例如 csrfcookie=v8g9e4ksfhw)。
  2. 在前端向後端發起請求時,取出 Cookie,并添加到 URL 的參數中(接上例 POST https://www.a.com/comment?csrfcookie=v8g9e4ksfhw)。
  3. 後端接口驗證 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 協定進行通信外,還需要有自己的一套加解密機制,對請求的參數進行保護,防止被篡改。

如何防篡改?對請求包進行簽名可以有效的防篡改。

具體過程如下:

  1. 用戶端使用約定好的秘鑰對傳輸的參數進行加密,得到簽名值 signature1,一般使用 HMAC
  2. 用戶端将簽名值也放入請求的參數中,發送請求給服務端。
  3. 服務端接收到用戶端的請求,然後使用約定好的秘鑰對請求的參數再次進行簽名,得到簽名值 signature2。
  4. 服務端比對 signature1 和 signature2 的值,如果對比一緻,認定為合法請求。如果對比不一緻,說明參數被篡改,為非法請求。

因為黑客不知道簽名的密鑰,是以即使截取到請求資料,對請求參數進行篡改,但是卻無法對參數進行簽名,無法得到修改後參數的簽名值 signature。

5.6 防重放

什麼是重放?如果惡意使用者抓取真實的接口請求包,不停地發起重複請求,這就是對接口的重放。

為什麼要重放?接口重放一般是針對寫接口的惡意請求,讀接口不會有什麼影響。比如發帖,發消息這種寫接口,如果不防重放,會出現很多垃圾内容和騷擾消息。

如何防重放?防重放的目的是不允許讓相同内容的請求重複發起。對于一個具體的請求,我們可以限制某個請求的生命周期,如果超過其生命周期,認定為非法請求,這樣便起到了防重放的效果。

具體做法是:

  1. 用戶端基于"請求内容+時間戳+密鑰"計算一個簽名 signature1,一般使用 HMAC。
  2. 用戶端請求背景接口時帶上簽名 signature1。
  3. 背景拿到簽名後,會使用相同的算法計算出一個簽名與前端帶來的簽名做比較,如果不一緻,說明請求非法,直接拒絕。

因為黑客不知道簽名秘鑰,沒有辦法生成新的簽名。

以上做法需要注意幾個問題:

  • 簽名計算使用的算法可能會被壞人破解。因為對于 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://mp.weixin.qq.com/s/3UNL1EZfgfGkQcdbwuMj8Q

繼續閱讀