Node-js 多進程 - 線程 —— 日誌系統架構優化實踐

1. 背景

  在日常的項目中,常常需要在用戶側記錄一些關鍵的行爲,以日誌的形式存儲在用戶本地,對日誌進行定期上報。這樣能夠在用戶反饋問題時,準確及時的對問題進行定位。

  爲了保證日誌信息傳輸的安全、縮小日誌文件的體積,在實際的日誌上傳過程中會對日誌進行加密和壓縮,最後上傳由若干個加密文件組成的一個壓縮包。

  爲了更清晰的查看用戶的日誌信息。需要搭建一個用戶日誌管理系統,在管理系統中可以清晰的查看用戶的日誌信息。但是用戶上傳的都是經過加密和壓縮過的文件,所以就需要在用戶上傳日誌後,實時的對用戶上傳的日誌進行解密和解壓縮,還原出用戶的關鍵操作。如下圖所示,是一個用戶基本的使用過程。

  但是解密和解壓縮都是十分耗時的操作,需要進行大量的計算,在衆多用戶龐大的日誌量的情況下無法立即完成所有的解密操作,所以上傳的日誌擁有狀態。(解密中、解密完成、解密失敗等)

  一個常見的日誌系統架構如下:

  其中按照解密狀態的變化,大體分爲三個階段:

  1. 用戶終端上傳日誌到 cos 並通知後臺日誌服務已經上傳了日誌,後臺日誌服務記錄這條日誌,將其狀態設置爲未解密

  2. 日誌服務通知解密服務對剛上傳的日誌進行解密,收到響應後將日誌的狀態更改爲解密中

  3. 解密服務進行解密,完成後將明文日誌上傳並通知日誌服務已完成解密,日誌服務將解密狀態更改爲解密完成。如果過程中出現錯誤,則將日誌解密狀態更改爲解密失敗

  但是在實際的項目使用過程中,發現系統中有很多問題,具體表現如下:

接下來將以這些問題爲線索,對其背後的技術實現進行深入探索。

2. 問題分析

  第一個問題是有些日誌上傳很久之後,狀態仍然爲解密中。根據表現,可以初步確定問題出現在上述的階段 3(日誌狀態已設置爲解密中,但並未進行進一步的狀態設置),因此,可以判斷是解密服務內部出現異常。

  解密服務使用 Node.js 實現,整體架構如下:

  解密服務 Master 主進程負責進程調度與負載均衡,由它開啓多個工作進程(Work Process)處理 cgi 請求,同時它也開啓一個解密進程專用於解密操作。下面將着重介紹 Node.js 實現多進程和其通信的方法

2.1 Node.js 實現多進程

2.1.1 使用多進程的好處

  進程是資源分配的最小單位,不同進程之間是隔離開來,內存不共享的,使用多進程將相對複雜且獨立的內容分隔開來,能降低代碼的複雜度,每個進程只需要關注其具體工作內容即可,降低了程序之間的耦合。並且子進程崩潰不影響主進程的穩定性,能夠增加系統的魯棒性。  進程作爲線程的容器,使用多進程也充分享受多線程所帶來的好處。在下文會有多線程的詳細介紹。

2.1.2 使用多進程的劣勢

  進程作爲資源分配的最小單位,啓動一個進程必須分配給它獨立的內存地址空間,需要建立衆多的數據表來維護它的代碼段、堆棧段和數據段,在進程切換時開銷很大,速度較爲緩慢。除此之外,進程之間的數據不共享,進程之間的數據傳輸會造成一定的消耗。

  因此,在使用多進程時應充分考慮程序的可靠性、運行效率等,創建適量的進程。

2.1.2 Node.js 提供的實現多進程的模塊

  Node.js 內部通過兩個庫創建子進程:child_process 和 cluster,下文先介紹 child_process 模塊。

  child_process 模塊提供了四個創建子進程的函數,分別爲:spawn、execFile、exec、fork,可以根據實際的需求選用適當的方法,各個函數的區別如下:

  其中 fork 用於開啓 Node.js 應用,在 Node.js 中較爲常用,其用法如下:

  一個簡單的 demo 如下:

// demo/parent.js
const ChildProcess = require('child_process');

console.log(`parent pid: ${process.pid}`)

const childProcess = ChildProcess.fork('./child.js');
childProcess.on('message', (msg) => {
    console.log("parent received:", msg);
})
// demo/child.js
console.log(`child pid: ${process.pid}`)

setInterval(() => {
    process.send(newDate());
}, 2000)
$ cd demo && node parent.js // 在demo目錄下執行parent.js文件

  結果:

  在任務管理器(活動監視器)中看到,確實創建了對應 pid 的 Node.js 進程:

2.2 Node.js 實現多進程通信

2.2.1 常見的進程通信方式

  試想有以下兩個獨立的進程,它們通過執行兩個 js 文件創建,那麼如何在它們之間傳遞信息呢?

// Process 1
console.log("PID 1:", process.pid);

setInterval(() => { // 保持進程不退出
    console.log("PROCESS 1 is alive");
}, 5000)
// Process 2
console.log("PID 2:", process.pid);

setInterval(() => { // 保持進程不退出
    console.log("PROCESS 2 is alive");
}, 5000)

  接下來介紹幾種通信方式:

1. 信號

  信號是一種通信機制,程序運行時會接受並處理一系列信號,並且可以發送信號。

1.1 發送信號

  可以通過 kill 指令向指定進程發送信號,如下例子表示向 pid 爲 3000 的進程發送 USR2 信號(用戶自定義信號)

// shell指令,可以直接在命令行中輸入
$ kill -USR2 3000

1.2 接收信號

  定義 process 在指定信號事件時,執行處理函數即可接收並處理信號。在收到未定義處理函數的信號時進程會直接退出

// javascript
process.on('SIGUSR2', () => {
    console.log("接收到了信號USR2");
}

1.3 示例

// Receiver
console.log("PID", process.pid);

setInterval(() => {
    console.log("PROCESS 1 is alive");
}, 5000)

process.on('SIGUSR2', () => {
    console.log("收到了USR2信號");
})

  假設 Receiver 執行之後的 pid 爲 58241,則:

// Sender
const ChildProcess = require('child_process');

console.log("PID", process.pid);

setInterval(() => {
    console.log("PROCESS 2 is alive");
}, 5000)

const result = ChildProcess.execSync('kill -USR2 58241');

  在運行 Sender 後,Receiver 成功收到信號,實現了進程間的通信。同樣的方式,Receiver 也可以向 Sender 發送信號。

2. 套接字通信

  通過在接受方和發送方之間建立 socket 連接實現全雙工通信,例如在兩者間建立 TCP 連接:

// Server
const net = require('net');

let server = net.createServer((client) => {
    client.on('data', (msg) => {
        console.log("ONCE", String(msg));
        client.write('server send message');
    })
})

server.listen(8087);
// Client
const net = require('net');

const client = new net.Socket();

client.connect('8087', '127.0.0.1');
client.on('data', data =>console.log(String(data)));
client.write('client send message');

  創建 server 和 client 進程後成功發送並接收消息,分別輸出以下內容:

3. 共享內存

  在兩個進程之間共享部分內存段,兩個進程都可以訪問,可用於進程之間的通信。Node.js 中暫無原生的共享內存方式,可通過使用 cpp 擴展模塊實現,實現較爲複雜,在此不再舉例。

4. 命名管道

  命名管道可以在不相關的進程之間和不同的計算機之間使用,建立命名管道時給他指定一個名字,任何進程都可以使用名字將其打開,根據給定權限進行通信。

  例如我們創建一個命名管道,通過它在 server 和 client 之間傳輸信息,例如 server 向 client 發送消息:

// shell
$ mkfifo /tmp/nfifo
// Server
const fs = require('fs');

fs.writeFile('/tmp/tmpipe', 'info to send', (data, err) =>console.log(data, err));
// Client

const fs = require('fs');

fs.readFile('/tmp/tmpipe', (err, data) => {
    console.log(err, String(data));
})

先創建命名管道 /tmp/nfifo 後運行 client,與讀取一般的文件不同,讀取一般的文件會直接返回結果,而讀取 fifo 則會等待,在 fifo 有數據寫入時返回結果,然後開啓 server,server 向 fifo 中寫入信息,client 將收到信息,並打印結果,如下所示:

5. 匿名管道

  匿名管道與命名管道類似,但是它是在調用 pipe 函數生成匿名管道後返回一個讀端和一個寫端,而不具備名字,沒有具名管道靈活,在此不做過多介紹。

2.2.2 Node.js 原生的通信方式

  原生的 Node.js 在 windows 中使用命名管道實現,在 * nix 系統採用 unix domain socket(套接字)實現,它們都可以實現全雙工通信,Node.js 對這些底層實現進行了封裝,表現在應用層上的進程間通信,只有簡單的 message 事件和 send () 方法,例如父子進程發送消息:

// 主進程 process.js
const fork = require('child_process').fork;

const worker = fork('./child_process.js');
worker.send('start');
worker.on('message', (msg) => {
    console.log(`SERVER RECEIVED: ${msg}`)
})

// 子進程 child_process.js

process.on('message', (msg) => {
    console.log("CLIENT RECEIVED", msg)
    process.send('done');
});

2.2.3 兄弟進程之間通信的實現

  Node.js 創建進程時便實現了其進程間通信,但這種方式只能夠用於父子進程之間的通信,而不能在兄弟進程之間通信,若要利用原生的方式實現兄弟進程之間的通信,則需要藉助它們公共的父進程,發送消息的子進程將消息發送給父進程,然後父進程收到消息時將消息轉發給接收消息的進程。但是使用這種方式進行進程間的通信經過父進程的轉發效率低下,所以我們可以根據 Node.js 原生的進程間通信方式實現兄弟進程的通信:在 windows 上使用命名管道,在 * nix 上使用 unix 域套接字,該方法與上文套接字通信類似,只是這裏不是監聽一個端口,而是使用一個文件。

// Server
const net = require('net');

let server = net.createServer(() => {
    console.log("Server start");
})
server.on('connection', (client) => {
    client.on('data', (msg) => {
        console.log(String(msg));
        client.write('server send message');
    })
})

server.listen('/tmp/unix.sock');
// Client
const net = require('net');

const client = new net.Socket();

client.connect('/tmp/unix.sock');
client.on('data', data =>console.log(String(data)));
client.write('client send message');

  啓動 server 後會在指定路徑創建文件,用於 ipc 通信。

2.2.4 本案例中的問題分析

  本項目中通過一個 requestManager 實現兄弟進程之間的通信,set 方法用於設定當指定序列號收到消息時執行的回調函數。

  在本項目中過程如下:

  1 和 2 流程:

  3 流程:

  解密數據處理片段:

  而本項目的第一個問題,就出現在這裏:程序在返回結果時,調用了 res.toString 方法,在出現異常時調用 e.toString 方法獲取異常字符串,而實際中項目拋出的異常可能爲空異常 null,null 不具有 toString 方法,所以向客戶端寫入數據失敗,導致瞭解密狀態的更新沒有觸發。

  提示:在處理異常時,返回的異常信息一般情況下應該能描述具體的異常,而不應該返回空值;其次,可以使用 String (e) 代替 e.toString (),並且不應該在捕獲到異常時靜默處理。

2.3 “粘包” 問題的解決

  在解決完上述的問題後,發現 bug 並沒有完全解決,於是發現了另一個問題:接收端每次接受的數據並不一定是發送的單條數據,而可能是多條數據的合體。當發送端只發送單條 JSON 數據時,服務端 JSON.parse 單條數據順利處理消息;然而,當接收端同時接受多條消息時,便會出現錯誤,最終造成進程間通信超時:

Uncaught SyntaxError: Unexpected token { inJSON

2.3.1 “粘包” 問題的出現原因

  由於 TCP 協議是面向字節流的,爲了減少網絡中報文的數量,默認採取 Nagle 算法進行優化,當向緩衝區寫入數據後不會立即將緩衝區的數據發送出去,而可能在寫入多條數據後將數據一同發送出去,所以接收端收到的消息可能是多條數據的組合體。除此之外,也有可能是發送端一次發送一條數據,但是接收端沒有及時讀取,導致後續一次讀取多條消息。

2.3.1 “粘包” 問題的解決辦法

  “粘包” 問題的根本原因就在於傳輸的數據邊界不明確,因此確定數據邊界即可。

  可以通過在發送的消息前指定消息的長度大小,服務端讀取指定長度大小的數據。

  除此之外,還能夠制定消息的起始和結束符號,起始符和結束符中間的內容即爲一條消息。

2.4 異常的處理

  在本項目中,解密會大量失敗,而大量失敗的原因是進程間通信失敗,查看具體原因後發現是解密進程已經退出,導致大量的失敗。接下來將探討 Node.js 進程退出的原因和其解決辦法。

2.4.1 Node.js 進程退出的原因

在實際 Node.js 進程使用中,如果異常處理不當,會造成進程的退出,使服務不可用。Node.js 退出的原因有以下幾種:

2.4.2 處理異常的方式

對於上述造成 Node.js 退出的原因,都有其解決辦法。

2.4.3 異常對於 Promise 狀態的影響

process.on('uncaughtException', err =>console.log(err));
let pro = newPromise((resolve, reject) => {
    thrownewError('error');
});
setInterval(() =>console.log(pro), 1000);

這種情況這個 promise 的狀態如何呢?在 promise 內部既沒有調用 resolve 方法,也沒有調用 reject 方法。那麼 promise 的狀態爲 pending 嗎?

-- 答案是否定的,在 promise 內部拋出異常,會立即將 promise 的狀態更改爲 reject,而不會使 promise 的狀態始終爲 pending。

那麼又有另外一個問題,如果當前不捕獲異常的情況下,這裏使用那個事件捕獲異常呢?

unhandledRejectionuncaughtException?

答案是都可以,這個異常會先由 unhandledRejection 的 handler 處理,如果該事件未定義則由 uncaughtException 的 handler 處理,如果兩個事件都未定義則會提示錯誤並終止進程,具體原因在此處不作過多討論。

2.5 Node.js 多線程

  由於需要進行大量的解密和解壓縮操作,在本項目中的解密進程中,創建了多個線程,接下來將對 Node.js 多線程做詳細的介紹。

2.5.1 使用多線程的好處

  前文已經提到過,進程是資源分配的最小單位,使用多進程能夠將關聯很小的部分隔離開來,使其各自關注自己的職責。

  而線程則是 CPU 調度的最小單位,使用多線程能夠充分利用 CPU 的多核特性,在每一個核心中執行一個線程,多線程併發執行,提高 CPU 的利用率,適合用於計算密集型任務。

2.5.2 Node.js 提供的實現多線程的模塊

  在 Node.js 中,內置了用於實現多線程的模塊 worker_threads ,該模塊提供瞭如下方法 / 變量:

2.5.3 單線程、多線程、多進程的比較

  接下來,將使用單線程、多線程、多進程完成相同的操作。

// 單線程
console.time('timer');
let j;

for(let i = 0;i<6e9;i++) {
    Math.random();
}

console.timeEnd('timer');
// 多線程

// 主線程 thread.js
console.time('timer');

const { Worker, isMainThread } = require('worker_threads')

let cnt = 15;
for(let i = 0;i<15;i++) {
    const worker = new Worker('./worker.js');
    worker.postMessage('start');
    worker.on('message', () => {
        if(--cnt === 0) {
            console.timeEnd('timer');
            process.exit(0);
        }
    })
}

// 工作線程 worker.js
const { parentPort, isMainThread } = require('worker_threads');

parentPort.on('message', () => {
    for(let i = 0;i<1e9;i++) {
        Math.random();
    }
    parentPort.postMessage('done');
})
// 多進程

// 主進程 process.js

console.time('timer');
const fork = require('child_process').fork;

let cnt = 15;
for(let i = 0;i<15;i++) {
    const worker = fork('./child_process.js');
    worker.send('start');
    worker.on('message', () => {
        if(--cnt === 0) {
            console.timeEnd('timer');
            process.exit(0);
        }
    })
}

// 子進程 child_process.js

process.on('message', () => {
    for(let i = 0;i<1e9;i++) {
        Math.random();
    }
    process.send('done');
});

  實際運行結果如下(測試機爲 8 核 CPU):

  分別爲單個線程、6 個線程、6 個進程的運行結果,(在多次實驗後)結果有以下規律:

  多線程 <多進程 < 單線程 < (多線程 / 多進程) * 6

  其原因如下:

  多線程:由於充分利用 CPU,所以執行的最快。

  多進程:由於每個進程中都有一個線程,同樣能充分利用 CPU,但是進程創建的開銷要比線程大,所以執行的略慢於多線程。

  單線程:由於 CPU 利用不充分所以慢於多線程和多進程,但是由於多線程 / 多進程的創建需要一定的開銷,所以快於單個線程執行時間 * 線程個數。

  結果與預期一致。

2.5.2 本案例中線程池的問題

  在本系統中,實現了一個線程池,它能夠在線程持續空閒的時候將線程退出,它會在線程創建時監聽它的退出事件。

worker.on('exit', () => {
  // 找到該線程對應的數據結構,然後刪除該線程的數據結構
  const position = this.workerQueue.findIndex(({worker}) => {
    return worker.threadId === threadId;
  });
  const exitedThread = this.workerQueue.splice(position, 1);
  // 退出時狀態是BUSY說明還在處理任務(非正常退出)
  this.totalWork -= exitedThread.state === THREAD_STATE.BUSY ? 1 : 0;
});

  當線程一段時間內都是空閒時,調用線程的 terminate 方法,將其退出。然而,這段代碼中的問題是,線程在調用 terminate 函數退出後,其 threadId 自動重置爲 - 1,所以這段代碼並不會在線程池中將其移除,而由於 splice (-1, 1) 會將線程池中的最後一個線程移出。這樣,當線程池分配任務時,會分配給已經退出的線程,而已經退出的線程不具備處理任務的能力,因此造成進程間通信超時。

2.6 內存泄漏問題的處理

  在實際的應用中一個服務端項目往往都會持續運行很長時間,Node.js 會自動對沒有引用的變量所佔用的內存進行回收,但是還有很多內存泄漏的問題,系統並不能夠自動對其進行處理,例如使用對象作爲緩存,在對象上不斷添加數據,而不對無用的緩存做清除,則會導致這個對象佔用的內存越來越大,直到達到內存分配的最大限度後進程自動退出。下文將介紹如何分析內存泄漏問題。

2.6.1 內存快照分析

  分析內存泄漏問題最基本的方式是通過內存快照,在 Node.js 中可以通過 heapdump 庫獲取內存快照,內存快照可以用於查看內存的具體佔用情況。

const heapdump = require('heapdump');

const A = { // 4587
    b: { // 4585
        c: { // 4583
            d: newArray(1e7),
        }
    }
}

heapdump.writeSnapshot();

  例如在 Chrome 調試工具中查看內存快照:

  在 Summary 快照總覽中可以看到內存分配的詳細信息。

  第一列是 Constructor 構造函數,表示該內存的對象由該構造函數創建,()包裹的部分爲內置的底層構造函數,後方的 x1407 表示有 1407 個實例通過該構造函數創建,下方 Object @4583 表示該 Object 實例的唯一內存標識爲 4583,下方 d::Array 表示其內部的鍵爲 d 的值爲一個 Array 類型的數據;

  第二列爲 Distance,距離頂層 GCroot 的距離,例如直接在全局作用域中的變量。

  第三列爲 Shallow Size,表示其自身真實佔用的內存大小。

  第四列爲 Retained Size,表示與其關聯的內存大小,此處和此處可釋放的子節點佔用的內存總和。

  從上圖可以看出,標記爲 4583 的對象,它的鍵爲 d 的數組下真實分配了 80 000 016 字節大小的數據,佔總堆分配的數據的 98%,點擊它查看詳情,可以看到它以 c 這個鍵存在於標記爲 4585 對象下,查看 4585 對象可以看到,它以 b 這個鍵存在於標記爲 4587 的對象下:

  查看標記爲 4587 的對象可以看到,它直接存在於垃圾回收根節點上 GCRoot,與代碼完全對應,相關對應關係如下:

const A = { // 4587
    b: { // 4585
        c: { // 4583
            d: e,
        }
    }
}

2.6.2 本案例中的內存泄漏問題

  在本案例中,也發現其一些任務始終存在於內存中,下圖爲時間間隔爲一天後內存的佔用量,可以看出內存佔用量提升的非常快,

  查看其內存佔用後發現是線程池中部分任務,由於進程間通信超時,始終沒有得到釋放,解決進程間通信超時問題,並且設置一個 timeout 超時釋放即可。

2.7 npm 包發佈流程

  在一個大型項目中,往往需要用到多方面的技術,如果各方面內容的實現都放在一起,會比較雜亂,耦合度高,難以閱讀和維護。因此,需要對程序的模塊進行劃分,對每一個模塊進行良好的設計,讓每一個模塊都各司其職,最後組成整個程序。

  在本項目中的 nodejs-i-p-c 進程間通信庫,nodejs-threadpool 線程池均以包的形式發佈到了 npm 上。接下來將介紹基本的 npm 包發佈流程。

  1. 註冊 npm 賬號(https://www.npmjs.com/) 在 npm 官網使用郵箱註冊賬號,需要注意的是 npm 官網登錄只能使用用戶名 + 密碼登錄,而不能使用郵箱 + 密碼登錄!

  2. 初始化本地 npm 包。在一個本地的空文件夾中運行 npm init 指令,創建一個 npm 倉庫,倉庫的名稱即爲將要發佈的包的名稱。(package.json 文件中的 name 字段)

  3. 登錄 npm 賬號 在本地命令行中運行 npm login 指令即可進行登錄操作,在輸入用戶名、密碼、郵箱後即可完成,登錄成功則會提示 Logged in as <username> on https://registry.npmjs.org/. npm whoami 指令可以查看當前登錄的賬戶。

  4. 在(2)中初始化的倉庫中運行 npm publish 即可快速發佈當前包 如果發佈失敗,可能是因爲包名重複,提示沒有權限發佈該包,需要更改包名重新發布。

  5. 使用 npm view <package-name> 驗證包發佈,如果出現該包的詳細信息則說明包發佈成功了!

  在包發佈成功之後其他人都能夠訪問到該包,通過 npm i <package-name> 即可安裝您發佈的包使用啦。

3. 成果展示

  處理前:日誌解密大量失敗,一些日誌持續停留在解密中狀態

    處理後:解密全部成功,無其它異常。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/s3DeAxrEbVmqtCHGP9lstg