Có một loại tải rất dễ làm database mệt: rất nhiều lệnh ghi nhỏ, mỗi lệnh chỉ mang một chút thông tin. Không phải đơn hàng, không phải giao dịch tiền, chỉ là "học viên này đang xem tới giây thứ 412". Ba mươi giây sau, con số thành 442. Nếu mất vài giây tiến độ, trải nghiệm có thể chưa bị ảnh hưởng nhiều. Nhưng nếu mọi con số đó đều ghi thẳng vào Postgres, thread ghi phụ này có thể làm hệ thống chậm trước cả các nghiệp vụ quan trọng hơn.
Trong Z-EDU, chúng tôi xử lý bài toán này bằng write-behind (ghi trễ): request của người dùng chỉ ghi vào Redis rồi trả về ngay; một worker gom dữ liệu và ghi xuống Postgres theo chu kỳ. Kết quả đo thực tế: 20 ping → 2 giao dịch DB, trong khi đường cũ tốn khoảng 80 truy vấn cho đúng 20 ping đó.
Bài này không quảng cáo write-behind như một công thức dùng ở mọi nơi. Đây là kỹ thuật đổi độ bền tức thời lấy thông lượng. Nếu áp dụng cho sai loại dữ liệu, bạn không tối ưu hệ thống; bạn đang tạo ra một cơ chế có thể mất dữ liệu trong im lặng.
1. Vấn đề: ghi dày, giá trị mỗi lần ghi rất thấp
Trình phát video gửi ping tiến độ mỗi 15 giây cho mỗi học viên đang xem. Đây là con số đã cân nhắc: thưa hơn thì khi thoát trang học viên có thể mất quá nhiều vị trí; dày hơn thì tốn tài nguyên không cần thiết. Nhưng hãy nhìn nó dưới góc độ tải ghi.
Một học viên xem hết bài giảng 25 phút sẽ gửi khoảng 100 ping. Đường xử lý cũ, viết theo cách "sạch sẽ" thông thường, làm đủ các việc sau cho mỗi ping: đọc tiến độ hiện tại, đọc metadata bài học để biết thời lượng và ngưỡng hoàn thành, cập nhật tiến độ, rồi cập nhật tổng hợp tiến độ khoá học. Bốn truy vấn. Nhân với 20 ping là khoảng 80 truy vấn. Nhân tiếp với vài trăm học viên cùng xem vào giờ cao điểm, con số đó không còn nhỏ.
Điểm mấu chốt là tải này không tăng theo giá trị nghiệp vụ. Nó chủ yếu tăng theo số học viên và độ dài video. Bạn đang trả chi phí connection pool, WAL và vacuum của Postgres cho một cột số nguyên mà người dùng thường chỉ đọc lại khi bấm "học tiếp".
Trước khi nghĩ tới bất kỳ giải pháp nào, phải làm một việc: chia dữ liệu theo mức độ được phép mất. Đây là điều kiện tiên quyết, không phải bước tuỳ chọn.
| Loại dữ liệu | Mất vài giây thì sao? | Cách ghi |
|---|---|---|
| Vị trí xem video, số giây đã xem | Học viên tua lại vài giây. Không ai phàn nàn. | Write-behind qua Redis |
| Bài nộp, câu trả lời bài test | Học viên mất công sức làm bài. Không chấp nhận được. | Ghi thẳng DB, trong giao dịch |
| Điểm số, kết quả chấm | Sai lệch học bạ. Tuyệt đối không. | Ghi thẳng DB, trong giao dịch |
| Thanh toán, ghi danh khoá học | Mất tiền, mất quyền truy cập. | Ghi thẳng DB, trong giao dịch |
Write-behind chỉ nên dùng cho dòng đầu tiên của bảng trên. Nếu bạn không kẻ được ranh giới đó thật rõ trong thiết kế và trong code, đừng dùng kỹ thuật này.
2. Kiến trúc: ping chạm Redis, worker dồn xuống Postgres
Luồng đầy đủ, bằng chữ:
Trình phát video (ping mỗi 15s)
|
v
POST /api/v1/progress/ping -- NestJS, xác thực JWT, kiểm tra quyền học viên
|
v
Redis: HSET progress:pending <courseId>:<lessonId>:<userId> {watchedSec, completed, ts}
SADD progress:dirty <cùng khoá>
|
+--> trả 204 ngay cho client (không chạm Postgres)
|
v
Worker (BullMQ, chu kỳ 5 giây)
| 1. lấy và xoá tập khoá "dirty" (atomic)
| 2. đọc giá trị mới nhất của từng khoá
| 3. GỘP thành MỘT câu INSERT ... ON CONFLICT DO UPDATE
v
PostgreSQL -- 1 giao dịch cho cả lô
Điểm cần nhấn: request của người dùng không chạm Postgres. Nó ghi vào một hash trong Redis và đánh dấu khoá đó là "bẩn". Nếu cùng một học viên ping 5 lần trong chu kỳ 5 giây, Redis chỉ giữ giá trị cuối; bốn ping trước bị hấp thụ hoàn toàn và không tạo tải xuống DB. Đó là nguồn gốc của con số 20 ping → 2 giao dịch: dữ liệu được gộp theo khoá (nhiều ping của cùng một bài học) và gộp theo lô (nhiều học viên trong cùng chu kỳ).
Phần nhận ping ở API, rút gọn:
// progress.service.ts
const PENDING = 'progress:pending';
const DIRTY = 'progress:dirty';
async ping(userId: string, lessonId: string, dto: PingDto): Promise<void> {
const key = `${dto.courseId}:${lessonId}:${userId}`;
const payload = JSON.stringify({
watchedSec: dto.watchedSec,
completed: dto.watchedSec >= dto.durationSec * COMPLETE_RATIO,
ts: Date.now(),
});
try {
await this.redis
.multi()
.hset(PENDING, key, payload) // ghi đè: chỉ giá trị mới nhất sống sót
.sadd(DIRTY, key) // đánh dấu cần dồn xuống DB
.exec();
} catch (err) {
// Redis chết -> không được im lặng nuốt ping, xem mục 4
this.logger.warn(`redis down, fallback to direct write: ${err.message}`);
await this.flushOne(key, payload);
}
}
Còn đây là phần worker. Nó chạy mỗi 5 giây, lấy tập khoá bẩn rồi ghi thành một lô. Chu kỳ 5 giây là lựa chọn có chủ đích: đủ ngắn để cửa sổ rủi ro chỉ vài giây, đủ dài để nhiều ping của cùng một học viên rơi vào cùng một lô.
// progress.worker.ts — chạy mỗi 5 giây
async flush(): Promise<number> {
// lấy & xoá tập dirty trong một bước, tránh worker khác nhặt trùng
const keys: string[] = await this.redis.spop(DIRTY, BATCH_MAX);
if (keys.length === 0) return 0;
const raw = await this.redis.hmget(PENDING, ...keys);
const rows = keys
.map((k, i) => raw[i] && this.toRow(k, JSON.parse(raw[i])))
.filter(Boolean);
if (rows.length === 0) return 0;
// MỘT câu INSERT cho cả lô, MỘT giao dịch
await this.prisma.$executeRaw(this.buildUpsert(rows));
await this.redis.hdel(PENDING, ...keys);
return rows.length;
}
Chú ý thứ tự: SPOP trước, ghi DB, rồi mới HDEL. Nếu worker chết giữa chừng, khoá đã bị
pop khỏi tập dirty nhưng giá trị vẫn còn trong hash pending. Một job dọn dẹp trong hàng đợi
zedu-maintenance sẽ quét các khoá mồ côi này và đưa chúng về lại tập dirty. Đây là phần rất dễ bị bỏ
qua nếu chỉ nhìn lướt: write-behind không chỉ là "ghi vào Redis rồi flush"; nó là một máy trạng thái nhỏ, và mỗi
điểm có thể lỗi đều cần đường xử lý.
3. Vì sao gộp bắt buộc phải idempotent
Đây là phần dễ làm sai nhất, đồng thời là phần quyết định write-behind an toàn hay nguy hiểm.
Trong hệ thống dồn lô, bạn không kiểm soát được thứ tự. Một lô có thể retry sau timeout mạng. Hai worker có thể chạy song song khi bạn scale ra nhiều replica. Một lô cũ, chậm, có thể chạm DB sau một lô mới hơn. Nếu quy tắc gộp là "giá trị đến sau thắng" (last write wins), kịch bản sau sớm muộn cũng xảy ra:
t=0s lô A: watchedSec = 300 (bị nghẽn mạng, chưa tới DB)
t=5s lô B: watchedSec = 340 -> ghi DB thành công. DB = 340
t=6s lô A tới nơi (retry) -> last write wins: DB = 300 ❌ TỤT LÙI
Học viên xem tới phút thứ 5, tắt máy, mở lại thì trình phát quay về phút thứ 4. Tệ hơn, nếu bài học đã được đánh dấu hoàn thành, lô cũ có thể xoá trạng thái đó, khiến tiến độ khoá học từ 100% tụt xuống 90%. Loại lỗi này thường không bùng lên ngay. Nó xuất hiện lẻ tẻ, và đến lúc có phản ánh đầu tiên thì log có thể không còn đủ để truy nguyên.
Cách chữa không nằm ở tầng ứng dụng (khoá, phiên bản, so sánh trong TypeScript — tất cả đều có race condition riêng của chúng). Cách chữa là đưa quy tắc gộp xuống chính câu SQL, để Postgres đảm bảo tính monotonic cho bạn, ngay trong một giao dịch:
INSERT INTO "LessonProgress"
("userId", "lessonId", "courseId", "watchedSec", "completedAt", "updatedAt")
VALUES
($1, $2, $3, $4, $5, NOW()),
($6, $7, $8, $9, $10, NOW())
-- ... phần còn lại của lô, cùng MỘT câu lệnh
ON CONFLICT ("userId", "lessonId") DO UPDATE SET
-- tiến độ CHỈ ĐƯỢC PHÉP đi lên, không bao giờ đi xuống
"watchedSec" = GREATEST(
"LessonProgress"."watchedSec",
EXCLUDED."watchedSec"
),
-- giữ MỐC HOÀN THÀNH ĐẦU TIÊN; lô về muộn không ghi đè mốc cũ,
-- và cũng không xoá được mốc đã có (COALESCE lấy giá trị cũ trước)
"completedAt" = COALESCE(
"LessonProgress"."completedAt",
EXCLUDED."completedAt"
),
"updatedAt" = NOW();
Đọc kỹ hai biểu thức. GREATEST biến phép cập nhật thành một phép hợp monotonic — giá trị chỉ đi lên, không bao giờ bị kéo xuống: dù lô đến theo thứ tự nào, dù chạy
lại bao nhiêu lần, kết quả cuối cùng luôn là giá trị lớn nhất từng thấy. COALESCE làm điều tương tự cho mốc thời gian
hoàn thành: một khi đã có mốc, không lô nào ghi đè được nó.
Hệ quả rất mạnh: lệnh này idempotent. Chạy nó một lần hay năm lần với cùng dữ liệu đều cho cùng kết quả. Nghĩa là retry trở thành an toàn tuyệt đối, và bạn có thể cho worker chạy song song nhiều replica mà không cần một dòng khoá phân tán nào. Toàn bộ độ phức tạp về đồng thời được nén vào một câu SQL — chỗ duy nhất trong hệ thống thực sự có khả năng đảm bảo nó.
Quy tắc: trong mọi hệ thống dồn lô, phép gộp phải commutative và monotonic. Nếu bạn phải hỏi "lô nào tới trước?" thì thiết kế đã sai.
Điều này cũng đặt ra một giới hạn cần nói rõ: write-behind kiểu này chỉ hợp với dữ liệu có phép gộp tự nhiên — max, min, cộng dồn, hợp tập. Với dữ liệu không có phép gộp như vậy (ví dụ "trạng thái mới nhất của một đơn hàng"), bạn không thể viết được biểu thức idempotent, và đó là tín hiệu rõ ràng rằng kỹ thuật này không dành cho nó.
4. Khi Redis chết
Câu hỏi bắt buộc phải trả lời trước khi đưa write-behind lên production: Redis chết thì sao? Nếu câu trả lời là "hệ thống ngừng nhận ping" thì cache đã trở thành một single point of failure.
Nguyên tắc của chúng tôi là fail-safe theo mức độ quan trọng của dữ liệu — mỗi tầng suy giảm theo một kiểu khác nhau, và kiểu suy giảm đó được chọn dựa trên việc mất dữ liệu ở tầng đó tốn bao nhiêu:
| Thành phần | Redis chết thì... | Vì sao chọn vậy |
|---|---|---|
| Cache đọc | Xuống thẳng DB. Chậm, vẫn đúng. | Chậm còn hơn sai, và còn hơn 500. |
| Tiến độ video (write-behind) | Ghi thẳng DB từng ping. Chậm, tốn, nhưng không mất. | Sự cố hiếm; chịu tải xấu trong lúc sự cố còn hơn mất tiến độ học viên. |
| Điểm, bài nộp | Không liên quan — vốn đã luôn ghi thẳng DB. | Chưa bao giờ được phép đi qua write-behind. |
| Rate-limit | Fail-open: cho request đi qua. | Không để một cái đếm tự làm sập cả API. |
Hãy để ý dòng rate-limit: nó fail-open, trong khi tiến độ chuyển sang đường ghi trực tiếp. Hai lựa chọn ngược nhau, và cả hai đều đúng — vì cái giá của việc sai khác nhau. Bỏ lỡ vài lần chặn rate-limit trong mười phút sự cố là chuyện chấp nhận được; còn để mất tiến độ học của toàn bộ học viên trong mười phút đó thì không. Đừng chọn chế độ suy giảm theo thói quen; hãy chọn nó bằng cách hỏi "mất cái này tốn bao nhiêu".
Trường hợp xấu nhất khi Redis chết: mất vài giây vị trí video của những ping đang nằm trong hash mà chưa kịp flush. Không mất điểm, không mất bài nộp — vì chúng chưa bao giờ đi qua con đường này.
5. Hàng đợi BullMQ: không chỉ có tiến độ
Khi đã có hạ tầng để đưa việc ra khỏi đường request, bạn sẽ thấy thêm nhiều phần có thể xử lý bất đồng bộ. Z-EDU chạy hai hàng đợi BullMQ:
-
zedu-maintenance— dọn tiến độ, các job định kỳ, gồm cả job quét khoá mồ côi đã nói ở mục 2. -
zedu-audit— nhật ký thao tác admin. Trước đây mỗi thao tác admin ghi 2 truy vấn inline vào đường request, nghĩa là mỗi lần một quản trị viên sửa một bài học, người đó phải chờ cả phần ghi log. Việc ghi log không cần chờ. Đẩy vào hàng đợi , request trả về sớm hơn, và nếu ghi log lỗi thì cũng không làm hỏng thao tác nghiệp vụ.
Về triển khai, chúng tôi chọn cách đơn giản nhất: worker chạy chung tiến trình với API. Không service riêng, không image riêng, không pipeline riêng.
// app.module.ts
const WORKER_ENABLED = process.env.WORKER_ENABLED !== '0';
@Module({
imports: [
// ...
...(WORKER_ENABLED ? [WorkerModule] : []),
],
})
export class AppModule {}
Nhưng chúng tôi để sẵn đường thoát. Cờ WORKER_ENABLED=0 tắt worker trong một tiến trình. Khi tải worker lớn đến mức
cần tách, việc phải làm chỉ là: dựng thêm một service trong Swarm dùng chính image đó, đặt
WORKER_ENABLED=1 cho nó và WORKER_ENABLED=0 cho các replica API. Không sửa một dòng code nào.
Đây là kiểu quyết định kiến trúc chúng tôi ưu tiên: chọn phương án đơn giản cho hiện tại, nhưng chuẩn bị sẵn một biến môi trường để phương án phức tạp hơn sau này không cần viết lại.
6. Quan sát: chỉ số bắt buộc phải có
Write-behind có một đặc tính cần cảnh giác: khi nó hỏng, không phải lúc nào hệ thống cũng báo lỗi. Worker chết, ping vẫn được nhận, API vẫn trả 204, người dùng vẫn xem video bình thường, không có lỗi 500 nào, không có exception nào trong log request. Dữ liệu chỉ đơn giản là chất đống trong Redis và không bao giờ tới Postgres. Bạn sẽ phát hiện ra sau vài giờ — hoặc vài ngày — khi một học viên hỏi tại sao tiến độ của họ không được ghi nhận.
Cách duy nhất để không rơi vào tình huống đó là đưa kích thước hàng đợithành một chỉ số hạng nhất. Endpoint
/health của Z-EDU trả về pendingProgress — số bản tiến độ đang chờ ghi:
// health.controller.ts
@Get('/health')
@Public()
async health() {
const pendingProgress = await this.redis.hlen('progress:pending');
return {
status: 'ok',
pendingProgress, // bình thường ≈ 0
};
}
Cách đọc chỉ số này:
- ≈ 0 — bình thường. Worker dồn lô kịp; hàng đợitrống ngay sau mỗi chu kỳ 5 giây.
- Nhấp nhô theo giờ cao điểm rồi về 0 — bình thường. Đó chính là việc gộp lô đang phát huy tác dụng.
- Tăng lên và ở lì đó — worker đã chết. Cảnh báo phải dựng trên chính đặc tính này: không phải "pendingProgress > N", mà là "pendingProgress > N liên tục trong nhiều phút". Một đỉnh nhọn là bình thường; một cao nguyên phẳng là sự cố.
Không có chỉ số độ sâu hàng đợithì đừng triển khai write-behind. Bạn không xây một hệ thống nhanh hơn — bạn xây một hệ thống mất dữ liệu mà không ai biết.
Kết quả đo được
| Tiêu chí | Đường cũ (ghi thẳng) | Write-behind |
|---|---|---|
| Tải DB cho 20 ping | ~80 truy vấn (4 truy vấn × 20 ping) | 2 giao dịch DB |
| DB trên đường request của người dùng | Có — mỗi ping đợi Postgres | Không — chỉ chạm Redis |
| Độ bền của vị trí video | Ghi xong là bền ngay | Cửa sổ rủi ro tối đa ~5 giây |
| An toàn khi retry / chạy song song | Phụ thuộc thứ tự đến | Idempotent nhờ GREATEST trong SQL |
| Độ phức tạp vận hành | Thấp | Cao hơn: thêm worker, hàng chờ, cảnh báo |
Hai dòng cuối là cái giá, và chúng tôi không muốn giấu chúng sau con số 80 → 2. Bạn đổi độ bền tức thời lấy thông lượng, đồng thời đổi sự đơn giản lấy hiệu năng. Với vị trí xem video, đó là đánh đổi hợp lý. Với điểm số, đó là một sai lầm nghiêm trọng.
7. Khi nào KHÔNG nên dùng write-behind
Checklist dưới đây giúp quyết định có nên dùng write-behind hay không. Chỉ cần trả lời "có" cho một dòng bất kỳ là bạn nên dừng lại:
- Mất vài giây dữ liệu này gây thiệt hại thật. Tiền, điểm số, hợp đồng, tồn kho, bài nộp. Dừng ngay.
- Người dùng cần đọc lại ngay giá trị vừa ghi từ một đường khác mà đường đó đọc thẳng DB. Bạn vừa tạo ra read-after-write không nhất quán. Hoặc cho đường đọc đi qua Redis, hoặc bỏ write-behind.
- Phép gộp không thể viết idempotent. Nếu quy tắc duy nhất bạn nghĩ ra được là "giá trị đến sau thắng", thì bạn sẽ có mất mát dữ liệu khi retry. Không có ngoại lệ.
- Bạn không thể phơi ra và cảnh báo trên độ sâu hàng chờ. Xem lại mục 6.
- Tải ghi vốn đã nhẹ. Nếu DB chưa hề than phiền, thứ bạn thêm vào chỉ là một tiến trình nền có thể chết trong im lặng. Đo trước, tối ưu sau.
- Có cách rẻ hơn. Trước khi dựng write-behind, hãy hỏi: giảm tần suất ping xuống được không? Gộp ở client được không? Bỏ bớt truy vấn thừa trong đường ghi được không? Chúng tôi đi con đường này vì đã thử và tần suất 15 giây là ngưỡng thấp nhất còn giữ được trải nghiệm.
Kết luận
Write-behind không phải một mẹo tăng tốc đơn giản. Nó là một quyết định có ý thức: hy sinh độ bền tức thời của một lớp dữ liệu cụ thể, đổi lấy việc database không phải gánh những lệnh ghi vô nghĩa. Nó chỉ đúng khi ba điều kiện cùng thoả: dữ liệu được phép mất vài giây, phép gộp viết được thành biểu thức idempotent trong SQL, và bạn có chỉ số để biết khi nào nó hỏng.
Ở Z-EDU, ba điều kiện này đều phù hợp: vị trí video có thể chấp nhận mất vài giây, GREATEST xử lý phần idempotent, và
pendingProgress trên /health lo phần cảnh báo. Đổi lại, 20 ping chỉ còn 2 giao dịch DB thay vì
khoảng 80 truy vấn.
Nếu bạn định áp kỹ thuật này vào hệ thống của mình, hãy bắt đầu từ bảng phân loại dữ liệu ở mục 1, không phải từ code ở mục 2. Phần khó nằm ở quyết định dữ liệu nào được phép trễ hoặc mất vài giây, chứ không phải ở đoạn code nói chuyện với Redis.