Đ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:

Trả lời ngắn (30 giây):

  1. 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.
  2. Hạn ghi nhận nằm trong chính câu INSERT (WHERE occurred_at <= hạn). occurred_at là 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.
  3. 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ăng version khi 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.
  4. 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.
  5. Đồ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ằng WHERE 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".
enrollmentMộ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.
idempotentChạ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.
outboxGhi "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.
heartbeatTín hiệu nhỏ trình duyệt gửi định kỳ, báo "tôi đang xem tới giây thứ X".
bitmapChuỗi bit, bit thứ i = 1 nghĩa là đoạn thứ i đã xem. Redis có SETBIT, BITCOUNT.
WALWrite-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.
HMACChữ ký tạo từ nội dung request + secret chung. Sửa 1 ký tự là chữ ký sai.
replay attackLấ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 grantKiể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.
SISStudent 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ầuAi giữ / làmVì sao
Ghi nhận hoạt động, không cộng trùng khi retryPostgreSQL — UNIQUE trên sổ cáiPhả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 10Tí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 INSERTChặ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ửaHàm sync_assignment làm sổ cái khớp trạng thái hiện tạiPublish, 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 + versionTrường chốt danh sách sau khi khoá học kết thúc.
Chưa thi → để trốngNULL từ DB tới JSON gửi trường0 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 focusRedis khoá theo sinh viên + bitmap → 1 INSERT khi đủ. Focus: trình duyệt dừng video + dừng heartbeatHeartbeat 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ườngLMS: job đẩy theo versionAPI 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 offlineTrường là chủ; LMS chỉ lưu bản saoMột trường dữ liệu chỉ một bên ghi → không bao giờ phải "gộp xung đột".
Dòng thời gian một học kỳ Kim thời gian chạy từ trái sang phải. Trước hạn ghi nhận, post, reply, quiz được cộng điểm ngay; bài nộp thì chờ chấm. Sau hạn, post mới bị từ chối, nhưng khi giảng viên publish điểm bài đã nộp trước hạn thì sinh viên vẫn được cộng. Phúc khảo và thi online cũng diễn ra sau hạn, nên điểm gửi trường còn đổi. Khi khoá học kết thúc, trường chốt danh sách thi, rồi gửi lại trạng thái được thi và điểm thi offline. Học + ghi nhận chuyên cần Chấm · phúc khảo · thi online điểm còn đổi → gửi bản mới Trường xét Thi offline +1 post +.5 reply +1 quiz … nộp bài 30/10 chờ chấm: 0 ✗ post quá hạn +1.5 publish bài nộp 30/10 7.33 thi trường → LMS được thi? trường → LMS điểm offline Hạn ghi nhận 31/10 23:59:59 +07 Kết thúc khoá · chốt DS Điểm tổng kết (LMS) = round(0,2 × chuyên cần + 0,8 × thi online, 2). Chưa thi → để trống. Hạn ghi nhận chặn hành động MỚI của sinh viên, không chặn việc chấm bài đã nộp đúng hạn. Mỗi dữ liệu một chủ: chuyên cần + online → LMS ghi. Được thi + điểm offline → trường ghi.
Hình 1 — Một học kỳ. Trước vạch đỏ, post/reply/quiz thành dòng sổ cái ngay; bài nộp thì chờ chấm. Sau vạch đỏ, post mới bị bỏ qua, nhưng khi giảng viên publish điểm bài nộp ngày 30/10, sinh viên vẫn được +1,5. Giữa vạch đỏ và vạch vàng, điểm còn đổi (chấm, phúc khảo, thi online) nên LMS gửi bản mới cho trường theo 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)

  1. 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ờ:
        deadline_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   | t
    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ột timestamptz, form gửi kèm múi giờ (Carbon::parse($input, 'Asia/Ho_Chi_Minh')), test có case "23:59:59 +07".
  2. 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_at từ lúc nhận request.
  3. 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).
  4. 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 khi record() trả false và now() > hạn.
  5. 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 → attendance từ 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:

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:

  1. Dùng submitted_at bị 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_at không bao giờ đổi.
  2. 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ên published; lịch sử điểm lưu ở bảng riêng.
  3. 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á.
  4. Publish cả lớp bằng vòng lặp 500 lần. Câu SQL trên xử lý theo assignment_id nên publish 1 bài hay 500 bài đều chỉ 2 câu lệnh.
  5. 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 >= 5 và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ấnThời gianGhi chú
Điểm 1 sinh viên (trang cá nhân)0,308 msIndex 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 ms500 lần Index Only Scan.
Cùng query lớp, nhưng view viết bằng subquery GROUP BY thay vì LATERAL967 msBẫ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ìnhMá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?

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):

  1. Mọi điểm lưu numeric(4,2). Không bao giờ dùng float / double.
  2. 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ả.
  3. 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.
  4. 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ệcChỉ PostgreSQLCó RedisKết luận
Ghi hoạt động (post, reply, quiz, nộp bài)1 INSERT, lab 1.286/giâyKhông nhanh hơn đáng kể, thêm rủi ro mất điểmDB
Đọc điểm 1 sinh viên0,3 msNhanh ngang, nhưng phải xoá cache mỗi khi có hoạt độngKhông cần cache
Bảng điểm lớp 500 người10,8 msCache → dễ cũ, phải invalidateKhô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 theo96 byte RAM / sinh viên / video, 0 lần ghi DB, 1 INSERT khi xem xongRedis
Chặn spam (50 post "ok" trong 1 phút, heartbeat dồn dập)Bảng đếm, thêm ghiRateLimiter, SET NX PXRedis
Cache access token của API trườngBảngCache::rememberRedis (tiện, không bắt buộc)
Giữ điểm chính thứcBền, có transaction, có backupMất khi failover (replication bất đồng bộ), bị evict khi đầy RAMKhô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ậtKiểm ở đâuTin được tới đâu
1 video tại 1 thời điểmServer (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 focusTrì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.
Heartbeat video qua Redis Trình duyệt chỉ gửi heartbeat mỗi 10 giây khi tab đang focus và video đang chạy. Redis giữ một khoá theo sinh viên ghi phiên xem nào đang có quyền, và một bitmap mỗi ô là một đoạn 10 giây. Mỗi heartbeat hợp lệ tô một ô. Heartbeat từ tab khác hoặc gửi dồn bị bỏ qua. Khi đủ 90 phần trăm số ô, Redis báo cho app ghi một dòng vào PostgreSQL, chỉ một lần. Trình duyệt 10 s, chỉ khi focus Redis (1 Lua script) vw:{s:SV} sid, vid, last sai tab → -2, gửi dồn → -1 bits: SETBIT seg 1 90% BITCOUNT ≥ 90% và ô vừa tô là ô mới ✗ tab khác → -2 PostgreSQL activity_log 1 INSERT video (ON CONFLICT DO NOTHING) — chỉ 1 lần
Hình 2 — Mỗi heartbeat hợp lệ tô 1 ô (1 ô = 10 giây video). Heartbeat đỏ đến từ tab/video không giữ quyền xem thì bị trả -2 (gửi dồn trong 9 giây thì -1). Khi đủ 90% số ô, app gọi 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

  1. Để 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ở.
  2. Start reset bộ đếm khoảng cách. Bản đầu của start.lua đặt last = 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ằng HSETNX và test lại ở trên. Bẫy này không lộ khi test "người dùng bình thường".
  3. Chỉ nghe visibilitychange. Theo MDN, trang chỉ thành hidden khi 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êm blur + document.hasFocus().
  4. 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 khi blur thì kiểm tra document.activeElement có phải iframe player không.
  5. disablePictureInPicture chỉ 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ạ.
  6. 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.
  7. 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.
  8. 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áo CROSSSLOT. Hashtag theo sinh viên (không theo video) vì khoá "1 video 1 lúc" là theo sinh viên.
  9. 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. sid do 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

7.1. Bẫy của job cập nhật

  1. 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 NULL là 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.
  2. Gia hạn riêng quá ngày kết thúc khoá. deadline_override sau ends_at thì 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).
  3. 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

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.

Điểm đổi trong lúc đang gửi sang trường Dòng kết quả của sinh viên 100 có version 1, synced_version 0. Job đọc và gửi version 1 sang trường. Trong lúc đang gửi, phúc khảo làm điểm đổi nên version thành 2. Trường trả OK cho bản đã gửi, job chỉ đánh dấu synced_version bằng 1. Vì version 2 lớn hơn 1 nên vòng sau job gửi tiếp version 2. course_results — SV 100 version 1 2 synced_version 0 1 Hệ thống trường upsert theo (SV, môn, kỳ) + version v1 v2 ① Job đọc: version 1 > synced 0 → cần gửi. ② Đang gọi API trường, gửi bản v1 (mất vài giây). ③ Cùng lúc: phúc khảo làm điểm đổi → job mục 7 tăng version = 2. ④ Trường trả OK cho v1 → UPDATE synced_version = 1 (đúng bản đã gửi). ⑤ Vòng sau: version 2 > synced 1 → gửi tiếp v2. Không mất bản chấm lại. Nếu ở bước ④ ghi "synced = true" (boolean) thì bản chấm lại v2 không bao giờ được gửi.
Hình 3 — Đánh dấu bằng số version đã gửi, không bằng cờ true/false. Điểm đổi (phúc khảo, publish muộn) giữa lúc đang gửi thì vòng sau tự gửi lại bản mớ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):

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):

  1. Trường đọc file đang ghi dở. Ghi tên .part rồ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ọ.
  2. Excel xoá số 0 đầu mã sinh viên (00123 → 123) và đổi 7.26 thành 7,26 theo 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.
  3. 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.
  4. 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()

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ềuCách chọnVì sao
LMS gọi API trườngTheo 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 LMSHMAC-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ểmSession/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ỏiThuậ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.

  1. Trường ký lúc gửi. Lấy giờ hiện tại ts (tính bằng giây), ghép chuỗi ts + "." + body, tính HMAC-SHA256(secret, chuỗi đó). Gửi ts trong header X-Timestamp, chữ ký trong X-Signature. message_id nằm trong body, nên cũng được ký.
  2. LMS kiểm 3 cổng theo thứ tự.
    • ① |now − ts| ≤ 300 giâ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ằng hash_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.
Ba cổng kiểm tra webhook: timestamp, chữ ký HMAC, message_id Request đi từ người gọi vào LMS, qua ba cổng: cổng 1 kiểm giờ lệch không quá 5 phút, cổng 2 kiểm chữ ký HMAC trên ts.body, cổng 3 kiểm message_id chưa có trong bảng. Lượt 1: trường gửi thật lúc 08:00, qua cả ba cổng, job được dispatch. Lượt 2: kẻ gian sửa body, cổng 2 chặn vì chữ ký sai. Lượt 3: gửi lại y nguyên lúc 08:02, qua cổng 1 và 2, cổng 3 thấy trùng nên trả 2xx và bỏ qua. Lượt 4: gửi lại y nguyên lúc 08:10, cổng 1 chặn vì lệch 10 phút. Lượt 5: gửi lại lúc 08:10 và sửa ts thành 08:10, qua cổng 1 nhưng cổng 2 chặn vì ts nằm trong chuỗi được ký. Người gọi trường / kẻ gian LMS now 08:00 now 08:00 now 08:02 now 08:10 now 08:10 ① timestamp |now − ts| ≤ 300s ② chữ ký HMAC(ts.body) ③ chống trùng UNIQUE message_id dispatch job ts 08:00 ts 08:00 ts 08:00 ts 08:00 ts 08:10 ✓ NHẬN ✗ chữ ký sai trùng → 2xx, bỏ qua ✗ lệch 10 phút ✗ chữ ký sai 1. Trường gửi thật lúc 08:00 → qua cả 3 cổng → lưu inbound_messages, dispatch job. 2. Kẻ gian sửa body (eligible false → true), giữ chữ ký cũ → cổng ② tính ra chữ ký khác. 3. Gửi lại y nguyên lúc 08:02 → qua ①② → ③ đã có m-001: trả 2xx, không xử lý lại. 4. Gửi lại y nguyên lúc 08:10 → cổng ① chặn: lệch 10 phút, quá ±5 phút. 5. Gửi lại lúc 08:10, sửa ts = 08:10 → qua ①, nhưng ts nằm trong chuỗi ký → ② sai. Kẻ gian không có secret: sửa body hay sửa ts đều làm chữ ký sai. Gửi lại y nguyên thì ① hoặc ③ chặn.
Hình 4 — Mỗi cổng chặn một kiểu tấn công. Sửa nội dung (body hay ts) thì cổng ② thấy chữ ký sai. Gửi lại y nguyên thì cổng ① chặn nếu đã quá 5 phút, cổng ③ chặn nếu còn trong 5 phút. Cổng ③ cũng là chỗ bỏ qua retry thật của trường (ký lại, cùng message_id).
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?

Đã 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ựcKẻ gian làm được
API key cố định trong headerGử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 UNIQUEKhông làm được gì: gửi lại cũng không có tác dụng.

Khi nào KHÔNG dùng HMAC

  1. 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:
    LMS tự ký message giả bằng secret của mình         NHẬN → dispatch job
    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.
  2. 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).
  3. 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.

  1. Tin school_id trong payload. LMS phục vụ nhiều trường: token/secret của trường A gửi payload ghi school_id = B là sửa được "được thi" của sinh viên trường B. school_id phải lấy từ credential đã xác thực (secret nào khớp / client_id nào), và khi map student_code → enrollment chỉ tìm trong trường đó.
  2. message_id nằm ngoài phần được ký (vd header X-Message-Id riê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).
  3. Ký thiếu ts (chỉ ký body). Không lộ ngay, vì inbound_messages vẫ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).
  4. Chỉ chặn timestamp. Trong cửa sổ 5 phút vẫn replay được → phải có message_id unique trong inbound_messages.
  5. So chữ ký bằng ==. Lộ thời gian so sánh (timing attack). Dùng hash_equals.
  6. 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ên message_id.
  7. Đồ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.
  8. 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_encode mặc định đổi ễ thành \u1ec5 (lab phần 7). Ký và verify trên raw body ($request->getContent()).
  9. 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.
  10. 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).
  11. 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.
  12. Token trường hết hạn giữa batch. Cache theo expires_in trừ 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

12. Tác hại / hạn chế

13. Trade-off: chọn cái nào, khi nào

Lựa chọnĐượcMấtChọn khi
Ghi sổ cái trong cùng transactionNhất quán tuyệt đối, không lag, ít codeCoupling giữa moduleMonolith, chung DB (mặc định)
Event + queue + outboxTách module/serviceLag (nguy hiểm đêm hạn chót), thêm outboxNguồ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ộtMặ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ạnSố gửi trường đổi nhiều lần, phải có versionLuậ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ênSai khi điểm còn đổi sau hạnChỉ 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 xemKhông bao giờ bị kẹt khi tab cũ chếtTab cũ bị dừng giữa chừngMặ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ặngHầu như không nên
Heartbeat qua RedisBớt ~500 UPDATE/s, WAL, bloatMất tiến độ xem dở khi Redis mấtCó video + nhiều người xem cùng lúc
Heartbeat thẳng DBKhông thêm hệ thốngWAL, dead tuple, replica lagVài trăm người xem cùng lúc
Push APINhanh, trả lỗi từng dòngPhụ thuộc API trường sốngTrường có API
File SFTPTrường cũ nào cũng làm đượcChậm (theo lịch), lỗi định dạngTrường không có API, hoặc dự phòng
WebhookGần realtimeCó thể mấtLuôn đi kèm poll
PollKhông mấtTrễ, tốn requestLưới an toàn / trường không gọi ra được
HMACĐơn giản, ký cả bodyPhả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 credentialsChuẩn, scope, thu hồi đượcThê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 / alertNgưỡ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ấtcũ 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 batchLệch mã sinh viên / mã môn giữa hai bên
inbound_messages chưa xử lý, độ trễ> 10 phútWorker chết hoặc payload lạ
Số hoạt động bị chặn vì quá hạnTă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)> 0Có 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á> 0Nhắ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útThêm worker trước mùa thi

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"

  1. Tra submissions: first_submitted_at là bao nhiêu, grade_status là gì?
  2. 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.
  3. published mà không có dòng sổ cái → lỗi: sync_assignment không được gọi ở một luồng nào đó (vd import điểm từ Excel đi đường khác). Chạy lại sync_assignment(assignment_id) — an toàn vì idempotent — rồi sửa luồng thiếu.
  4. 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.
  5. first_submitted_at là 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

  1. 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ì.
  2. Báo trường + PO. Hỏi hạn cuối trường cần dữ liệu.
  3. 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ấu synced_version theo file.
  4. 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

  1. 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.
  2. 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.
  3. 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"

  1. Có version guard → câu upsert ra INSERT 0 0, không đổi gì (test 9.1).
  2. Không có version guard → tra inbound_messages theo 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)

  1. 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.
  2. 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.
  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

  1. Trần theo loại đã chặn: post tối đa 3 điểm dù post bao nhiêu.
  2. Thêm RateLimiter (vd 5 post / 10 phút / người) và độ dài tối thiểu.
  3. 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"

  1. 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.
  2. 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.
  3. 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 tra document.activeElement.

Case 8 — Giảng viên quên publish điểm assignment trước khi kết thúc khoá

  1. 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ụ.
  2. 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.
  3. 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_results khô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