+93

NodeJS có thực sự nhanh như bạn nghĩ? 🤔

NodeJS dưới ánh mắt người đời

Có nhiều bạn đặt câu hỏi với mình quanh về vấn đề Hiệu Năng của NodeJS, chẳng hạn như:

  • Làm sao có thể tránh tình trạng, 1 Request nặng làm ảnh hưởng đến tất cả Request khác trên 1 Ứng dụng Web bằng NodeJS? (#1)
  • Tại sao NodeJS chạy nhanh, nhưng đôi lúc lại thấy chậm ở các API khác nhau? (#2)
  • Có những API, NodeJS cần con số ở hàng chục đơn vị giây để có thể Response? (#3)

Vậy làm thế nào 1 bông hoa tươi đẹp như NodeJS đây, không được đặt đúng nơi lại trở nên khô héo như vậy, Chúng ta cùng nhau làm rõ từng vấn đề nhé!

Xin chào, mình là Khánh Ney, sinh ra là một người đam mê tốc độ nhưng Ba Mẹ lại bắt làm Developer 🤫, cũng vì vậy mà lúc nhỏ mình rất thích Tốc Độ và ngay cả vào sân bóng mình cũng rất thích những pha đi bóng dài và vượt qua hàng hậu vệ với những pha bức tốc của mình (1-2 pha là thay người rồi 😄).

Cũng chính vì lúc xưa, khi mới bắt đầu tìm hiểu về các nền tảng xây dựng website, nhưng không hiểu ông nào đã có những bài viết giật tít mà làm cho mình mặc định NodeJS là chạy NHANH NHẤT, nhưng đời nào như là mơ, với tư tưởng đó mình đã làm việc với các project NodeJS với ý nghĩ ‘NodeJS vô đối, T nói nó nhanh là nó sẽ nhanh nhé 😎’, …

Nào là các khái niệm mang tính chất hàn lâm như: EventLoop, CallStack, Non-Blocking I/O, Asynchornous Code, .. Các khái niệm này đã đầy dẫy trên mạng, nhưng thực sự chưa hiểu hết cặng kẽ và không biết khi nào mình áp dụng vào ứng dụng. Mình nghĩ nói đến đây cũng có rất nhiều AE đã/đang/sẽ gặp các vấn đề này.

Và mình cứ mang suy nghĩ NodeJS là bá đạo trải qua nhiều project khác nhau, và điều gì tới cũng đã tới, mình đã có cơ hội ăn hành 😄 khi đối diện với các bài toán nếu dừng lại là biết thì chưa chắc giải quyết được.

Để một cầu thủ có tốc độ cao, bạn phải biết sự kết hợp giữa nhịp thở và các sải chân đều để giúp cơ thể có thể trạng tốt nhất khi chạy.

Việc điều phối Hơi Thở ảnh hưởng rất nhiều đến các bước chạy của bạn (nguồn: wikiHow)

Và trong những điều kiện và tình huống khác nhau cũng sẽ ảnh hưởng rất nhiều đến kết quả của 1 cuộc thi Marathon

  • Báo là ông vua trong chặn đường ngắn-nước rút, nhưng không phải thế mạnh cho các chặn đua dài
  • Và trong môi trường có nhiệt độ CAO, con người sẽ là người chiến thắng trong chặn đua trước NGỰA, SÓI, BÁO.

(tham khảo: https://qr.ae/pNWVOm)

Quay lại nào, bây giờ chúng ta cùng nhau giải thích cũng như phải hiểu được lý do tại sao Bông Hoa kia đẹp nhưng phải đúng nơi nhé, không thì sẽ thế này đây 🥀.

Non-Blocking I/O trong NodeJS

Như nhiều bài viết khác đã đề cập, NodeJS hoạt động với MainThread (Event Loop), Ngoài MainThread, LibUV còn cung cấp thêm 1 loại Thread khác giúp việc xử lý các tác vụ I/O tốt hơn, đó là ThreadPool, để giúp NodeJS có thể xử lý các tác vụ I/O hiệu quả. Ngày nay các hệ điều hành đã cung cấp các Asynchronous Interface giúp NodeJS có thể tương tác với OS(Hệ Điều Hành) và gắng cờ để đẩy sang OS xử lý, ví dụ: AIO của Linux, epool(linux), kqueue (OSX),… Nói tóm lại, Ngoài các tác vụ Async I/O mà có khả năng được xử lý bởi OS, thì ThreadPool xử lý.

→ Đó cũng là 1 trong những điểm mạnh của NodeJS giúp việc xử lý các tác vụ I/O trở nên đẹp hơn với thuật ngữ hay gọi ‘Non-Blocking I/O’.

Vậy còn các tác vụ tính toán qua CPU thì như thế nào

ví dụ:

  • Duyệt qua 1 triệu item trong array để tính toán 1 biểu thức nào đó .
  • Các bài toán liên quan tính toán trên việc duyệt mảng

Trong thực tế, nếu bạn gặp phải 1 trong những bài toán trên, bạn cũng có thể xử lý bằng những cách tách thành các array con, sẽ trong như sau:

    // hugeArray= 1.000.000.000 item
    
    var i,j,temparray,chunk = 10;
    for (i=0,j=hugeArray.length; i<j; i+=chunk) {
        temparray = hugeArray.slice(i,i+chunk);
        // hàm tính toán nặng
    }
    https://stackoverflow.com/a/8495740

Nhưng với cách làm này, độ phức tạp vẫn là 0(n), cũng chẳng cải thiện được là bao, và quan trọng vấn đề #1 của chúng ta vẫn chưa được giải quyết. Nếu hệ thống có một API chứa function như trên, thì xem như chúng ta tạch, hãy nhớ rằng:

Hiệu năng của một hệ thống chính là hiệu năng của function có hiệu năng kém nhất.

hoặc cũng có thể ngầm hiểu ’Một con sâu làm rầu nồi canh’. Chính vì vậy, hệ thống hoàn toàn có thể bị đánh sập chỉ bởi tồn tại ít nhất một API kiểu thế này.

    app.get('/huge-arr', (req, res) => { // 🚧 Block Route
        let times = 10000000000;
        let total = 0;
       
        for(let i=1; i<=times; i++) {
            total += +i;
        }
        res.json(`done, total: ${total}`);
    });
    
    app.get('/check-health', (req, res) => {
        res.json(`i'm fine`);
    });

Đoạn code trên mô tả việc khi một người dùng truy cập vào API: ‘/huge-arr’, chúng ta cần duyệt qua 10.000.000.000 item, và mỗi lần chạy phải tính toán biểu thức có độ phức tạp và tốn thời gian (function tính tổng qua từng lần lặp), và trong lúc đó, các request vào API: ‘/check-health’ sẽ pending cho đến khi API ‘/huge-arr’ trả về kết quả qua res.json(‘done, …’).

/huge-arr làm block tất cả các API khác

Vậy làm cách nào để có thể giải quyết vấn đề trên (tránh việc API gây nghẽn đến toàn bộ API còn lại cho đến khi được giải phóng), Chúng ta có 2 giải pháp để giải quyết:

  • Kĩ Thuật Off-loading
  • Kĩ Thuật Partitioning

Nếu các bạn để ý, Quy trình đẩy các tác vụ mà Hệ Điều Hành đã hỗ trợ xử lý các tác vụ I/O qua Interfaces giao tiếp trực tiếp với NodeJS cũng được xem là kĩ thuật Off-Loading. Nhưng đó là ở 1 level khác trong kiến trúc NodeJS. Từ khi NodeJS v10.5 đã giới thiệu WorkerThreads và LTS tại v12.x. Với WorkerThreads, Chúng ta có thể chủ động cấu hình và xử lý các tác vụ nặng liên quan đến CPU với Kĩ Thuật Off-Loading.

ảnh nguồn: https://nodesource.com/blog/worker-threads-nodejs/

Off-Loading:

Chúng ta vẫn chưa giảm được độ phức tạp thuật toán (O(n)), nhưng nó giúp Chúng ta có thể sử dụng các Worker xử lý các tác vụ phức tạp cao/nặng thay thế cho Main-Thread. Điều này cũng có thể hình dung đơn giản, Với những tiền tạo có Tốc Độ cao, thay vì dốc bóng từ sân nhà, Họ có thể chuyền cho các Hậu Vệ cánh, và ghi bàn sau pha tạt cánh từ Hậu Vệ 😜.

nguồn: giphy.com ⚽

Nhưng số lượng Worker là Hữu Hạn, và chắc chắn sẽ có thời điểm số lượng Request lớn hơn số lượng Worker sẵn sàng nhận job (tham khảo code Off-Loading trong NodeJS: https://nodesource.com/blog/worker-threads-nodejs/). Và rồi chúng ta phải nhắc thêm về Kĩ Thuật thứ 2: Partitioning.

Partitioning

là kĩ thuật dựa vào nguyên tắc hoạt động của các Phase trong EventLoop (mình sẽ nói ở bài sau) giúp chúng ta có thể giảm O(n) → O(1) cho các bài toán duyệt mảng số lượng lớn, để dễ hình dung hơn chúng ta có ví dụ sau 📉:

    app.get('/huge-arr-with-ronaldo', (req, res) => { // 💚 Healthy Route
        let times = 10000000;
         /**
         * @param {*} cbRunHugeArrWithRonaldo callback trả về giá trị total
         */
        function runHugeArrWithRonaldo(n, cbRunHugeArrWithRonaldo) { 
            let total = 0;
            const FIRST_ITEM_FOR_LOOP = 1;
            
            function helpSplit(i, cbHelpSplit) {
                total += i;
                if (i == times) 
                    return cbHelpSplit(total);
                setImmediate(helpSplit.bind(null, i+1, cbHelpSplit)); // setImmediate giúp chuyển sang tick tiếp theo và đợi đến phase tiếp theo với giá trị i+1 và sẵn sàng nhận các external event(request) để xử lý (tham khảo: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/)
            }
    
            helpSplit(FIRST_ITEM_FOR_LOOP, totalResult => {
                return cbRunHugeArrWithRonaldo(totalResult);
            })
        }
        runHugeArrWithRonaldo(times, totalReuslt => {
            res.json(`done, total: ${totalReuslt}`);
        })
    });
    
    app.get('/check-health', (req, res) => {
        res.json(`i'm fine`);
    });

Như các bạn cũng đã thấy, việc tách và xử lý 1 item/1 tick 🔁 (1 lần lặp, khái niệm liên quan EventLoop) đã giúp App Chúng ta có thể tiếp tục nhận các Request khác mà không lo Request hiện tại xử lý xong chưa (cuối mảng). khi người dùng truy cập vào ‘/huge-arr-with-ronaldo’, cũng không còn bị pending mà vẫn có thể nhận những request khác VD như vào API ‘/check-health’ mà không phải chờ phải hồi từ API ‘/huge-arr-with-ronaldo’;

Thường thì mình hay áp dụng với rule như sau:

  • Offloading: cho các tác vụ nặng + tính toán nặng 📝
  • Partitioning: Tác vụ duyệt mảng 🔁

Giải đáp thắc mắc

Vậy nếu kết hợp 2 kĩ thuật trên chúng ta đã có thể giải quyết cho những câu hỏi từ đầu bài theo thứ tự như sau:

Câu hỏi 3: Có những API, NodeJS cần con số ở hàng chục đơn vị giây để có thể Response? (#3) → Ở ý này cần làm rõ, nếu nút thắt nằm ở Database(Query với 1 khối lượng lớn dữ liệu) thì chúng ta có thể cải thiện qua việc Cache hoặc tối ưu Index trong Database. Nhưng nếu vấn đề nằm tại Javascript Code, chúng ta cần xác định đây là tác vụ về I/O hay CPU.

  • 💿 IO-intensive : Xem lại chúng ta có xử lý ổn các tác vụ I/O hay chưa, có block các tác vụ này hay không?
  • 🖥️ CPU-intensive: Áp dụng 1 trong 2 hoặc cả 2 kĩ thuật (Offloading-Partitioning) giảm thiểu việc EventLoop (Main-Thread) phải hứng và xử lý các tác vụ nặng đó. (Bonous: Ngoài ra chúng ta cần quan tâm đến các thông số trên Metric EventLoop, GC, Memory/CPU Profiler, Database Profiler, … để có thể cải thiện hiệu năng ở các khâu một cách tốt nhất)

Câu hỏi 1: Làm sao có thể tránh tình trạng, 1 Request nặng làm ảnh hưởng đến tất cả Request khác trên 1 Ứng dụng Web bằng NodeJS? (#1)
Câu hỏi 2: Tại sao người ta lại nói NodeJS chạy nhanh, nhưng đôi lúc lại thấy chậm ở các API khác nhau? (#2)
→ Như Câu Hỏi 3 mình đề cập, với các request nặng cần phân tích là liên quan đến CPU hay I/O để có các phương án xư lý thích hợp (sự kết hợp giữa các tools PerformanceTest + Monitoring sẽ giúp chúng ta có cái nhìn khách/trực quan và xác định được nút thắt là điều quan trọng nhất)

Hi vọng tới đây các bạn cũng đã hiểu được 1 phần nào đó về Ưu/Nhược điểm của NodeJS trong từng loại tác vụ, và đương nhiên khi có các Nhược điểm trên chúng ta lại trân trọng hơn các ưu điểm của NodeJS và không ưu muội như mình lúc vừa mới học.

Học, học nữa, học mãi ...

⚠️ Thật ra 2 kĩ thuật trên là một phần giúp chúng ta có thể xử lý, tất nhiên sẽ cần rất nhiều yếu tố khác, chẳng hạn như:

  • Làm sao có thể detect được API nào là API Nghẽng (Bottleneck) ⏳?
  • API đó nghẽng ở Level nào: Network, Infrastructure, Application, Database, … hay những yếu tố khác 🌎?
  • Bạn có sử dụng Worker(thành phần xử lý job từ WorkerThreads) có đang bị over?
  • Code Javascript của bạn có thật sự tốt, ở những phần xử lý liên quan: Code Đồng Bộ/bất đồng bộ, REDOS, Garbage Collection,…?

Ở những bài viết tiếp theo, mình sẽ đi sâu vào từng chủ đề và trả lời từng câu hỏi qua các chủ đề mình hướng đến. 😁 Bingo, giờ thì tạm biệt và hẹn gặp lại ở các bài viết tiếp theo.
Nếu quan tâm thì hãy Upvote và Clip lại bài viết này và theo dõi thêm Serie NodeJS và những câu chuyện Tối Ưu Performance nhé!


All rights reserved

Bình luận

Đăng nhập để bình luận
Avatar
@8bu
thg 1 4, 2021 4:26 SA

Thank a vì bài viết hay & bổ ích. Đúng cái em cần mặc dù em là FE Dev 😄. E xin góp ý xíu ở Partitioning độ phức tạp thuật toán xét toàn diện thì nó vẫn thế chứ e thấy nó k giảm hẳn xuống từ O(n) -> O(1) đâu nhỉ. Partitioning chủ yếu để giải quyết vấn đề blocking của app đúng k ạ?

Avatar
@khanhney
thg 1 4, 2021 4:35 SA

Cám ơn lời góp ý chia sẽ từ bạn, có thể trong câu từ mình dùng chưa phù hợp, nhưng nếu context phù hợp hơn nó sẽ là n(times) * O(1) với 1 cho từng tick bạn nhé

Avatar
@Moctra
thg 1 4, 2021 4:38 SA

Bài viết rất bổ ích 👍🏻

Avatar
@khanhney
thg 1 4, 2021 8:16 SA

cám ơn bạn 😜

Avatar
@huukimit
thg 1 5, 2021 2:52 SA

Đối thủ nặng ký trong sự kiện Tết của Viblo là đây. =))

Avatar
@khanhney
thg 1 5, 2021 8:38 SA

Đồng đội là đây mà nhỉ 😅

Avatar
@Kimkhanhne
thg 1 6, 2021 11:52 SA

Mấy vấn đề này thực tế chắc nhiều bạn cần đây !

Avatar
@khanhney
thg 1 6, 2021 1:26 CH

cám ơn bạn, khi viết post này mình chỉ sợ lý thuyết lang mang lại làm cho người đọc khó hiểu, và khi nói tới NodeJS thì lại vẫn là 'Nodejs là nhất' 😂 khi thật sự chưa hiểu được vấn đề sẽ gặp và giải quyết từ đâu

Avatar
@tienganh1247
thg 1 7, 2021 3:26 CH

Chất. Lên.

Avatar
@khanhney
thg 1 7, 2021 3:55 CH

😆 tiết gì cho mình 1 upvote nhỉ

Avatar
@poppycute99
thg 7 7, 2021 9:24 SA

Cho em hỏi ví dụ 1 API mà xử lý mất 2s thì trong 2s đó các API còn lại sẽ bi pendding hay sao anh

Avatar
@vulongc3
thg 2 18, 2022 9:18 SA

function runHugeArrWithRonaldo(n, cbRunHugeArrWithRonaldo) Đoạn param là items và cbRunHugeArrWithRonaldo chứ b nhỉ? 😄

Avatar
@dongtt.fd
thg 12 14, 2022 8:13 SA

Sao tôi copy code của ông chạy với vòng for thì hết 2ms mà với callback Partitioning mất 8.5s nhỉ, tôi chạy có 500k đơn vị, hay bài toán chỉ hữu dụng với dữ liệu lớn lên đến trăm triệu ?

Avatar
@mycool
thg 6 9, 2023 8:05 SA

chào bạn, mình thấy ở đây bạn nói hơi mâu thuẫn, nên mình không hiểu: "Như nhiều bài viết khác đã đề cập, NodeJS hoạt động với MainThread (Event Loop), Ngoài MainThread, LibUV còn cung cấp thêm 1 loại Thread khác giúp việc xử lý các tác vụ I/O tốt hơn, đó là ThreadPool, để giúp NodeJS có thể xử lý các tác vụ I/O hiệu quả. Ngày nay các hệ điều hành đã cung cấp các Asynchronous Interface giúp NodeJS có thể tương tác với OS(Hệ Điều Hành) và gắng cờ để đẩy sang OS xử lý, ví dụ: AIO của Linux, epool(linux), kqueue (OSX),… Nói tóm lại, Ngoài các tác vụ Async I/O mà có khả năng được xử lý bởi OS, thì ThreadPool xử lý." ở đoạn đầu bạn nói rằng "LibUV còn cung cấp thêm 1 loại Thread khác giúp việc xử lý các tác vụ I/O tốt hơn, đó là ThreadPool", nhưng đoạn cuối bạn lại nói rằng "Ngoài các tác vụ Async I/O mà có khả năng được xử lý bởi OS, thì ThreadPool xử lý.", 2 cái này mâu thuẫn nhau, vậy thread pool nó xử lý các tác vụ asynIO hay nó xử lý tác task mà worker_thread cần tính toán? còn hệ điều hành là như thế nào nhỉ, nó đóng vai trò gì ở đây ạ? mk cảm ơn bạn

Avatar
+93
Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí