Điểm chuyên cần, điểm tổng kết và đồng bộ hai chiều với hệ thống của trường
Đề bài phỏng vấn: LMS tính điểm chuyên cần của sinh viên. Điểm tăng khi
sinh viên post bài forum, reply, làm quiz, nộp assignment, xem xong video… Điểm chuyên cần có
hạn ghi nhận: qua hạn thì làm gì cũng không tăng nữa.
Điểm tổng kết = 20% chuyên cần + 80% bài thi final online (quiz), lấy 2 chữ số thập
phân. LMS gửi điểm tổng kết sang hệ thống của trường để trường quyết định sinh viên có được
thi cuối kỳ offline không. Trường gửi ngược lại trạng thái "được thi / không được
thi" và điểm thi offline. Em làm thế nào? Có Redis thì tốt hơn ở đâu so với chỉ dùng database?
Authen và đồng bộ ra sao?
Luật nghiệp vụ đã chốt với PO:
- Assignment chỉ được điểm chuyên cần khi giảng viên chấm và bấm publish (có trạng thái draft). Điểm có thể bị sửa khi sinh viên phúc khảo, phúc khảo có hạn riêng.
- Video: mỗi sinh viên chỉ xem 1 video tại 1 thời điểm, và tab đang xem phải được focus.
- Chưa thi thì điểm để trống (không phải 0).
- Trường chốt danh sách thi sau khi kết thúc khoá học. Trước mốc đó điểm gửi sang vẫn được cập nhật.
Trả lời ngắn (30 giây):
- Không cộng điểm vào một cột. Mỗi hoạt động được tính điểm là một dòng trong bảng
activity_log(sổ cái — ledger), cóUNIQUE (enrollment_id, activity_type, source_id)để retry không cộng trùng. Điểm chuyên cần = tính lại từ sổ cái khi đọc: đo thật 0,3 ms / 1 sinh viên, 10,8 ms / lớp 500 sinh viên trên 1,5 triệu dòng. Không cần cache.- Hạn ghi nhận nằm trong chính câu INSERT (
WHERE occurred_at <= hạn).occurred_atlà lúc sinh viên làm (với assignment: lúc nộp lần đầu), không phải lúc worker xử lý hay lúc giảng viên chấm. Nhờ vậy bài nộp đúng hạn mà chấm muộn vẫn được điểm.- Không chốt sổ một lần. Sau hạn, điểm chuyên cần vẫn đổi (chấm bài, publish, phúc khảo). Job cập nhật
course_resultsđịnh kỳ, chỉ tăngversionkhi số thật sự đổi, và dừng trước khi khoá học kết thúc 2 giờ — lúc trường chốt danh sách thi.- Redis chỉ đáng dùng ở heartbeat video (tín hiệu "đang xem" gửi mỗi 10 giây): một khoá theo sinh viên (1 video 1 lúc, tab mở sau thắng), bitmap đánh dấu đoạn đã xem, đủ 90% mới ghi PostgreSQL một lần. Chỉ dùng DB thì là ~500 UPDATE/giây lúc cao điểm, đo được ~207 byte WAL mỗi lần. Không để Redis giữ điểm — điểm là dữ liệu phải đúng tuyệt đối.
- Đồng bộ: mỗi trường dữ liệu chỉ có một bên được ghi. LMS sở hữu chuyên cần + điểm online; trường sở hữu "được thi" + điểm offline. Gửi đi bằng
version/synced_version(bảng kết quả tự làm outbox), nhận về bằng webhook ký HMAC + poll đối soát, ghi bằngWHERE school_version < mới. At-least-once (gửi ít nhất một lần) + bên nhận idempotent = kết quả như gửi đúng một lần.
Mọi output trong bài là chạy thật: SQL trên PostgreSQL 18.4 (lab
my-test-pgdb, máy 4 core), Redis 8.8.0 container tạm, PHP 8.3 (image
laravel-app), Node cho phần JavaScript. Code Laravel trong bài là code minh
hoạ — logic SQL/Lua/HMAC bên trong đã chạy thật, phần khung Laravel thì chưa chạy trong một
app thật. Chỗ nào chưa verify có ghi rõ.
Giả định quy mô (ví dụ minh hoạ, không phải số của hệ thống nào): 50.000
sinh viên, mỗi người ~5 môn/kỳ → 250.000 enrollment (lượt đăng ký môn). Mỗi enrollment
~60 hoạt động được tính điểm → ~15 triệu dòng activity_log/kỳ. Cao điểm 5.000
người xem video cùng lúc, heartbeat 10 giây/lần → ~500 request/giây. Stack: Laravel +
PostgreSQL + Redis.
Thuật ngữ dùng trong bài
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| ledger (sổ cái) | Bảng chỉ ghi thêm, mỗi sự kiện một dòng. Số tổng được tính lại từ đây, nên luôn truy vết được "vì sao ra con số này". |
| enrollment | Một lượt sinh viên học một môn/lớp trong một kỳ. Điểm gắn với enrollment, không gắn với sinh viên. |
| idempotent | Chạy lại nhiều lần cho cùng kết quả như chạy một lần. |
| freeze (chốt sổ) | Chụp lại điểm tại một thời điểm, sau đó không tự đổi nữa. |
| outbox | Ghi "việc cần gửi" vào DB cùng transaction với dữ liệu, rồi một job khác đọc ra gửi đi. Không mất việc khi app chết giữa chừng. |
| at-least-once | Đảm bảo gửi ít nhất một lần, có thể trùng. Bên nhận phải tự chống trùng. |
| heartbeat | Tín hiệu nhỏ trình duyệt gửi định kỳ, báo "tôi đang xem tới giây thứ X". |
| bitmap | Chuỗi bit, bit thứ i = 1 nghĩa là đoạn thứ i đã xem. Redis có SETBIT, BITCOUNT. |
| WAL | Write-Ahead Log: mọi thay đổi của PostgreSQL ghi vào log này trước, replica cũng đọc log này. Ghi nhiều = đĩa, backup, replica cùng nặng. |
| HMAC | Chữ ký tạo từ nội dung request + secret chung. Sửa 1 ký tự là chữ ký sai. |
| replay attack | Lấy được một request hợp lệ rồi gửi lại y nguyên. Chữ ký vẫn đúng vì nội dung không đổi, nên chỉ có chữ ký thì không chặn được (mục 10.1). |
| client credentials grant | Kiểu OAuth2 cho máy nói chuyện với máy: hệ thống dùng client_id + client_secret đổi lấy access token, không có người dùng đăng nhập. |
| SIS | Student Information System — hệ thống quản lý đào tạo của trường. |
1. Tách đề bài thành các yêu cầu thật
| Yêu cầu | Ai giữ / làm | Vì sao |
|---|---|---|
| Ghi nhận hoạt động, không cộng trùng khi retry | PostgreSQL — UNIQUE trên sổ cái | Phải đúng cả khi job chạy 2 lần, Redis chết, code có bug. |
| Mỗi loại hoạt động có trần, tổng tối đa 10 | Tính khi đọc, từ sổ cái + bảng luật | Đổi luật giữa kỳ thì mọi người tự đúng lại, không phải sửa dữ liệu. |
| Hạn ghi nhận | Điều kiện trong câu INSERT | Chặn ở tầng cuối cùng, không phụ thuộc code gọi. |
| Assignment: chỉ tính khi đã publish, phúc khảo có thể sửa | Hàm sync_assignment làm sổ cái khớp trạng thái hiện tại | Publish, thu hồi, phúc khảo, retry đều gọi cùng một hàm, chạy lại bao nhiêu lần cũng đúng. |
| Điểm gửi trường còn đổi tới khi kết thúc khoá | Job cập nhật course_results + version | Trường chốt danh sách sau khi khoá học kết thúc. |
| Chưa thi → để trống | NULL từ DB tới JSON gửi trường | 0 và "chưa thi" là hai nghĩa khác nhau (mục 7.2). |
| Làm tròn 2 chữ số | PostgreSQL numeric, làm tròn ở một chỗ | PHP, JS, Excel làm tròn khác nhau (mục 5). |
| Xem xong video, 1 video 1 lúc, tab phải focus | Redis khoá theo sinh viên + bitmap → 1 INSERT khi đủ. Focus: trình duyệt dừng video + dừng heartbeat | Heartbeat là luồng ghi dày nhất, giá trị thấp. Focus chỉ kiểm được ở client. |
| Gửi điểm tổng kết cho trường | LMS: job đẩy theo version | API trường có thể chậm/sập, không được chặn request của sinh viên. |
| Nhận "được thi" + điểm offline | Trường là chủ; LMS chỉ lưu bản sao | Một trường dữ liệu chỉ một bên ghi → không bao giờ phải "gộp xung đột". |
version. Qua vạch vàng thì trường đã chốt.2. Kiến trúc tổng thể
flowchart LR SV["Sinh viên
(web / app)"] --> API["Laravel API"] API -->|"post, reply, quiz, publish điểm:
ghi sổ cái cùng transaction"| PG[("PostgreSQL
activity_log
course_results
exam_status")] API -->|"heartbeat video 10 s"| R[("Redis
khoá theo SV + bitmap")] R -->|"đủ 90%: INSERT 1 lần"| PG JOB["Scheduler
cập nhật kết quả + đẩy điểm"] --> PG JOB -->|"HTTPS + OAuth2 token
batch 500, có version"| SIS["Hệ thống trường
(SIS)"] SIS -->|"webhook ký HMAC:
được thi, điểm offline"| WH["Webhook LMS"] WH --> PG JOB -.->|"poll updated_since mỗi giờ
(lưới an toàn)"| SIS JOB -.->|"dự phòng: CSV qua SFTP"| SF["SFTP của trường"]
Nguyên tắc chính: một trường dữ liệu chỉ có một bên được ghi (single
writer). LMS không bao giờ sửa "được thi", trường không bao giờ sửa điểm chuyên cần. Nhờ vậy
đồng bộ hai chiều thật ra là hai luồng một chiều độc lập — không có bài toán gộp xung
đột (conflict resolution), chỉ còn bài toán "đến trễ / đến trùng", giải bằng version.
3. Ghi nhận hoạt động: sổ cái + chặn hạn trong câu INSERT
3.1. Schema
CREATE TABLE courses (
id bigint PRIMARY KEY,
attendance_deadline timestamptz NOT NULL -- luôn timestamptz, xem bẫy 3.5
);
CREATE TABLE enrollments (
id bigint PRIMARY KEY,
course_id bigint NOT NULL REFERENCES courses(id),
student_id bigint NOT NULL,
deadline_override timestamptz, -- gia hạn riêng (ốm, bảo lưu...)
UNIQUE (course_id, student_id)
);
CREATE TABLE score_rules ( -- luật điểm theo môn
course_id bigint NOT NULL REFERENCES courses(id),
activity_type text NOT NULL, -- forum_post, forum_reply, quiz, assignment, video
points numeric(4,2) NOT NULL, -- mỗi lần được bao nhiêu
max_points numeric(4,2) NOT NULL, -- trần của loại này
PRIMARY KEY (course_id, activity_type)
);
CREATE TABLE activity_log ( -- sổ cái
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
enrollment_id bigint NOT NULL REFERENCES enrollments(id),
activity_type text NOT NULL,
source_id bigint NOT NULL, -- id của post / reply / quiz / bài nộp / video
occurred_at timestamptz NOT NULL,
UNIQUE (enrollment_id, activity_type, source_id) -- ← chống cộng trùng nằm ở đây
);
source_id quyết định "cái gì tính là một lần": forum post → id của post (post 5
bài là 5 dòng); video → id video (xem 3 lần vẫn 1 dòng); quiz → id quiz (làm lại nhiều lần vẫn 1
dòng). Đây là quyết định nghiệp vụ, phải hỏi PO, không phải tự chọn.
3.2. Câu ghi: chống trùng + chặn hạn trong một lệnh
INSERT INTO activity_log (enrollment_id, activity_type, source_id, occurred_at)
SELECT e.id, $2, $3, $4
FROM enrollments e
JOIN courses c ON c.id = e.course_id
WHERE e.id = $1
AND $4 <= COALESCE(e.deadline_override, c.attendance_deadline)
ON CONFLICT (enrollment_id, activity_type, source_id) DO NOTHING
RETURNING id;
Chạy thật (hạn lớp = 2026-10-31 23:59:59+07, sinh viên 101 được gia hạn tới 7/11):
--- 1. post lan dau, truoc han
id
----
1
INSERT 0 1
--- 2. job retry: cung post 9001 lan nua
(0 rows)
INSERT 0 0
--- 3. sau han 2 giay
(0 rows)
INSERT 0 0
--- 4. SV 101 duoc gia han toi 7/11
id
----
3
INSERT 0 1
Để ý id nhảy từ 1 sang 3: lần retry (case 2) đã "đốt" một giá trị sequence dù không insert. Bình thường, đừng dựa vào id liên tục.
3.3. Gọi từ đâu: cùng transaction với việc tạo post, không qua queue
Cách lười nhất mà đúng nhất: ghi dòng sổ cái trong cùng transaction với việc tạo post / nộp bài. Post tồn tại ⇔ điểm được tính. Không có queue lag, không có "event bị mất sau commit". Chi phí thêm: một INSERT theo index, dưới 1 ms.
// app/Attendance/AttendanceRecorder.php — code minh hoạ
final class AttendanceRecorder
{
/** true = được tính; false = trùng hoặc quá hạn */
public function record(int $enrollmentId, ActivityType $type, int $sourceId, ?CarbonInterface $at = null): bool
{
return DB::affectingStatement(<<<'SQL'
INSERT INTO activity_log (enrollment_id, activity_type, source_id, occurred_at)
SELECT e.id, ?, ?, COALESCE(?::timestamptz, now())
FROM enrollments e JOIN courses c ON c.id = e.course_id
WHERE e.id = ?
AND COALESCE(?::timestamptz, now()) <= COALESCE(e.deadline_override, c.attendance_deadline)
ON CONFLICT (enrollment_id, activity_type, source_id) DO NOTHING
SQL, [$type->value, $sourceId, $at, $enrollmentId, $at]) === 1;
}
}
// Controller tạo post
DB::transaction(function () use ($request, $enrollment, $recorder) {
$post = $enrollment->posts()->create($request->validated());
$recorder->record($enrollment->id, ActivityType::ForumPost, $post->id);
});
$at = null → dùng now() của PostgreSQL: một đồng hồ duy
nhất cho mọi app server, không lo máy nào lệch giờ. Chỉ truyền $at khi sự
kiện đến muộn từ nơi khác (vd video, mục 6) và đã ghi lại thời điểm thật ở lúc nhận request.
Khi nào mới dùng event + queue? Khi nguồn hoạt động ở service khác (vd quiz
chạy trên NestJS, DB riêng). Khi đó phải mang theo occurred_at lúc sự kiện xảy ra,
và phải có outbox — afterCommit vẫn mất event nếu process chết giữa commit và push
(đã đo trong bài gửi mail at-least-once).
3.4. Hai worker ghi cùng một sự kiện — test thật
Worker A insert rồi giữ transaction 2 giây; worker B insert đúng dòng đó 0,5 giây sau:
----- worker A -----
BEGIN
id
----
24
INSERT 0 1
COMMIT
----- worker B -----
id
----
(0 rows)
INSERT 0 0
Time: 1558.756 ms (00:01.559)
count
-------
1
B chờ A commit (1,56 s) rồi mới biết là trùng → DO NOTHING. Kết
quả đúng 1 dòng. Không cần Redis lock, không cần SELECT ... FOR UPDATE.
3.5. Bẫy khi ghi nhận (xếp theo mức hay dính)
- Hạn lưu sai múi giờ — sinh viên được thêm 7 tiếng. Admin gõ
"31/10 23:59:59" trên form, app (timezone UTC) lưu thẳng chuỗi không kèm múi giờ:
Sinh viên post lúc 6h30 sáng 1/11 giờ Việt Nam vẫn được điểm. Bẫy nổ trễ: chỉ lộ ra đúng đêm hạn chót. Chữa: cộtdeadline_bi_luu | sv_post_luc_6h30_sang_vn | van_duoc_cong_diem ------------------------+--------------------------+-------------------- 2026-10-31 23:59:59+00 | 2026-10-31 23:30:00+00 | ttimestamptz, form gửi kèm múi giờ (Carbon::parse($input, 'Asia/Ho_Chi_Minh')), test có case "23:59:59 +07". - Dùng giờ worker thay vì giờ sự kiện. Đêm hạn chót queue ùn 5 phút → bài nộp
23:58 được worker xử lý lúc 00:03 → mất điểm oan. Chữa: ghi đồng bộ trong transaction (mục 3.3),
hoặc mang
occurred_attừ lúc nhận request. - Tin giờ của client. App mobile gửi
occurred_at→ sinh viên chỉnh giờ máy. Chữa: chỉ dùng giờ server. Chế độ offline của app là yêu cầu nghiệp vụ riêng, phải có luật riêng (vd chỉ chấp nhận nếu sync trước hạn). - 0 dòng không nói lý do. "Trùng" và "quá hạn" đều ra
INSERT 0 0. Nếu cần đếm "bị chặn vì quá hạn" (để trả lời khiếu nại, để vẽ metric), log thêm ở app khirecord()trả false vànow() > hạn. - Xoá post spam. Giảng viên xoá bài "ok ok" → xoá luôn dòng sổ cái trong cùng
transaction (trước khi khoá học kết thúc). Vì điểm tính từ sổ cái nên tự giảm — đã test: xoá dòng quiz của
SV 101 →
attendancetừ 1.00 về0.00. Sau khi khoá học kết thúc thì điểm gửi trường không tự đổi nữa (mục 7).
3.6. Assignment: draft → publish → phúc khảo
Luật: bài nộp chỉ được điểm chuyên cần khi giảng viên chấm và publish. Draft chưa tính. Phúc khảo có thể sửa điểm. Có ba câu hỏi phải trả lời đúng:
- So với hạn bằng giờ nào? Giờ sinh viên nộp lần đầu
(
first_submitted_at). Giảng viên chấm chậm không được làm sinh viên mất điểm. - Mấy dòng sổ cái? Một dòng cho mỗi (enrollment, assignment):
source_id = assignment_id, không phải id của lần nộp. Nộp lại 3 lần vẫn 1 dòng. - Gọi khi nào? Không viết code riêng cho "publish", "thu hồi", "phúc khảo". Chỉ có một câu SQL làm sổ cái khớp với trạng thái hiện tại, và mọi thao tác đổi điểm đều gọi nó trong cùng transaction. Chạy lại bao nhiêu lần cũng ra cùng kết quả.
CREATE TABLE submissions (
enrollment_id bigint NOT NULL REFERENCES enrollments(id),
assignment_id bigint NOT NULL,
first_submitted_at timestamptz NOT NULL, -- so với hạn bằng cột này
last_submitted_at timestamptz NOT NULL, -- nộp lại chỉ đổi cột này
grade numeric(4,2),
grade_status text NOT NULL DEFAULT 'ungraded' CHECK (grade_status IN ('ungraded','draft','published')),
PRIMARY KEY (enrollment_id, assignment_id)
);
-- sync_assignment($1 = assignment_id): làm sổ cái khớp trạng thái publish hiện tại
WITH ins AS (
INSERT INTO activity_log (enrollment_id, activity_type, source_id, occurred_at)
SELECT s.enrollment_id, 'assignment', s.assignment_id, s.first_submitted_at
FROM submissions s
JOIN enrollments e ON e.id = s.enrollment_id
JOIN courses c ON c.id = e.course_id
WHERE s.assignment_id = $1 AND s.grade_status = 'published'
AND s.first_submitted_at <= COALESCE(e.deadline_override, c.attendance_deadline)
ON CONFLICT DO NOTHING
RETURNING 1)
, del AS (
DELETE FROM activity_log l
WHERE l.activity_type = 'assignment' AND l.source_id = $1
AND NOT EXISTS (SELECT 1 FROM submissions s
WHERE s.enrollment_id = l.enrollment_id AND s.assignment_id = $1
AND s.grade_status = 'published')
RETURNING 1)
SELECT (SELECT count(*) FROM ins) AS them, (SELECT count(*) FROM del) AS xoa;
Chạy thật. Hạn ghi nhận 31/10. SV 100 nộp 30/10; SV 101 nộp 29/10 rồi nộp lại 2/11; SV 102 nộp 1/11 (trễ):
--- 5/11 (sau han): giang vien cham, luu DRAFT
them | xoa
------+-----
0 | 0 ← draft: chưa ai được điểm
--- 6/11: bam PUBLISH ca lop
2 | 0 ← 100 và 101 được điểm, 102 nộp trễ thì không
enrollment_id | occurred_at
---------------+------------------------
100 | 2026-10-30 03:00:00+00
101 | 2026-10-29 02:00:00+00
--- sai: neu dung last_submitted_at thi SV 101 mat diem
enrollment_id | duoc_tinh_neu_dung_last
100 | t
101 | f ← nộp lại sau hạn làm mất điểm đã có
102 | f
--- phuc khao SV 100: diem 6.5 -> 8.0, van published
0 | 0 ← điểm bài đổi, điểm chuyên cần không đổi
--- giang vien thu hoi publish cua SV 101 (cham nham bai)
0 | 1 ← mất điểm chuyên cần cho tới khi publish lại
--- chay lai lan nua (retry): khong doi gi
0 | 0
// Code minh hoạ — mọi chỗ đổi trạng thái điểm đều đi qua đây
DB::transaction(function () use ($assignment, $ids) {
Submission::where('assignment_id', $assignment->id)->whereIn('enrollment_id', $ids)
->update(['grade_status' => 'published']); // publish 1 bài hay cả lớp đều vậy
$this->attendance->syncAssignment($assignment->id); // câu SQL ở trên
});
Bẫy riêng của assignment:
- Dùng
submitted_atbị ghi đè khi nộp lại. Output ở trên: SV 101 nộp đúng hạn, nộp lại bản sửa sau hạn → mất điểm chuyên cần. Bẫy nổ trễ: chỉ lộ ra khi có người nộp lại. Chữa: giữfirst_submitted_atkhông bao giờ đổi. - Quy trình phúc khảo đưa điểm về draft. Nếu màn hình phúc khảo làm bài
"published → draft → published", thì trong lúc chờ, sinh viên mất 1,5 điểm chuyên cần, và LMS gửi
bản điểm thấp hơn sang trường rồi lại gửi bản cao hơn. Chữa: phúc khảo chỉ đổi
grade, giữ nguyênpublished; lịch sử điểm lưu ở bảng riêng. - Hạn phúc khảo sau ngày kết thúc khoá. Trường đã chốt danh sách thi mà điểm vẫn
đổi → không gửi được nữa. Chữa: validate lúc tạo assignment:
'appeal_deadline' => ['required', 'date', 'before:'.$course->ends_at->subHours(2)]. Chặn lúc nhập cấu hình rẻ hơn nhiều so với xử lý tay lúc cuối khoá. - Publish cả lớp bằng vòng lặp 500 lần. Câu SQL trên xử lý theo
assignment_idnên publish 1 bài hay 500 bài đều chỉ 2 câu lệnh. - Luật "đạt mới được điểm". Hiện tại publish là đủ, điểm bao nhiêu cũng được. Nếu
PO đổi thành "từ 5 điểm trở lên", chỉ thêm
AND s.grade >= 5vào cả hai nhánh (INSERT và NOT EXISTS). Khi đó phúc khảo từ 4,5 lên 6 sẽ tự thêm dòng.
4. Tính điểm chuyên cần: tính khi đọc, không cần cache
CREATE VIEW attendance AS
SELECT e.id AS enrollment_id, e.course_id,
LEAST(10, COALESCE(SUM(LEAST(r.max_points, r.points * a.cnt)), 0))::numeric(4,2) AS attendance
FROM enrollments e
LEFT JOIN LATERAL (SELECT activity_type, count(*) AS cnt
FROM activity_log l
WHERE l.enrollment_id = e.id
GROUP BY 1) a ON true
LEFT JOIN score_rules r ON r.course_id = e.course_id AND r.activity_type = a.activity_type
GROUP BY e.id;
Kết quả với một sinh viên (5 luật: post 1đ trần 3, reply 0,5đ trần 2, quiz 1đ trần 2, nộp bài 1,5đ trần 3, video 0,25đ trần 2):
activity_type | cnt | points | max_points | diem
---------------+-----+--------+------------+------
assignment | 1 | 1.50 | 3.00 | 1.50
forum_post | 6 | 1.00 | 3.00 | 3.00 ← 6 post nhưng chạm trần 3
forum_reply | 3 | 0.50 | 2.00 | 1.50
quiz | 2 | 1.00 | 2.00 | 2.00
video | 9 | 0.25 | 2.00 | 2.00 ← 9 × 0,25 = 2,25 → trần 2
enrollment_id | attendance
---------------+------------
100 | 10.00 ← tổng 10,00 (trần tổng 10)
4.1. Đo thật trên 1,5 triệu dòng (10% khối lượng một kỳ)
25.000 enrollment × 60 hoạt động, bảng + index 220 MB:
| Truy vấn | Thời gian | Ghi chú |
|---|---|---|
| Điểm 1 sinh viên (trang cá nhân) | 0,308 ms | Index Only Scan 60 dòng. Khi bảng lên 15 triệu dòng vẫn là 60 dòng/sinh viên. |
| Bảng điểm lớp 500 sinh viên (giảng viên) | 10,8 ms | 500 lần Index Only Scan. |
Cùng query lớp, nhưng view viết bằng subquery GROUP BY thay vì LATERAL | 967 ms | Bẫy — xem dưới. |
| INSERT sổ cái (pgbench, 16 client, có check hạn) | 1.286 lệnh/giây, 12,4 ms trung bình | Máy lab 4 core, synchronous_commit = on. Cao điểm ước tính của đề chỉ vài chục/giây. |
Bẫy view 90 lần chậm hơn: bản đầu tiên tôi viết view bằng
LEFT JOIN (SELECT enrollment_id, activity_type, count(*) FROM activity_log GROUP BY 1, 2).
Hỏi 1 sinh viên thì PostgreSQL vẫn đẩy được điều kiện vào trong. Nhưng hỏi theo lớp
(WHERE course_id = 20) thì không đẩy được — nó GROUP BY toàn bộ 1,5 triệu dòng:
-> GroupAggregate (actual time=0.109..680.059 rows=125006.00 loops=1)
Group Key: tmp_activity_log.enrollment_id, tmp_activity_log.activity_type
-> Index Only Scan ... (actual time=0.095..430.378 rows=1500023.00 loops=1)
Execution Time: 967.636 ms
Viết lại bằng LATERAL (tính riêng cho từng enrollment đã lọc) → 10,8 ms. Bẫy nổ trễ:
lúc dev chỉ có vài nghìn dòng thì cả hai đều nhanh. Luôn EXPLAIN ANALYZE với dữ liệu
cỡ thật.
Vì sao không giữ một cột attendance_score rồi cộng dồn?
- Trần theo từng loại + trần tổng làm câu cộng dồn phức tạp; muốn đúng phải khoá row.
- Đổi luật giữa kỳ (giảng viên sửa reply từ 0,5 thành 1) → phải chạy backfill cả kỳ.
- Không truy vết được. Sinh viên khiếu nại "em có 7 điểm chứ không phải 6" → không có gì để đối chiếu.
- Retry cộng 2 lần là lệch vĩnh viễn (drift), không tự sửa được.
Chỉ nên thêm cột cộng dồn khi đo được đọc là nút cổ chai (vd bảng xếp hạng realtime hàng nghìn request/giây) — và khi đó cột ấy chỉ là cache, sổ cái vẫn là nguồn gốc.
5. Làm tròn 2 chữ số: nhỏ nhưng dễ bị khiếu nại nhất
Chạy thật trên PostgreSQL 18.4, PHP 8.3.33, Node:
-- PostgreSQL
numeric_2675 | numeric_25 | float_25 | float_35
--------------+------------+----------+----------
2.68 | 3 | 2 | 4 ← float làm tròn "về số chẵn"
SELECT round(2.675::float8, 2);
ERROR: function round(double precision, integer) does not exist
f | n
---------------------+-----
0.30000000000000004 | 0.3 ← 0.1 + 0.2
// PHP 8.3
round(2.675, 2) → 2.68 round(1.005, 2) → 1.01 bcadd("0.1","0.2",2) → "0.30"
// JavaScript
(2.675).toFixed(2) → "2.67" (1.005).toFixed(2) → "1.00"
Cùng con số, backend PHP ra 2.68, frontend JS toFixed ra 2.67. Nếu trang điểm của
sinh viên tự làm tròn bằng JS, sinh viên sẽ thấy số khác với số gửi cho trường.
5.1. Làm tròn từng phần trước hay chỉ làm tròn cuối?
Bài thi online 30 câu → điểm thi = đúng/30 × 10, ra số lẻ vô hạn (22/30 → 7,3333…). Thử mọi tổ hợp chuyên cần 0–10 (bước 0,25) × số câu đúng 0–30:
tong_to_hop | lech_001
-------------+----------
1271 | 820 ← 64,5% tổ hợp lệch nhau 0,01
e_raw | e_hien_thi | cuoi_tu_raw | cuoi_tu_so_hien_thi
--------------------+------------+-------------+---------------------
7.3333333333333333 | 7.33 | 7.27 | 7.26
Sinh viên thấy "chuyên cần 7,00, thi online 7,33", tự bấm máy: 0,2×7 + 0,8×7,33 = 7,264 → 7,26. Hệ thống tính từ số thô ra 7,27. Nhân với 250.000 enrollment là hàng nghìn khiếu nại.
Luật chốt (đưa vào tài liệu tích hợp với trường):
- Mọi điểm lưu
numeric(4,2). Không bao giờ dùngfloat/double. - Làm tròn từng thành phần về 2 chữ số tại lúc nó sinh ra (điểm thi:
round(đúng * 10 / tổng, 2)), rồi tính tổng kết từ chính các số đã hiển thị. Ai bấm máy lại cũng ra cùng kết quả. - Làm tròn ở một chỗ duy nhất: PostgreSQL. PHP chỉ đọc ra và hiển thị chuỗi, frontend không tự tính lại.
- Hay: khi hai thành phần đều đúng 2 chữ số,
0,2A + 0,8E = (A + 4E) / 5— chữ số thứ 3 luôn chẵn, không bao giờ rơi đúng ,xx5. Nên tranh cãi "half-up hay half-even" biến mất. Đây là lý do thứ hai để làm tròn thành phần trước.
6. Redis tối ưu hơn ở đâu so với chỉ dùng database
| Việc | Chỉ PostgreSQL | Có Redis | Kết luận |
|---|---|---|---|
| Ghi hoạt động (post, reply, quiz, nộp bài) | 1 INSERT, lab 1.286/giây | Không nhanh hơn đáng kể, thêm rủi ro mất điểm | DB |
| Đọc điểm 1 sinh viên | 0,3 ms | Nhanh ngang, nhưng phải xoá cache mỗi khi có hoạt động | Không cần cache |
| Bảng điểm lớp 500 người | 10,8 ms | Cache → dễ cũ, phải invalidate | Không cần cache |
| Heartbeat video (~500/giây cao điểm) | 500 UPDATE/giây, ~207 byte WAL/lần ≈ 370 MB WAL/giờ, dead tuple, replica phải replay theo | 96 byte RAM / sinh viên / video, 0 lần ghi DB, 1 INSERT khi xem xong | Redis |
| Chặn spam (50 post "ok" trong 1 phút, heartbeat dồn dập) | Bảng đếm, thêm ghi | RateLimiter, SET NX PX | Redis |
| Cache access token của API trường | Bảng | Cache::remember | Redis (tiện, không bắt buộc) |
| Giữ điểm chính thức | Bền, có transaction, có backup | Mất khi failover (replication bất đồng bộ), bị evict khi đầy RAM | Không bao giờ Redis |
Nói thẳng khi phỏng vấn: với quy mô này, phần tính điểm không cần Redis. Redis đáng tiền ở đúng một chỗ: luồng heartbeat — ghi dày, giá trị từng lần thấp, mất vài giây dữ liệu cũng không sao.
6.1. Chi phí heartbeat nếu ghi thẳng PostgreSQL — đo thật
UPDATE video_progress SET watched = set_bit(watched, :seg, 1), last_pos = :seg * 10, updated_at = now()
WHERE enrollment_id = :e AND video_id = 1;
-- pgbench 16 client, 32.000 lần update trên 5.000 dòng
tps = 1718.934357
WAL sinh ra: 6459 kB → 207 byte / lần update
n_tup_upd | n_tup_hot_upd | n_dead_tup
32000 | 31434 | 728
PostgreSQL chịu được (1.718 tps lab), nhưng 500 heartbeat/giây là chiếm ~30% sức ghi của máy lab chỉ để lưu dữ liệu giá trị thấp. 207 byte là số đo khi trang đã "ấm"; ngay sau checkpoint, lần sửa đầu tiên của mỗi trang ghi cả trang 8 KB (full page write) nên WAL thật còn lớn hơn. Tất cả đi qua backup và replica.
6.2. Thiết kế Redis cho heartbeat: 1 video 1 lúc, tab phải focus
Hai luật của PO chia làm hai tầng, và phải nói rõ tầng nào kiểm được thật:
| Luật | Kiểm ở đâu | Tin được tới đâu |
|---|---|---|
| 1 video tại 1 thời điểm | Server (Redis): một khoá theo sinh viên ghi phiên xem nào đang có quyền. Tab mở sau thắng, tab cũ nhận -2 và tự dừng. | Chắc chắn — client không lách được. |
| Tab phải focus | Trình duyệt: mất focus / bị ẩn → dừng video + ngừng gửi heartbeat. Server chỉ thấy "không có heartbeat" nên không tô ô nào. | Chỉ ở mức client. Người sửa được JavaScript thì lách được. Nói thẳng điều này khi phỏng vấn. |
AttendanceRecorder::record() một
lần — PostgreSQL không thấy 99% số heartbeat.Vì sao "tab mở sau thắng" chứ không phải "tab đầu giữ chỗ"? Tab đóng đột ngột (tắt máy, mất mạng) không kịp báo. Nếu tab đầu giữ chỗ thì sinh viên bị khoá tới khi key hết hạn và gọi hỗ trợ. Cho tab mới giành quyền thì không bao giờ bị kẹt, và vẫn đúng luật "1 video 1 lúc".
-- start.lua — gọi khi bấm Play
-- KEYS[1] = vw:{s:<student>} ARGV: sid (ngẫu nhiên, server sinh), video_id, ttl giây
redis.call('HSET', KEYS[1], 'sid', ARGV[1], 'vid', ARGV[2])
redis.call('HSETNX', KEYS[1], 'last', 0) -- KHÔNG reset: mở tab mới không lách được khoảng cách
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1
-- heartbeat.lua
-- KEYS[1] = vw:{s:<student>} hash: sid, vid, last (ms)
-- KEYS[2] = vp:{s:<student>}:<enr>:<vid> bitmap đoạn đã xem
-- ARGV: sid, video_id, segment, min_gap_ms, ttl_owner, ttl_bits
local o = redis.call('HMGET', KEYS[1], 'sid', 'vid', 'last')
if o[1] ~= ARGV[1] or o[2] ~= ARGV[2] then return -2 end -- tab/video khác đang giữ quyền xem
local t = redis.call('TIME') -- đồng hồ của Redis, không tin client
local now = t[1] * 1000 + math.floor(t[2] / 1000)
if now - tonumber(o[3]) < tonumber(ARGV[4]) then return -1 end -- gửi dồn
redis.call('HSET', KEYS[1], 'last', now)
redis.call('EXPIRE', KEYS[1], ARGV[5])
local was = redis.call('SETBIT', KEYS[2], ARGV[3], 1)
redis.call('EXPIRE', KEYS[2], ARGV[6])
return { was, redis.call('BITCOUNT', KEYS[2]) }
Chạy thật trên Redis 8.8.0 (test dùng min_gap 1,5 giây cho nhanh; production đặt
9.000 ms cho đoạn 10 giây):
--- tab A mo video 7
tab A hb doan 0: 0 1 ← ô mới, đã xem 1 ô
tab A gui don ngay: -1 ← gửi dồn
--- tab B mo video 9 (mon khac)
tab A hb tiep video 7: -2 ← tab A mất quyền, client dừng video
tab B hb video 9: 0 1
tab B gia sid, doi sang video 7: -2 ← sid đúng nhưng sai video
--- tab B dong khong bao, 30s sau key het han
hb bat ky: -2 ← phải bấm Play lại (gọi start)
--- bản start.lua đầu tiên reset last = 0 → kẻ gian gọi start xen giữa mỗi heartbeat:
--- sau khi sửa thành HSETNX (min_gap 9000 ms):
start moi + hb doan 0 ngay lap tuc: 0 1
start moi + hb doan 1 ngay lap tuc: -1
start moi + hb doan 2 ngay lap tuc: -1
--- RAM bitmap cho video 60 phut (360 doan): MEMORY USAGE 96 byte
// Controller heartbeat — code minh hoạ
$seg = intdiv($request->integer('position'), 10);
abort_if($seg < 0 || $seg >= $video->segments, 422); // số đoạn lấy từ server, không từ client
$s = "{s:{$student->id}}"; // hashtag: 2 key cùng slot trên Cluster
$r = Redis::eval($heartbeatLua, 2, "vw:$s", "vp:$s:{$enr->id}:{$video->id}",
$request->string('sid'), $video->id, $seg, 9000, 30, 14 * 86400);
if ($r === -2) return response()->json(['state' => 'taken'], 409); // client dừng video, hiện thông báo
if (is_array($r) && $r[0] === 0 && $r[1] >= (int) ceil($video->segments * 0.9)) {
$recorder->record($enr->id, ActivityType::Video, $video->id); // UNIQUE lo phần trùng
}
return response()->noContent();
// Trình duyệt — chỉ gửi heartbeat khi video chạy VÀ tab focus
const canWatch = () => document.visibilityState === 'visible' && document.hasFocus();
const stop = () => { if (!video.paused) video.pause(); };
document.addEventListener('visibilitychange', () => { if (!canWatch()) stop(); });
window.addEventListener('blur', stop); // bị che một phần / bấm sang app khác: visibility vẫn "visible"
video.disablePictureInPicture = true;
setInterval(async () => {
if (video.paused || !canWatch()) return;
const res = await fetch(`/api/videos/${videoId}/heartbeat`, { method: 'POST', headers,
body: JSON.stringify({ sid, position: Math.floor(video.currentTime) }) });
if (res.status === 409) { stop(); showTakenBanner(); } // đang xem ở tab khác
}, 10_000);
Vì sao chống gian lận được ở mức đủ dùng: mỗi heartbeat chỉ tô đúng một ô, khoảng cách tối thiểu 9 giây tính bằng đồng hồ Redis, và mỗi sinh viên chỉ có một phiên được tô. Muốn "xem xong" video 60 phút, script giả mạo vẫn phải chạy thật ~54 phút và không xem song song được video khác. Không chặn được 100% — chấp nhận, vì video chỉ góp tối đa 2/10 điểm chuyên cần.
6.3. Bẫy của phần video + Redis
- Để Redis giữ điểm (write-behind cho điểm). Replication Redis là bất đồng bộ:
failover có thể mất lệnh đã trả OK.
allkeys-lruđầy RAM là xoá key âm thầm. Với điểm thi → sai sót có tính pháp lý. Redis chỉ giữ thứ mất được: tiến độ xem dở. - Start reset bộ đếm khoảng cách. Bản đầu của
start.luađặtlast = 0→ gọi start trước mỗi heartbeat là tô ô liên tục, xem 60 phút trong vài giây. Đã sửa bằngHSETNXvà test lại ở trên. Bẫy này không lộ khi test "người dùng bình thường". - Chỉ nghe
visibilitychange. Theo MDN, trang chỉ thànhhiddenkhi thu nhỏ, đổi tab, hoặc bị che hoàn toàn. Bị che một phần, hay đang gõ ở app khác trên màn hình thứ hai, vẫn làvisible→ phải thêmblur+document.hasFocus(). - Player nhúng bằng iframe (YouTube, Vimeo). Bấm vào player là focus chuyển vào
iframe → trang cha nhận
blur→ video tự dừng ngay khi sinh viên bấm Play. Chưa verify trong trình duyệt thật (máy lab không có browser headless). Cách an toàn: dùng<video>của chính mình (HLS), hoặc khiblurthì kiểm tradocument.activeElementcó phải iframe player không. disablePictureInPicturechỉ là "yêu cầu". MDN ghi rõ cài đặt của người dùng có thể ghi đè. PiP làm tab bị ẩn → code ở trên dừng video, nên kết quả vẫn đúng luật, chỉ là trải nghiệm hơi lạ.- Nhiều màn hình = nhiều phàn nàn. Sinh viên xem video ở màn hình 1, ghi chép ở màn hình 2 → video dừng liên tục. Đây là hệ quả của luật "phải focus", PO phải chấp nhận và FAQ phải ghi rõ trước khi khai giảng.
- Redis restart mất bitmap. Sinh viên xem dở phải xem lại phần chưa đủ 90%.
Điểm đã ghi không mất (đã ở PostgreSQL). Giảm thiệt hại: AOF
everysec; lưu vị trí xem (để resume) xuống DB khi pause/đóng tab, không phải mỗi heartbeat. - Key không cùng slot trên Redis Cluster. Script dùng 2 key → cả hai phải có cùng
hashtag
{s:<student>}, nếu không Cluster báoCROSSSLOT. Hashtag theo sinh viên (không theo video) vì khoá "1 video 1 lúc" là theo sinh viên. - Lấy số đoạn từ client. Client gửi "video có 10 đoạn" → xem 9 đoạn là xong.
Số đoạn phải lấy từ metadata video ở server.
siddo server sinh lúc start, đủ dài để không đoán được.
7. Bảng kết quả: cập nhật tới khi kết thúc khoá, không chốt một lần
Cách dễ nghĩ tới nhất là job "chốt sổ" (freeze) chạy một lần sau hạn + 30 phút. Với luật này thì cách đó sai: sau hạn, điểm chuyên cần vẫn đổi vì giảng viên còn chấm và publish bài nộp đúng hạn, còn phúc khảo; điểm thi online cũng đến sau. Mốc cứng thật sự là khi khoá học kết thúc — lúc trường chốt danh sách thi.
CREATE TABLE courses (
id bigint PRIMARY KEY,
attendance_deadline timestamptz NOT NULL, -- chặn hành động MỚI của sinh viên
ends_at timestamptz NOT NULL -- trường chốt danh sách thi
);
CREATE TABLE course_results (
enrollment_id bigint PRIMARY KEY REFERENCES enrollments(id),
attendance numeric(4,2) NOT NULL,
online_exam numeric(4,2), -- NULL = chưa thi
final_score numeric(4,2) GENERATED ALWAYS AS (round(0.2 * attendance + 0.8 * online_exam, 2)) STORED,
version int NOT NULL DEFAULT 1,
synced_version int NOT NULL DEFAULT 0
);
CREATE INDEX course_results_unsynced ON course_results (enrollment_id) WHERE version > synced_version;
-- Job chạy mỗi 10 phút. Chỉ các môn đã qua hạn ghi nhận và còn trước kết thúc khoá 2 giờ.
INSERT INTO course_results AS r (enrollment_id, attendance, online_exam)
SELECT a.enrollment_id, a.attendance, fe.score
FROM attendance a
JOIN courses c ON c.id = a.course_id
LEFT JOIN final_exam_scores fe ON fe.enrollment_id = a.enrollment_id -- chỉ có dòng khi đã thi + chấm
WHERE c.attendance_deadline < now() AND now() < c.ends_at - interval '2 hours'
ON CONFLICT (enrollment_id) DO UPDATE
SET attendance = EXCLUDED.attendance, online_exam = EXCLUDED.online_exam, version = r.version + 1
WHERE (r.attendance, r.online_exam) IS DISTINCT FROM (EXCLUDED.attendance, EXCLUDED.online_exam);
Chạy thật (SV 100 đã thi 7,33 và có 1 bài được publish; SV 101 bị thu hồi publish, chưa thi; SV 102 thi sau):
--- lan 1
INSERT 0 3
--- lan 2, khong co gi doi
INSERT 0 0 ← không tăng version vô ích
--- SV 102 thi online xong
INSERT 0 1
enrollment_id | attendance | online_exam | final_score | version
---------------+------------+-------------+-------------+---------
100 | 1.50 | 7.33 | 6.16 | 1
101 | 0.00 | | | 1 ← chưa thi: để trống
102 | 0.00 | 9.00 | 7.20 | 2
--- sau moc khoa hoc ket thuc: job khong dong vao nua
INSERT 0 0
- Một job lo hết: publish, thu hồi, phúc khảo, thi online, sửa luật điểm — mọi thứ
đổi ở nguồn thì vòng sau tự vào
course_results. Không phải gắn hook ở từng màn hình. Trang của sinh viên vẫn đọc trực tiếp viewattendancenên luôn thấy số mới nhất. - Chỉ tăng version khi số thật sự đổi → chỉ gửi sang trường những dòng đã đổi.
- Dừng trước kết thúc khoá 2 giờ để đợt gửi cuối kịp tới trường và đối soát xong trước khi trường chốt. Sau mốc này, mọi thay đổi đi qua màn hình giáo vụ (có lý do, người sửa, audit log) và quy trình phúc khảo với trường.
- Trên UI, trước hạn phúc khảo cuối cùng hiện chữ "tạm tính" cạnh điểm chuyên cần — giảm hẳn khiếu nại kiểu "sao điểm em tự đổi".
final_scorelà generated column: chỉ một chỗ tính. Giới hạn: nếu mỗi môn có trọng số khác (30/70…) thì generated column không đọc được bảng khác → đưa công thức vào câu job, trọng số lưu ởcourses.
7.1. Bẫy của job cập nhật
- Dùng
<>thay vìIS DISTINCT FROM— sinh viên thi xong mà điểm không bao giờ vào. So sánh với NULL ra NULL, vàWHERE NULLlà không update:sv_vua_thi_xong_dung_dau_khac | is_distinct -------------------------------+------------- | t(6.50, NULL) <> (6.50, 7.33)ra NULL → điểm thi online không được cập nhật → trường nhận "chưa thi" cho người đã thi. Bẫy nổ trễ: chỉ dính khi dòng được tạo trước lúc có điểm thi, tức đúng luồng thật, còn test với dữ liệu tạo sẵn đủ cả hai thì không thấy. - Gia hạn riêng quá ngày kết thúc khoá.
deadline_overridesauends_atthì sinh viên làm thêm cũng không gửi được. Validate lúc nhập:before:ends_at - 2 giờ, cùng luật với hạn phúc khảo (mục 3.6). - Job chạy lâu. Cả kỳ 250.000 enrollment × ~0,02 ms/người (đo ở mục 4.1) ≈ 5 giây mỗi vòng. Ổn với chu kỳ 10 phút; nếu lớn hơn nhiều thì chia theo môn, hoặc chỉ tính các enrollment có dòng sổ cái / điểm thi đổi từ vòng trước.
7.2. "Chưa thi thì để trống": NULL phải sống sót từ DB tới trường
0 điểm và "chưa thi" là hai thứ khác nhau: một người bị 0 vì làm bài sai hết, người kia chưa hề thi. Để trống phải giữ nguyên nghĩa qua mọi tầng. Chạy thật:
-- PostgreSQL: sắp xếp giảm dần, NULL lên ĐẦU
enrollment_id | final_score
---------------+-------------
101 | ← người chưa thi đứng đầu bảng xếp hạng
102 | 7.20
100 | 6.16
avg_bo_qua_null | avg_coi_null_la_0
-----------------+-------------------
6.68 | 4.45 ← báo cáo "điểm trung bình lớp" lệch tuỳ ai viết query
// PHP 8.3
number_format(null, 2)
Deprecated: number_format(): Passing null to parameter #1 ($num) of type float is deprecated
string(4) "0.00" ← UI hiện 0.00 cho người chưa thi
json_encode(["final_score" => null, "sai" => (float) null])
{"final_score":null,"sai":0} ← ép kiểu float là gửi 0 sang trường
- SQL:
ORDER BY final_score DESC NULLS LAST; báo cáo ghi rõ "trung bình của người đã thi". - PHP: model cast
'decimal:2'và hiển thị$r->final_score ?? '—'. Không bao giờ(float)trước khi kiểm tra null. - API/CSV gửi trường: JSON
null, CSV ô trống. Ghi vào hợp đồng: trường không được import ô trống thành 0. - Test: luôn có một fixture "sinh viên chưa thi" trong test gửi điểm.
8. Đồng bộ LMS → trường (điểm tổng kết)
8.1. Bảng kết quả chính là outbox
Không cần bảng outbox riêng. version > synced_version nghĩa là "còn phải gửi".
Mỗi lần điểm thật sự đổi thì version + 1 (job ở mục 7 làm việc này). Job gửi
đọc các dòng này, gửi bản mới nhất, rồi đánh dấu đúng version đã gửi.
Chạy thật đúng kịch bản trên:
--- trong luc dang goi API, giang vien cham lai bai thi SV 100
UPDATE 1
--- API tra OK cho ban da gui (version 1): chi danh dau dung version da gui
UPDATE course_results SET synced_version = 1 WHERE enrollment_id IN (100, 101) AND synced_version < 1;
enrollment_id | final_score | version | synced_version | con_phai_gui
---------------+-------------+---------+----------------+--------------
100 | 8.40 | 2 | 1 | t
101 | 6.06 | 1 | 1 | f
8.2. Job đẩy điểm (code minh hoạ)
// routes/console.php — mỗi trường một lịch, không chạy chồng, chỉ 1 server
Schedule::command('results:push', [$schoolId])->everyFiveMinutes()->withoutOverlapping()->onOneServer();
// PushResultsToSchool command
$token = Cache::remember("school:{$school->id}:token", 3000, fn () =>
Http::asForm()->post($school->token_url, [
'grant_type' => 'client_credentials',
'client_id' => $school->client_id,
'client_secret' => $school->client_secret,
'scope' => 'results:write',
])->throw()->json('access_token'));
CourseResult::forSchool($school)
->whereColumn('version', '>', 'synced_version')
->chunkById(500, function ($rows) use ($school, $token) {
$res = Http::withToken($token)->timeout(20)
->retry([1000, 5000, 15000], throw: false)
->post($school->results_url, ['items' => $rows->map->toSchoolPayload()]);
// toSchoolPayload: student_code, course_code, term, attendance, online_exam, final_score, version
// chưa thi: online_exam = null, final_score = null — gửi luôn, để trường biết sinh viên chưa thi
if ($res->status() === 401) { Cache::forget("school:{$school->id}:token"); return false; }
$res->throw();
foreach ($res->json('accepted') as $item) { // trường trả từng dòng nhận / từ chối
CourseResult::whereKey($item['enrollment_ref'])
->where('synced_version', '<', $item['version'])
->update(['synced_version' => $item['version']]);
}
SyncError::recordMany($school, $res->json('rejected')); // vd student_code không có bên trường
}, 'enrollment_id');
Hợp đồng với trường (phải thống nhất trước khi code):
- Trường upsert theo khoá
(student_code, course_code, term)và bỏ qua bản có version nhỏ hơn hoặc bằng bản đang có. Nhờ đó retry / gửi trùng là vô hại. - Trả kết quả từng dòng (accepted / rejected + lý do), không phải "cả batch OK/lỗi". Một mã sinh viên sai không được làm hỏng 499 dòng còn lại.
- Mốc chốt = kết thúc khoá học. Trước mốc, trường nhận bản mới theo version bao
nhiêu lần cũng được. Sau mốc, trường trả
409→ LMS hiện lên màn hình giáo vụ để xử lý tay. - Đợt gửi cuối + đối soát: job mục 7 dừng lúc
ends_at - 2h; job gửi đẩy nốt, rồi gọi API đối soát (trường trả số dòng + tổngfinal_scoretheo môn). Hai bên khớp thì mới coi là xong. Không khớp → còn 2 giờ để xử lý trước khi trường chốt. nullnghĩa là chưa thi. Trường không được import thành 0.- Luật làm tròn (mục 5) ghi vào tài liệu tích hợp.
8.3. Dự phòng khi trường không có API: file CSV qua SFTP
// config/filesystems.php — cần composer require league/flysystem-sftp-v3
'school_sftp' => ['driver' => 'sftp', 'host' => env('SCHOOL_SFTP_HOST'),
'username' => env('SCHOOL_SFTP_USER'), 'privateKey' => env('SCHOOL_SFTP_KEY'),
'hostFingerprint' => env('SCHOOL_SFTP_FINGERPRINT'), 'root' => '/lms'],
$name = "results_{$term}_{$course}_" . now()->format('YmdHis');
$disk->put("outbound/{$name}.csv.part", $csv); // ghi tên tạm
$disk->put("outbound/{$name}.sha256", hash('sha256', $csv) . " {$name}.csv\n");
$disk->move("outbound/{$name}.csv.part", "outbound/{$name}.csv"); // đổi tên khi đã ghi xong
Bẫy khi trao đổi file (hay gặp ở trường dùng Excel):
- Trường đọc file đang ghi dở. Ghi tên
.partrồi đổi tên; kèm checksum + số dòng. Chưa verify: rename trên SFTP của trường có nguyên tử (atomic) hay không tuỳ server — hỏi họ. - Excel xoá số 0 đầu mã sinh viên (
00123→123) và đổi7.26thành7,26theo locale. Thống nhất: UTF-8 có BOM (để tên tiếng Việt không vỡ), dấu chấm thập phân, mã sinh viên để trong ngoặc kép. - File không có version. Vẫn phải có cột
version— trường import lại file cũ cũng không ghi đè bản mới. - Không cài
hostFingerprint→ dễ bị giả mạo server (man-in-the-middle).
9. Đồng bộ trường → LMS (được thi, điểm offline)
sequenceDiagram participant S as Hệ thống trường participant W as LMS webhook participant DB as PostgreSQL participant Q as Queue worker S->>W: POST /api/school/exam-status
header X-Timestamp, X-Signature · body có message_id W->>W: kiểm tra chữ ký HMAC + timestamp lệch dưới 5 phút W->>DB: INSERT inbound_messages ON CONFLICT (school_id, message_id) DO NOTHING W-->>S: 202 Accepted (trả ngay, chưa xử lý) W->>Q: dispatch ProcessExamStatus Q->>DB: UPSERT exam_status ... WHERE school_version nhỏ hơn bản mới Note over Q,DB: bản cũ đến trễ → INSERT 0 0, không ghi đè Q->>DB: UPDATE inbound_messages SET processed_at = now()
- Lưu nguyên payload trước, xử lý sau. Trả 202 trong vài ms. Xử lý lỗi thì payload vẫn còn để chạy lại, và có bằng chứng "trường đã gửi gì, lúc nào" khi hai bên cãi nhau.
- Webhook để nhanh, poll để đúng. Webhook có thể mất (LMS đang deploy, mạng
chập). Mỗi giờ LMS gọi
GET /exam-status?updated_since=<cursor>; lùi cursor vài phút để bù lệch giờ hai hệ thống. Nhận lại bản đã có là vô hại nhờ version guard. - Nếu trường chỉ có file: đọc thư mục
inbound/theo lịch, xử lý xong chuyển sangprocessed/, dùng tên file làmmessage_id.
9.1. Bản cũ đến trễ — test thật
INSERT INTO exam_status VALUES (100, false, NULL, 2) -- trường gửi v2: KHÔNG đủ điều kiện
ON CONFLICT (enrollment_id) DO UPDATE SET eligible = EXCLUDED.eligible,
offline_score = EXCLUDED.offline_score, school_version = EXCLUDED.school_version
WHERE exam_status.school_version < EXCLUDED.school_version;
INSERT 0 1
-- v1 (đủ điều kiện) đến trễ, cùng câu lệnh
INSERT 0 0
enrollment_id | eligible | offline_score | school_version
---------------+----------+---------------+----------------
100 | f | | 2 ← giữ đúng bản mới
--- khong co WHERE version: ban tre ghi de
INSERT 0 1
100 | t | | 1 ← sinh viên "được thi" sai
Nếu trường không có version: dùng updated_at của trường (kém hơn — hai lần sửa cùng
một giây). Không có cả hai thì phải yêu cầu họ thêm; không thì chỉ dựa được vào poll định kỳ lấy
toàn bộ trạng thái (full snapshot).
10. Authen và phân quyền
| Chiều | Cách chọn | Vì sao |
|---|---|---|
| LMS gọi API trường | Theo cái trường có. Tốt nhất: OAuth2 client credentials, token cache tới gần hết hạn. Thêm IP allowlist / VPN, mTLS nếu trường yêu cầu. | LMS là khách, không chọn được. |
| Trường gọi webhook LMS | HMAC-SHA256 trên timestamp.body + chống replay (timestamp ±5 phút + message_id duy nhất) + IP allowlist + HTTPS. Cơ chế chi tiết ở mục 10.1. | Đơn giản nhất mà đủ chặt: không cần server token, chữ ký bảo vệ cả nội dung body. |
| Trường gọi API LMS (nếu họ muốn OAuth2) | Laravel Passport passport:client --client, route dùng EnsureClientIsResourceOwner::using('exam-status:write'). | Chuẩn, có scope, thu hồi được từng client. |
| Sinh viên / giảng viên xem điểm | Session/SSO như bình thường + Policy: sinh viên chỉ xem enrollment của mình, giảng viên chỉ xem lớp mình dạy. | Điểm là dữ liệu cá nhân. |
10.1. Webhook từ trường: HMAC trên ts.body + chống replay
Endpoint POST /api/school/exam-status mở ra internet, ai cũng gọi được. Với mỗi
request, LMS phải trả lời 3 câu:
| Câu hỏi | Thuật ngữ | Phần nào trả lời |
|---|---|---|
| Có đúng trường A gửi không? | authenticity (xác thực ai là người gửi) | HMAC |
| Nội dung có bị sửa trên đường đi không? | integrity (dữ liệu còn nguyên vẹn) | HMAC |
| Có phải request cũ bị gửi lại không? | replay attack (gửi lại request cũ) | timestamp + message_id |
HTTPS không trả lời được câu 1. TLS chỉ chứng minh cho người gọi biết server LMS là thật. Nó không cho LMS biết ai đang gọi.
Cách hoạt động. Hai bên giữ chung một secret. Secret được trao một lần qua kênh khác, không bao giờ đi kèm request.
- Trường ký lúc gửi. Lấy giờ hiện tại
ts(tính bằng giây), ghép chuỗits + "." + body, tínhHMAC-SHA256(secret, chuỗi đó). Gửitstrong headerX-Timestamp, chữ ký trongX-Signature.message_idnằm trong body, nên cũng được ký. - LMS kiểm 3 cổng theo thứ tự.
- ①
|now − ts| ≤ 300giây: request còn mới không. - ② Tự tính lại HMAC trên byte thô của body (
$request->getContent()), so bằnghash_equals: có đúng người giữ secret ký không, nội dung có bị sửa không. - ③
INSERT inbound_messages … ON CONFLICT (school_id, message_id) DO NOTHING. Chèn được 1 dòng thì dispatch job. 0 dòng là bản trùng: không xử lý lại, nhưng vẫn trả 2xx.
- ①
message_id).- Secret không bao giờ đi trên mạng. Chỉ chữ ký đi. Mỗi bên tự tính chữ ký, rồi LMS so hai kết quả.
- HMAC là keyed hash (hàm băm cần khoá). Có secret thì tính chữ ký rất dễ. Không có secret thì không tạo được chữ ký đúng cho nội dung khác. Nhìn chữ ký không đoán ngược ra được secret.
- Sửa 1 chữ là chữ ký đổi hoàn toàn. Lab phần 1:
chuỗi được ký : 1790812800.{"message_id":"m-001","student_code":"SV100","eligible":false,"version":2}
X-Signature : 5f8bc592aceecb85a7c0f2fbcc01d3f98ed8e63d0e8381395244439bb481f289
sửa false -> true : 32a823865757fa5b7387814ec82ea47d4df7e8decdfca2f801c1e0bd60268c1e
Trường dùng ngôn ngữ nào cũng ký được. Tính lại bằng openssl cho đúng chữ ký trên:
printf '%s' '1790812800.{"message_id":"m-001","student_code":"SV100","eligible":false,"version":2}' \
| openssl dgst -sha256 -hmac whsec_demo_truong_A
# SHA2-256(stdin)= 5f8bc592aceecb85a7c0f2fbcc01d3f98ed8e63d0e8381395244439bb481f289
Dấu . chỉ để ngăn cách. ts chỉ chứa chữ số (ctype_digit), nên
luôn biết ts dừng ở đâu và body bắt đầu ở đâu.
Code middleware. Nhận 2 secret để xoay khoá không gián đoạn (bẫy 10 ở mục 10.2).
// Middleware VerifySchoolSignature — phần verify đã chạy thật bằng PHP 8.3
function verify(array $secrets, string $ts, string $sig, string $body, int $now): string {
if (!ctype_digit($ts) || abs($now - (int) $ts) > 300) return 'reject: timestamp';
foreach ($secrets as $secret) { // 2 secret khi đang xoay khoá
if (hash_equals(hash_hmac('sha256', "$ts.$body", $secret), $sig)) return 'ok';
}
return 'reject: signature';
}
dung chu ky ok
body bi sua 1 ky tu reject: signature
replay sau 10 phut reject: timestamp
da bo old-secret reject: signature
sua timestamp de replay reject: signature
all passed
Mỗi cổng chặn cái gì — lab. Giả sử kẻ gian lấy được 1 request thật (từ log proxy, APM...) nhưng không có secret. Lab phần 2:
trường gửi thật NHẬN → dispatch job
kẻ gian sửa body false→true, giữ chữ ký reject: signature
kẻ gian tự ký bằng secret đoán reject: signature
trường retry vì timeout: ts mới, ký lại, cùng id trùng message_id → trả 2xx, bỏ qua
replay y nguyên sau 2 phút trùng message_id → trả 2xx, bỏ qua
replay y nguyên sau 10 phút reject: timestamp
replay sau 10 phút, sửa X-Timestamp = now reject: signature
m-003 gửi lỗi, queue gửi lại y nguyên sau 6 phút reject: timestamp
m-003 retry đúng: ký lại lúc gửi NHẬN → dispatch job
đồng hồ server trường chậm 6 phút reject: timestamp
→ m-001 bị gửi 6 lần (1 thật + 1 retry + 4 tấn công), trong bảng có: 1 dòng
Vì sao ký ts.body, không chỉ ký body? Xem dòng "sửa
X-Timestamp = now". Nếu ts không nằm trong chữ ký, kẻ gian đổi ts thành giờ hiện tại là qua cổng ①.
Vì ts nằm trong chữ ký, đổi ts thì chữ ký sai ngay. Nhờ vậy, request bị lấy trộm chỉ dùng được trong
5 phút.
Vì sao là ±5 phút?
- Không thể để 0. Đồng hồ hai server luôn lệch nhau một chút: clock skew (lệch đồng hồ giữa các máy). Còn phải cộng thời gian đi qua mạng và thời gian trường xếp hàng gửi.
- Không nên để dài. Cửa sổ càng dài thì request bị lấy trộm dùng được càng lâu, và cổng ③ phải nhớ id càng lâu.
- Dấu ± (
abs) chặn cả request có giờ "ở tương lai". Lab phần 3, đồng hồ trường chạy nhanh 1 ngày:
Nếu chỉ chặn quá khứ, request đó dùng lại được suốt 1 ngày, phá giả định "cổng ③ chỉ cần nhớ id 10 phút" ở dưới. Cólúc gửi chỉ chặn quá khứ: pass | abs (±): reject +1 giờ chỉ chặn quá khứ: pass | abs (±): reject +1 ngày chỉ chặn quá khứ: pass | abs (±): pass +1 ngày 5 phút 1 giây chỉ chặn quá khứ: reject | abs (±): rejectabsthì bị chặn ngay lúc gửi, lỗi lệch giờ lộ ra sớm. (Dòng "+1 ngày": lúc đó đồng hồ LMS đã bắt kịp ts nên lọt cổng ①, cổng ③ vẫn chặn bản trùng.)
Đã chặn timestamp, sao vẫn cần message_id? Trong 5 phút đó, request
gửi lại y nguyên vẫn qua cả cổng ① và ② (dòng "sau 2 phút"). Chỉ ràng buộc UNIQUE trong DB chặn được
nó.
Vì sao cần cả hai, không chọn một? Chỉ có timestamp thì còn hở 5 phút. Chỉ có
message_id thì phải nhớ mọi id mãi mãi. Lab phần 5, thiết kế sai (chỉ ký
body), 31 ngày sau có job dọn bảng:
trường gửi thật NHẬN → dispatch job
replay 10 phút sau, sửa ts = now trùng message_id → trả 2xx, bỏ qua
(31 ngày sau, job dọn bảng: DELETE 1)
replay 31 ngày sau, sửa ts = now NHẬN → dispatch job ← lọt
Thiết kế đúng, sau cùng job dọn bảng đó (lab phần 4): replay y nguyên ra reject: timestamp,
sửa ts ra reject: signature. Có cả hai thì cổng ③ chỉ cần nhớ id khoảng 10 phút (cửa sổ
±5 phút): timestamp giới hạn thời gian phải nhớ, message_id chặn replay
bên trong khoảng đó. Bài này vẫn giữ inbound_messages lâu, nhưng là để làm bằng
chứng và chạy lại, không phải để chống replay.
message_id còn là idempotency key (khoá để không xử lý trùng) khi trường retry thật: ký
lại với ts mới, giữ id cũ. LMS bỏ qua bản trùng nhưng vẫn trả 2xx để trường dừng retry.
Lợi ích so với cách khác. Câu hỏi để so: kẻ gian lấy được 1 request thật thì làm được gì?
| Cách xác thực | Kẻ gian làm được |
|---|---|
| API key cố định trong header | Gửi bất kỳ nội dung nào, mãi mãi, tới khi đổi key. Key đi kèm mọi request, nên key lộ ra log là lộ luôn quyền gọi. |
| HMAC(body) | Không sửa được nội dung, nhưng gửi lại y nguyên được, mãi mãi. |
| HMAC(ts.body) | Gửi lại y nguyên trong 5 phút. |
HMAC(ts.body) + message_id UNIQUE | Không làm được gì: gửi lại cũng không có tác dụng. |
- So với OAuth2 client credentials: không cần endpoint cấp token, không phải cache token, không tốn thêm một lượt gọi mạng. Phía trường chỉ cần hàm HMAC, ngôn ngữ nào cũng có sẵn. Webhook chỉ đi một chiều nên không cần nhiều hơn.
- So với mTLS (cả hai phía đều có cert): không phải cấp và gia hạn cert cho từng trường.
- Kiểm trong code app: chữ ký vẫn có tác dụng sau khi TLS đã kết thúc ở load balancer hoặc CDN.
- Là cách làm phổ biến (tra qua Context7 ngày 2026-10-01): stripe-node ký
{timestamp}.{payload}, cửa sổ mặc định 300 giây, chỉ chặn ts quá cũ (src/Webhooks.ts). Standard Webhooks kýmsg_id.timestamp.payload, dùngwebhook-idlàm idempotency key; thư viện của họ cho lệch 5 phút và chặn cả ts ở tương lai (spec).
Khi nào KHÔNG dùng HMAC
- Khi cần non-repudiation (bên gửi không thể chối là mình đã gửi). HMAC dùng khoá
đối xứng: hai bên giữ cùng một secret, nên LMS cũng tự ký được message giả. Lab phần 8:
Khi hai bên tranh chấp, chữ ký HMAC không chứng minh được với bên thứ ba rằng trường đã gửi. Mục 9 nói lưu payload để có bằng chứng — bằng chứng đó chỉ có giá trị khi hai bên tin log của nhau. Cần chắc hơn thì dùng chữ ký bất đối xứng (Ed25519/RSA): trường giữ private key, LMS chỉ có public key nên không ký thay trường được.LMS tự ký message giả bằng secret của mình NHẬN → dispatch job - Khi trường cần gọi nhiều API của LMS, cần scope và thu hồi quyền từng client: OAuth2 client credentials (dòng 3 bảng mục 10).
- Khi bên gửi không tự tính được chữ ký (hệ thống cũ chỉ gửi được header cố định): API key + IP allowlist + mTLS ở load balancer, chấp nhận rủi ro cao hơn.
Chạy lại lab. Từ gốc repo. Môi trường đã test: PHP 8.3.33 (image
laravel-app, có pdo_pgsql), PostgreSQL 18.4 (my-test-pgdb). Script
tự tạo rồi tự DROP bảng tmp_inbound_messages. Giờ được giả lập bằng tham số nên lần nào
chạy cũng ra cùng output. Output đầy đủ: labs/laravel/out/hmac_webhook_lab.txt.
docker run --rm --network container:my-test-pgdb -v "$PWD/labs/laravel":/lab:ro \
--entrypoint php laravel-app /lab/hmac_webhook_lab.php
10.2. Bẫy về authen
Xếp theo mức nguy hiểm. Bẫy nào có lab thì output nằm ở mục 10.1.
- Tin
school_idtrong payload. LMS phục vụ nhiều trường: token/secret của trường A gửi payload ghischool_id = Blà sửa được "được thi" của sinh viên trường B.school_idphải lấy từ credential đã xác thực (secret nào khớp / client_id nào), và khi mapstudent_code→ enrollment chỉ tìm trong trường đó. message_idnằm ngoài phần được ký (vd headerX-Message-Idriêng). Kẻ gian đổi id là replay được trong 5 phút. Lab phần 6:replay sau 1 phút, đổi X-Message-Id → NHẬN → dispatch job. Chữa: để id trong body, hoặc đưa id vào chuỗi ký như Standard Webhooks (msg_id.timestamp.payload).- Ký thiếu ts (chỉ ký body). Không lộ ngay, vì
inbound_messagesvẫn chặn bản trùng. Nổ muộn: khi có người thêm job dọn bảng, request cũ sửa ts là lọt (lab phần 5). - Chỉ chặn timestamp. Trong cửa sổ 5 phút vẫn replay được → phải có
message_idunique tronginbound_messages. - So chữ ký bằng
==. Lộ thời gian so sánh (timing attack). Dùnghash_equals. - Queue retry gửi lại request đã ký sẵn. Trường lưu nguyên request (cả ts và chữ
ký), 6 phút sau gửi lại →
reject: timestamp. Message mất, chỉ còn poll cứu. Nổ muộn: chỉ lộ khi LMS sập quá 5 phút. Chữa: thống nhất với trường là ký lại mỗi lần gửi, giữ nguyênmessage_id. - Đồng hồ server trường lệch quá 5 phút. Mọi webhook bị 401 mà không ai báo. Dữ liệu chỉ về qua poll, chậm tới 1 giờ. Chữa: log lý do reject theo từng trường, alert khi số reject tăng; hai bên chạy NTP.
- Ký trên JSON đã parse rồi encode lại. Thứ tự key / khoảng trắng đổi → chữ ký
sai dù không ai sửa gì. Tiếng Việt còn dễ dính hơn:
json_encodemặc định đổiễthành\u1ec5(lab phần 7). Ký và verify trên raw body ($request->getContent()). - Trả 409 / 4xx cho bản trùng. Bản trùng nên trả 2xx. Trả 4xx thì phía gửi thường hiểu là lỗi và retry tiếp. Phần này tuỳ code bên trường, chưa verify.
- Xoay secret làm gián đoạn. Cho phép 2 secret cùng lúc (test "da bo old-secret" ở mục 10.1 cho thấy lúc bỏ secret cũ mới bị chặn).
- Lộ secret. Kẻ gian ký được mọi thứ, HMAC không cứu được. Mỗi trường một secret riêng, lưu mã hoá, có quy trình xoay như bẫy trên.
- Token trường hết hạn giữa batch. Cache theo
expires_intrừ biên; gặp 401 thì xoá cache và thử lại một lần, không retry vô hạn.
11. Lợi ích của thiết kế này
- Đúng khi có lỗi: retry, job chạy 2 lần, gửi trùng, đến trễ đều vô hại — do UNIQUE và version nằm ở DB, không nằm ở "code cẩn thận".
- Truy vết được: mỗi điểm chuyên cần giải thích được bằng danh sách dòng sổ cái; mỗi lần gửi/nhận với trường có payload lưu lại.
- Đổi luật rẻ: sửa
score_rulestrước khi kết thúc khoá là mọi người tự đúng lại, và tự gửi lại trường. - Ít hệ thống: PostgreSQL + Redis + queue sẵn có của Laravel. Không Kafka, không service tính điểm riêng.
12. Tác hại / hạn chế
- Sổ cái lớn dần: ~15 triệu dòng / ~2,2 GB mỗi kỳ (suy từ 1,5 triệu dòng = 220 MB trong lab). Vài kỳ sau cần partition theo kỳ hoặc archive kỳ cũ.
- Ghi đồng bộ trong transaction làm module forum/quiz phụ thuộc module chuyên cần (coupling). Chấp nhận được trong monolith; tách service thì phải đổi sang event + outbox.
- Điểm chỉ "gần realtime" với trường: chậm tối đa job cập nhật (10 phút) + job gửi (5 phút) + retry.
- Chống gian lận video không tuyệt đối. Luật "tab phải focus" chỉ kiểm được ở trình duyệt; và nó làm phiền người dùng nhiều màn hình.
- Điểm chuyên cần còn đổi sau hạn ghi nhận (chấm muộn, phúc khảo) → sinh viên thấy số "nhảy". Phải ghi "tạm tính" trên UI tới hạn phúc khảo cuối.
- Phụ thuộc hợp đồng với trường (version, trả từng dòng, luật làm tròn). Trường không chịu làm thì phần đồng bộ yếu đi rõ rệt — phải nói rõ rủi ro với PO.
13. Trade-off: chọn cái nào, khi nào
| Lựa chọn | Được | Mất | Chọn khi |
|---|---|---|---|
| Ghi sổ cái trong cùng transaction | Nhất quán tuyệt đối, không lag, ít code | Coupling giữa module | Monolith, chung DB (mặc định) |
| Event + queue + outbox | Tách module/service | Lag (nguy hiểm đêm hạn chót), thêm outbox | Nguồn hoạt động ở service khác |
| Tính điểm khi đọc từ sổ cái | Đúng, truy vết, đổi luật miễn phí | Query dài hơn 1 cột | Mặc định — 0,3 ms là đủ |
| Cột cộng dồn | Đọc O(1) | Drift, không truy vết, đổi luật phải backfill | Đọc cực nhiều, và chỉ làm cache |
| Cập nhật kết quả tới khi kết thúc khoá | Đúng với chấm muộn, phúc khảo, thi sau hạn | Số gửi trường đổi nhiều lần, phải có version | Luật hiện tại (trường chốt khi kết thúc khoá) |
| Chốt sổ một lần sau hạn | Đơn giản, số đứng yên | Sai khi điểm còn đổi sau hạn | Chỉ khi mọi hoạt động có điểm ngay lúc làm (không có chấm tay) |
| Tab mở sau giành quyền xem | Không bao giờ bị kẹt khi tab cũ chết | Tab cũ bị dừng giữa chừng | Mặc định |
| Tab đầu giữ chỗ | Không bị "cướp" | Kẹt tới khi key hết hạn nếu tab cũ chết im lặng | Hầu như không nên |
| Heartbeat qua Redis | Bớt ~500 UPDATE/s, WAL, bloat | Mất tiến độ xem dở khi Redis mất | Có video + nhiều người xem cùng lúc |
| Heartbeat thẳng DB | Không thêm hệ thống | WAL, dead tuple, replica lag | Vài trăm người xem cùng lúc |
| Push API | Nhanh, trả lỗi từng dòng | Phụ thuộc API trường sống | Trường có API |
| File SFTP | Trường cũ nào cũng làm được | Chậm (theo lịch), lỗi định dạng | Trường không có API, hoặc dự phòng |
| Webhook | Gần realtime | Có thể mất | Luôn đi kèm poll |
| Poll | Không mất | Trễ, tốn request | Lưới an toàn / trường không gọi ra được |
| HMAC | Đơn giản, ký cả body | Phải tự lo xoay secret, chống replay; không chống chối bỏ được (hai bên cùng giữ secret) | Webhook một chiều |
| OAuth2 client credentials | Chuẩn, scope, thu hồi được | Thêm token server (Passport) | Trường gọi nhiều API của LMS |
14. Khó khăn khi vận hành thực tế
| Metric / alert | Ngưỡng gợi ý (ví dụ minh hoạ) | Nói lên điều gì |
|---|---|---|
Số dòng version > synced_version theo trường, và tuổi dòng cũ nhất | cũ nhất > 2 giờ | API trường chết / token sai / job treo |
| Tỉ lệ dòng bị trường từ chối | > 1% một batch | Lệch mã sinh viên / mã môn giữa hai bên |
inbound_messages chưa xử lý, độ trễ | > 10 phút | Worker chết hoặc payload lạ |
| Số hoạt động bị chặn vì quá hạn | Tăng vọt ngay sau hạn là bình thường; tăng trước hạn là lỗi múi giờ | Bắt bẫy 3.5 sớm |
| Chênh lệch khi đối soát hằng đêm (LMS vs trường) | > 0 | Có dòng bị mất / ghi đè sai |
| Tỉ lệ heartbeat trả -1 (gửi dồn) / -2 (tab khác) | đột biến | -1: script hoặc client lỗi. -2 tăng mạnh: có thể player iframe làm mất focus, hoặc sinh viên mở nhiều tab |
| Bài nộp đúng hạn nhưng chưa publish, khi còn 3 ngày tới kết thúc khoá | > 0 | Nhắc giảng viên. Qua mốc là sinh viên mất điểm chuyên cần oan |
| Dòng chưa gửi khi còn 24 giờ tới kết thúc khoá | > 0 và cũ hơn 1 giờ | Báo động mức cao — sau mốc chỉ còn xử lý tay |
| Queue lag đêm hạn chót | > 1 phút | Thêm worker trước mùa thi |
- Con người: giáo vụ là người nhận khiếu nại đầu tiên. Cần một màn hình "vì sao sinh viên X có điểm này" (liệt kê sổ cái + luật) — không thì mọi khiếu nại đều thành ticket cho dev.
- Lịch làm việc hai bên: tuần chốt điểm là lúc trường hay bảo trì hệ thống. Hỏi lịch trước, chuẩn bị sẵn phương án xuất CSV tay.
- Môi trường test của trường thường không có hoặc dữ liệu rất khác production. Viết contract test (kiểm tra format request/response) dựa trên tài liệu tích hợp để bớt phụ thuộc.
15. Case thực tế và cách xử lý
Case 1 — "Em nộp bài 23:58 mà không có điểm chuyên cần"
- Tra
submissions:first_submitted_atlà bao nhiêu,grade_statuslà gì? draft/ungraded→ đúng luật là chưa tính. Trả lời: "điểm sẽ vào khi giảng viên publish", và UI nên hiện sẵn dòng "chờ chấm" thay vì im lặng.publishedmà không có dòng sổ cái → lỗi:sync_assignmentkhông được gọi ở một luồng nào đó (vd import điểm từ Excel đi đường khác). Chạy lạisync_assignment(assignment_id)— an toàn vì idempotent — rồi sửa luồng thiếu.- Có dòng nhưng tổng không tăng → loại assignment đã chạm trần. Cho sinh viên xem bảng điểm theo loại.
first_submitted_atlà 00:00:03 → đúng luật không tính. Trả lời kèm giờ server.
Case 2 — API trường sập đúng tuần chốt danh sách thi
- Alert "dòng chưa gửi cũ nhất > 2 giờ" nổ. Job vẫn tự retry mỗi 5 phút, không mất gì.
- Báo trường + PO. Hỏi hạn cuối trường cần dữ liệu.
- Gần hạn mà API chưa lên → xuất CSV từ đúng các dòng
version > synced_version, gửi qua SFTP. Sau khi trường xác nhận import, đánh dấusynced_versiontheo file. - API lên lại, job gửi lại những gì còn lệch — vô hại vì trường bỏ qua version cũ.
Case 3 — Phúc khảo làm điểm đổi sau khi đã gửi trường
- Trước kết thúc khoá: không ai phải làm gì. Phúc khảo đổi
grade(vẫn published) hoặc điểm thi → job mục 7 thấy số đổi →version + 1→ job gửi đẩy bản mới (Hình 3). Trường nhận bản mới vì version lớn hơn. - Sau kết thúc khoá: job mục 7 không chạm nữa. Giáo vụ sửa qua màn hình riêng (lý
do, người sửa, audit log) → gửi → trường trả
409→ hai bên làm theo quy trình phúc khảo của trường. - Phòng từ gốc: hạn phúc khảo phải trước kết thúc khoá 2 giờ (validate ở mục 3.6).
Case 4 — Trường gửi lại file/webhook cũ, sinh viên đang "không được thi" thành "được thi"
- Có version guard → câu upsert ra
INSERT 0 0, không đổi gì (test 9.1). - Không có version guard → tra
inbound_messagestheo thời gian để biết bản nào đến sau, ghi lại trạng thái đúng, rồi thêm guard. Báo trường vì có thể họ cũng gửi sai.
Case 5 — Luật điểm nhập sai giữa kỳ (reply 5 điểm thay vì 0,5)
- Sửa
score_rules. Vì điểm tính khi đọc từ sổ cái → mọi sinh viên đúng lại ngay, không cần backfill. - Trước kết thúc khoá: job mục 7 tự cập nhật
course_results+version→ tự gửi lại trường. Sau kết thúc khoá: đi quy trình xử lý tay như Case 3. - Thêm validate ở form:
points ≤ max_points ≤ 10.
Case 6 — Sinh viên spam 50 bài "ok" lên forum để lấy điểm
- Trần theo loại đã chặn: post tối đa 3 điểm dù post bao nhiêu.
- Thêm
RateLimiter(vd 5 post / 10 phút / người) và độ dài tối thiểu. - Giảng viên xoá bài spam → xoá dòng sổ cái cùng transaction → điểm tự giảm (đã test).
Case 7 — "Video cứ tự dừng, em không xem được"
- Xem metric heartbeat -2 của sinh viên đó. Nhiều -2 → đang mở video ở tab/máy khác (tab mới giành quyền). Hướng dẫn đóng tab kia.
- Không có -2 mà heartbeat thưa → client tự dừng vì mất focus. Hỏi: có dùng 2 màn hình, có đang mở cửa sổ khác đè lên không? Đó là đúng luật — FAQ phải ghi trước.
- Nhiều sinh viên cùng bị ngay khi bấm Play → nghi player iframe làm trang cha nhận
blur(bẫy 6.3-4). Tái hiện trên trình duyệt thật, sửa bằng cách kiểm tradocument.activeElement.
Case 8 — Giảng viên quên publish điểm assignment trước khi kết thúc khoá
- Alert "bài nộp đúng hạn chưa publish" khi còn 3 ngày → gửi mail cho giảng viên + giáo vụ.
- Còn 1 ngày vẫn chưa publish → giáo vụ gọi trực tiếp. Đây là việc của con người, hệ thống chỉ báo sớm.
- Qua mốc → sinh viên thiếu điểm chuyên cần. Publish muộn vẫn làm sổ cái đúng, nhưng
course_resultskhông tự đổi nữa → giáo vụ gửi bổ sung theo quy trình phúc khảo.
16. Câu hỏi phụ interviewer hay hỏi tiếp
Sao không cộng điểm thẳng vào một cột cho nhanh?
Đọc từ sổ cái đã 0,3 ms. Cột cộng dồn thì retry là lệch vĩnh viễn, đổi luật phải backfill, không
truy vết được khi khiếu nại. Chỉ thêm khi đo được đọc là nút cổ chai, và khi đó nó là cache.
Sao không để Redis giữ điểm rồi flush xuống DB định kỳ?
Redis replication bất đồng bộ, failover mất lệnh đã OK; đầy RAM thì evict. Điểm là dữ liệu có tính
pháp lý. Với ~vài chục lệnh ghi/giây thì PostgreSQL (1.286/s trong lab) còn dư rất nhiều.
Làm sao gửi sang trường "đúng một lần"?
Không có exactly-once qua mạng. Làm at-least-once (retry đến khi OK) + bên nhận idempotent (upsert
theo khoá + bỏ version cũ) → kết quả cuối giống gửi đúng một lần.
Đồng hồ các server lệch nhau thì hạn ghi nhận có sai không?
Dùng now() của PostgreSQL làm đồng hồ duy nhất. Chỉ primary quyết định, app server lệch
giờ không ảnh hưởng.
Quy mô tăng 10 lần (500.000 sinh viên)?
Đọc điểm vẫn là index lookup ~60 dòng/người → không đổi. Sổ cái 150 triệu dòng/kỳ → partition theo
kỳ. Heartbeat 5.000/s → Redis Cluster, hashtag theo {s:<student>}. Job đẩy điểm chia theo
trường, chạy song song.
Tại sao không dùng Kafka / RabbitMQ để đồng bộ với trường?
Hai tổ chức khác nhau, trường chỉ mở HTTP hoặc SFTP. Khối lượng ~250.000 dòng/kỳ, gửi theo batch
500 là ~500 request. Thêm Kafka là thêm một hệ thống phải vận hành mà không giải quyết vấn đề nào ở đây.
Tại sao cần poll khi đã có webhook?
Webhook có thể mất (LMS đang deploy, trường không retry). Poll theo updated_since là lưới
an toàn; nhận trùng vô hại nhờ version.
Đã có version guard ở DB rồi, chống replay ở webhook để làm gì?
Version guard giữ dữ liệu: bản cũ không ghi đè được exam_status. Chống replay giữ
cửa vào: không dispatch job thừa, không làm bẩn inbound_messages (bảng làm bằng
chứng), và che cả loại message không có version. Hai lớp chặn hai việc khác nhau (mục 10.1).
Trường gửi "được thi" cho một sinh viên LMS không có?
Không làm hỏng cả batch: lưu dòng lỗi vào bảng lỗi đồng bộ, trả về cho trường trong kết quả từng dòng,
giáo vụ xử lý. Thường do mã môn/mã sinh viên hai bên lệch — cần bảng mapping mã.
Sinh viên chưa thi online thì điểm tổng kết là gì?
Để trống: final_score NULL, vẫn gửi sang trường dưới dạng JSON null để trường
biết sinh viên này chưa thi. Trường quyết định có cho thi offline không. LMS không đổi NULL thành 0 —
và phải canh ba chỗ hay tự đổi: number_format(null), ép (float),
COALESCE(..., 0) trong báo cáo (mục 7.2).
Server có kiểm được "tab đang focus" không?
Không. Server chỉ thấy heartbeat đến hay không. Focus do JavaScript kiểm, người sửa được JS thì lách
được. Cái server kiểm chắc chắn là: 1 video 1 lúc (khoá theo sinh viên), không tô nhanh hơn thời gian
thật (khoảng cách tính bằng đồng hồ Redis), và đủ 90% đoạn. Với 2/10 điểm thì mức đó là đủ.
Tại sao so hạn bằng giờ nộp lần đầu chứ không phải giờ publish?
Hạn ghi nhận là hạn cho hành động của sinh viên. Giảng viên chấm chậm không phải lỗi của sinh
viên. Và phải là lần nộp đầu: nộp lại bản sửa sau hạn không được làm mất điểm đã có (đã test
ở mục 3.6).
Sao không chốt điểm ngay sau hạn ghi nhận cho đơn giản?
Vì điểm chuyên cần còn đổi sau hạn: bài nộp đúng hạn được publish muộn, phúc khảo. Chốt sớm là chốt số
sai. Mốc cứng thật là lúc trường chốt danh sách thi (kết thúc khoá) — nên hệ thống cập nhật theo version
tới sát mốc đó.
Phụ lục: chạy lại các test trong bài
P=/var/www/chiennn/postgresql-learning
$P/scripts/psql.sh # tạo bảng tmp_* như mục 3.1, chạy các câu lệnh trong bài
cd $P && python3 -c "from pgtest import parallel; ..." # test 2 worker (mục 3.4)
docker exec my-test-pgdb pgbench -U postgres -d isolation_demo -n -f /tmp/rec.sql -c 16 -j 4 -T 10
docker run -d --rm --name tmp-redis-lab redis:alpine # Lua heartbeat (mục 6.2)
docker exec tmp-redis-lab redis-cli --eval /start.lua 'vw:{s:5001}' , tabA 7 30
docker exec tmp-redis-lab redis-cli --eval /hb.lua 'vw:{s:5001}' 'vp:{s:5001}:100:7' , tabA 7 0 9000 30 1209600
# HMAC + chống replay (mục 10.1), chạy từ gốc repo, tự tạo rồi DROP tmp_inbound_messages
docker run --rm --network container:my-test-pgdb -v "$PWD/labs/laravel":/lab:ro --entrypoint php laravel-app /lab/hmac_webhook_lab.php
# dọn: DROP các bảng tmp_*, docker stop tmp-redis-lab