Bỏ qua tới nội dung
LẬP TRÌNHGIẢI THÍCH

Server 12 đô gánh 64 nghìn người: 13 ngôn ngữ và 5 bí quyết tối ưu

Một server 1 nhân CPU, 2 GB RAM giá 12 đô chịu được 64 nghìn người dùng cùng lúc. Kết quả của 13 ngôn ngữ, vì sao 64 nghìn là trần, và 5 kỹ thuật bạn có thể học theo.

BBeernary26/10/2026 · 14 PHÚT ĐỌC
COVER.BMP
Server 12 đô gánh 64 nghìn người: 13 ngôn ngữ và 5 bí quyết tối ưu
▲ Thử thách tối ưu backend trên server 1 nhân, 2 GB RAM với 28 bài nộp từ 13 ngôn ngữ

Bạn vừa làm xong một ứng dụng nhỏ, thuê con VPS rẻ nhất cho đỡ tốn, rồi bắt đầu lo: lỡ một hôm bài đăng lên xu hướng, vài nghìn người vào cùng lúc thì con máy một nhân này có sập không? Phản xạ quen thuộc là nâng cấp máy, thêm Redis, thêm Postgres, thêm Nginx. Nhưng một thử thách lập trình gần đây cho thấy điều ngược lại: một server giá 12 đô mỗi tháng, chỉ một nhân CPU và 2 GB RAM, đã chịu được 64 nghìn người dùng cùng lúc, gấp hơn bốn lần kỷ lục trước đó.

Bài này đi qua luật chơi, kết quả của từng ngôn ngữ, và quan trọng nhất là những kỹ thuật thật sự tạo ra khác biệt để bạn có thể áp dụng vào dự án của mình.

Thử thách này bắt đầu từ đâu và luật chơi khắt khe cỡ nào?

Mọi chuyện bắt đầu từ một bài benchmark trước đó: dùng các framework phổ biến kèm Postgres và Nginx trên cùng cấu hình máy, kỷ lục cao nhất chỉ là Rust với khoảng 14 nghìn người dùng đồng thời. Thế là một thử thách mở được tung ra cho cộng đồng: ai làm được tốt hơn? Kết quả nhận về 28 bài nộp từ 13 ngôn ngữ khác nhau.

Những tủ rack chứa máy chủ trong phòng máy, mỗi khay là một server riêng

Cấu hình máy rất khiêm tốn: một nhân CPU dùng chung, 2 GB RAM, 50 GB ổ cứng. Luật chơi thì chặt:

  • Cùng một API mạng xã hội kiểu Twitter: đăng bài, xem feed, mở bài, bấm thích, có xác thực bằng token và phải kiểm tra chữ ký token. Mọi bài nộp phải vượt qua 42 bài kiểm tra chức năng.
  • Database bắt buộc là SQLite chạy chung tiến trình với ứng dụng. Cấm Redis, cấm Postgres, cấm mọi server cache tách riêng.
  • Giao dịch phải ghi xong mới được báo thành công: không được trả "OK" cho người dùng rồi mới lặng lẽ ghi xuống đĩa sau.
  • Cấm chỉnh kernel Linux: không được nâng giới hạn hệ điều hành để ăn gian.

Luật cấm Redis và Postgres quan trọng hơn bạn nghĩ. Nó buộc mọi người phải tối ưu chính ứng dụng, thay vì đẩy việc khó sang một dịch vụ khác.

Đo bằng K6: thế nào mới tính là "chịu được"?

Tải được tạo bằng K6 (công cụ kiểm thử tải mã nguồn mở), giả lập người dùng thật: tải feed, mở bài, bấm thích, đăng bài. Một bài nộp được tính là trụ được ở mức N người dùng khi duy trì đủ 5 phút với ba điều kiện:

Tiêu chí Ngưỡng
Tỉ lệ lỗi dưới 1%
95% request (P95) dưới 500 ms
99% request (P99) dưới 1 giây

Kết quả một lần chạy K6 trong terminal với các chỉ số thời gian phản hồi p90 và p95

Có những bài nhanh tới mức một máy bắn tải không đủ sức, người tổ chức phải dùng bốn máy ảo cùng lúc để tạo đủ lưu lượng.

P99 là gì?

Hình dung 100 khách vào quán. P99 dưới 1 giây nghĩa là 99 người được phục vụ trong vòng 1 giây, chỉ tối đa 1 người phải chờ lâu hơn. Con số trung bình có thể đẹp, nhưng P99 mới cho biết những người kém may mắn nhất chịu trận ra sao.

Bảng xếp hạng: 13 ngôn ngữ về đích thế nào?

Đây là kết quả tốt nhất của từng ngôn ngữ, xếp từ thấp đến cao:

Ngôn ngữ Người dùng đồng thời Điểm nhấn
Ruby (Rails thuần) khoảng 3.500 Cách làm framework truyền thống
Ruby tối ưu 14.000 Server Iodine viết bằng C, SQLite tự sinh JSON
Erlang 15.000 Cowboy, bị ép chạy một scheduler
Python 22.000 Gỡ middleware của FastAPI
Go 22.000 Group commit, nghẽn ở số kết nối
C# 32.000 Kestrel cấp thấp, gần như không tạo rác
Fortran 38.000 SQLite lo JSON lẫn kiểm tra token
PHP 40.000 Swoole, ứng dụng chạy thường trực
Rust (Axum) 43.000 Ngốn hơn 1 GB RAM
C 46.000 io_uring, SQLite biên dịch riêng
TypeScript (Bun) 47.000 Đọc thẳng byte từ TCP socket
Zig 61.000 uSockets, tự giới hạn 60 nghìn kết nối
C++ 64.000 Một luồng, một event loop
Rust (io_uring) 64.000 Chỉ dùng nửa CPU, dưới 100 MB RAM

Nhìn bảng này, điều bất ngờ không phải là ai đứng đầu, mà là khoảng cách giữa cách viết "bình thường" và cách viết tối ưu trong cùng một ngôn ngữ.

Nhóm cuối bảng: Ruby, Erlang, Python, Go đã vấp ở đâu?

Ruby: Rails chỉ trụ được khoảng 3.500 người. Bài Ruby nhanh nhất đạt 14 nghìn nhờ thay server bằng Iodine (viết bằng C), bật YJIT, và một mẹo xuất hiện lặp đi lặp lại trong cả cuộc thi: để SQLite tự sinh JSON, bỏ hẳn bước dựng object Ruby rồi chuyển ngược sang JSON.

Erlang đạt 15 nghìn người với web server Cowboy. Erlang sinh ra để chia việc cho nhiều nhân, nhưng trên máy một nhân nó bị ép chạy đúng một scheduler. Tới khoảng 16 nghìn người, CPU cạn kiệt, độ trễ vọt quá bốn giây.

Python gây bất ngờ lớn: từ khoảng 2.150 người với FastAPI lên 22 nghìn, gấp 10 lần. Tác giả đo profile và phát hiện CPU tiêu phần lớn vào middleware của framework. Gỡ hết, cho request đi thẳng tới handler, riêng bước đó đã giúp hiệu năng tăng gấp đôi.

Go cũng dừng ở 22 nghìn. Bài này dùng group commit: một goroutine duy nhất giữ database, gom tới 256 request ghi vào một giao dịch rồi trả lời tất cả cùng lúc. Điểm nghẽn lại nằm ở kết nối: vượt 16 nghìn, server bắt client ngắt rồi nối lại, và chi phí bắt tay lại ăn mòn CPU.

Bài học từ Python

Trước khi đổi ngôn ngữ, hãy chạy profiler. Rất nhiều khi thứ làm ứng dụng chậm không phải ngôn ngữ, mà là các lớp middleware bạn thêm vào "cho tiện" rồi quên mất.

Nhóm giữa: C#, Fortran và PHP lội ngược dòng ra sao?

C# nhảy từ 4.400 lên 32 nghìn người. Vẫn dùng Kestrel, nhưng bỏ middleware và routing. Bộ đệm JSON được tái sử dụng, token được kiểm tra trên stack, nên gần như không sinh rác cho garbage collector (bộ dọn rác). Trên máy một nhân, mỗi lần GC chạy là một lần cả ứng dụng phải chờ.

Fortran là cái tên khiến nhiều người phải đọc lại hai lần. Ngôn ngữ ra đời từ thập niên 1950 này đạt 38 nghìn người, vượt cả Python, Go và C#. Bí quyết không nằm ở Fortran: HTTP do thư viện libmicrohttpd lo, JSON và cả việc kiểm tra token đều giao cho SQLite bằng câu SQL, cộng thêm group commit.

Máy tính IBM 704 thập niên 1950, nơi trình biên dịch Fortran đầu tiên ra đời

Ngôn ngữ cũ, kiến trúc mới. Fortran thắng không phải vì nó nhanh, mà vì nó gần như không phải làm gì.

PHP có cú lội ngược dòng ngoạn mục nhất. Laravel truyền thống chỉ trụ được 750 người. Bản mới đạt 40 nghìn, gấp hơn 50 lần, trong khoảng 250 dòng code. Bí mật là Swoole: ứng dụng khởi động một lần và chạy thường trực, mở sẵn kết nối database, không phải khởi tạo lại toàn bộ framework cho từng request như mô hình PHP truyền thống.

Cuộc đua đỉnh: C, TypeScript, Zig, Rust và C++

C đạt 46 nghìn người nhờ io_uring của Linux, cơ chế cho phép gom nhiều lệnh gọi hệ thống thành từng lô thay vì gọi lẻ từng cái. SQLite còn được biên dịch lại, tối ưu riêng cho đúng các truy vấn của ứng dụng. RAM chỉ 39 MB, ít nhất cuộc thi.

TypeScript trên Bun tăng từ 4.200 lên 47 nghìn, hơn 11 lần, trong khoảng 350 dòng code. Tác giả bỏ luôn HTTP server có sẵn của Bun, đọc thẳng byte từ TCP socket, và để SQLite trả JSON dạng byte thô ghi thẳng ra socket.

Zig đạt 61 nghìn với khoảng 680 dòng code, dùng uSockets và group commit. Nó dừng lại chỉ vì tác giả tự cài cứng giới hạn 60 nghìn kết nối. Gỡ giới hạn đó, nhiều khả năng Zig cũng chạm trần.

Một tủ rack xếp kín các server 1U với cáp mạng gom gọn sang một bên

Rust có hai bài. Bản dùng framework Axum đạt 43 nghìn nhưng ngốn hơn 1 GB RAM. Bản thứ hai viết lại với io_uring, gom sự kiện thành lô rồi commit một lần, đạt 64 nghìn người mà chỉ dùng nửa CPU và chưa tới 100 MB RAM.

C++ đồng hạng nhất với 64 nghìn người theo triết lý tối giản: một luồng, một event loop, không thread pool, không khoá. Tác giả thu nhỏ cache của SQLite, ánh xạ database vào bộ nhớ (mmap) và dời việc checkpoint ra khỏi đường phản hồi request.

Vì sao 64 nghìn là "trần" của bài test, không phải của Rust?

Thủ phạm là conntrack (bảng theo dõi kết nối của tường lửa Linux). Mỗi kết nối TCP đi qua tường lửa chiếm một mục trong bảng này. Trên máy thi đấu, bảng chỉ chứa khoảng 65 nghìn mục; vượt qua là kernel bắt đầu bỏ gói tin, và luật lại cấm chỉnh kernel.

Switch mạng với nhiều dây cáp Ethernet đang cắm, mỗi đèn xanh là một kết nối đang hoạt động

Trên server của mình, bạn có thể tự kiểm tra giới hạn và số mục đang dùng:

bash
# Giới hạn tối đa của bảng conntrack
sysctl net.netfilter.nf_conntrack_max

# Số kết nối đang được theo dõi
cat /proc/sys/net/netfilter/nf_conntrack_count

# Kernel đã bỏ gói tin vì bảng đầy chưa?
dmesg | grep -i "nf_conntrack: table full"

Nói cách khác, cả Rust lẫn C++ đều đụng trần của hệ điều hành chứ chưa đụng trần của chính mình. Rust còn dư một nửa CPU, nên giới hạn thật của nó vẫn là ẩn số.

Đồng hạng, nhưng vì sao Rust được coi là thắng?

Hai con số giải thích tất cả: P99 của C++ là 910 ms, sát nút ngưỡng 1 giây; P99 của Rust chỉ 224 ms. Rust còn dư địa lớn hơn hẳn.

C++ còn một dấu sao: SQLite chạy chế độ WAL với synchronous=NORMAL. Ở chế độ này, database không bị hỏng khi mất điện, nhưng giao dịch vừa commit có thể bị mất nếu máy sập đột ngột. Về mặt kỹ thuật, nó lách gần sát với luật "ghi xong mới báo thành công".

Năm kỹ thuật thật sự tạo ra khác biệt

Gom lại từ các bài dẫn đầu, có năm kỹ thuật lặp đi lặp lại:

  1. Bỏ Nginx, phục vụ HTTP trực tiếp. Trên máy một nhân, thêm một tiến trình là thêm việc chuyển dữ liệu qua lại và thêm lần chuyển ngữ cảnh. Reverse proxy hữu ích khi có nhiều nhân và nhiều dịch vụ, nhưng ở đây nó chỉ là chi phí.
  2. Chuẩn bị truy vấn một lần, dùng lại một kết nối duy nhất. Prepared statement được biên dịch sẵn lúc khởi động, mọi request dùng lại. SQLite chỉ cho một luồng ghi tại một thời điểm, nên nhiều kết nối không giúp gì mà còn tranh khoá.
  3. Để SQLite tự sinh JSON. Thay vì đọc từng dòng, dựng object, rồi serialize, câu SQL trả thẳng chuỗi JSON hoàn chỉnh.
  4. Group commit. Gom nhiều lần ghi vào một giao dịch, chỉ tốn một lần đồng bộ xuống đĩa, xong mới trả lời cả nhóm.
  5. Bỏ hẳn framework, tự viết server, tự quản lý bộ nhớ và event loop.

Cáp mạng chằng chịt cắm vào switch, minh hoạ những lớp trung gian mà request phải đi qua

Kỹ thuật thứ ba trông như thế này. Feed được dựng thành JSON ngay trong SQLite bằng các hàm json_object và json_group_array có sẵn:

sql
SELECT json_group_array(
  json_object('id', id, 'author', author, 'text', body, 'likes', likes)
)
FROM (SELECT * FROM posts ORDER BY id DESC LIMIT 20);

Ứng dụng chỉ việc lấy chuỗi kết quả ghi thẳng ra socket, không tạo ra một object trung gian nào.

Còn group commit thì giống xe buýt so với taxi. Mỗi lần commit là một lần SQLite phải đợi ổ đĩa xác nhận đã ghi, rất tốn thời gian. Chở từng khách bằng taxi thì 256 khách tốn 256 chuyến; gom lên một chuyến xe buýt thì chỉ tốn một chuyến, và mọi khách vẫn chỉ được báo "đã tới" khi xe thật sự tới nơi.

Có nên mang những kỹ thuật này vào production?

Câu trả lời ngắn: học ý tưởng, đừng chép code.

Cái giá của tốc độ

Bài C++ có hơn 1.300 dòng trong một file duy nhất, tự viết parser HTTP lẫn JSON. Bài Rust dài khoảng 2.400 dòng, đầy code unsafe. Group commit mà xử lý lỗi sai thì một giao dịch hỏng có thể kéo cả loạt request trong lô bị rollback theo.

Tự viết parser HTTP nghĩa là tự gánh mọi lỗ hổng bảo mật mà các server trưởng thành đã vá suốt nhiều năm. Code tối ưu tới từng byte cũng khó bảo trì, khó đổi tính năng, khó tìm người tiếp quản.

Kỹ thuật viên ngồi cạnh tủ server với laptop, minh hoạ chi phí bảo trì của hệ thống tự viết

Những thứ an toàn để áp dụng ngay vào dự án thật:

  • [ ]Chạy profiler trước khi kết luận "ngôn ngữ chậm"
  • [ ]Gỡ middleware không dùng tới, nhất là ở các endpoint nóng
  • [ ]Dùng prepared statement và tái sử dụng kết nối database
  • [ ]Thử để database tự sinh JSON cho các truy vấn đọc nhiều
  • [ ]Với PHP, cân nhắc mô hình chạy thường trực như Swoole
  • [ ]Xem P99 chứ không chỉ xem thời gian phản hồi trung bình

Cùng một server 12 đô, Laravel chỉ trụ 750 người, còn Rust chạm 64 nghìn. Kiến trúc quan trọng không kém ngôn ngữ.

Tóm tắt nhanh

  • Một server 1 nhân, 2 GB RAM giá 12 đô đã chịu được 64 nghìn người dùng đồng thời, gấp hơn 4 lần kỷ lục cũ khoảng 14 nghìn.
  • Rust (io_uring) và C++ đồng hạng nhất, nhưng Rust có P99 224 ms so với 910 ms, chỉ dùng nửa CPU và dưới 100 MB RAM.
  • Trần 64 nghìn đến từ bảng conntrack của Linux, không phải từ ngôn ngữ.
  • Năm kỹ thuật chung: bỏ Nginx, một kết nối với prepared statement, SQLite sinh JSON, group commit, bỏ framework.
  • Cùng một ngôn ngữ, cách viết tối ưu có thể nhanh hơn 10 đến 50 lần cách viết thông thường.

Câu hỏi thường gặp

SQLite có đủ sức cho website thật không?

Thử thách này cho thấy SQLite chạy chung tiến trình có thể phục vụ hàng chục nghìn người đồng thời trên một máy rất yếu. Giới hạn chính là chỉ một luồng ghi tại một thời điểm, nên các bài dẫn đầu đều gom ghi bằng group commit. Nếu bạn cần nhiều server cùng ghi vào một database thì SQLite không phải lựa chọn phù hợp.

Có phải cứ đổi sang Rust hay C++ là nhanh hơn?

Không. Rust dùng Axum chỉ đạt 43 nghìn và tốn hơn 1 GB RAM, trong khi Fortran và PHP được viết lại đúng cách đạt 38 đến 40 nghìn. Python tăng 10 lần mà không đổi ngôn ngữ. Kiến trúc và việc bỏ bớt lớp trung gian quyết định nhiều hơn.

Group commit có làm mất dữ liệu không?

Nếu làm đúng thì không: request chỉ được báo thành công sau khi cả lô đã commit xuống đĩa. Rủi ro nằm ở xử lý lỗi, vì một lỗi trong lô có thể khiến cả lô rollback, nên code phải tách và thử lại các request vô can.

Vì sao bỏ Nginx lại nhanh hơn?

Trên máy chỉ có một nhân CPU, Nginx là một tiến trình nữa tranh CPU với ứng dụng, và mỗi request phải đi qua hai chặng thay vì một. Khi có nhiều nhân, cần TLS, cân bằng tải hay phục vụ file tĩnh, Nginx vẫn rất đáng dùng.

BVIẾT BỞIBeernary

ĐỌC TIẾP_

BÌNH LUẬN (0)

LOGIN.EXE
> ĐĂNG NHẬP ĐỂ BÌNH LUẬNDùng tài khoản Google, không cần tạo mật khẩu. Tên và ảnh đại diện Google sẽ hiện cạnh bình luận.

> CHƯA CÓ BÌNH LUẬN. HÃY LÀ NGƯỜI ĐẦU TIÊN_