0

Thiết kế pipeline phần thưởng idempotent cho phiên chơi trình duyệt ngắn

1. Vì sao phần thưởng sau phiên là một bài toán backend, không chỉ là UI?

Trong game trình duyệt có phiên chơi ngắn, người chơi thường hoàn thành một lượt rồi lập tức nhìn điểm, tiến trình và mục tiêu tiếp theo. Khoảnh khắc này có vẻ đơn giản, nhưng phía sau có nhiều tình huống dễ tạo lỗi: mạng chập chờn đúng lúc gửi kết quả, người chơi nhấn nút hai lần, trình duyệt tải lại trang, tab bị đóng rồi mở lại, hoặc client gửi lại yêu cầu sau khi không nhận được phản hồi.

Nếu backend xử lý mỗi request như một sự kiện hoàn toàn mới, cùng một phiên có thể cộng điểm hai lần, mở khóa thành tựu lặp lại hoặc làm lệch tiến trình nhiệm vụ. Nếu backend lại từ chối mọi request lặp, client có thể không lấy được kết quả hợp lệ sau một lần timeout. Mục tiêu đúng hơn là:

Một kết quả hợp lệ chỉ được áp dụng một lần, nhưng có thể được đọc lại an toàn nhiều lần.

Đó là bài toán idempotency: cùng một ý định nghiệp vụ được gửi lại nhiều lần nhưng trạng thái cuối cùng không thay đổi sau lần xử lý đầu tiên.

2. Tách ba định danh có ý nghĩa khác nhau

Một lỗi thiết kế phổ biến là dùng duy nhất userId để nhận biết request. Điều này không đủ, vì một người chơi có thể hoàn thành nhiều phiên khác nhau. Mô hình rõ ràng hơn nên có ba khóa:

  • sessionId: định danh một phiên chơi cụ thể.
  • commandId: định danh một lần client yêu cầu hoàn tất phiên.
  • playerId: định danh chủ sở hữu tiến trình.

sessionId trả lời câu hỏi “kết quả này thuộc phiên nào?”. commandId giúp theo dõi retry ở tầng giao tiếp. playerId xác định bản ghi tiến trình cần cập nhật. Không nên thay sessionId bằng timestamp do client tạo: đồng hồ có thể lệch, hai request có thể cùng thời điểm, và timestamp không chứng minh hai request thuộc cùng một phiên.

Ví dụ kiểu dữ liệu minh họa bằng TypeScript:

type CompleteSessionCommand = {
  commandId: string;
  sessionId: string;
  playerId: string;
  score: number;
  completedAt: string;
};

type RewardDelta = {
  pointsAdded: number;
  taskChanges: Array<{ taskId: string; before: number; after: number }>;
  achievementIds: string[];
  collectionItemIds: string[];
};

type CompletionReceipt = {
  sessionId: string;
  applied: boolean;
  reward: RewardDelta;
  progressVersion: number;
};

Đây là mô hình tham khảo để thảo luận kiến trúc, không phải mã nguồn hay mô tả stack thực tế của ARMCP.

3. Quy tắc nghiệp vụ: một phiên chỉ có một biên lai hoàn tất

Thay vì kiểm tra “đã thấy request này chưa?” trong bộ nhớ tạm, hãy lưu một completion receipt bền vững theo sessionId. Receipt là bằng chứng rằng phần thưởng của phiên đã được tính và áp dụng.

Ràng buộc dữ liệu quan trọng nhất có thể là:

UNIQUE (player_id, session_id)

Khi request đầu tiên thành công, hệ thống tạo receipt. Khi request thứ hai dùng cùng playerId và sessionId, hệ thống không tính lại phần thưởng; nó trả về receipt đã có. Nhờ vậy, retry trở thành thao tác đọc kết quả cũ thay vì tạo tác động mới.

Không nên chỉ dựa vào cờ boolean như rewardApplied=true trong bản ghi phiên rồi cập nhật tiến trình ở một bước khác. Nếu tiến trình được cộng nhưng cờ chưa lưu do lỗi giữa chừng, request tiếp theo có thể cộng lại. Receipt và cập nhật tiến trình cần nằm trong cùng một ranh giới giao dịch.

4. Giao dịch tối thiểu để tránh cộng hai lần

Một luồng xử lý an toàn có thể được mô tả như sau:

async function completeSession(cmd: CompleteSessionCommand): Promise<CompletionReceipt> {
  return database.transaction(async (tx) => {
    const existing = await tx.receipts.findByPlayerAndSession(
      cmd.playerId,
      cmd.sessionId
    );

    if (existing) {
      return {
        ...existing,
        applied: false
      };
    }

    const session = await tx.sessions.lockForUpdate(cmd.sessionId);

    if (!session || session.playerId !== cmd.playerId) {
      throw new DomainError("SESSION_NOT_FOUND");
    }

    if (session.status !== "PLAYING") {
      throw new DomainError("INVALID_SESSION_STATE");
    }

    const reward = calculateReward(session, cmd.score);
    const progress = await tx.progress.applyDelta(cmd.playerId, reward);

    const receipt = await tx.receipts.insert({
      playerId: cmd.playerId,
      sessionId: cmd.sessionId,
      commandId: cmd.commandId,
      reward,
      progressVersion: progress.version
    });

    await tx.sessions.markCompleted(cmd.sessionId, cmd.completedAt);

    return {
      ...receipt,
      applied: true
    };
  });
}

Có bốn điểm đáng chú ý:

  1. Tìm receipt trước khi tính phần thưởng.
  2. Khóa bản ghi phiên trong giao dịch để hai request đồng thời không cùng đi tiếp.
  3. Áp dụng delta tiến trình và ghi receipt trong cùng giao dịch.
  4. Trả lại cùng dữ liệu nghiệp vụ cho retry, chỉ thay applied để client biết đây là kết quả đã tồn tại.

Ràng buộc UNIQUE vẫn cần thiết dù đã khóa. Khóa giúp giảm xung đột, còn UNIQUE là hàng rào cuối cùng nếu hai worker chạy trong điều kiện cạnh tranh ngoài dự kiến.

5. Không trộn “tính phần thưởng” với “hiển thị phần thưởng”

Hàm calculateReward nên là hàm xác định: cùng một snapshot phiên và cùng một score luôn tạo cùng RewardDelta. Nó không nên đọc giờ hiện tại, random không có seed, hoặc dữ liệu thay đổi bên ngoài mà không được ghi vào snapshot.

Điều này mang lại ba lợi ích:

  • Có thể kiểm thử bằng bảng input/output.
  • Có thể tái tạo receipt khi điều tra lỗi.
  • Có thể so sánh phiên bản thuật toán khi thay đổi quy tắc.

UI không nên tự suy ra phần thưởng từ score. Client chỉ hiển thị receipt do server trả về. Nếu client tự tính “có lẽ được 20 điểm” rồi server trả 15 điểm, người chơi sẽ thấy trạng thái nhảy ngược. Receipt nên chứa đủ delta để màn hình kết quả giải thích điều gì vừa thay đổi, nhưng snapshot tổng vẫn cần version để tránh ghi đè dữ liệu mới hơn.

6. Retry ở client: gửi lại cùng commandId, không tạo lệnh mới

Khi request timeout, client thường không biết server đã xử lý hay chưa. Vì vậy retry phải giữ nguyên commandId và sessionId. Nếu mỗi lần retry sinh commandId mới, log sẽ khó nối các lần thử với cùng một ý định.

Một chiến lược đơn giản:

  • Lần đầu gửi ngay sau khi phiên kết thúc.
  • Nếu timeout, chờ ngắn rồi gửi lại cùng payload.
  • Nếu nhận 200 với receipt, đóng hàng đợi retry.
  • Nếu nhận lỗi nghiệp vụ cố định, dừng retry và hiển thị trạng thái rõ ràng.
  • Nếu tab đóng, lưu command đang chờ vào storage phù hợp rồi gửi lại khi quay lại.
  • Không retry vô hạn; đặt số lần và thời gian tối đa.

Client cũng cần phân biệt “request chưa được gửi”, “đang chờ”, “đã nhận receipt” và “cần người dùng thử lại”. Một nút bấm bị vô hiệu hóa trong 500 ms không phải là cơ chế chống cộng hai lần; nó chỉ giảm nhấp đúp ở UI.

7. Xử lý tiến trình song song bằng version

Ngay sau khi hoàn tất phiên A, người chơi có thể bắt đầu phiên B trên một tab khác hoặc nhận cập nhật nhiệm vụ từ một hành động khác. Nếu mỗi response chứa toàn bộ progress và client ghi đè không điều kiện, response cũ có thể xóa thay đổi mới.

progressVersion giải quyết bằng quy tắc đơn giản: client chỉ thay snapshot khi version nhận được lớn hơn version đang có. Với thao tác ghi, backend có thể dùng optimistic concurrency:

UPDATE player_progress
SET points = points + :delta,
    version = version + 1
WHERE player_id = :playerId
  AND version = :expectedVersion;

Nếu số dòng cập nhật bằng 0, worker đọc snapshot mới và tính lại delta hoặc thử lại theo chính sách đã định. Quan trọng là phần thưởng phiên vẫn được bảo vệ bởi receipt; retry giao dịch không được tạo receipt thứ hai.

8. Quan sát hệ thống: log theo ý định, không theo request rời rạc

Log tốt nên cho phép trả lời bốn câu hỏi:

  • Phiên nào được hoàn tất?
  • Request nào là lần đầu và request nào là retry?
  • Delta nào đã được áp dụng?
  • Tiến trình chuyển từ version nào sang version nào?

Một event có cấu trúc có thể chứa sessionId, commandId, playerId đã được ẩn danh phù hợp, receiptId, retryCount, pointsAdded, progressVersion và durationMs. Không ghi toàn bộ payload nhạy cảm chỉ để debug.

Các metric hữu ích:

  • tỷ lệ completion request bị retry;
  • số lần UNIQUE conflict;
  • thời gian từ lần gửi đầu đến khi nhận receipt;
  • số receipt được đọc lại;
  • số phiên ở trạng thái PLAYING quá lâu;
  • tỷ lệ client nhận progressVersion cũ hơn snapshot hiện tại.

Nếu số retry tăng nhưng lỗi nghiệp vụ không tăng, vấn đề có thể nằm ở mạng hoặc timeout. Nếu UNIQUE conflict tăng, có thể client gửi đồng thời hoặc worker xử lý quá chậm. Metric giúp phân biệt thay vì đoán.

9. Bộ kiểm thử nhỏ nhưng có giá trị cao

Ngoài happy path, nên có ít nhất các trường hợp sau:

  1. Hai request giống hệt đến tuần tự: request sau trả receipt cũ.
  2. Hai request giống hệt đến đồng thời: chỉ một delta được áp dụng.
  3. Cùng sessionId nhưng khác playerId: từ chối.
  4. Cùng playerId nhưng hai sessionId khác nhau: cả hai hợp lệ.
  5. Timeout sau khi commit nhưng trước khi response đến: retry đọc receipt.
  6. Lỗi giữa applyDelta và insert receipt: toàn bộ giao dịch rollback.
  7. Client nhận response cũ sau response mới: progressVersion ngăn ghi đè.
  8. score ngoài miền hợp lệ: từ chối trước khi tính reward.
  9. phiên không ở PLAYING: không áp dụng delta.
  10. worker retry giao dịch vì deadlock: vẫn chỉ có một receipt.

Kiểm thử đồng thời cần chạy thật sự với hai transaction, không chỉ gọi hàm hai lần liên tiếp. Các lỗi double reward thường chỉ xuất hiện khi timing trùng nhau.

10. Trường hợp thực tế để rà soát mô hình

Một ví dụ công khai phù hợp là Angel Arena của ARMCP, trải nghiệm game trên trình duyệt có các phiên chơi cùng điểm, cấp bậc hoặc level, nhiệm vụ, yếu tố sưu tập, thành tựu, tiến trình và bonus. Với cấu trúc đó, receipt sau phiên có thể trả lời rõ ba nhóm câu hỏi:

  • Kết quả phiên: score và trạng thái hoàn tất.
  • Thay đổi tích lũy: điểm cộng thêm, nhiệm vụ thay đổi, mục sưu tập hoặc thành tựu vừa mở.
  • Snapshot mới: level/rank và progressVersion sau giao dịch.

Điểm quan trọng không phải sao chép giao diện hay suy đoán backend của trò chơi. Đây là một trường hợp quan sát được để kiểm tra xem mô hình dữ liệu có đủ diễn đạt những gì người chơi cần hiểu sau một phiên hay không.

11. Checklist trước khi đưa vào production

  • [ ] sessionId được tạo một lần và không thay đổi khi retry.
  • [ ] Có UNIQUE theo playerId + sessionId.
  • [ ] Receipt, delta và trạng thái phiên nằm trong cùng giao dịch.
  • [ ] calculateReward là hàm xác định với input đã lưu.
  • [ ] Client gửi lại cùng commandId.
  • [ ] UI hiển thị receipt từ server thay vì tự tính phần thưởng.
  • [ ] Snapshot tiến trình có version.
  • [ ] Log nối được request đầu và các retry.
  • [ ] Có test đồng thời bằng hai transaction.
  • [ ] Có metric cho retry, conflict và receipt replay.

Idempotency không làm hệ thống tránh được mọi lỗi mạng. Nó làm một điều thực tế hơn: biến retry từ rủi ro cộng phần thưởng hai lần thành một đường phục hồi có thể dự đoán. Với phiên chơi ngắn, nơi người dùng mong thấy kết quả ngay, đây là nền tảng để cả backend lẫn giao diện xử lý gián đoạn một cách nhất quán.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí