Xin chào, lại là mình, một NodeJS developer đã đi làm được một thời gian. Thực ra mình cũng chưa phải đối mặt với những con số traffic khổng lồ như 1k, 10k hay 1M user đâu, nhưng trong quá trình làm việc, mình đã cố gắng học hỏi và áp dụng các best practice để code clean, hiệu quả, scalable và high performance.
Trước khi đi vào chi tiết các kỹ thuật scaling, mình ôn lại một số đặc điểm quan trọng của NodeJS để hiểu tại sao việc scaling NodeJS lại có những đặc thù riêng.
NodeJS là gì? Có những đặc điểm quan trọng nào?
NodeJS là một JavaScript runtime được xây dựng trên V8 engine của Chrome, cho phép chạy JavaScript ở ngoài browser. Các đặc điểm quan trọng của NodeJS mà mình có thể thấy trực tiếp trên document của NodeJS, bao gồm: event-driven
, asynchronous
, non-blocking I/O
, single thread
.
1. Event-Driven
NodeJS được thiết kế theo kiến trúc event-driven, mọi thứ đều xoay quanh các events. Khi có một event xảy ra (như request HTTP, file được đọc xong, database trả về kết quả, EventEmitter, …), NodeJS sẽ gọi các callback, Promise tương ứng.
2. Asynchronous
Hầu hết các API trong NodeJS đều hoạt động theo cơ chế asynchronous. Không cần chờ đợi một tác vụ hoàn thành mới có thể thực hiện tác vụ tiếp theo. Thay vào đó, bạn có thể khởi động nhiều tác vụ cùng lúc và xử lý kết quả khi chúng sẵn sàng. Ví dụ như fs.readFile()
, fs.writeFile()
, http.get()
, eventEmitter.on()
, …
3. Non-blocking I/O
Đây là điểm mạnh lớn nhất của NodeJS. Thay vì chờ đợi các tác vụ I/O (đọc file, query database, gọi API) hoàn thành rồi mới làm việc khác, NodeJS sẽ delegate cho libuv (Thread pool và Event Loop), sau đó, tiếp tục xử lý các tác vụ khác. Khi các tác vụ I/O hoàn thành, libuv sẽ thông báo cho NodeJS biết để gọi các callback, Promise tương ứng.
4. Single Thread
NodeJS hoạt động trên một thead duy nhất (main thread) nhưng điều này không có nghĩa là NodeJS chỉ có thể xử lý một việc tại một thời điểm. Main thread chịu trách nhiệm quản lý các event và callback, không phải chờ đợi các tác vụ I/O hoàn thành. Thread pool và Event Loop chịu trách nhiệm xử lý các tác vụ I/O. Nhờ đó mà NodeJS có thể xử lý nhiều request đồng thời mà không cần tạo thread mới cho mỗi request.
5 Cách Scale Node.js Application
1. Load Balancing với Nginx
Khi NodeJS application bắt đầu nhận nhiều request hơn, việc chạy chỉ một instance có thể không đủ. Đây là lúc load balancer trở nên cần thiết, và mình dùng Nginx để làm load balancer.
Tại sao cần Load Balancer?
- Phân phối traffic đều cho nhiều instance
- Tăng độ tin cậy - nếu một instance crash, các instance khác vẫn hoạt động
- Có thể scale horizontal dễ dàng
Cách setup Nginx làm load balancer:
upstream nodejs_backend { ip_hash; server 127.0.0.1:3000 weight=3 max_fails=3 fail_timeout=30s; server 127.0.0.1:3001 weight=2 max_fails=3 fail_timeout=30s; server 127.0.0.1:3002 weight=1 max_fails=3 fail_timeout=30s;
} server { listen 80; server_name example.com; location / { proxy_pass http://nodejs_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; }
}
2. Sử dụng worker_threads để tối ưu
Vì NodeJS là single thread, nên chỉ dùng được 1 core CPU. Trong khi đó, thực tế server có thể có 2, 4, 8, 16 core. Để tận dụng được tối đa core CPU của server, mình sử dụng các built-in module là worker_threads
.
worker_threads
cho phép NodeJS chạy song song trong các threads riêng biệt, chia sẻ memory trong cùng một process. Giữ các main thread với worker thread và giữa các worker thread có thể giao tiếp với nhau. Vì main thread và worker threads là riêng biệt nên OS sẽ phân bổ worker thread ở các core khác, giúp tận dụng tối đa core CPU.
Case study thực tế mình đã áp dụng worker_threads
trong hệ thống risk managment. Mình chia hệ thống thành 2 phần là:
Main Thread có nhiệm vụ quan trọng và cấp bách nhất:
- Theo dõi danh mục đầu tư (portfolio) của người dùng.
- Thực hiện lệnh force close ngay lập tức nếu khoản lỗ đạt ngưỡng.
Worker Threads được dùng để xử lý các tác vụ nặng, tiêu tốn nhiều tài nguyên CPU:
- Nhận và xử lý dữ liệu thị trường real-time từ Binance.
- Cập nhật trạng thái các lệnh giao dịch.
Main thread luôn "rảnh rỗi" để quản lí rủi ro, các worker threads xử lý dữ liệu nền mà không làm nghẽn hệ thống. Cách tiếp cận này giúp tận dụng tối đa các core CPU của server, đảm bảo tính ổn định và tốc độ phản hồi của hệ thống.
Ngoài worker_threads, thì NodeJS cũng còn 2 module built-in khác có thể sử dụng để tối ưu core CPU là cluster
và child_process
3. Tách services theo role
Thực tế, mình thường phân chia các instance của NodeJS application theo các role riêng biệt. Các role-based instance này có thể chạy độ lập, chịu trách nhiệm khác nhau, scale độc lập và deploy riêng. Mình sẽ sử dụng REST, gRPC, Message Queue (BullMQ, Redis Pub/Sub) để giao tiếp giữa các instances.
Một vài lợi ích có thể kể đến:
- Module hóa được codebase, dễ quản lí hơn.
- Có thể scale các instances đang bị tải cao dễ dàng.
- Có thể deploy riêng theo role
Các role trên thực tế mình hay định nghĩa gồm:
- api: chuyên để xử lí request của user
- worker: chuyên để xử lí các tác vụ nặng về I/O (ví dụ như tính toán nặng, xử lí dữ liệu thị trường, xử lí file lớn, …)
- load_balancer: chuyên để xử lí điều hướng traffic
- publisher/subscriber: publisher dùng để produce các task, còn subscriber sẽ consume. Thường role “api” sẽ là publisher, role “worker” sẽ là consumer. Đôi khi, publisher và consumer là các instance riêng lẻ.
4. Tối ưu I/O bằng Worker instance
Với các tác vụ tốn thời gian như tính toán phức tạp, xử lí file lớn, xử lí database, làm báo cáo. Các tác vụ này sẽ được đẩy vào Queue (BullMQ) để xử lý, và một Worker instance sẽ có nhiệm vụ xử lý chúng ở background.
Worker instance này thường sẽ là một server có cấu hình tương tự hoặc cao hơn, nhiều tài nguyên để xử lí các tác vụ nặng. Đồng thời, việc tách riêng Worker ra một server riêng còn giảm thiểu rủi ro khi Worker xử lí nặng làm ảnh hưởng đến các instance khác.
Các lợi ích có thể kể đến:
- Giảm thời gian phản hồi cho user
- Chạy xử lý song song ở background
- Scale worker theo tải hệ thống
5. Sử dụng Redis để cache và tận dụng các data structure
Caching là một các phổ biến và hiệu quả để cải thiện performance của không chỉ NodeJS application.
Một số loại cache mình đã xử dụng thực tế như cache lại query từ database, cache response api, cache token, session.
Ngoài ra, mình cũng sử dụng Sorted Set data structure của Redis. Đây là case study thực tế mà mình đã áp dụng. Trong một ứng dụng trading, mình cần track position của user và tính toán mức giá mà tại đó user sẽ bị lỗ 5%. Thay vì query database và sort mỗi lần, mình sử dụng Redis Sorted Set.
Vấn đề:
- Có nhiều user với nhiều position khác nhau
- Cần nhanh chóng tìm ra những user có nguy cơ lỗ >= 5% tại mức giá hiện tại
- Data thay đổi liên tục theo real-time price
Tại sao Sorted Set hiệu quả:
- Tự động sort: Không cần sort lại mỗi lần query
- Unique members: Mỗi user chỉ có một record, tự động update
- Range queries: Có thể query theo range price rất nhanh O(log(N)) (binary search)
- Memory efficient: Chỉ lưu userId và score, metadata riêng biệt
Lời kết
Đây là 5 cách mà mình đã học được và áp dụng để scale cho phần lớn Node.js applications. Các ý chính mình nghĩ là cần nắm bắt là:
- Load balancer để chia tải
- Tận dụng CPU core
- Phân tách services
- Queue/Worker để xử lý async
- Redis để tăng tốc độ
Hi vọng sẽ hữu ích với mọi người ^_^