🎉 Giảm 20% cho đơn hàng đầu tiên!✦🚚 Miễn phí vận chuyển cho đơn trên 500.000₫✦⚡ Flash Sale — Số lượng có hạn!✦🎁 Quà tặng kèm cho đơn từ 1.000.000₫✦🎉 Giảm 20% cho đơn hàng đầu tiên!✦🚚 Miễn phí vận chuyển cho đơn trên 500.000₫✦⚡ Flash Sale — Số lượng có hạn!✦🎁 Quà tặng kèm cho đơn từ 1.000.000₫✦
Server 12 đô (1 vCPU, 2GB RAM) gánh 280.000 user: 218 lập trình viên đã tối ưu tới tầng sâu nhất như thế nào?
Quay lại Blog

Server 12 đô (1 vCPU, 2GB RAM) gánh 280.000 user: 218 lập trình viên đã tối ưu tới tầng sâu nhất như thế nào?

5 tháng 11, 202613 phút đọcVũ Khôi
Tin tức
#Wotech
Vũ Khôi

Vũ Khôi

Tác giả tại Wotech Electronics Blog

Ảnh bài toán của cuộc thi tối ưu hiệu năng: một server 12 đô la với 1 vCPU, 2GB RAM phải phục vụ API mạng xã hội dùng SQLite dưới tải người dùng thật

Server 12 đô (1 vCPU, 2GB RAM) gánh 280.000 user: 218 lập trình viên đã tối ưu tới tầng sâu nhất như thế nào?

Một chiếc server giá 12 đô la với vỏn vẹn 1 vCPU và 2GB RAM có thể phục vụ bao nhiêu người dùng cùng lúc? Trong một thử thách do Arjay McCandless tổ chức, 218 lập trình viên đã nộp bài dự thi xây API mạng xã hội trên đúng cấu hình "nghèo" đó — và 10 bài tốt nhất xử lý được từ 240.000 đến 280.000 người dùng đồng thời, tương đương khoảng 23.000–26.800 request mỗi giây, với độ trễ P95 dưới 500ms. Bài phân tích dưới đây tổng hợp những kỹ thuật tầng sâu làm nên con số khó tin đó.

Trả lời nhanh

  • Bài toán: xây một Social Media API chạy trên server 1 vCPU, 2GB RAM (giá thuê khoảng 12 USD/tháng), bắt buộc dùng SQLite, cấm cache kết quả feed và cấm lưu trạng thái xác thực token — mọi request ghi đều phải kiểm tra token lại từ đầu và commit xuống database thành công mới được trả response.
  • Kết quả cao nhất: 280.000 người dùng đồng thời (~26.800 request/giây), P95 dưới 500ms, P99 dưới 1 giây, tỷ lệ lỗi dưới 1% trong 5 phút tải liên tục — trên dữ liệu gốc 500.000 bài viết và 2 triệu lượt like.
  • Công thức chung của cả Top 10: dùng io_uring với Direct Descriptors để vượt giới hạn kết nối, xử lý theo lô (batching) từ tầng I/O đến transaction, tái sử dụng buffer để tránh cấp phát bộ nhớ, và tách việc checkpoint WAL của SQLite khỏi luồng xử lý chính.
  • Hai nhà đồng vô địch đi theo hai hướng cực đoan: một bài dùng ngôn ngữ chứng minh toán học Lean để sinh code C đã được kiểm chứng đúng; bài còn lại tự viết lại cả storage engine đọc/ghi định dạng file của SQLite — đạt P99 chỉ 23ms nhưng bị ban tổ chức nhận xét là vượt quá tinh thần luật chơi và không ai dám dùng ngoài đời thật.

Bài toán của cuộc thi là gì?

Thử thách yêu cầu xây dựng một API mạng xã hội với các thao tác: tải feed (20 bài mới nhất kèm tác giả và số like), mở bài viết, tạo bài viết, thả like và health check. Luật chơi được siết chặt để không ai "ăn gian" bằng bộ nhớ đệm:

  • Bắt buộc dùng SQLite làm cơ sở dữ liệu, trên dữ liệu ban đầu gồm 500.000 bài viết và 2 triệu lượt like.
  • Cấm cache kết quả feed, cấm lưu trạng thái xác thực token — mỗi request ghi vẫn phải verify token lại từ đầu.
  • Thao tác ghi phải commit thành công xuống database trước khi trả response.
  • Hệ thống phải duy trì tải liên tục 5 phút với P95 dưới 500ms, P99 dưới 1 giây và tỷ lệ lỗi dưới 1%.

Vì sao cách làm thông thường sẽ sập ngay?

Ở một web app thông thường, chỉ riêng một request đọc /feed đã phải đi qua hàng loạt bước: nhận network packet qua socket, HTTP server parse bytes thành method/path/headers, chạy qua middleware, gọi SQLite driver thực thi query quét qua các B-Tree và database page, ánh xạ từng dòng dữ liệu thành object, serialize ra JSON rồi mới đẩy ngược ra socket. Lặp lại quy trình đó hàng chục nghìn lần mỗi giây trên đúng 1 vCPU là quá sức.

Chưa kể giới hạn ở tầng hệ điều hành: mỗi process chỉ có tối đa 65.000 file descriptor. Khi hàng trăm nghìn người dùng cùng giữ kết nối persistent (keep-alive), nếu cứ đóng mở connection liên tục để né giới hạn thì riêng chi phí CPU cho việc accept và hủy kết nối đã đủ làm sập server.

"Vũ khí" chung của Top 10: io_uring và xử lý theo lô

Giải pháp xuất hiện trong cả 10 bài đạt điểm cao nhất là io_uring của Linux kết hợp Direct Descriptors: thay vì dùng file descriptor thông thường của process, các socket được lưu trực tiếp trong các bảng gắn với ring của io_uring và chia tải qua nhiều ring, vượt qua giới hạn 65.000 kết nối. Đồng thời io_uring cho phép gom các thao tác I/O và nhận kết quả theo từng lô, tiết kiệm tối đa chi phí chuyển đổi ngữ cảnh (context switch).

Trên nền đó, mỗi tác giả biến tấu theo ngôn ngữ của mình — từ Assembly thuần, C, Rust, F#, Crystal, Ada cho đến Lean, ngôn ngữ vốn dùng để chứng minh định lý toán học.

Những cách tiếp cận đáng chú ý nhất trong Top 10

Cách tiếp cận Kết quả Kỹ thuật then chốt
Web server viết toàn bộ bằng x86_64 Assembly 240.000 users, ~23.000 RPS, tốn RAM ít nhất (545MB) Tự viết HTTP parsing, routing, JSON, xác thực, event loop; đặt giới hạn cứng số completion xử lý mỗi vòng để bảo vệ P99
F# "bẻ lái" Kestrel của ASP.NET Core 247.000 users, ~24.000 RPS, P99 chỉ 72ms Thay toàn bộ tầng transport bằng io_uring tự viết, bỏ qua pipeline HTTP của Kestrel; ghi JSON trực tiếp vào buffer; WAL checkpoint chạy ở thread riêng
C và Assembly của cùng một tác giả Cả hai cùng đạt 250.000 users, ~24.100 RPS Chờ chủ động 6ms để gom batch 128 request rồi commit một thể; bản C có P99 tốt hơn (294ms so với 397ms) và ít RAM hơn
Rust tối ưu cách lấy dữ liệu feed 264.000 users, ~25.000 RPS Không framework, không async runtime; ID gần nhau thì quét một khoảng nhỏ (range scan), ID rời nhau thì fallback về query chuẩn
C: SIMD cho xác thực + chọc thẳng B-Tree 266.000 users, ~25.500 RPS Gom token theo batch, dùng SIMD kiểm tra nhiều chữ ký cùng lúc; bỏ qua tầng SQL, gọi thẳng B-Tree cursor nội bộ của SQLite
Crystal tắt hẳn Garbage Collector 270.000 users, ~25.700 RPS Quản lý bộ nhớ thủ công hoàn toàn, parse tại chỗ, buffer dùng chung, vùng nhớ Arena cố định; SQLite biên dịch kèm PGO
Ada với cơ chế điều phối checkpoint rõ ràng 270.000 users, ~25.900 RPS, P99 177ms Phối hợp WAL checkpoint giữa process chính và helper process; chỉ định chính xác thao tác cần hủy ở tầng kernel khi đóng kết nối hàng loạt

Vài chi tiết trong top này cho thấy tối ưu hiệu năng thường thắng ở những chỗ rất nhỏ: bản Assembly của một tác giả ban đầu chỉ để hằng số kết nối đồng thời (in-flight accepts) là 16 nên đạt 125.000 users — chỉ đổi con số đó lên 256, điểm số lập tức gấp đôi lên 250.000. Bản F# thì giảm thời gian chờ ghi từ 57ms xuống 6ms chỉ bằng cách cho thread checkpoint được đánh thức ngay lập tức thay vì để nó "ngủ" 50ms.

Hai nhà đồng vô địch: toán học đối đầu "tự viết lại database engine"

Ở mức 280.000 users (~26.800 RPS) có hai hướng đi cực kỳ đặc biệt:

  • Dùng Lean — ngôn ngữ chứng minh định lý — để sinh code C (P99 316ms). Tác giả định nghĩa API và các bất biến (invariant) của dữ liệu trong Lean: ví dụ ID bài viết liên tiếp nhau không có khoảng trống, timestamp tăng dần theo ID. Sau đó tác giả chứng minh bằng toán học rằng kế hoạch đọc tối ưu sẽ cho kết quả giống hệt đặc tả, rồi sinh ra code C để chạy. Nhờ các bất biến này, server đọc lùi trực tiếp trên các dòng dữ liệu qua B-Tree mà không cần tra qua timestamp index; nếu tại runtime dữ liệu không còn giữ tính chất đó, hệ thống tự fallback về SQL chuẩn.
  • Tự viết storage engine đọc/ghi định dạng file của SQLite bằng C (P99 chỉ 23ms). Bài này thậm chí không liên kết thư viện SQLite, mà tự viết engine đọc/ghi trực tiếp cấu trúc B-Tree và WAL của file SQLite, giữ một bảng con trỏ trỏ thẳng tới từng database page trong bộ nhớ hoặc WAL frame. Đây là P99 thấp kỷ lục của cuộc thi — nhưng chính ban tổ chức cũng nhận định cách làm đã đi quá giới hạn của luật chơi ("dùng SQLite"), và không ai dám tự viết lại cả một database engine như vậy trên môi trường production thật vì rủi ro mất dữ liệu và gánh nặng bảo trì quá lớn.

Bài học nào dùng được cho công việc hằng ngày?

Hiếm ai đi làm mà phải tự viết lại HTTP server hay can thiệp vào nội tạng của SQLite. Nhưng việc cả 10 bài thi — bất kể ngôn ngữ — cùng hội tụ về một chỗ cho thấy những nguyên lý tối ưu cốt lõi nhất:

  • Kiểm soát chặt lượng công việc thật sự diễn ra trên mỗi request: từ câu query và kế hoạch thực thi (query plan), đến số lần cấp phát bộ nhớ (allocation) và quá trình serialize dữ liệu.
  • Tận dụng xử lý theo lô (batching) và tái sử dụng vùng nhớ (buffer reuse) để giảm tải cho CPU và bộ dọn rác — chi phí overhead được chia đều cho nhiều request thay vì trả riêng từng cái.
  • Đừng để luồng chính đang phục vụ người dùng bị chặn bởi việc nhà của hệ thống: các tác vụ dọn dẹp hay đồng bộ đĩa như database checkpoint phải được tách ra khỏi đường đi của request.

Tác giả bài phân tích cũng để lại một câu hỏi mở cho cộng đồng backend: nếu được giao một VPS 1 vCPU, 2GB RAM và dùng stack quen thuộc hằng ngày (Java/Spring Boot, Go, Node.js hay .NET) với cấu hình mặc định, hệ thống sẽ chịu được khoảng bao nhiêu RPS trước khi chạm trần — và chỗ đầu tiên bạn tối ưu sẽ là đâu?

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

io_uring là gì và vì sao mọi bài thi điểm cao đều dùng nó?

io_uring là cơ chế I/O bất đồng bộ của nhân Linux, cho phép ứng dụng gửi và nhận kết quả của nhiều thao tác I/O theo lô qua các hàng đợi vòng (ring) dùng chung với kernel. Kết hợp Direct Descriptors (lưu socket ngay trong bảng của ring thay vì file descriptor của process), nó giúp một process phục vụ số kết nối vượt xa giới hạn 65.000 file descriptor thông thường và cắt giảm mạnh chi phí chuyển đổi ngữ cảnh — đúng hai nút thắt lớn nhất của bài toán này.

Vì sao cuộc thi cấm cache nếu mục tiêu là chịu tải cao nhất?

Vì cache sẽ biến bài toán thành cuộc thi về bộ nhớ đệm thay vì về năng lực xử lý thật: nếu được cache feed và trạng thái xác thực token, server gần như không phải đụng vào database. Cấm cache buộc mọi bài dự thi phải tối ưu ở tầng sâu — I/O, query, cấp phát bộ nhớ — và mọi thao tác ghi vẫn phải verify token, commit thật xuống SQLite.

Bản tự viết lại storage engine có thật sự "thắng" không?

Về con số, nó đạt P99 23ms — thấp nhất cuộc thi, đồng hạng nhất 280.000 users. Nhưng ban tổ chức nhận định nó đã vượt quá tinh thần luật "dùng SQLite", và cách làm này không áp dụng được ngoài đời: tự viết engine đọc/ghi định dạng database mang rủi ro mất dữ liệu và chi phí bảo trì khổng lồ. Nó thắng như một màn trình diễn kỹ thuật, không phải một giải pháp.

P95, P99 nghĩa là gì?

P95 và P99 là các phân vị độ trễ: P95 dưới 500ms nghĩa là 95% số request hoàn thành trong dưới 500ms; P99 dưới 1 giây nghĩa là 99% request xong trong dưới 1 giây. Người ta dùng phân vị thay vì trung bình vì giá trị trung bình che giấu được những request chậm nhất — nhóm gây khó chịu nhất cho người dùng thật.

Những kỹ thuật này có áp dụng được cho hệ thống thật không?

Phần nguyên lý thì có: batching, tái sử dụng buffer, giảm cấp phát bộ nhớ trên đường nóng, tách việc checkpoint/dọn dẹp khỏi luồng phục vụ request, và soi kỹ query plan đều là việc làm được trong các stack thông thường. Phần cực đoan — viết server bằng Assembly, tắt GC, tự viết storage engine — là trình diễn trong khuôn khổ cuộc thi, đổi độ tin cậy và khả năng bảo trì lấy hiệu năng.

Kết luận

Cuộc thi cho thấy giới hạn của phần cứng rẻ tiền nằm xa hơn nhiều so với mặc định chúng ta vẫn nghĩ — vấn đề thường nằm ở phần mềm chưa được tối ưu chứ không nằm ở con số 1 vCPU hay 2GB RAM. Và bài học lớn nhất không phải một kỹ thuật cụ thể nào, mà là sự hội tụ: khi bị dồn tới giới hạn, mọi ngôn ngữ, mọi trường phái đều quay về cùng vài nguyên lý nền tảng — ít việc thừa trên mỗi request, làm theo lô, dùng lại bộ nhớ, và không bao giờ để việc nhà của hệ thống chặn đường người dùng.

Nguồn: bài phân tích của Lữ Minh Khương trong nhóm VGU Tech Alumni trên Facebook (04/11/2026) về thử thách tối ưu do Arjay McCandless tổ chức; số liệu là kết quả cuộc thi được bài viết tổng hợp lại, biên tập và hệ thống hóa bởi Wotech Electronics. Ảnh minh họa: ảnh bài toán đính kèm bài viết gốc.