Node-js 多進程 - 線程 —— 日誌系統架構優化實踐
1. 背景
在日常的項目中,常常需要在用戶側記錄一些關鍵的行爲,以日誌的形式存儲在用戶本地,對日誌進行定期上報。這樣能夠在用戶反饋問題時,準確及時的對問題進行定位。
爲了保證日誌信息傳輸的安全、縮小日誌文件的體積,在實際的日誌上傳過程中會對日誌進行加密和壓縮,最後上傳由若干個加密文件組成的一個壓縮包。
爲了更清晰的查看用戶的日誌信息。需要搭建一個用戶日誌管理系統,在管理系統中可以清晰的查看用戶的日誌信息。但是用戶上傳的都是經過加密和壓縮過的文件,所以就需要在用戶上傳日誌後,實時的對用戶上傳的日誌進行解密和解壓縮,還原出用戶的關鍵操作。如下圖所示,是一個用戶基本的使用過程。
但是解密和解壓縮都是十分耗時的操作,需要進行大量的計算,在衆多用戶龐大的日誌量的情況下無法立即完成所有的解密操作,所以上傳的日誌擁有狀態。(解密中、解密完成、解密失敗等)
一個常見的日誌系統架構如下:
其中按照解密狀態的變化,大體分爲三個階段:
-
用戶終端上傳日誌到 cos 並通知後臺日誌服務已經上傳了日誌,後臺日誌服務記錄這條日誌,將其狀態設置爲未解密。
-
日誌服務通知解密服務對剛上傳的日誌進行解密,收到響應後將日誌的狀態更改爲解密中。
-
解密服務進行解密,完成後將明文日誌上傳並通知日誌服務已完成解密,日誌服務將解密狀態更改爲解密完成。如果過程中出現錯誤,則將日誌解密狀態更改爲解密失敗。
但是在實際的項目使用過程中,發現系統中有很多問題,具體表現如下:
-
有些日誌在上傳很久以後,狀態仍然爲解密中。
-
日誌會大量解密失敗。(只要有一個步驟出現錯誤,狀態就會設置爲解密失敗)
接下來將以這些問題爲線索,對其背後的技術實現進行深入探索。
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 退出的原因有以下幾種:
-
Node.js 事件循環不再需要執行任何額外的工作,這是一種最常見的進程退出原因,當運行一個 js 文件時,發現文件執行完成之後,進程會自動退出,其原因就是因爲事件循環不需要執行額外的工作。阻止此類進程退出可以不斷在事件循環中添加事件,如使用 setInterval 方法定時添加任務。
-
顯式調用
process.exit()
方法,該方法可接受一個參數,表示返回代碼,代碼爲 0 表示正常退出,否則爲異常。 -
未捕獲的異常, 未捕獲的異常會導致進程退出並打印錯誤信息。使用
process.setUncaughtExceptionCaptureCallback(fn)
可以在有未捕獲異常時調用 fn,防止進程的退出。 -
**未兌現的承諾,**未捕獲的
Promise.reject
在高版本的 Node.js(v15 以後)會導致進程的退出,而在低版本不會。 -
未監聽的錯誤事件,
new EventEmitter().emit('error')
若沒有監聽 error 事件則會導致進程退出,處理方法同未捕獲的異常 -
未處理的信號,在向進程發送信號時,若沒有設置監聽函數,則進程會退出。
$ kill -USR2 <程序中輸出的pid>
2.4.2 處理異常的方式
對於上述造成 Node.js 退出的原因,都有其解決辦法。
-
Node.js 事件循環不再需要執行任何額外的工作,可以在事件循環中定時添加任務,例如
setInterval
會定時添加任務,阻止進程退出。 -
顯示調用
process.exit()
方法,在程序中非必要情況下,不要調用 exit 方法。 -
未捕獲的異常,使用
try { ... } catch (e) { }
對異常進行捕獲,並且可以設置process.setUncaughtExceptionCaptureCallback(fn)
可以在有未捕獲異常時調用 fn,防止進程的退出,作爲兜底策略。 -
未兌現的承諾,在 promise 後調用
.catch
方法或者設置process.on('unhandledRejection', fn)
,防止進程退出,作爲兜底策略。 -
未監聽的錯誤事件,在觸發'error' 事件前,可以通過
EventEmitter.listenerCount
方法查看其監聽器的個數,如果沒有監聽器,則使用其它策略提示錯誤。 -
未處理的信號,對於信號量,設置監聽函數
process.on('信號量', fn)
監聽其信號量的接受,防止進程退出。
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。
那麼又有另外一個問題,如果當前不捕獲異常的情況下,這裏使用那個事件捕獲異常呢?
unhandledRejection
?uncaughtException
?
答案是都可以,這個異常會先由 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 ,該模塊提供瞭如下方法 / 變量:
-
isMainThread
:當線程不運行在 Worker 線程中時,爲 true。 -
Worker
類:代表獨立的 javascript 執行線程: -
-
parentPort
:用於父子線程之間的信息傳輸:// 子線程 -> 父線程 // 子線程中 const { parentPort } = require('worker_threads'); parentPort.postMessage(`msg`); // 父線程中 const { Worker } = require('worker_threads'); const worker = new Worker('filepath'); worker.on('message', (msg) => { console.log(msg) });
// 父線程 -> 子線程 // 父線程中 const { Worker } = require('worker_threads'); const worker = new Worker('filepath'); worker.postMessage(`msg`); // 子線程中 const { parentPort } = require('worker_threads'); parentPort.on('message', (msg) =>console.log(msg));
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 包發佈流程。
-
註冊 npm 賬號(https://www.npmjs.com/) 在 npm 官網使用郵箱註冊賬號,需要注意的是 npm 官網登錄只能使用用戶名 + 密碼登錄,而不能使用郵箱 + 密碼登錄!
-
初始化本地 npm 包。在一個本地的空文件夾中運行
npm init
指令,創建一個 npm 倉庫,倉庫的名稱即爲將要發佈的包的名稱。(package.json 文件中的 name 字段) -
登錄 npm 賬號 在本地命令行中運行
npm login
指令即可進行登錄操作,在輸入用戶名、密碼、郵箱後即可完成,登錄成功則會提示Logged in as <username> on https://registry.npmjs.org/.
npm whoami 指令可以查看當前登錄的賬戶。 -
在(2)中初始化的倉庫中運行
npm publish
即可快速發佈當前包 如果發佈失敗,可能是因爲包名重複,提示沒有權限發佈該包,需要更改包名重新發布。 -
使用
npm view <package-name>
驗證包發佈,如果出現該包的詳細信息則說明包發佈成功了!
在包發佈成功之後其他人都能夠訪問到該包,通過 npm i <package-name>
即可安裝您發佈的包使用啦。
3. 成果展示
處理前:日誌解密大量失敗,一些日誌持續停留在解密中狀態
處理後:解密全部成功,無其它異常。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/s3DeAxrEbVmqtCHGP9lstg