Hiện tại, với vai trò là một lập trình viên độc lập (solo developer), tôi đang vận hành đồng thời hai sản phẩm:
- một ứng dụng ước tính và báo giá tại hiện trường (field estimation) dành cho các kỹ thuật viên/thợ dịch vụ, giúp họ tính chi phí và báo giá công việc ngay tại chỗ;
- và một nền tảng cộng đồng kiêm chợ mua bán (community marketplace), với các tính năng như đăng tin tuyển dụng, mua bán đồ đã qua sử dụng và nhắn tin thời gian thực.
Hai sản phẩm khác nhau, nhóm người dùng cũng khác nhau, nhưng bộ công nghệ (tech stack) gần như giống hệt nhau — và cả hai đều đang chạy hoàn toàn trên các máy shared-cpu-1x, gói rẻ nhất mà Fly.io cung cấp.
Đây là một giới hạn được tôi chủ động lựa chọn.
Thay vì chọn cấu hình máy chủ theo mức tải đỉnh (peak load), tôi cố gắng khai thác tối đa hiệu năng của một máy nhỏ, đo chính xác ngưỡng mà hệ thống bắt đầu gặp điểm nghẽn (bottleneck) hoặc sụp đổ khi tải cao, rồi để bộ tự động co giãn (autoscaler) của Fly.io tự mở rộng hoặc thu hẹp theo chiều ngang (scale out / scale in), thêm hoặc bớt máy dựa trên chính ngưỡng đã đo đó.
Bài viết này sẽ đi vào các kỹ thuật tối ưu hóa giúp mô hình đó khả thi, các số liệu kiểm thử tải (load testing) dùng để xác định ngưỡng mở rộng (scaling threshold), và cách việc tăng, giảm số máy thực tế diễn ra trên Fly.io.
Triết lý: đo một máy, rồi nhân lên#
Chiến lược mở rộng quy mô của cả hai ứng dụng đều gồm ba bước giống nhau:
- Tối ưu một máy nhỏ cho nhanh nhất trong khả năng hợp lý.
- Kiểm thử tải cho đến khi máy gãy, rồi ghi lại số yêu cầu đồng thời (concurrency) mà tại đó độ trễ (latency) bắt đầu vọt lên.
- Cấu hình bộ tự động co giãn của Fly để bật máy thứ hai ngay trước điểm đó, và dừng các máy đang rảnh khi lưu lượng truy cập (traffic) giảm.
Khi đó, năng lực xử lý tăng gần như tuyến tính theo số máy, còn chi phí nền chỉ là một máy ảo (VM) nhỏ cho mỗi ứng dụng. Mọi thứ trong phần còn lại của bài đều phục vụ một trong ba bước này.
Tối ưu cho một máy nhỏ chạy nhanh#
Granian: WSGI cho API, ASGI với uvloop cho WebSocket#
Phía máy chủ (backend) của cả hai sản phẩm đều là Django 6 + Django REST Framework. API chạy trên Granian, một máy chủ viết bằng Rust, nhưng dùng WSGI với nhóm luồng chặn (blocking thread pool) chứ không phải ASGI:
granian --interface wsgi project.wsgi:application \
--workers 1 --blocking-threads 16 \
--workers-max-rss 1400 \
--workers-kill-timeout 15 --respawn-failed-workersLúc đầu tôi dùng --interface asgi --loop uvloop, và đó là một quyết định sai. Mọi view HTTP trong hai ứng dụng này đều là đồng bộ (sync), mà trình xử lý ASGI của Django lại chạy view đồng bộ ở chế độ thread_sensitive, tức mỗi tiến trình worker chỉ xử lý được một view tại một thời điểm. Kết quả là với ASGI, mỗi worker chỉ xử lý được khoảng một yêu cầu đồng thời, lại còn phải nạp nguyên bộ asyncio/uvloop/Channels vào RAM mà chẳng được lợi gì. Còn WSGI với --blocking-threads thì chạy chính những view đồng bộ đó trong một nhóm luồng thực thụ: một worker × 16 luồng = 16 yêu cầu nặng về I/O chạy đồng thời (đọc cơ sở dữ liệu, tạo URL ký trước (presigned URL) cho S3), mà bộ nhớ thường trú lại còn thấp hơn bản bất đồng bộ.
Tại sao chỉ dùng một worker? Vì máy ảo là shared-cpu-1x, tức chỉ có một nhân. Thêm worker thứ hai cũng không chạy Python song song được trên một nhân (GIL cộng với chỉ một CPU), nhưng lại tốn thêm nguyên một tiến trình Django thường trú trong bộ nhớ; chính Granian cũng cảnh báo đúng chuyện này. Với các tác vụ nặng về I/O, mười sáu luồng trên một worker là mức trần thực tế của một máy 1 vCPU. Tham số --workers-max-rss 1400 sẽ khởi động lại worker nếu nó phình quá giới hạn bộ nhớ, nên nếu một tác vụ tạo PDF hay xử lý ảnh chạy mất kiểm soát thì worker sẽ được thay mới, thay vì kéo cả máy đến chỗ cạn bộ nhớ (OOM).
WebSocket được tách ra chạy trong một tiến trình Granian riêng: một ứng dụng ASGI dùng uvloop và --ws, với một worker chuyên giữ các kết nối dài hạn. Phần API đồng bộ hoàn toàn không cần tới ASGI: khi một view cần đẩy dữ liệu xuống socket, nó gọi async_to_sync(channel_layer.group_send) qua Redis, và thông điệp vẫn đến được tiến trình WebSocket. Hai máy chủ, mỗi cái chạy đúng giao diện (interface) mà nó làm tốt nhất. Docker trên máy cục bộ cũng chạy cùng nhóm luồng WSGI đó, nên khi kiểm thử, laptop của tôi mô phỏng khá sát một máy trong môi trường production.
Dùng Rust ở các đường xử lý nóng#
Ở đường xử lý nóng (hot path) nào của Python mà có sẵn một thư viện viết bằng Rust thay thế được ngay, tôi đều dùng:
- orjson qua
drf-orjson-renderer: mọi phản hồi của API đều được tuần tự hóa bằng Rust thay vì bộ mã hóa JSON của thư viện chuẩn. - blurhash-rs: tạo ảnh giữ chỗ (placeholder) ngay lúc ảnh được tải lên.
- uuid-utils: sinh UUID bằng Rust.
- psycopg 3 (binary): trình điều khiển (driver) Postgres được tăng tốc bằng C.
Không thư viện nào trong số này làm thay đổi kiến trúc; chúng chỉ nâng mức trần của một vCPU lên, để bạn chưa phải thêm máy thứ hai sớm.
Loại bỏ những độ trễ ẩn#
Cải thiện lớn nhất trong mã nguồn của cả hai ứng dụng không đến từ thuật toán nào cả, mà từ việc tái sử dụng kết nối. Trong nhóm luồng của Granian, mỗi luồng mới lại tạo một đối tượng client S3 của boto3 mới toanh, và endpoint nào trả về ảnh hay PDF cũng phải gánh chi phí đó. Tôi viết một lớp riêng, SharedConnectionS3Storage, để lưu đệm (cache) client theo từng tiến trình:
Thời gian tạo URL có chữ ký trên các luồng mới giảm từ ~150ms xuống ~1ms.
Với cơ sở dữ liệu cũng vậy: Postgres trên Fly nằm sau PgBouncer, nên ứng dụng chạy với conn_max_age=0 và để bộ gộp kết nối (pooler) lo phần tái sử dụng kết nối, kèm connect_timeout 5 giây để khi gặp kết nối SSL đã chết thì báo lỗi ngay, chứ không làm treo cả worker.
Đặt bộ nhớ đệm đúng chỗ đang thực sự nghẽn#
Đợt kiểm thử tải phía chợ mua bán cho tôi một bài học đáng nhớ: mức trần nằm ở số kết nối Postgres, không phải CPU. Phần lớn lưu lượng duyệt tin đến từ người dùng ẩn danh, và nội dung họ xem giống hệt nhau. Khi tải lên cao, những truy vấn danh sách giống hệt nhau đó làm đầy nhóm kết nối, trong khi CPU vẫn ngồi chơi.
Cách sửa là thêm một lớp bộ nhớ đệm Redis cho các trang danh sách mà người dùng ẩn danh xem, và vô hiệu hóa bộ nhớ đệm bằng bộ đếm phiên bản theo từng không gian tên (namespace): mỗi thao tác ghi chỉ cần tăng một bộ đếm (O(1), không phải theo dõi từng khóa), thế là mọi trang đã lưu đệm của tài nguyên đó lập tức hết hiệu lực. Một thời gian sống (TTL) ngắn làm lưới an toàn cho những trường hợp bị sót. Trả trang duyệt tin thẳng từ Redis gỡ được đúng điểm nghẽn thật, và để cái máy nhỏ dành kết nối cơ sở dữ liệu cho người dùng đã đăng nhập.
Chạy tác vụ nền mà không cần trình trung gian#
Cả hai ứng dụng đều dùng django-tasks-db, framework tác vụ của Django lưu hàng đợi trong cơ sở dữ liệu, thay cho Celery. Một tiến trình db_worker riêng liên tục thăm dò cơ sở dữ liệu để lấy việc: xử lý ảnh, tạo PDF, dịch máy, gửi nhắc nhở. Không cần trình trung gian thông điệp (broker), không phải dựng và giám sát thêm dịch vụ nào, và tiến trình API không bao giờ bị những việc nặng chặn lại. Worker còn kết nối thẳng tới Postgres, bỏ qua PgBouncer, vì các tác vụ chạy lâu không hợp với chế độ gộp kết nối theo giao dịch (transaction pooling).
Bộ nhớ: jemalloc và bộ nhớ hoán đổi để dự phòng#
Xử lý ảnh và PDF làm bộ nhớ tăng vọt theo những cách mà khi tính toán cấu hình cho trạng thái ổn định bạn không lường trước được. Tôi dùng hai cách giảm thiểu rẻ tiền: nạp sẵn jemalloc trong Docker image để giảm phân mảnh bộ nhớ, và bật bộ nhớ hoán đổi (swap) trên mọi máy (2GB trên máy API, 1GB trên máy WebSocket), để khi bộ nhớ tăng vọt thì dữ liệu được đẩy xuống đĩa chứ tiến trình không bị hệ thống buộc dừng vì cạn bộ nhớ. Bộ nhớ hoán đổi không phải tính năng giúp tăng hiệu năng; nó là một khoản bảo hiểm, cho phép tôi giữ mức cấp phát bộ nhớ ở mức nhỏ.
Kiểm thử tải: tìm điểm gãy#
Một ngưỡng chỉ đáng tin khi nó đã được đo thực tế. Mỗi dự án có bộ công cụ kiểm thử riêng:
- Ứng dụng báo giá tại hiện trường có một bộ công cụ kiểm thử sức chịu tải tự viết bằng Python (
scripts/stress-test.py), chạy lần lượt qua các mức yêu cầu đồng thời và báo cáo số yêu cầu mỗi giây (RPS), p50/p95/p99 cùng tỉ lệ lỗi. Đi kèm là mấy lệnh quản trị (management command) sinh ra hàng trăm công việc trông như thật, để các endpoint danh sách được truy vấn trên khối lượng dữ liệu sát thực tế. - Chợ mua bán dùng Locust với kiểu tải bậc thang, tăng từ 50 → 500 người dùng theo từng bậc 30 giây để tìm điểm bão hòa, với trọng số các endpoint khớp với lưu lượng thực tế (phần lớn là duyệt tin). Ngoài ra còn một bộ k6 riêng, tăng từ 5 → 50 người dùng ảo, chạy thẳng vào môi trường production với ngưỡng p95 < 2s.
Vài con số tiêu biểu từ lượt đo của ứng dụng báo giá tại hiện trường (một worker Granian × 16 luồng chặn, chạy Docker trên máy phát triển của tôi):
| Endpoint | Số yêu cầu đồng thời | RPS | p95 | Lỗi |
|---|---|---|---|---|
| health | 25 | 602 | 52ms | 0% |
| danh sách công việc | 25 | 309 | 260ms | 0% |
| danh sách khách hàng | 25 | 357 | 61ms | 0% |
| danh sách công việc | 50 | 309 | 347ms | 0% |
Còn con số quyết định mọi thứ khác đến từ đợt kiểm thử tải chợ mua bán trên một máy shared-cpu-1x duy nhất. Với bản ASGI cũ, đây là một bức tường cứng: các view đồng bộ bị xếp hàng tuần tự, chỉ còn khoảng ba suất xử lý (slot) dùng được, nên p95 giữ dưới 300ms cho đến khoảng 5 yêu cầu đồng thời, rồi sụp hẳn khi vượt ~8–10, vì mọi yêu cầu đều phải xếp hàng sau cái khóa thread-sensitive. Chính việc chuyển API sang nhóm luồng WSGI đã đẩy điểm gãy đó ra xa: 16 suất đồng thời thực sự trên mỗi máy thay vì ~3, và mức trần mới này là nền tảng cho toàn bộ cấu hình tự động co giãn.
Cơ chế co giãn trên Fly.io#
Mô hình của Fly khá đơn giản: ứng dụng của bạn là một nhóm máy giống hệt nhau đứng sau một proxy, và proxy đếm tải trên từng máy. Bạn chỉ cần khai báo hai con số:
[http_service.concurrency]
type = 'requests'
soft_limit = 12 # past this, start another machine
hard_limit = 24 # past this, shed load instead of queueing
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 1Khi tải tăng: khi số yêu cầu đang xử lý trên một máy vượt giới hạn mềm (soft limit), proxy sẽ bật một máy đang dừng và chuyển lưu lượng mới sang đó. Nhóm luồng WSGI cho mỗi máy 16 suất đồng thời thực sự, nên tôi đặt giới hạn mềm ở 12, tức khoảng 75% nhóm luồng, để máy số 2 khởi động khi các luồng vẫn còn dư sức, thay vì đợi đến lúc người dùng phải chờ tới vài giây. Giới hạn cứng (hard limit) đặt ở 24, gấp 1.5 lần nhóm luồng, đóng vai trò cầu dao: những đợt tăng vọt ngắn thì xếp hàng chờ luồng, còn vượt quá mức đó thì proxy sẽ từ chối bớt tải, thay vì để một máy bị quá tải tới mức sập. (Bản ASGI cũ chạy 8/20, tính theo ~3 suất thực của nó; chính việc nới rộng nhóm luồng mới cho phép nâng cả hai con số lên.)
Khi tải giảm: khi lưu lượng giảm, Fly tự động dừng các máy đang rảnh, xuống tới min_machines_running = 1. Máy đã dừng không tốn đồng tiền tính toán nào, chỉ tốn phần lưu trữ rootfs, nên chi phí nền của mỗi ứng dụng đúng nghĩa đen là một máy ảo nhỏ, mà lúc nào cũng có sẵn năng lực dự phòng cho những đợt tăng tải.
Ban đầu, ở đoạn này tôi có viết rằng một máy đã dừng “khởi động nguội (cold start) mất chưa tới một giây”. Câu đó sai, và cái sai ấy khá đắt. Đến khi chịu đo thực tế thay vì đoán, tôi mới thấy một lần khởi động nguội mất 25 đến 33 giây, và bất cứ yêu cầu nào proxy chuyển tới máy trong khoảng thời gian đó đều bị hết thời gian chờ (timeout) ở tầng biên (edge): 22% số yêu cầu trong một ngày, rơi đúng vào những endpoint mà ứng dụng di động gọi định kỳ, trong khi mọi lần kiểm tra tình trạng (health check) vẫn báo xanh.
Cách sửa không nằm ở mấy con số co giãn phía trên; chúng vẫn ổn. Vấn đề nằm ở những gì máy làm trong lúc khởi động, và ở việc yêu cầu Fly tạm ngưng (suspend) máy thay vì dừng hẳn (stop), để mỗi lần đánh thức máy chỉ còn là một lần khôi phục bộ nhớ mất khoảng 3 giây. Tôi đã viết riêng một bài về chuyện này: Thu về 0, và mỗi lần khởi động lại là một lần sập 30 giây, nói về cách tôi chẩn đoán và những gì cần thay đổi. Nếu bạn đang chạy bất cứ thứ gì trên Fly với auto_stop_machines, hãy đọc bài đó trước khi tin vào đoạn này.
Có một điểm tinh tế: kiểu đếm tải là requests, không phải connections. Máy khách hiện đại giữ kết nối duy trì (keep-alive) luôn mở, nên nếu đếm theo kết nối thì con số gần như đứng yên dù tải thực tế tăng, và bộ tự động co giãn sẽ không bao giờ được kích hoạt. Đếm số yêu cầu đang xử lý mới phản ánh đúng khối lượng công việc thực.
WebSocket co giãn theo một trục khác#
Chợ mua bán tách máy chủ WebSocket ra thành một ứng dụng Fly thứ hai, dùng chung Docker image. Ứng dụng API chạy một worker × 16 luồng chặn dưới WSGI và đếm theo yêu cầu (giới hạn mềm 12). Ứng dụng WebSocket thì chạy 1 worker dưới ASGI với uvloop, trên một máy 512MB, và đếm theo kết nối:
[http_service.concurrency]
type = 'connections'
soft_limit = 1500
hard_limit = 2000Giữ một socket sống lâu gần như không tốn gì, nhưng nó chiếm một suất hàng giờ liền, trong khi một yêu cầu HTTP chỉ chiếm suất vài mili giây, nên không thể dùng chung một tín hiệu co giãn cho cả hai. Tách riêng ra thì mỗi nhóm máy co giãn theo đúng thước đo phản ánh tải thật của nó, và năng lực WebSocket lúc rảnh chỉ tốn 512MB chứ không phải 2GB. Về phía Channels, lớp Redis chạy với capacity: 1500, thông điệp hết hạn sau 60 giây, cộng thêm cơ chế duy trì kết nối cho socket mỗi 30 giây, vì proxy Redis của Fly sẽ tự đóng các socket BRPOP sống lâu mà Channels cần tới khi chúng không hoạt động.
Đẩy việc tính toán ra tầng biên và sang phía máy khách#
Việc rẻ nhất cho máy chủ là việc máy chủ không bao giờ phải làm:
- Tệp tĩnh và tệp đa phương tiện (media) nằm trên Tigris, dịch vụ lưu trữ tương thích S3 của Fly, được phân phối qua CDN của Tigris, với URL có chữ ký cho tệp đa phương tiện riêng tư. Không một byte ảnh nào phải đi qua các máy Django.
- Suy luận AI chạy trên điện thoại của người dùng. Cả hai ứng dụng đều nhúng một LLM chạy cục bộ qua llama.rn (chợ mua bán chạy một mô hình 4B đã lượng tử hóa, ~2.7GB, và trả kết quả ra dần; ứng dụng báo giá tại hiện trường thì nối cơ chế gọi công cụ (tool calling) vào lớp dữ liệu để trợ lý tạo và cập nhật được công việc). Phía máy chủ không tốn đồng nào cho suy luận, và tính năng này chạy được cả khi ngoại tuyến.
- Ứng dụng di động ưu tiên ngoại tuyến (offline-first) (Expo SDK 57, SQLite trên thiết bị, Dexie trên web, một hàng đợi đồng bộ có cơ chế thử lại) giúp ứng dụng gom các thao tác ghi lại thành từng đợt và chịu được những vùng mất sóng, thay vì dội vào API hàng loạt yêu cầu lắt nhắt theo từng lần gõ phím.
Phần còn lại của bộ công nghệ, điểm nhanh#
Cả hai ứng dụng dùng Firebase Auth cộng với khóa truy cập (passkey) WebAuthn, kèm một phần mềm trung gian (middleware) tự viết để xác thực kết nối WebSocket trước khi nâng cấp giao thức. Postgres cho môi trường production, SQLite khi phát triển. django-unfold cho trang quản trị, django-simple-history để lưu nhật ký kiểm toán (audit trail). uv và ruff làm công cụ phát triển. Phía di động là React Native 0.86 / React 19 / TypeScript với Expo Router, Gluestack UI v3 và Tailwind 4, cập nhật OTA qua Expo Updates tự vận hành, còn gói thuê bao thì qua RevenueCat. Khâu kiểm soát chất lượng: pytest + mypy + bandit ở phía máy chủ, Jest + Detox + Playwright ở phía máy khách.
Những gì bạn nên “học lỏm”#
Điều đáng mang về không nằm ở một thư viện cụ thể nào, mà ở cả vòng lặp. Tối ưu cho một máy rẻ tiền chạy thật nhanh (dùng Rust ở đường xử lý nóng, tái sử dụng kết nối, đặt bộ nhớ đệm chắn trước điểm nghẽn thật, đẩy được gì sang CDN và máy khách thì đẩy). Kiểm thử tải cho đến khi tìm ra chính xác mức yêu cầu đồng thời mà máy gãy. Đặt giới hạn mềm của bộ tự động co giãn ngay tại điểm gãy đó, để các máy rảnh tự dừng, và giữ tối thiểu một máy luôn chạy. Hai ứng dụng đang chạy production, có tính năng thời gian thực, có trợ lý AI, mà hóa đơn hằng tháng lúc bình thường chỉ là vài máy ảo rẻ nhất trên nền tảng.
