node 異常資料響應排查(pm2 Cluster Mode、非同步)

linong發表於2022-05-09

昨天收到一個鐵子的反饋

node 裡面寫的一個 js 檔案裡面的方法,這個 js 檔案裡面有宣告一個 var 全域性變數(global),然後上面說到的方法就是先判斷全域性變數是否有值,要是就值就直接返回,要是沒有值就通過介面去獲取值;
然後在頁面上特定的一個操作之後,會把這個全域性變數的值清空為null,然後就走上面的獲取介面的值
本地正常,伺服器上錯誤

他們給出的資訊肯定是不夠的,我就順嘴問了幾個資訊

  • 是不是報錯?因為報錯會導致 Node 應用重啟,繼而導致狀態失效。

    沒報錯
  • 能否提供復現程式碼?這裡一般取決於專案的體量,或者目前問題定位進度

    無法提供。本地正常,伺服器上錯誤
  • 執行環境是什麼?commonjs?ESM?Ts?這裡想看看是不是有什麼騷操作,比如說 serverless 之類的無法儲存狀態。

    Node
  • 然後還問了本地和伺服器通過什麼啟動的服務?這裡我想確認一下是不是 Cluster ,因為 Cluster 狀態是不共享,需要特殊方案。

    node

其實到這裡我就知道,這個人不是專業做 Node 的,前面的資訊有可能也有毒。

這個時候突然給我發來了日誌截圖,這直接破案了。Cluster 模式資料共享問題,本地 node 起的服務所以不存在這個問題,伺服器應該是 pm2 start index.js -i 4 之類的。

0| www xxxxx
1| www xxxxx
3| www xxxxx
0| www xxxxx

接下來就是最小復現 demo 排查問題,修復方案了。

復現 Cluster 資料共享問題

其實在他讓我看到是 Cluster 的時候就已經定位到問題了,非常明顯的資料共享問題

下面來看我們的復現例子,可以發現單個例項輸出是正確的,正是因為請求落到不同的機器(例項)導致不同的響應

if (!global.a) {
    global.a = 1
}
console.log(global.a, Date.now())
function randomTask() {
    console.log(++global.a, Date.now())
    if (global.a < 5) {
        setTimeout(randomTask, Math.random() * 1000)
    }
}
randomTask();

image.png

Statelessify your application
Be sure your application is stateless meaning that no local data is stored in the process, for example sessions/websocket connections, session-memory and related. Use Redis, Mongo or other databases to share states between processes.
Another resource on how to write efficient, production ready stateless application is The Twelve Factor Application manifesto.

修復

不啟動 Cluster 叢集模式

因為本地是非 Cluster 叢集模式,所以表現正常。那麼第一個解決辦法就是生產環境也不開啟叢集模式,但是一般來說這個方案是不可取的,生產環境的請求比較高,叢集模式才是最優解法。

增加單例項的資料服務 | 降為單例項模式

類似於 redis ,只不過是新建一個單例項的 nodeJs 指令碼。獲取資料&更新資料都是請求這個指令碼服務。

因為不使用叢集模式所以也就不存在共享問題了。同時也避免了上一個解法的問題,因為資料服務不對外開放,只給內網的服務開通,所以請求量級不會太大。

redis

Published & subscribe

通過 redis 來實現釋出訂閱功能。更新資料的時候 Published 所有 Worker 更新資料。Subscribe 收到更新的時候更新自己的資料。

程式碼如下。
至於為什麼會有多個 redis 例項呢?這是因為一個 redis 例項只能為釋出者或者訂閱者,所以我們需要有兩個例項,一個用來發布更新後的資料,一個用來監聽其他 worker 發來的更新。

// ioredis
const Redis = require("ioredis")
let redisClient3 = new Redis()
let redisClient4 = new Redis()

setInterval(() => {
    const message = { foo: Math.random(), pid: process.pid };
    const channel = `my-channel-${1 + Math.round(Math.random())}`;
    
    redisClient3.publish(channel, JSON.stringify(message));
    console.log("Published %s to %s", message, channel);
}, 5000);

redisClient4.subscribe("my-channel-1", "my-channel-2", (err, count) => {
    if (err) {
        console.error("Failed to subscribe: %s", err.message);
    } else {
        console.log(
            `Subscribed successfully! This client is currently subscribed to ${count} channels.`
        );
    }
});

redisClient4.on("message", (channel, message) => {
    console.log(`Received ${message} from ${channel}`);
});

fs

因為叢集例項間無法通訊,所以需要找到一個可以共同訪問的,那麼本地磁碟也是一個可行的方案。但是 fs 有可能會存在衝突,還是不建議使用了。

image.png

試一下了好像也不會報錯,也不會出現內容錯亂,就是有可能取出來的內容是空。

const fs = require('fs');
const str = `process.pid: ${process.pid}`.repeat(999) + '\n';
console.log(`process.pid: ${process.pid}`)
const test = ()=>{
    for(var i = 0; i < 10; i++){
        console.log(`process.pid: ${process.pid} ${i}`)
        fs.writeFile('message.txt', `${i} ${str}`, (err)=>{
            if(err) console.log(err)
        });
    }
    setTimeout(test, Math.random() * 100);
    // setTimeout(test);
}
test();

cluster 模組

因為 pm2 啟動的全是 Worker 所以這個方案不太適合我們。

if (cluster.isMaster) {
  const worker = cluster.fork();
  worker.send('你好');
} else if (cluster.isWorker) {
  process.on('message', (msg) => {
    process.send(msg);
  });
}

相關文章