Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Bài 5 — Idempotency: gọi nhiều lần vẫn một kết quả

Đã đọc 0/9 mục · còn khoảng 4 phút
Cách chúng tôi ghi nhận

Một mục được tính là đã đọc khi nó nằm giữa màn hình đủ lâu so với độ dài của nó (bảng và sơ đồ tính một nửa), bạn vẫn đang cuộn hoặc bấm phím, và bạn đã thấy tới cuối mục. Lướt nhanh hoặc để tab mở không được tính. Chúng tôi chỉ lưu thời gian đọc từng mục — không lưu gì khác.

Retry và queue "at-least-once" đảm bảo một điều phũ phàng: một thao tác sẽ bị gọi nhiều lần. Timeout ở phía bạn không có nghĩa là hệ thống ngoài chưa xử lý — có thể nó đã charge tiền xong nhưng phản hồi bị rớt. Nếu bạn retry, khách bị trừ hai lần. Idempotency là thuộc tính: gọi cùng một thao tác N lần cho ra đúng một kết quả như gọi một lần.

Idempotency key

Cơ chế phổ biến nhất: mỗi thao tác mang một idempotency key duy nhất, ổn định qua các lần retry. Hệ thống ngoài lưu key này; lần gọi thứ hai với cùng key sẽ trả về kết quả cũ thay vì thực hiện lại.

Key phải sinh từ thứ ổn định của nghiệp vụ, không phải từ thời gian hay số ngẫu nhiên mỗi lần gọi:

# ĐÚNG: key ổn định theo đơn + bước
idem_key = f"order:{order_id}:charge"

# SAI: mỗi lần retry ra key khác -> mất tác dụng
idem_key = str(uuid4())   # sinh lại mỗi lần gọi

Sinh key một lần, lưu vào step_data, và tái sử dụng ở mọi lần retry.

Phía nhận: bảng khử trùng lặp

Nếu bạn là người xây service nhận request (hoặc consumer của một queue), tự bảo vệ bằng bảng dedup:

CREATE TABLE processed_ops (
  idem_key   VARCHAR(128) PRIMARY KEY,
  result     JSON,
  created_at TIMESTAMP DEFAULT now()
);
def charge(idem_key, amount):
    existing = db.get("processed_ops", idem_key)
    if existing:
        return existing.result          # trả kết quả cũ, KHÔNG charge lại
    result = do_real_charge(amount)
    db.insert("processed_ops", idem_key, result)  # UNIQUE chặn đua
    return result

Khóa PRIMARY KEY trên idem_key biến hai request song song thành một: request thứ hai đụng ràng buộc unique và phải đọc kết quả đã có.

Thao tác vốn dĩ idempotent

Một số thao tác tự nhiên đã idempotent, hãy tận dụng:

  • PUT status = 'PAID' (đặt giá trị) idempotent; balance = balance - 100 (cộng dồn) thì không.
  • "Tạo nếu chưa có" (upsert theo natural key) idempotent hơn "tạo mới" (INSERT sinh ID mới mỗi lần).
Khi thiết kế, ưu tiên phép đặt thay vì phép cộng dồn.

Khung tư duy 3 lớp phòng thủ

  • Key ổn định sinh từ nghiệp vụ, lưu bền.
  • Phía gửi luôn gửi cùng key khi retry.
  • Phía nhận khử trùng lặp bằng key (hoặc dùng natural key + upsert).
Chỉ cần một trong ba lớp làm đúng là bạn tránh được double-effect; có cả ba thì rất vững.

Các bước áp dụng

  • Với mỗi bước có side-effect, xác định key nghiệp vụ ổn định (order_id+tên bước).
  • Sinh key một lần khi tạo bước, lưu vào step_data.
  • Truyền key đó vào adapter ở mọi lần gọi, kể cả retry.
  • Nếu kiểm soát được phía nhận, thêm bảng processed_ops với PK là key.
  • Đổi các cập nhật "cộng dồn" thành "đặt giá trị" khi có thể.
  • Test: gọi cùng thao tác hai lần, xác nhận chỉ một hiệu ứng.

Thời gian sống của idempotency key

Bảng processed_ops không thể phình mãi. Đặt chính sách dọn (TTL) hợp lý: key nên sống đủ lâu để bao phủ toàn bộ cửa sổ retry có thể xảy ra (ví dụ vài giờ tới vài ngày, tùy quy trình). Dọn quá sớm là nguy hiểm — nếu một retry rất trễ tới sau khi key đã bị xóa, bạn sẽ xử lý lại thao tác. Với các bước tiền bạc, hãy giữ key lâu hơn và chỉ dọn khi chắc chắn không còn retry nào bay tới.

Idempotency với event/message queue

Phần lớn message broker giao "at-least-once", nghĩa là consumer sẽ nhận message trùng. Đừng tin vào 'exactly-once' của broker; hãy tự khử trùng lặp ở consumer bằng một khóa nghiệp vụ. Một mẫu phổ biến: mỗi message mang message_id, consumer ghi message_id vào bảng dedup trong cùng transaction với thao tác nghiệp vụ. Nếu commit thành công, message được ack; nếu message trùng tới sau, ràng buộc unique chặn nó.

def handle(msg):
    with db.transaction():
        if db.exists("seen_messages", msg.id):
            return  # đã xử lý
        apply_business_effect(msg)
        db.insert("seen_messages", msg.id)  # cùng transaction

Gói cả hai trong một transaction là mấu chốt: hiệu ứng nghiệp vụ và dấu "đã xử lý" phải thành công hoặc thất bại cùng nhau.

Sai lầm thường gặp

  • Sinh key mới mỗi lần gọi (uuid trong vòng retry): idempotency vô hiệu.
  • Key trùng giữa các bước: dùng chung một key cho "reserve" và "charge" khiến hệ thống nhầm là trùng lặp, bỏ qua bước thật.
  • Chỉ chặn trùng bằng SELECT rồi INSERT không có ràng buộc unique: hai request song song lọt cả hai. Phải để DB ép unique.
  • Tưởng mọi retry đều an toàn mà không kiểm tra thao tác có idempotent không — chuyển khoản cộng dồn là cạm bẫy kinh điển.
Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi