Giới Thiệu
Hãy tưởng tượng bạn đã xây dựng một ứng dụng backend, thử nghiệm trên localhost và mọi yêu cầu được xử lý nhanh chóng với mức sử dụng tài nguyên tối thiểu. Nhưng khi tải của máy chủ tăng lên, các yêu cầu trở nên chậm chạp hoặc thậm chí bị timeout. Bạn cảm thấy bối rối — vấn đề có thể nằm ở logic backend, ngôn ngữ lập trình, truy vấn cơ sở dữ liệu, máy chủ lưu trữ hay reverse proxy?
Trong bài viết này, chúng ta sẽ đi sâu vào cách mà các yêu cầu HTTP được thực thi bên trong máy chủ. Bằng cách hiểu rõ những gì máy chủ của bạn đang thực hiện, bạn sẽ có khả năng nhận diện các điểm nghẽn hiệu suất.
Cách Thiết Lập Kết Nối TCP
Chúng ta sẽ không đi quá sâu vào vấn đề này, chỉ đủ từ ngữ để theo dõi phần còn lại của bài viết. (Nếu bạn muốn một bài viết chuyên sâu về TCP, hãy để lại bình luận và tôi sẽ viết một bài riêng.)
TCP sử dụng một quy trình handshake ba bước:
- SYN: máy khách gửi yêu cầu đồng bộ đến máy chủ.
- SYN/ACK: máy chủ xác nhận và phản hồi bằng yêu cầu đồng bộ của chính nó.
- ACK: máy khách xác nhận, và cả hai bên đều đồng bộ.
Tại thời điểm này, kết nối TCP đã được thiết lập.
👉 Tại sao lại là TCP?
Bởi vì HTTP/1.1 và HTTP/2 — các phiên bản được sử dụng rộng rãi nhất — được xây dựng trên TCP. (HTTP/3 sử dụng QUIC, một giao thức khác mà chúng ta sẽ không đề cập ở đây.)
Vai Trò Của Kernel
Khi bạn gửi một yêu cầu HTTP đến một máy chủ, kernel Linux sẽ quản lý nó bằng cách sử dụng hai hàng đợi:
1. Hàng Đợi SYN
Hàng đợi này giữ các kết nối nửa mở. Sau khi máy chủ phản hồi bằng SYN/ACK nhưng trước khi có ACK cuối cùng từ máy khách, kết nối sẽ nằm ở đây.
2. Hàng Đợi Accept
Khi quá trình handshake hoàn tất, kết nối sẽ được chuyển đến hàng đợi accept. Tại thời điểm này, kernel đã hoàn tất — trách nhiệm của ứng dụng backend là gọi đến hàm accept() để tiếp nhận kết nối.
Chi tiết quan trọng: nếu hàng đợi accept đầy, các kết nối mới sẽ bị từ chối (hoặc được reset). Điều này được kiểm soát bởi tham số kernel:
cat /proc/sys/net/core/somaxconn
-> 128
(default thường là 128). Tăng giá trị này có thể giúp máy chủ xử lý tốt hơn dưới tải nặng.
Một chi tiết khác: nếu ACK từ máy khách mất quá nhiều thời gian, kernel sẽ tự động loại bỏ các kết nối.
Vai Trò Của Ứng Dụng Backend
Sau khi một kết nối vào hàng đợi accept, ứng dụng phải gọi hàm accept() để bắt đầu xử lý nó.
Hiệu suất đồng thời của ứng dụng của bạn phụ thuộc vào khả năng:
- Chấp nhận kết nối mới.
- Thực thi các yêu cầu mà không làm chậm các kết nối khác.
Cách Các Backend Khác Nhau Xử Lý Kết Nối
Chúng ta cần phân biệt hai giai đoạn:
- Chấp nhận kết nối: cách mà máy chủ gọi
accept(). - Thực thi kết nối: cách mà yêu cầu HTTP được xử lý.
Node.js
- Đơn luồng cho cả việc chấp nhận và thực thi yêu cầu.
- Nổi tiếng với I/O không chặn. Khi một yêu cầu đang chờ một hoạt động I/O (như truy vấn cơ sở dữ liệu), Node.js có thể chấp nhận các kết nối mới.
👉 Vấn đề: nếu một yêu cầu là CPU-bound (ví dụ: một vòng lặp lớn hoặc đọc file đồng bộ), vòng lặp sự kiện sẽ bị chặn. Các yêu cầu khác thậm chí sẽ không được ghi lại cho đến khi tác vụ CPU hoàn tất.
Ví dụ:
javascript
import express from 'express';
const app = express();
const LIMIT = 1_000_000_000;
function processData() {
let sum = 0;
for (let i = 0; i < LIMIT; i++) sum += i;
return sum;
}
app.get('/blocking', (req, res) => {
console.log('Yêu cầu đang chặn nhận');
const result = processData();
res.send(`Kết quả là ${result}`);
});
app.listen(3000);
Gọi /blocking hai lần liên tiếp nhanh chóng: yêu cầu thứ hai sẽ không được ghi lại cho đến khi yêu cầu đầu tiên hoàn tất.
Go
- Chấp nhận kết nối trên luồng chính.
- Ngay lập tức tạo một goroutine cho mỗi yêu cầu, giúp cho luồng chính có thể tiếp tục chấp nhận các kết nối mới.
Từ tài liệu net/http:
Serve chấp nhận các kết nối đến, tạo một goroutine dịch vụ mới cho mỗi kết nối. Các goroutine dịch vụ đọc các yêu cầu và sau đó gọi hàm xử lý để phản hồi.
Mô hình này cân bằng giữa sự đơn giản và khả năng đồng thời.
Python (máy chủ WSGI như Gunicorn/uWSGI)
- Khả năng đồng thời phụ thuộc vào các worker (luồng hoặc tiến trình).
- Khi một kết nối được chấp nhận, nó được gán cho một worker.
- Nhiều worker giúp ngăn một yêu cầu chậm chạp làm chậm các yêu cầu khác.
- Tải công việc CPU-bound vẫn sẽ chặn các worker cá nhân, vì vậy việc mở rộng thường có nghĩa là chạy nhiều tiến trình.
PHP (Apache/Nginx + PHP-FPM)
- Máy chủ web (Apache hoặc Nginx) xử lý quản lý kết nối.
- Mỗi yêu cầu được chuyển đến một worker PHP (qua mod_php hoặc PHP-FPM).
- Các yêu cầu chạy trong các tiến trình cô lập, vì vậy một yêu cầu sẽ không chặn yêu cầu khác — nhưng mỗi worker thì nặng hơn so với một goroutine nhẹ hoặc vòng lặp sự kiện không đồng bộ.
Các Công Nghệ Khác (đề cập nhanh)
- Java (Servlets, Tomcat, Netty): một luồng cho mỗi yêu cầu (Tomcat) hoặc dựa trên sự kiện (Netty).
- Rust (Tokio, Actix): vòng lặp sự kiện không đồng bộ, tương tự Node.js nhưng được tối ưu hóa cao.
Các Lệnh Hữu Ích
Chơi với những lệnh này để quan sát các hàng đợi và kết nối:
cat /proc/sys/net/ipv4/tcp_max_syn_backlog # độ dài hàng đợi SYN tối đa
cat /proc/sys/net/core/somaxconn # độ dài hàng đợi accept tối đa
ss -tln # hiển thị thông tin socket + hàng đợi
netstat -anp # phương pháp thay thế cũ hơn
Để kiểm tra tải:
ab -n 1000 -c 100 http://localhost:3000/
wrk -t12 -c400 -d30s http://localhost:3000/
Kết Luận
Đến đây, bạn nên có cái nhìn rõ ràng hơn về những gì xảy ra khi một máy khách kết nối đến backend của bạn:
- Kernel quản lý quá trình handshake và các hàng đợi.
- Ứng dụng backend phải chấp nhận và thực thi các kết nối một cách hiệu quả.
- Các runtime khác nhau xử lý điều này theo cách khác nhau: vòng lặp sự kiện, goroutines, workers hoặc tiến trình.
Điểm mấu chốt: các vấn đề hiệu suất thường không chỉ liên quan đến mã của bạn hoặc các truy vấn cơ sở dữ liệu — chúng có thể bắt nguồn từ giới hạn hàng đợi, tải công việc chặn, hoặc cách mà runtime của bạn xử lý tính đồng thời.
Trong phần hai, chúng ta sẽ xem cách mà kernel quản lý gửi phản hồi sử dụng các hàng đợi gửi/nhận (sendq và recvq).