從0開始學架構(二)

 此係列文章爲極客時間0開始學架構學習後感悟總結,雖然隔了一段時間了,那麼就再看一遍而且進行感悟昇華,排版格式上有問題,後期再複習時也會進行更新web

 

一.    高性能數據庫集羣:讀寫分離算法

讀寫分離的基本原理是將數據庫讀寫操做分散到不一樣的節點上。數據庫

數據庫服務器搭建主從集羣,一主一從、一主多從均可以編程

數據庫主機負責讀寫操做,從機只負責讀操做後端

數據庫主機經過複製將數據同步到從機,每臺數據庫服務器都存儲了全部的業務數據瀏覽器

業務服務器將寫操做發給數據庫主機,將讀操做發給數據庫從機緩存

 

         從代碼層面與運維層面實現讀寫分離很簡單並不複雜,複雜來源於讀寫分離實現後引出的兩個問題。複製延遲、分配機制。安全

複製延遲體現於,從主機寫入數據後因爲複製須要時間,此時馬上查詢從機沒法獲取剛纔新寫加的數據,對應的解決方式有3種:服務器

  1. 寫操做後的讀操做指定發給數據庫主服務器。這種方式對代碼侵入性大,容易引發bug
  2. 讀從機失敗後再讀一次主機。這種方式只須要對底層數據庫訪問的 API 進行封裝便可,實現代價較小,不足之處在於若是有不少二次讀取,將大大增長主機的讀操做壓力
  3. 關鍵業務讀寫操做所有指向主機,非關鍵業務採用讀寫分離。這種方式可能形成的影響就是非關鍵業務更新較慢一點,可是核心業務有保證。

分配機制通常則爲兩種選型,程序代碼封裝與中間件封裝。網絡

  1. 程序代碼封裝的特色爲,實現簡單、可定製、不通用、故障狀況下可能須要修改並重啓
  2. 中間件封裝的特色爲,有標準的接口、可通用、增長了運維成本

 

二.    高性能數據庫集羣:分庫分表

業務分庫指的是按照業務模塊將數據分散到不一樣的數據庫服務器。例如,一個簡單的電商網站,包括用戶、商品、訂單三個業務模塊,咱們能夠將用戶數據、商品數據、訂單數據分開放到三臺不一樣的數據庫服務器上,而不是將全部數據都放在一臺數據庫服務器上。相應的也會帶來一些問題

 

  1. join 操做問題。業務分庫後,本來在同一個數據庫中的表分散到不一樣數據庫中,致使沒法使用 SQL 的 join 查詢
  2. 事務問題。本來在同一個數據庫中不一樣的表能夠在同一個事務中修改,業務分庫後,表分散到不一樣的數據庫中,沒法經過事務統一修改。雖然數據庫廠商提供了一些分佈式事務的解決方案(例如,MySQL 的 XA),但性能實在過低,與高性能存儲的目標是相違背的
  3. 成本問題。業務分庫同時也帶來了成本的代價,原本 1 臺服務器搞定的事情,如今要 3 臺,若是考慮備份,那就是 2 臺變成了 6 臺

既然有問題,那麼咱們對應的解決方式呢,咱們回想架構設計三原則:合適、簡單、演化,對應的問題一會兒就豁然開朗了

  1. 業務初期,沒有必要大規模分庫
  2. 等業務發展成功後,再進行分庫
  3. 單機數據庫的承受能力是小規模公司徹底達不到的

 

將不一樣業務數據分散存儲到不一樣的數據庫服務器,可以支撐百萬甚至千萬用戶規模的業務,但若是業務繼續發展,同一業務的單表數據也會達到單臺數據庫服務器的處理瓶頸,此時就須要對單表數據進行拆分。

垂直分表適合將表中某些不經常使用且佔了大量空間的列拆分出去。垂直分表引入的複雜性主要體如今表操做的數量要增長。所以要區分好經常使用與不經常使用字段

水平分表適合錶行數特別大的表,有的公司要求單錶行數超過 5000 萬就必須進行分表,這個數字能夠做爲參考,但並非絕對標準,關鍵仍是要看錶的訪問性能。對於一些比較複雜的表,可能超過 1000 萬就要分表了;而對於一些簡單的表,即便存儲數據超過 1 億行,也能夠不分表。但無論怎樣,當看到表的數據量達到千萬級別時,做爲架構師就要警覺起來,由於這極可能是架構的性能瓶頸或者隱患。引出的問題有:

  1. 路由。水平分表後,某條數據具體屬於哪一個切分後的子表,須要增長路由算法進行計算,這個算法會引入必定的複雜性。

    a)         範圍路由:範圍路由的優勢是能夠隨着數據的增長平滑地擴充新的表,一個比較隱含的缺點是分佈不均勻

    b)         Hash 路由:Hash 路由的優缺點和範圍路由基本相反,Hash 路由的優勢是表分佈比較均勻,缺點是擴充新的表很麻煩,全部數據都要重分佈

    c)         配置路由:配置路由設計簡單,使用起來很是靈活,尤爲是在擴充表的時候,只須要遷移指定的數據,而後修改路由表就能夠了,缺點就是必須多查詢一次,會影響總體性能;並且路由表自己若是太大(例如,幾億條數據),性能一樣可能成爲瓶頸,若是咱們再次將路由表分庫分表,則又面臨一個死循環式的路由算法選擇問題。

  2.Join操做。水平分表後,數據分散在多個表中,若是須要與其餘表進行 join 查詢,須要在業務代碼或者數據庫中間件中進行屢次 join 查詢,而後將結果合併。

  3.Count操做。水平分表後,雖然物理上數據分散到多個表中,但某些業務邏輯上仍是會將這些表看成一個表來處理

    a)         count相加:具體作法是在業務代碼或者數據庫中間件中對每一個表進行 count() 操做,而後將結果相加。這種方式實現簡單,缺點就是性能比較低。

    b)         記錄數表:具體作法是新建一張表,假如表名爲「記錄數表」,包含 table_name、row_count 兩個字段,每次插入或者刪除子表數據成功後,都更新「記錄數表」。

  4.order by操做。水平分表後,數據分散到多個子表中,排序操做沒法在數據庫中完成,只能由業務代碼或者數據庫中間件分別查詢每一個子表中的數據,而後彙總進行排序

 

三.    NoSql

關係數據庫通過幾十年的發展後已經很是成熟,強大的 SQL 功能和 ACID 的屬性,使得關係數據庫普遍應用於各式各樣的系統中,但這並不意味着關係數據庫是完美的,關係數據庫存在以下缺點:

  1. 關係數據庫存儲的是行記錄,沒法存儲數據結構
  2. 關係數據庫的 schema 擴展很不方便
  3. 關係數據庫在大數據場景下 I/O 較高
  4. 關係數據庫的全文搜索功能比較弱

針對上述問題,分別誕生了不一樣的 NoSQL 解決方案,這些方案與關係數據庫相比,在某些應用場景下表現更好。但世上沒有免費的午飯,NoSQL 方案帶來的優點,本質上是犧牲 ACID 中的某個或者某幾個特性,所以咱們不能盲目地迷信 NoSQL 是銀彈,而應該將 NoSQL 做爲 SQL 的一個有力補充,NoSQL != No SQL,而是 NoSQL = Not Only SQL

  1. K-V 存儲:解決關係數據庫沒法存儲數據結構的問題,以 Redis 爲表明。
  2. 文檔數據庫:解決關係數據庫強 schema 約束的問題,以 MongoDB 爲表明。
  3. 列式數據庫:解決關係數據庫大數據場景下的 I/O 問題,以 HBase 爲表明。
  4. 全文搜索引擎:解決關係數據庫的全文搜索性能問題,以 Elasticsearch 爲表明。

K-V 存儲

K-V 存儲的全稱是 Key-Value 存儲,其中 Key 是數據的標識,和關係數據庫中的主鍵含義同樣,Value 就是具體的數據。

Redis 是 K-V 存儲的典型表明,它是一款開源(基於 BSD 許可)的高性能 K-V 緩存和存儲系統。Redis 的 Value 是具體的數據結構,包括 string、hash、list、set、sorted set、bitmap 和 hyperloglog,因此經常被稱爲數據結構服務器。

Redis支持的操做在關係型數據庫中實現很麻煩,並且須要進行屢次 SQL 操做,性能很低。例如:刪除第一個值並更新全部的id

Redis 的缺點主要體如今並不支持完整的 ACID 事務,Redis 雖然提供事務功能,但 Redis 的事務和關係數據庫的事務不可同日而語,Redis 的事務只能保證隔離性和一致性(I 和 C),沒法保證原子性和持久性(A 和 D)。雖然 Redis 並無嚴格遵循 ACID 原則,但實際上大部分業務也不須要嚴格遵循 ACID 原則。

 

文檔數據庫

爲了解決關係數據庫 schema 帶來的問題,文檔數據庫應運而生。文檔數據庫最大的特色就是 no-schema,能夠存儲和讀取任意的數據。目前絕大部分文檔數據庫存儲的數據格式是 JSON(或者 BSON),由於 JSON 數據是自描述的,無須在使用前定義字段,讀取一個 JSON 中不存在的字段也不會致使 SQL 那樣的語法錯誤。

優勢有

  1. 新增字段簡單
  2. 歷史數據不會出錯
  3. 能夠很容易存儲複雜數據

缺點是

  1. 不支持事務。
  2. 沒法實現關係數據庫的 join 操做

 

 

列式數據庫

顧名思義,列式數據庫就是按照列來存儲數據的數據庫,與之對應的傳統關係數據庫被稱爲「行式數據庫」,由於關係數據庫是按照行來存儲數據的。

優勢是

  1. 業務同時讀取多個列時效率高,由於這些列都是按行存儲在一塊兒的,一次磁盤操做就可以把一行數據中的各個列都讀取到內存中。
  2. 可以一次性完成對一行中的多個列的寫操做,保證了針對行數據寫操做的原子性和一致性;不然若是採用列存儲,可能會出現某次寫操做,有的列成功了,有的列失敗了,致使數據不一致
  3. 高壓縮率

缺點在於必須在特定場景下才能發揮它的優點

 

全文搜索引擎

全文搜索引擎的技術原理被稱爲「倒排索引」(Inverted index),也常被稱爲反向索引、置入檔案或反向檔案,是一種索引方法,其基本原理是創建單詞到文檔的索引。之因此被稱爲「倒排」索引,是和「正排「索引相對的,「正排索引」的基本原理是創建文檔到單詞的索引。

正排索引示例:

倒排索引示例:

四.    高性能緩存架構

雖然咱們能夠經過各類手段來提高存儲系統的性能,但在某些複雜的業務場景下,單純依靠存儲系統的性能提高不夠的,典型的場景有:

  1. 須要通過複雜運算後得出的數據,存儲系統無能爲力
  2. 讀多寫少的數據,存儲系統有心無力

緩存就是爲了彌補存儲系統在這些複雜業務場景下的不足,其基本原理是將可能重複使用的數據放到內存中,一次生成、屢次使用,避免每次使用都去訪問存儲系統。緩存可以帶來性能的大幅提高,以 Memcache 爲例,單臺 Memcache 服務器簡單的 key-value 查詢可以達到 TPS 50000 以上,其基本的架構是:

緩存雖然可以大大減輕存儲系統的壓力,但同時也給架構引入了更多複雜性。

緩存穿透

緩存穿透是指緩存沒有發揮做用,業務系統雖然去緩存查詢數據,但緩存中沒有數據,業務系統須要再次去存儲系統查詢數據。一般狀況下有兩種狀況:

  1. 存儲數據不存在

第一種狀況是被訪問的數據確實不存在。通常狀況下,若是存儲系統中沒有某個數據,則不會在緩存中存儲相應的數據,這樣就致使用戶查詢的時候,在緩存中找不到對應的數據,每次都要去存儲系統中再查詢一遍,而後返回數據不存在。緩存在這個場景中並無起到分擔存儲系統訪問壓力的做用。一般狀況下,業務上讀取不存在的數據的請求量並不會太大,但若是出現一些異常狀況,例如被黑客攻擊,故意大量訪問某些讀取不存在數據的業務,有可能會將存儲系統拖垮。這種狀況的解決辦法比較簡單,若是查詢存儲系統的數據沒有找到,則直接設置一個默認值(能夠是空值,也能夠是具體的值)存到緩存中,這樣第二次讀取緩存時就會獲取到默認值,而不會繼續訪問存儲系統。

  1. 緩存數據生成耗費大量時間或者資源

第二種狀況是存儲系統中存在數據,但生成緩存數據須要耗費較長時間或者耗費大量資源。若是恰好在業務訪問的時候緩存失效了,那麼也會出現緩存沒有發揮做用,訪問壓力所有集中在存儲系統上的狀況。典型的就是電商的商品分頁,假設咱們在某個電商平臺上選擇「手機」這個類別查看,因爲數據巨大,不能把全部數據都緩存起來,只能按照分頁來進行緩存,因爲難以預測用戶到底會訪問哪些分頁,所以業務上最簡單的就是每次點擊分頁的時候按分頁計算和生成緩存。一般狀況下這樣實現是基本知足要求的,可是若是被競爭對手用爬蟲來遍歷的時候,系統性能就可能出現問題。這種狀況並無太好的解決方案,由於爬蟲會遍歷全部的數據,並且何時來爬取也是不肯定的,多是天天都來,也多是每週,也多是一個月來一次,咱們也不可能爲了應對爬蟲而將全部數據永久緩存。一般的應對方案要麼就是識別爬蟲而後禁止訪問,但這可能會影響 SEO 和推廣;要麼就是作好監控,發現問題後及時處理,由於爬蟲不是攻擊,不會進行暴力破壞,對系統的影響是逐步的,監控發現問題後有時間進行處理。

 

緩存雪崩

緩存雪崩是指當緩存失效(過時)後引發系統性能急劇降低的狀況。當緩存過時被清除後,業務系統須要從新生成緩存,所以須要再次訪問存儲系統,再次進行運算,這個處理步驟耗時幾十毫秒甚至上百毫秒。而對於一個高併發的業務系統來講,幾百毫秒內可能會接到幾百上千個請求。因爲舊的緩存已經被清除,新的緩存還未生成,而且處理這些請求的線程都不知道另外有一個線程正在生成緩存,所以全部的請求都會去從新生成緩存,都會去訪問存儲系統,從而對存儲系統形成巨大的性能壓力。這些壓力又會拖慢整個系統,嚴重的會形成數據庫宕機,從而造成一系列連鎖反應,形成整個系統崩潰。

 

  1. 更新鎖:對緩存更新操做進行加鎖保護,保證只有一個線程可以進行緩存更新,未能獲取更新鎖的線程要麼等待鎖釋放後從新讀取緩存,要麼就返回空值或者默認值。對於採用分佈式集羣的業務系統,因爲存在幾十上百臺服務器,即便單臺服務器只有一個線程更新緩存,但幾十上百臺服務器一塊兒算下來也會有幾十上百個線程同時來更新緩存,一樣存在雪崩的問題。所以分佈式集羣的業務系統要實現更新鎖機制,須要用到分佈式鎖,如 ZooKeeper。
  2. 後臺更新:由後臺線程來更新緩存,而不是由業務線程來更新緩存,緩存自己的有效期設置爲永久,後臺線程定時更新緩存。後臺定時機制須要考慮一種特殊的場景,當緩存系統內存不夠時,會「踢掉」一些緩存數據,從緩存被「踢掉」到下一次定時更新緩存的這段時間內,業務線程讀取緩存返回空值,而業務線程自己又不會去更新緩存,所以業務上看到的現象就是數據丟了。

 

緩存熱點

緩存熱點的解決方案就是複製多份緩存副本,將請求分散到多個緩存服務器上,減輕緩存熱點致使的單臺緩存服務器壓力。

雖然緩存系統自己的性能比較高,但對於一些特別熱點的數據,若是大部分甚至全部的業務請求都命中同一份緩存數據,則這份數據所在的緩存服務器的壓力也很大。例如,某明星微博發佈「咱們」來宣告戀愛了,短期內上千萬的用戶都會來圍觀。對於粉絲數超過 100 萬的明星,每條微博均可以生成 100 份緩存,緩存的數據是同樣的,經過在緩存的 key 裏面加上編號進行區分,每次讀緩存時都隨機讀取其中某份緩存。緩存副本設計有一個細節須要注意,就是不一樣的緩存副本不要設置統一的過時時間,不然就會出現全部緩存副本同時生成同時失效的狀況,從而引起緩存雪崩效應。正確的作法是設定一個過時時間範圍,不一樣的緩存副本的過時時間是指定範圍內的隨機值。

 

五.    單服務器高性能模式:PPC與TPC

單服務器高性能的關鍵之一就是服務器採起的併發模型,併發模型有以下兩個關鍵設計點:

  1. 服務器如何管理鏈接。
  2. 服務器如何處理請求。

以上兩個設計點最終都和操做系統的 I/O 模型及進程模型相關。

  1. I/O 模型:阻塞、非阻塞、同步、異步。
  2. 進程模型:單進程、多進程、多線程。

 

PPC模式

PPC 是 Process Per Connection 的縮寫,其含義是指每次有新的鏈接就新建一個進程去專門處理這個鏈接的請求,這是傳統的 UNIX 網絡服務器所採用的模型。基本的流程圖是:

  1. 父進程接受鏈接(圖中 accept)。
  2. 父進程「fork」子進程(圖中 fork)。
  3. 子進程處理鏈接的讀寫請求(圖中子進程 read、業務處理、write)。
  4. 子進程關閉鏈接(圖中子進程中的 close)。

注意,圖中有一個小細節,父進程「fork」子進程後,直接調用了 close,看起來好像是關閉了鏈接,其實只是將鏈接的文件描述符引用計數減一,真正的關閉鏈接是等子進程也調用 close 後,鏈接對應的文件描述符引用計數變爲 0 後,操做系統纔會真正關閉鏈接。

PPC 模式實現簡單,比較適合服務器的鏈接數沒那麼多的狀況,例如數據庫服務器。對於普通的業務服務器,在互聯網興起以前,因爲服務器的訪問量和併發量並無那麼大,這種模式其實運做得也挺好,世界上第一個 web 服務器 CERN httpd 就採用了這種模式。互聯網興起後,服務器的併發和訪問量從幾十劇增到成千上萬,這種模式的弊端就凸顯出來了,主要體如今這幾個方面:

  1. fork 代價高:站在操做系統的角度,建立一個進程的代價是很高的,須要分配不少內核資源,須要將內存映像從父進程複製到子進程。即便如今的操做系統在複製內存映像時用到了 Copy on Write(寫時複製)技術,整體來講建立進程的代價仍是很大的。
  2. 父子進程通訊複雜:父進程「fork」子進程時,文件描述符能夠經過內存映像複製從父進程傳到子進程,但「fork」完成後,父子進程通訊就比較麻煩了,須要採用 IPC(Interprocess Communication)之類的進程通訊方案。例如,子進程須要在 close 以前告訴父進程本身處理了多少個請求以支撐父進程進行全局的統計,那麼子進程和父進程必須採用 IPC 方案來傳遞信息。
  3. 支持的併發鏈接數量有限:若是每一個鏈接存活時間比較長,並且新的鏈接又源源不斷的進來,則進程數量會愈來愈多,操做系統進程調度和切換的頻率也愈來愈高,系統的壓力也會愈來愈大。所以,通常狀況下,PPC 方案能處理的併發鏈接數量最大也就幾百。

 

Prefork模式

PPC 模式中,當鏈接進來時才 fork 新進程來處理鏈接請求,因爲 fork 進程代價高,用戶訪問時可能感受比較慢,prefork 模式的出現就是爲了解決這個問題。顧名思義,prefork 就是提早建立進程(pre-fork)。系統在啓動的時候就預先建立好進程,而後纔開始接受用戶的請求,當有新的鏈接進來的時候,就能夠省去 fork 進程的操做,讓用戶訪問更快、體驗更好。prefork 的基本示意圖是:

prefork 的實現關鍵就是多個子進程都 accept 同一個 socket,當有新的鏈接進入時,操做系統保證只有一個進程能最後 accept 成功。但這裏也存在一個小小的問題:「驚羣」現象,就是指雖然只有一個子進程能 accept 成功,但全部阻塞在 accept 上的子進程都會被喚醒,這樣就致使了沒必要要的進程調度和上下文切換了。幸運的是,操做系統能夠解決這個問題,例如 Linux 2.6 版本後內核已經解決了 accept 驚羣問題。prefork 模式和 PPC 同樣,仍是存在父子進程通訊複雜、支持的併發鏈接數量有限的問題,所以目前實際應用也很少。Apache 服務器提供了 MPM prefork 模式,推薦在須要可靠性或者與舊軟件兼容的站點時採用這種模式,默認狀況下最大支持 256 個併發鏈接。

 

TPC模式

TPC 是 Thread Per Connection 的縮寫,其含義是指每次有新的鏈接就新建一個線程去專門處理這個鏈接的請求。與進程相比,線程更輕量級,建立線程的消耗比進程要少得多;同時多線程是共享進程內存空間的,線程通訊相比進程通訊更簡單。所以,TPC 其實是解決或者弱化了 PPC fork 代價高的問題和父子進程通訊複雜的問題。

  1. 父進程接受鏈接(圖中 accept)。
  2. 父進程建立子線程(圖中 pthread)。
  3. 子線程處理鏈接的讀寫請求(圖中子線程 read、業務處理、write)。
  4. 子線程關閉鏈接(圖中子線程中的 close)。

注意,和 PPC 相比,主進程不用「close」鏈接了。緣由是在於子線程是共享主進程的進程空間的,鏈接的文件描述符並無被複制,所以只須要一次 close 便可。TPC 雖然解決了 fork 代價高和進程通訊複雜的問題,可是也引入了新的問題,具體表如今:

  1. 建立線程雖然比建立進程代價低,但並非沒有代價,高併發時(例如每秒上萬鏈接)仍是有性能問題。
  2. 無須進程間通訊,可是線程間的互斥和共享又引入了複雜度,可能一不當心就致使了死鎖問題。
  3. 多線程會出現互相影響的狀況,某個線程出現異常時,可能致使整個進程退出(例如內存越界)。

除了引入了新的問題,TPC 仍是存在 CPU 線程調度和切換代價的問題。所以,TPC 方案本質上和 PPC 方案基本相似,在併發幾百鏈接的場景下,反而更多地是採用 PPC 的方案,由於 PPC 方案不會有死鎖的風險,也不會多進程互相影響,穩定性更高。

 

Prethread模式

好熟悉的名詞,與上面那個貌似有殊途同歸之妙。和 prefork 相似,prethread 模式會預先建立線程,而後纔開始接受用戶的請求,當有新的鏈接進來的時候,就能夠省去建立線程的操做,讓用戶感受更快、體驗更好。因爲多線程之間數據共享和通訊比較方便,所以實際上 prethread 的實現方式相比 prefork 要靈活一些,常見的實現方式有下面幾種:

  1. 主進程 accept,而後將鏈接交給某個線程處理。
  2. 子線程都嘗試去 accept,最終只有一個線程 accept 成功,方案的基本示意圖以下:

Apache 服務器的 MPM worker 模式本質上就是一種 prethread 方案,但稍微作了改進。Apache 服務器會首先建立多個進程,每一個進程裏面再建立多個線程,這樣作主要是爲了考慮穩定性,即:即便某個子進程裏面的某個線程異常致使整個子進程退出,還會有其餘子進程繼續提供服務,不會致使整個服務器所有掛掉。prethread 理論上能夠比 prefork 支持更多的併發鏈接,Apache 服務器 MPM worker 模式默認支持 16 × 25 = 400 個併發處理線程。

 

六.    單服務器高性能模式:Reactor與Proactor

上一節爲單服務器高性能的 PPC 和 TPC 模式,它們的優勢是實現簡單,缺點是都沒法支撐高併發的場景,尤爲是互聯網發展到如今,各類海量用戶業務的出現,PPC 和 TPC 徹底無能爲力。所以咱們引出應對高併發場景的單服務器高性能架構模式:Reactor 和 Proactor。

Reactor

PPC 模式最主要的問題就是每一個鏈接都要建立進程(,鏈接結束後進程就銷燬了,這樣作實際上是很大的浪費。爲了解決這個問題,一個天然而然的想法就是資源複用,即再也不單獨爲每一個鏈接建立進程,而是建立一個進程池,將鏈接分配給進程,一個進程能夠處理多個鏈接的業務。引入資源池的處理方式後,會引出一個新的問題:進程如何才能高效地處理多個鏈接的業務?當一個鏈接一個進程時,進程能夠採用「read -> 業務處理 -> write」的處理流程,若是當前鏈接沒有數據能夠讀,則進程就阻塞在 read 操做上。這種阻塞的方式在一個鏈接一個進程的場景下沒有問題,但若是一個進程處理多個鏈接,進程阻塞在某個鏈接的 read 操做上,此時即便其餘鏈接有數據可讀,進程也沒法去處理,很顯然這樣是沒法作到高性能的。解決這個問題的最簡單的方式是將 read 操做改成非阻塞,而後進程不斷地輪詢多個鏈接。這種方式可以解決阻塞的問題,但解決的方式並不優雅。首先,輪詢是要消耗 CPU 的;其次,若是一個進程處理幾千上萬的鏈接,則輪詢的效率是很低的。爲了可以更好地解決上述問題,很容易能夠想到,只有當鏈接上有數據的時候進程纔去處理,這就是 I/O 多路複用技術的來源。I/O 多路複用技術概括起來有兩個關鍵實現點:

  1. 當多條鏈接共用一個阻塞對象後,進程只須要在一個阻塞對象上等待,而無須再輪詢全部鏈接,常見的實現方式有 select、epoll、kqueue 等。
  2. 當某條鏈接有新的數據能夠處理時,操做系統會通知進程,進程從阻塞狀態返回,開始進行業務處理。

事件反應的意思,能夠通俗地理解爲「來了一個事件我就有相應的反應」,這裏的「我」就是 Reactor,具體的反應就是咱們寫的代碼,Reactor 會根據事件類型來調用相應的代碼進行處理。Reactor 模式也叫 Dispatcher 模式(在不少開源的系統裏面會看到這個名稱的類,其實就是實現 Reactor 模式的),更加貼近模式自己的含義,即 I/O 多路複用統一監聽事件,收到事件後分配(Dispatch)給某個進程。

Reactor 模式的核心組成部分包括 Reactor 和處理資源池(進程池或線程池),其中 Reactor 負責監聽和分配事件,處理資源池負責處理事件。初看 Reactor 的實現是比較簡單的,但實際上結合不一樣的業務場景,Reactor 模式的具體實現方案靈活多變,主要體如今:

  1. Reactor 的數量能夠變化:能夠是一個 Reactor,也能夠是多個 Reactor。
  2. 資源池的數量能夠變化:以進程爲例,能夠是單個進程,也能夠是多個進程(線程相似)。

將上面兩個因素排列組合一下,理論上能夠有 4 種選擇,但因爲「多 Reactor 單進程」實現方案相比「單 Reactor 單進程」方案,既複雜又沒有性能優點,所以「多 Reactor 單進程」方案僅僅是一個理論上的方案,實際沒有應用。最終 Reactor 模式有這三種典型的實現方案:

  1. 單 Reactor 單進程 / 線程。
  2. 單 Reactor 多線程。
  3. 多 Reactor 多進程 / 線程。

以上方案具體選擇進程仍是線程,更多地是和編程語言及平臺相關。例如,Java 語言通常使用線程(例如,Netty),C 語言使用進程和線程均可以。例如,Nginx 使用進程,Memcache 使用線程。

單 Reactor 單進程 / 線程

  1. Reactor 對象經過 select 監控鏈接事件,收到事件後經過 dispatch 進行分發。
  2. 若是是鏈接創建的事件,則由 Acceptor 處理,Acceptor 經過 accept 接受鏈接,並建立一個 Handler 來處理鏈接後續的各類事件。
  3. 若是不是鏈接創建事件,則 Reactor 會調用鏈接對應的 Handler(第 2 步中建立的 Handler)來進行響應。
  4. Handler 會完成 read-> 業務處理 ->send 的完整業務流程。

單 Reactor 單進程的模式優勢就是很簡單,沒有進程間通訊,沒有進程競爭,所有都在同一個進程內完成。但其缺點也是很是明顯,具體表現有:

  1. 只有一個進程,沒法發揮多核 CPU 的性能;只能採起部署多個系統來利用多核 CPU,但這樣會帶來運維複雜度,原本只要維護一個系統,用這種方式須要在一臺機器上維護多套系統。
  2. Handler 在處理某個鏈接上的業務時,整個進程沒法處理其餘鏈接的事件,很容易致使性能瓶頸。

所以,單 Reactor 單進程的方案在實踐中應用場景很少,只適用於業務處理很是快速的場景,目前比較著名的開源軟件中使用單 Reactor 單進程的是 Redis。

 

單 Reactor 多線程

  1. 主線程中,Reactor 對象經過 select 監控鏈接事件,收到事件後經過 dispatch 進行分發。
  2. 若是是鏈接創建的事件,則由 Acceptor 處理,Acceptor 經過 accept 接受鏈接,並建立一個 Handler 來處理鏈接後續的各類事件。
  3. 若是不是鏈接創建事件,則 Reactor 會調用鏈接對應的 Handler(第 2 步中建立的 Handler)來進行響應。
  4. Handler 只負責響應事件,不進行業務處理;Handler 經過 read 讀取到數據後,會發給 Processor 進行業務處理。
  5. Processor 會在獨立的子線程中完成真正的業務處理,而後將響應結果發給主進程的 Handler 處理;Handler 收到響應後經過 send 將響應結果返回給 client。

單 Reator 多線程方案可以充分利用多核多 CPU 的處理能力,但同時也存在下面的問題:

  1. 多線程數據共享和訪問比較複雜。例如,子線程完成業務處理後,要把結果傳遞給主線程的 Reactor 進行發送,這裏涉及共享數據的互斥和保護機制。
  2. Reactor 承擔全部事件的監聽和響應,只在主線程中運行,瞬間高併發時會成爲性能瓶頸。

你可能會發現,我只列出了「單 Reactor 多線程」方案,沒有列出「單 Reactor 多進程」方案,這是什麼緣由呢?主要緣由在於若是採用多進程,子進程完成業務處理後,將結果返回給父進程,並通知父進程發送給哪一個 client,這是很麻煩的事情。由於父進程只是經過 Reactor 監聽各個鏈接上的事件而後進行分配,子進程與父進程通訊時並非一個鏈接。若是要將父進程和子進程之間的通訊模擬爲一個鏈接,並加入 Reactor 進行監聽,則是比較複雜的。而採用多線程時,由於多線程是共享數據的,所以線程間通訊是很是方便的。雖然要額外考慮線程間共享數據時的同步問題,但這個複雜度比進程間通訊的複雜度要低不少。

 

多 Reactor 多進程 / 線程

  1. 父進程中 mainReactor 對象經過 select 監控鏈接創建事件,收到事件後經過 Acceptor 接收,將新的鏈接分配給某個子進程。
  2. 子進程的 subReactor 將 mainReactor 分配的鏈接加入鏈接隊列進行監聽,並建立一個 Handler 用於處理鏈接的各類事件。
  3. 當有新的事件發生時,subReactor 會調用鏈接對應的 Handler(即第 2 步中建立的 Handler)來進行響應。
  4. Handler 完成 read→業務處理→send 的完整業務流程。

多 Reactor 多進程 / 線程的方案看起來比單 Reactor 多線程要複雜,但實際實現時反而更加簡單,主要緣由是:

  1. 父進程和子進程的職責很是明確,父進程只負責接收新鏈接,子進程負責完成後續的業務處理。
  2. 父進程和子進程的交互很簡單,父進程只須要把新鏈接傳給子進程,子進程無須返回數據。
  3. 子進程之間是互相獨立的,無須同步共享之類的處理(這裏僅限於網絡模型相關的 select、read、send 等無須同步共享,「業務處理」仍是有可能須要同步共享的)。

目前著名的開源系統 Nginx 採用的是多 Reactor 多進程,採用多 Reactor 多線程的實現有 Memcache 和 Netty。

 

Proactor

Reactor 是非阻塞同步網絡模型,由於真正的 read 和 send 操做都須要用戶進程同步操做。這裏的「同步」指用戶進程在執行 read 和 send 這類 I/O 操做的時候是同步的,若是把 I/O 操做改成異步就可以進一步提高性能,這就是異步網絡模型 Proactor。Proactor 中文翻譯爲「前攝器」比較難理解,與其相似的單詞是 proactive,含義爲「主動的」,所以咱們照貓畫虎翻譯爲「主動器」反而更好理解。Reactor 能夠理解爲「來了事件我通知你,你來處理」,而 Proactor 能夠理解爲「來了事件我來處理,處理完了我通知你」。這裏的「我」就是操做系統內核,「事件」就是有新鏈接、有數據可讀、有數據可寫的這些 I/O 事件,「你」就是咱們的程序代碼。

  1. Proactor Initiator 負責建立 Proactor 和 Handler,並將 Proactor 和 Handler 都經過 Asynchronous Operation Processor 註冊到內核。
  2. Asynchronous Operation Processor 負責處理註冊請求,並完成 I/O 操做。
  3. Asynchronous Operation Processor 完成 I/O 操做後通知 Proactor。
  4. Proactor 根據不一樣的事件類型回調不一樣的 Handler 進行業務處理。
  5. Handler 完成業務處理,Handler 也能夠註冊新的 Handler 到內核進程。

理論上 Proactor 比 Reactor 效率要高一些,異步 I/O 可以充分利用 DMA 特性,讓 I/O 操做與計算重疊,但要實現真正的異步 I/O,操做系統須要作大量的工做。目前 Windows 下經過 IOCP 實現了真正的異步 I/O,而在 Linux 系統下的 AIO 並不完善,所以在 Linux 下實現高併發網絡編程時都是以 Reactor 模式爲主。因此即便 Boost.Asio 號稱實現了 Proactor 模型,其實它在 Windows 下采用 IOCP,而在 Linux 下是用 Reactor 模式(採用 epoll)模擬出來的異步模型。

 

七.    高性能負載均衡:分類及架構

單服務器不管如何優化,不管採用多好的硬件,總會有一個性能天花板,當單服務器的性能沒法知足業務需求時,就須要設計高性能集羣來提高系統總體的處理性能。高性能集羣的本質很簡單,經過增長更多的服務器來提高系統總體的計算能力。因爲計算自己存在一個特色:一樣的輸入數據和邏輯,不管在哪臺服務器上執行,都應該獲得相同的輸出。所以高性能集羣設計的複雜度主要體如今任務分配這部分,須要設計合理的任務分配策略,將計算任務分配到多臺服務器上執行。高性能集羣的複雜性主要體如今須要增長一個任務分配器,以及爲任務選擇一個合適的任務分配算法。

 

DNS 負載均衡

DNS 是最簡單也是最多見的負載均衡方式,通常用來實現地理級別的均衡。例如,北方的用戶訪問北京的機房,南方的用戶訪問深圳的機房。DNS 負載均衡的本質是 DNS 解析同一個域名能夠返回不一樣的 IP 地址。例如,一樣是 www.baidu.com,北方用戶解析後獲取的地址是 61.135.165.224(這是北京機房的 IP),南方用戶解析後獲取的地址是 14.215.177.38(這是深圳機房的 IP)。

DNS 負載均衡實現簡單、成本低,但也存在粒度太粗、負載均衡算法少等缺點。仔細分析一下優缺點,其優勢有:

  1. 簡單、成本低:負載均衡工做交給 DNS 服務器處理,無須本身開發或者維護負載均衡設備。
  2. 就近訪問,提高訪問速度:DNS 解析時能夠根據請求來源 IP,解析成距離用戶最近的服務器地址,能夠加快訪問速度,改善性能。

缺點有:

  1. 更新不及時:DNS 緩存的時間比較長,修改 DNS 配置後,因爲緩存的緣由,仍是有不少用戶會繼續訪問修改前的 IP,這樣的訪問會失敗,達不到負載均衡的目的,而且也影響用戶正常使用業務。
  2. 擴展性差:DNS 負載均衡的控制權在域名商那裏,沒法根據業務特色針對其作更多的定製化功能和擴展特性。
  3. 分配策略比較簡單:DNS 負載均衡支持的算法少;不能區分服務器的差別(不能根據系統與服務的狀態來判斷負載);也沒法感知後端服務器的狀態。

針對 DNS 負載均衡的一些缺點,對於時延和故障敏感的業務,有一些公司本身實現了 HTTP-DNS 的功能,即便用 HTTP 協議實現一個私有的 DNS 系統。這樣的方案和通用的 DNS 優缺點正好相反。

 

硬件負載均衡

硬件負載均衡是經過單獨的硬件設備來實現負載均衡功能,這類設備和路由器、交換機相似,能夠理解爲一個用於負載均衡的基礎網絡設備。目前業界典型的硬件負載均衡設備有兩款:F5 和 A10。這類設備性能強勁、功能強大,但價格都不便宜,通常只有「土豪」公司纔會考慮使用此類設備。普通業務量級的公司一是負擔不起,二是業務量沒那麼大,用這些設備也是浪費。

硬件負載均衡的優勢是:

  1. 功能強大:全面支持各層級的負載均衡,支持全面的負載均衡算法,支持全局負載均衡。
  2. 性能強大:對比一下,軟件負載均衡支持到 10 萬級併發已經很厲害了,硬件負載均衡能夠支持 100 萬以上的併發。
  3. 穩定性高:商用硬件負載均衡,通過了良好的嚴格測試,通過大規模使用,穩定性高。
  4. 支持安全防禦:硬件均衡設備除具有負載均衡功能外,還具有防火牆、防 DDoS 攻擊等安全功能。

硬件負載均衡的缺點是:

  1. 價格昂貴:最普通的一臺 F5 就是一臺「馬 6」,好一點的就是「Q7」了。
  2. 擴展能力差:硬件設備,能夠根據業務進行配置,但沒法進行擴展和定製。

 

軟件負載均衡

軟件負載均衡經過負載均衡軟件來實現負載均衡功能,常見的有 Nginx 和 LVS,其中 Nginx 是軟件的 7 層負載均衡,LVS 是 Linux 內核的 4 層負載均衡。4 層和 7 層的區別就在於協議和靈活性,Nginx 支持 HTTP、E-mail 協議;而 LVS 是 4 層負載均衡,和協議無關,幾乎全部應用均可以作,例如,聊天、數據庫等。

軟件和硬件的最主要區別就在於性能,硬件負載均衡性能遠遠高於軟件負載均衡性能。Ngxin 的性能是萬級,通常的 Linux 服務器上裝一個 Nginx 大概能到 5 萬 / 秒;LVS 的性能是十萬級,聽說可達到 80 萬 / 秒;而 F5 性能是百萬級,從 200 萬 / 秒到 800 萬 / 秒都有(數據來源網絡,僅供參考,如需採用請根據實際業務場景進行性能測試)。固然,軟件負載均衡的最大優點是便宜,一臺普通的 Linux 服務器批發價大概就是 1 萬元左右,相比 F5 的價格,那就是自行車和寶馬的區別了。

除了使用開源的系統進行負載均衡,若是業務比較特殊,也可能基於開源系統進行定製(例如,Nginx 插件),甚至進行自研。

下面是 Nginx 的負載均衡架構示意圖:

軟件負載均衡的優勢:

  1. 簡單:不管是部署仍是維護都比較簡單。
  2. 便宜:只要買個 Linux 服務器,裝上軟件便可。
  3. 靈活:4 層和 7 層負載均衡能夠根據業務進行選擇;也能夠根據業務進行比較方便的擴展,例如,能夠經過 Nginx 的插件來實現業務的定製化功能。

缺點:

  1. 功能沒有硬件負載均衡那麼強大。
  2. 性能通常:一個 Nginx 大約能支撐 5 萬併發。
  3. 通常不具有防火牆和防 DDoS 攻擊等安全功能。

 

負載均衡典型架構

前面咱們介紹了 3 種常見的負載均衡機制:DNS 負載均衡、硬件負載均衡、軟件負載均衡,每種方式都有一些優缺點,但並不意味着在實際應用中只能基於它們的優缺點進行非此即彼的選擇,反而是基於它們的優缺點進行組合使用。具體來講,組合的基本原則爲:DNS 負載均衡用於實現地理級別的負載均衡;硬件負載均衡用於實現集羣級別的負載均衡;軟件負載均衡用於實現機器級別的負載均衡。

  1. 地理級別負載均衡:www.xxx.com 部署在北京、廣州、上海三個機房,當用戶訪問時,DNS 會根據用戶的地理位置來決定返回哪一個機房的 IP,圖中返回了廣州機房的 IP 地址,這樣用戶就訪問到廣州機房了。
  2. 集羣級別負載均衡:廣州機房的負載均衡用的是 F5 設備,F5 收到用戶請求後,進行集羣級別的負載均衡,將用戶請求發給 3 個本地集羣中的一個,咱們假設 F5 將用戶請求發給了「廣州集羣 2」。
  3. 機器級別的負載均衡:廣州集羣 2 的負載均衡用的是 Nginx,Nginx 收到用戶請求後,將用戶請求發送給集羣裏面的某臺服務器,服務器處理用戶的業務請求並返回業務響應。

須要注意的是,上圖只是一個示例,通常在大型業務場景下才會這樣用,若是業務量沒這麼大,則沒有必要嚴格照搬這套架構。例如,一個大學的論壇,徹底能夠不須要 DNS 負載均衡,也不須要 F5 設備,只須要用 Nginx 做爲一個簡單的負載均衡就足夠了。

 

八.    高性能負載均衡:算法

負載均衡算法數量較多,並且能夠根據一些業務特性進行定製開發,拋開細節上的差別,根據算法指望達到的目的,大致上能夠分爲下面幾類。

  1. 任務平分類:負載均衡系統將收到的任務平均分配給服務器進行處理,這裏的「平均」能夠是絕對數量的平均,也能夠是比例或者權重上的平均。
  2. 負載均衡類:負載均衡系統根據服務器的負載來進行分配,這裏的負載並不必定是一般意義上咱們說的「CPU 負載」,而是系統當前的壓力,能夠用 CPU 負載來衡量,也能夠用鏈接數、I/O 使用率、網卡吞吐量等來衡量系統的壓力。
  3. 性能最優類:負載均衡系統根據服務器的響應時間來進行任務分配,優先將新任務分配給響應最快的服務器。
  4. Hash 類:負載均衡系統根據任務中的某些關鍵信息進行 Hash 運算,將相同 Hash 值的請求分配到同一臺服務器上。常見的有源地址 Hash、目標地址 Hash、session id hash、用戶 ID Hash 等。

輪詢

負載均衡系統收到請求後,按照順序輪流分配到服務器上。輪詢是最簡單的一個策略,無須關注服務器自己的狀態,例如:

  1. 某個服務器當前由於觸發了程序 bug 進入了死循環致使 CPU 負載很高,負載均衡系統是不感知的,仍是會繼續將請求源源不斷地發送給它。
  2. 集羣中有新的機器是 32 核的,老的機器是 16 核的,負載均衡系統也是不關注的,新老機器分配的任務數是同樣的。

須要注意的是負載均衡系統無須關注「服務器自己狀態」,這裏的關鍵詞是「自己」。也就是說,只要服務器在運行,運行狀態是不關注的。但若是服務器直接宕機了,或者服務器和負載均衡系統斷連了,這時負載均衡系統是可以感知的,也須要作出相應的處理。例如,將服務器從可分配服務器列表中刪除,不然就會出現服務器都宕機了,任務還不斷地分配給它,這明顯是不合理的。

總而言之,「簡單」是輪詢算法的優勢,也是它的缺點。

 

加權輪詢

負載均衡系統根據服務器權重進行任務分配,這裏的權重通常是根據硬件配置進行靜態配置的,採用動態的方式計算會更加契合業務,但複雜度也會更高。

加權輪詢是輪詢的一種特殊形式,其主要目的就是爲了解決不一樣服務器處理能力有差別的問題。例如,集羣中有新的機器是 32 核的,老的機器是 16 核的,那麼理論上咱們能夠假設新機器的處理能力是老機器的 2 倍,負載均衡系統就能夠按照 2:1 的比例分配更多的任務給新機器,從而充分利用新機器的性能。

加權輪詢解決了輪詢算法中沒法根據服務器的配置差別進行任務分配的問題,但一樣存在沒法根據服務器的狀態差別進行任務分配的問題。

 

負載最低優先

負載均衡系統將任務分配給當前負載最低的服務器,這裏的負載根據不一樣的任務類型和業務場景,能夠用不一樣的指標來衡量。例如:

  1. LVS 這種 4 層網絡負載均衡設備,能夠以「鏈接數」來判斷服務器的狀態,服務器鏈接數越大,代表服務器壓力越大。
  2. Nginx 這種 7 層網絡負載系統,能夠以「HTTP 請求數」來判斷服務器狀態(Nginx 內置的負載均衡算法不支持這種方式,須要進行擴展)。
  3. 若是咱們本身開發負載均衡系統,能夠根據業務特色來選擇指標衡量系統壓力。若是是 CPU 密集型,能夠以「CPU 負載」來衡量系統壓力;若是是 I/O 密集型,能夠以「I/O 負載」來衡量系統壓力。

負載最低優先的算法解決了輪詢算法中沒法感知服務器狀態的問題,由此帶來的代價是複雜度要增長不少。例如:

  1. 最少鏈接數優先的算法要求負載均衡系通通計每一個服務器當前創建的鏈接,其應用場景僅限於負載均衡接收的任何鏈接請求都會轉發給服務器進行處理,不然若是負載均衡系統和服務器之間是固定的鏈接池方式,就不適合採起這種算法。例如,LVS 能夠採起這種算法進行負載均衡,而一個經過鏈接池的方式鏈接 MySQL 集羣的負載均衡系統就不適合採起這種算法進行負載均衡。
  2. CPU 負載最低優先的算法要求負載均衡系統以某種方式收集每一個服務器的 CPU 負載,並且要肯定是以 1 分鐘的負載爲標準,仍是以 15 分鐘的負載爲標準,不存在 1 分鐘確定比 15 分鐘要好或者差。不一樣業務最優的時間間隔是不同的,時間間隔過短容易形成頻繁波動,時間間隔太長又可能形成峯值來臨時響應緩慢。

負載最低優先算法基本上可以比較完美地解決輪詢算法的缺點,由於採用這種算法後,負載均衡系統須要感知服務器當前的運行狀態。固然,其代價是複雜度大幅上升。通俗來說,輪詢多是 5 行代碼就能實現的算法,而負載最低優先算法可能要 1000 行才能實現,甚至須要負載均衡系統和服務器都要開發代碼。負載最低優先算法若是自己沒有設計好,或者不適合業務的運行特色,算法自己就可能成爲性能的瓶頸,或者引起不少莫名其妙的問題。因此負載最低優先算法雖然效果看起來很美好,但實際上真正應用的場景反而沒有輪詢(包括加權輪詢)那麼多。

 

性能最優類

負載最低優先類算法是站在服務器的角度來進行分配的,而性能最優優先類算法則是站在客戶端的角度來進行分配的,優先將任務分配給處理速度最快的服務器,經過這種方式達到最快響應客戶端的目的。

和負載最低優先類算法相似,性能最優優先類算法本質上也是感知了服務器的狀態,只是經過響應時間這個外部標準來衡量服務器狀態而已。所以性能最優優先類算法存在的問題和負載最低優先類算法相似,複雜度都很高,主要體如今:

  1. 負載均衡系統須要收集和分析每一個服務器每一個任務的響應時間,在大量任務處理的場景下,這種收集和統計自己也會消耗較多的性能。
  2. 爲了減小這種統計上的消耗,能夠採起採樣的方式來統計,即不統計全部任務的響應時間,而是抽樣統計部分任務的響應時間來估算總體任務的響應時間。採樣統計雖然可以減小性能消耗,但使得複雜度進一步上升,由於要肯定合適的採樣率,採樣率過低會致使結果不許確,採樣率過高會致使性能消耗較大,找到合適的採樣率也是一件複雜的事情。
  3. 不管是所有統計仍是採樣統計,都須要選擇合適的週期:是 10 秒內性能最優,仍是 1 分鐘內性能最優,仍是 5 分鐘內性能最優……沒有放之四海而皆準的週期,須要根據實際業務進行判斷和選擇,這也是一件比較複雜的事情,甚至出現系統上線後須要不斷地調優才能達到最優設計。

 

Hash類

負載均衡系統根據任務中的某些關鍵信息進行 Hash 運算,將相同 Hash 值的請求分配到同一臺服務器上,這樣作的目的主要是爲了知足特定的業務需求。例如:

  1. 源地址 Hash:未來源於同一個源 IP 地址的任務分配給同一個服務器進行處理,適合於存在事務、會話的業務。例如,當咱們經過瀏覽器登陸網上銀行時,會生成一個會話信息,這個會話是臨時的,關閉瀏覽器後就失效。網上銀行後臺無須持久化會話信息,只須要在某臺服務器上臨時保存這個會話就能夠了,但須要保證用戶在會話存在期間,每次都能訪問到同一個服務器,這種業務場景就能夠用源地址 Hash 來實現。
  2. ID Hash:將某個 ID 標識的業務分配到同一個服務器中進行處理,這裏的 ID 通常是臨時性數據的 ID(如 session id)。例如,上述的網上銀行登陸的例子,用 session id hash 一樣能夠實現同一個會話期間,用戶每次都是訪問到同一臺服務器的目的。
相關文章
相關標籤/搜索