Node.js:淺析高併發與分佈式集羣

本文首發於個人我的博客: kmknkk.xin
不足之處歡迎斧正!

Node特性:高併發

在解釋node爲何可以作到高併發以前,不妨先了解一下node的其餘幾個特性:node

單線程

咱們先來明確一個概念,即:node是單線程的,這一點與JavaScript在瀏覽器中的特性相同,而且在node中JavaScript主線程與其餘線程(例如I/O線程)是沒法共享狀態的。nginx

單線程的好處就是:redis

  • 無需像多線程那樣去關注線程之間的狀態同步問題
  • 沒有線程切換所帶來的開銷
  • 沒有死鎖存在

固然單線程也有許多壞處:sql

  • 沒法充分利用多核CPU
  • 大量計算佔用CPU會致使應用阻塞(即不適用CPU密集型)
  • 錯誤會引發整個應用的退出

不過在今天看來,這些壞處都已經再也不是問題或者獲得了適當的解決:數據庫

(1) 建立進程 or 細分實例後端

關於第一個問題,最直白解決方案就是使用 child_process核心模塊或者 cluster:child_process 和 net 組合應用。咱們能夠經過在一臺多核服務器上建立多個進程(一般使用 fork操做)來充分利用每一個核心,不過要處理好進程間通訊問題。

另外一個方案是,咱們能夠將物理機器劃分爲多臺單核的虛擬機,並經過pm2等工具,管理多臺虛擬機造成一個集羣架構,高效運行所需服務,至於每臺機器間的通訊(狀態同步)我這裏先按下不表,在下文的Node分佈式架構中再作詳細說明。瀏覽器

(2) 時間片輪轉安全

關於第二點,我跟小夥伴討論事後認爲能夠經過時間片輪轉方式,在單線程上模擬多線程,適當減小應用阻塞的感受(雖然這種方法不會真的像多線程那樣節約時間)

(3) 負載均衡、壞點監控/隔離服務器

至於第三點,我跟小夥伴們也討論過,認爲主要的痛點就在於node不一樣於JAVA,它所實現的邏輯是以異步爲主的。

這就致使了node沒法像JAVA同樣方便地使用 try/catch 來來捕獲並繞過錯誤,由於沒法肯定異步任務會什麼時候傳回異常。而在單線程環境下,繞不過錯誤就意味着致使應用退出,重啓恢復的間隙會致使服務中斷,這是咱們不肯意看到的。多線程

固然,在服務器資源豐富的當下,咱們能夠經過 pm2 或 nginx 這些工具,動態的判斷服務狀態。在服務出錯時隔離壞點服務器,將請求轉發到正常服務器上,並重啓壞點服務器以繼續提供服務。這也是Node分佈式架構的一部分。

異步I/O

你可能會問,既然node是單線程的,事件所有在一個線程上處理,那不是應該效率很低、與高併發相悖嗎?

偏偏相反,node的性能很高。緣由之一就是node具備異步I/O特性,每當有I/O請求發生時,node會提供給該請求一個I/O線程。而後node就無論這個I/O的操做過程了,而是繼續執行主線程上的事件,只須要在該請求返回回調時在處理便可。也就是node省去了許多等待請求的時間。

這也是node支持高併發的重要緣由之一

實際上不光是I/O操做,node的絕大多數操做都是以這種異步的方式進行的。它就像是一個組織者,無需事必躬親,只須要告訴成員們如何正確的進行操做並接受反饋、處理關鍵步驟,就能使得整個團隊高效運行。

事務驅動

你可能又要問了,node怎麼知道請求返回了回調,又應該什麼時候去處理這些回調呢?

答案就是node的另外一特性:事務驅動,即主線程經過event loop事件循環觸發的方式來運行程序

這是node支持高併發的另外一重要緣由

圖解node環境下的Event loop:

┌───────────────────────┐
┌─>│        timers         │<————— 執行 setTimeout()、setInterval() 的回調
│  └──────────┬────────────┘
|             |<-- 執行全部 Next Tick Queue 以及 MicroTask Queue 的回調
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │<————— 執行幾乎全部的回調,除了 close callbacks 以及 timers 調度的回調和 setImmediate() 調度的回調
│  └──────────┬────────────┘
|             |<-- 執行全部 Next Tick Queue 以及 MicroTask Queue 的回調
│  ┌──────────┴────────────┐
│  │     idle, prepare     │<————— 內部調用,可忽略
│  └──────────┬────────────┘     
|             |<-- 執行全部 Next Tick Queue 以及 MicroTask Queue 的回調
|             |                   ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │ - (retrieve new I/O events; node will block here when appropriate)
│  │         poll          │<─────┤  connections, │ 
│  └──────────┬────────────┘      │   data, etc.  │ 
│             |                   |               | 
|             |                   └───────────────┘
|             |<-- 執行全部 Next Tick Queue 以及 MicroTask Queue 的回調
|  ┌──────────┴────────────┐      
│  │        check          │<————— setImmediate() 的回調將會在這個階段執行
│  └──────────┬────────────┘
|             |<-- 執行全部 Next Tick Queue 以及 MicroTask Queue 的回調
│  ┌──────────┴────────────┐
└──┤    close callbacks    │<————— socket.on('close', ...)
   └───────────────────────┘

poll階段:

當進入到poll階段,而且沒有timers被調用的時候,會發生下面的狀況:

(1)若是poll隊列不爲空:

  • Event Loop 將同步的執行poll queue裏的callback(新的I/O事件),直到queue爲空或者執行的callback到達上線。

(2)若是poll隊列爲空:

  • 若是腳本調用了setImmediate(), Event Loop將會結束poll階段而且進入到check階段執行setImmediate()的回調。
  • 若是腳本沒有setImmediate()調用,Event Loop將會等待回調(新的I/O事件)被添加到隊列中,而後當即執行它們。

當進入到poll階段,而且調用了timers的話,會發生下面的狀況:

  • 一旦poll queue是空的話,Event Loop會檢查是否timers, 若是有1個或多個timers時間已經到達,Event Loop將會回到timer階段並執行那些timer的callback(即進入到下一次tick)。

優先級:

根據上面的圖,咱們不可貴出:

Next Tick Queue > MicroTask Queue

那麼setTimeout、setInterval和setImmediate誰快呢?

答案是:不肯定

單單從執行圖上看,若是二者都是在mian module裏定義的,那麼:setTimeout、setInterval > setImmediate

可是有兩個條件制約了這一結論:

  • event loop初始化須要必定時間
  • setTimeout有最小毫秒數(通常認爲最少1ms)

因此當 event loop準備時間 > setTimeout毫秒數時,進入timers檢查時已有setTimeout的任務,故timeout先輸出。反之則immediate先輸出。

若是是在poll階段定義的setTimeout和setImmediate,那麼immediate先於timeout輸出。緣由是在poll階段,會先進入check階段再進入timers階段。例如:

const fs = require('fs');

fs.readFile('./test.txt', 'utf8', (err, data) => {
    setTimeout( () => {
        console.log('setTimeout');
    }, 0);
    setImmediate( () => {
        console.log('setImmediate');
    })
})

/**
 *
 * console:
 * > setImmediate
 * > setTimeout
 *
 **/

多說一句:
因爲timer須要從紅黑樹中取出定時器來判斷時間是否到了,時間複雜度爲O(lg(n)),故若是想當即異步執行一個事件,最好不要用 setTimeout(func, 0)。而是使用 process.nextTick() 來完成。


分佈式Node架構

我瞭解到的Node集羣架構主要分爲如下幾個模塊:

Nginx(負載均衡、調度) -> Node集羣 -> Redis(同步狀態)

按個人理解整理了一副圖:

Node集羣架構

固然,這應該是比較理想狀態下的架構方式。由於雖然 Redis 的讀/寫至關快,但這是由於其將數據存儲在內存池裏,在內存上進行相關操做。

這對於服務器的內存負荷是至關高的,因此一般咱們仍是會在架構中加入 Mysql,以下圖:

Mysql-Redis

先解釋一下這幅圖:
當用戶數據到來時,將數據先寫入 Mysql,Node 須要數據時再去 Redis 讀取,若沒有找到再去 Mysql 裏面查詢想要的數據,並寫入 Redis,下次使用時就能夠直接去 Redis 裏面查詢了。

加入 Mysql 相較於只在 Redis 裏讀/寫的好處有:

(1)避免了短時間內無用的數據寫入 Redis,佔用內存,減輕 Redis 負擔

(2)在後期須要對數據進行特定查詢、分析的時候(好比分析運營活動用戶漲幅),SQL關係查詢能提供很大的幫助

固然在應對短期大流量寫入的時候,咱們也能夠直接將數據寫入 Redis,以達到快速存儲數據、增長服務器應對流量能力的目的,等流量下去了再單獨將數據寫入 Mysql。


簡單介紹完了大致的架構組成,接下來咱們來細看每一個部分的細節:

流量接入層

流量接入層所作的就是對全部接受的流量進行處理,提供瞭如下服務:

  • 流量緩衝
  • 分流和轉發

  • 超時檢測

    • 與用戶創建鏈接超時
    • 讀取用戶body超時
    • 鏈接後端超時
    • 讀後端響應頭超時
    • 寫響應超時
    • 與用戶長鏈接超時
  • 集羣健康檢查/隔離壞點服務器

    • 隔離壞點服務器並嘗試修復/重啓,直到該服務器恢復正常
  • 失敗重試機制

    • 在請求轉發到某集羣某機器上,返回失敗後,將該請求轉發到該集羣的別的機器,或者跨集羣的機器上進行重試
  • 鏈接池/會話保持機制

    • 對於延遲敏感用戶使用鏈接池機制,減小創建鏈接的時間
  • 安全防禦
  • 數據分析

當轉發到各個產品線後就到了負載層工做的時候了:將請求根據狀況轉發到各地機房

固然,這個平臺並不止轉發這一個功能,你能夠把它理解爲一個大型的私有云系統,提供如下服務:

  • 文件上傳/服務線上部署
  • 線上配置修改
  • 設置定時任務
  • 線上系統監控/日誌打印服務
  • 線上實例管理
  • 鏡像中心
  • 等等...

Node集羣層

這一層主要的工做是:

(1)編寫可靠的 Node 代碼,爲需求提供後端服務

(2)編寫高性能查詢語句,與 Redis、Mysql 交互,提升查詢效率

(3)經過 Redis 同步集羣裏各個 Node 服務的狀態

(4)經過硬件管理平臺,管理/監控物理機器的狀態、管理IP地址等

(固然這部分我只是粗淺地列列條目,仍是須要時間來積累、深刻理解)

數據庫層

這一層主要的工做是:

(1)建立 Mysql 並設計相關頁、表;創建必要的索引、外鍵,提高查詢便利性

(2)部署 redis 並向 Node 層提供相應接口

總結

雖然 Node 的單線程特性給其提供的服務帶來了許多問題,但只要咱們積極面對這些問題,用合理的方法(如使用 child_process 等模塊或構建分佈式集羣)去解決他們,發揮 Node 的各類優點,就能夠享受到它所帶來的好處!

待更新:

  • Redis相關特性
  • sql查詢性能指標 & 優化策略
  • Node內存監控 & 內存泄露排查/處理
相關文章
相關標籤/搜索