Tối ưu LMS cho 5.000 CCU - Phần 2: Singleflight, Cache Tuning và Kế hoạch Scale
Tháng 4/2026 - EVO LMS Engineering Team
Ở phần 1, chúng tôi phát hiện Go proxy là bottleneck bất ngờ — MaxIdleConnsPerHost = 2 khiến proxy tạo hàng nghìn TCP connections mới mỗi giây thay vì tái sử dụng. Ba bản sửa đơn giản (custom Transport, Redis pool, Gunicorn workers) đã giảm 75% CPU proxy và cải thiện response time 38%.
Nhưng failure rate vẫn ở mức 70%, và server 16 cores đã chạm trần phần cứng. Bài viết này là phần tiếp theo: tối ưu sâu hơn ở tầng phần mềm, đo đạc chi tiết, và đưa ra kế hoạch scale.
Vấn đề còn lại
Sau vòng 1, kết quả stress test cho thấy:
- Throughput ~860 req/s nhưng 70% requests timeout
- Go-proxy không còn là bottleneck (56% CPU)
- Django chiếm ~1.000% CPU (16 cores gần max)
- Cache hit rate chỉ 47% — hơn nửa requests vẫn đổ về Django
Cache hit rate thấp nghĩa là Django phải xử lý quá nhiều. Tại sao cache chỉ đạt 47% khi chúng tôi đã cache tất cả GET endpoints?
Thundering herd
Stress test dùng 20 tài khoản mô phỏng 5.000 VUs. Khi 250 virtual users cùng gọi /api/users/me/ với cùng một JWT, tất cả đều cache miss vì không ai kịp populate cache trước.

Request thứ 1 đến Go proxy → cache miss → gọi Django. Trong khi Django đang xử lý, request 2 đến 250 cũng đến → tất cả đều miss → tất cả đều gọi Django. Kết quả: 250 requests giống hệt nhau đổ về Django, 249 trong đó là lãng phí hoàn toàn.
Bản sửa vòng 2
1. Singleflight — Request Coalescing
singleflight là pattern đơn giản nhưng hiệu quả cao: khi nhiều goroutines cùng yêu cầu một key, chỉ goroutine đầu tiên thực sự gọi backend. Các goroutines còn lại chờ kết quả từ goroutine đầu, rồi tất cả nhận cùng response.
import "golang.org/x/sync/singleflight"
type Handler struct {
// ...existing fields...
sfGroup singleflight.Group
}
// Trong ServeHTTP, thay vì proxy trực tiếp khi cache miss:
sfResult, _, _ := h.sfGroup.Do(cacheKey, func() (interface{}, error) {
rec := &responseRecorder{...}
h.proxy.ServeHTTP(rec, r)
// Cache response vào Redis
h.cache.Set(ctx, cacheKey, resp, route.TTL, indexKey)
return resp, nil
})
// Tất cả goroutines nhận cùng sfResult
250 concurrent cache misses cho cùng key → chỉ 1 request đến Django. Giảm 99.6% unnecessary backend calls cho trường hợp này.
2. Cache TTL tuning
Các endpoints student dashboard hiếm khi thay đổi trong một session. User profile không đổi mỗi phút, lịch học không đổi mỗi 30 giây. Chúng tôi tăng TTL dựa trên tần suất thay đổi thực tế:
| Endpoint | TTL cũ | TTL mới | Lý do |
|---|---|---|---|
/api/users/me/ |
60s | 300s | Profile hiếm khi đổi |
/api/learner/profile/learning_progress/ |
120s | 300s | Tiến độ cập nhật sau submit bài |
/api/announcements/my_announcements/ |
30s | 120s | Thông báo mới thường theo giờ |
/api/learner/activity-logs/this_week_* |
60s | 180s | Lịch tuần thay đổi hàng ngày |
/api/learner/topic-logs/child-items/ |
300s | 600s | Content structure rất ổn định |
/api/course-classes/activities/* |
60s | 180s | Activity data ít đổi |
TTL dài hơn nghĩa là cache có hiệu lực lâu hơn → hit rate tăng → ít requests đến Django.
3. Database indexes cho Announcement
Profiling cho thấy announcement queries chậm do:
- JSONField scope_ids__contains dùng @> operator nhưng không có GIN index → full table scan
- Thiếu compound index cho filter combination thường dùng nhất
# Compound index cho query phổ biến nhất
models.Index(
fields=["university", "is_deleted", "display_time", "end_date"],
name="ann_univ_active_time_idx"
)
# GIN index cho JSONField contains queries
GinIndex(fields=["scope_ids"], name="ann_scope_ids_gin_idx")
GinIndex(fields=["announcement_recipient"], name="ann_recipient_gin_idx")
# Notification indexes
models.Index(fields=["user", "is_deleted"], name="ann_notif_user_deleted_idx")
models.Index(fields=["user", "status", "is_deleted"], name="ann_notif_user_status_idx")
Kết quả: 3 vòng tối ưu
Cùng kịch bản test, cùng server, 5.000 VUs.

Bảng so sánh chi tiết
| Metric | Baseline | Vòng 1 | Vòng 2 | Cải thiện tổng |
|---|---|---|---|---|
| Max VUs | 2.882 | 5.000 | 5.000 | +73% |
| Throughput | 866 req/s | 862 req/s | 1.278 req/s | +48% |
| Failed requests | 68,7% | 70,3% | 45,3% | -23pp |
| Avg response (success) | 2,67s | 1,66s | 904ms | -66% |
| p95 response (success) | 14,15s | 13,4s | 4,76s | -66% |
| Go-proxy peak CPU | 218% | 56% | 64% | -71% |
| Go-proxy peak RAM | 675 MB | — | 632 MB | -6% |
Cache hit rate theo thời gian

Trong giai đoạn warm-up (0-1.000 VUs), cache hit rate đạt 58-66% — cao hơn đáng kể so với baseline 47%. Khi VUs tăng lên 5.000, hit rate giảm xuống 40% do thundering herd với 20 shared accounts vẫn tạo pressure.
Điểm quan trọng: Trong production thực tế với 5.000 users khác nhau, mỗi user chỉ miss lần đầu rồi hit liên tục trong cửa sổ TTL. Ước tính cache hit rate production: 75-85%.
Còn lại gì chưa giải quyết?
Failure rate 45% vẫn cao. Nhưng nguyên nhân rõ ràng: 16 cores đã dùng 93-97% CPU. Server đang ở giới hạn vật lý.
Lỗi PgBouncer timeout xác nhận điều này:
connection to server at "pgbouncer" port 5432 failed: timeout expired
ASGI callable returned without starting response
Khi CPU bão hòa → Django xử lý chậm → DB connections bị hold lâu → PgBouncer pool cạn → timeout cascade. Đây không phải lỗi PgBouncer — đây là triệu chứng của CPU bound.
Bài toán scale
Workload 5.000 CCU
$$5.000 \text{ users} \times 12 \text{ req/trang} \times \frac{1 \text{ load}}{12{,}5s} = 4.800 \text{ req/s}$$
Capacity hiện tại
$$\text{Throughput đo được} = 1.278 \text{ req/s ở 96\% CPU (16 cores)}$$
$$\text{Per-core} = \frac{1.278}{16} \approx 80 \text{ req/s/core}$$
$$\text{Per-core (70\% util, an toàn)} = 80 \times 0{,}7 = 56 \text{ req/s/core}$$
Cores cần theo cache hit rate
$$\text{Django req/s} = 4.800 \times (1 - \text{cache\_hit\_rate})$$
$$\text{Cores cần} = \frac{\text{Django req/s}}{56}$$

| Cache Hit Rate | Django req/s | Cores cần (70% util) |
|---|---|---|
| 60% (worst case) | 1.920 | 34 |
| 70% | 1.440 | 26 |
| 75% (conservative) | 1.200 | 22 |
| 80% (expected) | 960 | 18 |
| 85% (optimistic) | 720 | 13 |
Với cache hit rate production dự kiến 75-80%, chúng tôi cần 18-22 cores.
Scale dọc: 16 → 32 cores
Chúng tôi chọn scale dọc vì:
Đơn giản. Resize VPS mất 5-10 phút. Không thay đổi architecture, không thêm load balancer, không cần setup distributed system. Cùng một Dokploy deploy pipeline, cùng monitoring, cùng debug workflow.
Đủ capacity. 32 cores cho $32 \times 56 = 1.792$ req/s ở 70% utilization. Với cache 75%, cần 1.200 req/s → headroom 49%. Với cache 80%, cần 960 req/s → headroom 87%.
Tự động scale workers. Gunicorn config đã dùng cpu_count * 2 + 1. Trên 32 cores → 65 workers tự động, không cần thay đổi code.
Chi phí hợp lý. Tăng ~$80-120/tháng so với hiện tại. So với thuê thêm server + thời gian setup load balancer + ops overhead, scale dọc rẻ hơn cả về tiền lẫn thời gian.
Khi nào cần scale ngang?
| Tiêu chí | Scale dọc | Scale ngang |
|---|---|---|
| Phù hợp cho | < 10.000 CCU | > 10.000 CCU |
| High availability | Không (single point of failure) | Có |
| Complexity | Thấp | Trung bình-Cao |
| Time to implement | 10 phút | 4-8 giờ |
Scale ngang khi cần:
- High availability (zero-downtime guarantee)
- >10.000 CCU (vượt khả năng 1 server)
- Compliance requirement (phải có redundancy)
Hướng đi tiếp theo
Ngay lập tức
- Resize server 16 → 32 cores, 32GB → 64GB RAM
- Chạy stress test xác nhận — kỳ vọng failure rate < 10%
Tháng tới
- Monitor cache hit rate production → điều chỉnh TTL
- Profile slow endpoints:
topic-logs,announcements - Tối ưu heavy prefetch queries trong activity service
Khi cần >10.000 CCU
- Tách DB sang server riêng
- Thêm app node thứ 2 sau Traefik load balancer
- Read replicas cho PostgreSQL
- Redis cluster riêng cho caching
Bài học
- Singleflight là pattern phải có cho cache proxy. Bất kỳ reverse proxy nào có cache layer đều nên implement request coalescing. Chi phí: thêm 1 dependency + ~10 dòng code. Lợi ích: giảm backend load theo bội số concurrent users.
- Cache TTL nên dựa trên tần suất thay đổi thực tế, không phải cảm tính. User profile không thay đổi mỗi phút — đừng cache 60 giây. Lịch tuần không đổi mỗi phút — đừng cache 60 giây. Mỗi giây TTL tăng thêm là một request Django không phải xử lý.
- GIN index cho JSONField
containsqueries. PostgreSQL@>operator trên JSONField không dùng B-tree index. Không có GIN index = full table scan mỗi lần query. - Scale dọc trước, ngang sau. Nếu bài toán giải được bằng resize VPS trong 10 phút, đừng dành 2 ngày setup distributed system. Complexity là chi phí ẩn lớn nhất.
- Stress test với shared accounts phóng đại thundering herd nhưng làm giảm cache hit rate giả tạo. Kết quả test là lower bound — production sẽ tốt hơn.
Đây là phần 2 trong series tối ưu hiệu năng EVO LMS. Phần 1 cover việc phát hiện và sửa Go proxy bottleneck. Phần 3 sẽ chia sẻ kết quả sau khi scale dọc và stress test lại.