Điểm cuối idempotent trong Go: khóa, bảng dedup và cơ chế retry
Thiết kế điểm cuối idempotent trong Go bằng idempotency key, bảng dedup PostgreSQL và handler an toàn trước retry cho thanh toán, import và webhook.

Tại sao retry tạo bản sao (và vì sao idempotency quan trọng)
Retries xảy ra ngay cả khi không có gì “sai.” Client bị timeout trong khi server vẫn đang xử lý. Kết nối di động rớt và app thử lại. Job runner nhận 502 và tự động gửi lại cùng request. Với at-least-once delivery (thường gặp ở queue và webhook), bản sao là điều bình thường.
Đó là lý do idempotency quan trọng: các request lặp lại nên dẫn đến kết quả cuối cùng giống như gọi một lần.
Một vài thuật ngữ dễ lẫn:
- Safe: gọi không thay đổi trạng thái (như đọc).
- Idempotent: gọi nhiều lần có cùng hiệu quả như gọi một lần.
- At-least-once: sender retry cho đến khi “dính”, vì vậy receiver phải xử lý bản sao.
Nếu không có idempotency, retry có thể gây hậu quả thật sự. Một endpoint thanh toán có thể trừ hai lần nếu lần đầu thành công nhưng response không tới client. Endpoint import có thể tạo bản ghi trùng khi worker retry sau timeout. Một webhook handler có thể xử lý cùng event hai lần và gửi hai email.
Điểm then chốt: idempotency là hợp đồng API, không phải chi tiết triển khai nội bộ. Client cần biết họ có thể retry gì, gửi khóa nào, và mong đợi phản hồi gì khi phát hiện bản sao. Nếu bạn thay đổi hành vi một cách im lặng, bạn phá vỡ logic retry và tạo ra các kiểu lỗi mới.
Idempotency cũng không thay thế giám sát và đối chiếu. Theo dõi tỉ lệ bản sao, ghi log các quyết định “replay”, và định kỳ so sánh hệ thống bên ngoài (ví dụ nhà cung cấp thanh toán) với cơ sở dữ liệu của bạn.
Chọn phạm vi idempotency và luật cho từng endpoint
Trước khi thêm bảng hay middleware, quyết định “request giống nhau” nghĩa là gì và server hứa làm gì khi client retry.
Hầu hết vấn đề xuất hiện ở POST vì nó thường tạo thứ gì đó hoặc kích hoạt side effect (trừ tiền, gửi tin, khởi import). PATCH cũng có thể cần idempotency nếu nó kích hoạt side effect, không chỉ cập nhật trường. GET không nên thay đổi trạng thái.
Định nghĩa phạm vi: nơi khóa là duy nhất
Chọn phạm vi khớp với quy tắc nghiệp vụ. Quá rộng sẽ chặn công việc hợp lệ. Quá hẹp cho phép tạo bản sao.
Phạm vi phổ biến:
- Theo endpoint + khách hàng
- Theo endpoint + đối tượng bên ngoài (ví dụ invoice_id hoặc order_id)
- Theo endpoint + tenant (cho hệ thống đa tenant)
- Theo endpoint + phương thức thanh toán + số tiền (chỉ khi quy tắc sản phẩm bạn cho phép)
Ví dụ: với endpoint “Create payment”, hãy làm khóa duy nhất theo khách hàng. Với “Ingest webhook event”, scope theo event ID từ nhà cung cấp (tính duy nhất toàn cục từ provider).
Quyết định trả gì khi phát hiện bản sao
Khi bản sao tới, trả lại kết quả giống lần thành công đầu tiên. Thực tế là replay cùng mã trạng thái HTTP và cùng thân response (hoặc ít nhất cùng ID tài nguyên và trạng thái).
Client phụ thuộc vào điều này. Nếu lần đầu thành công nhưng mạng rớt, retry không được tạo thêm một charge hay job import thứ hai.
Chọn cửa sổ giữ khóa
Khóa nên hết hạn. Giữ đủ lâu để bao phủ retry thực tế và job trì hoãn.
- Thanh toán: 24 đến 72 giờ là phổ biến.
- Import: một tuần có thể hợp lý nếu người dùng có thể retry muộn.
- Webhook: khớp với chính sách retry của provider.
Định nghĩa “request giống nhau”: khóa rõ ràng hay hash thân?
Một idempotency key rõ ràng (header hoặc field) thường là quy tắc sạch nhất.
Hash thân request có thể là phương án dự phòng, nhưng dễ vỡ trước thay đổi vô hại (thứ tự trường, khoảng trắng, timestamp). Nếu dùng hashing, chuẩn hóa input và chặt chẽ về trường được bao gồm.
Idempotency keys: hoạt động thực tế
Idempotency key là hợp đồng đơn giản giữa client và server: “Nếu bạn thấy khóa này nữa, coi là cùng một request.” Đây là một trong những công cụ thiết thực nhất cho API an toàn với retry.
Khóa có thể xuất phát từ cả hai phía, nhưng với hầu hết API nên do client tạo. Client biết khi nào đang retry cùng hành động, nên có thể tái sử dụng cùng khóa qua các lần thử. Khóa do server tạo hữu ích khi bạn tạo một tài nguyên “draft” trước (ví dụ import job) rồi cho client retry bằng cách tham chiếu job ID, nhưng chúng không giúp cho request đầu tiên.
Dùng một chuỗi ngẫu nhiên, khó đoán. Hướng tới ít nhất 128 bit randomness (ví dụ 32 hex chars hoặc một UUID). Đừng tạo khóa từ timestamp hay user ID.
Trên server, lưu khóa kèm ngữ cảnh đủ để phát hiện lạm dụng và replay kết quả gốc:
- Ai gọi (account hoặc user ID)
- Endpoint hoặc thao tác áp dụng
- Hash của các trường request quan trọng
- Trạng thái hiện tại (in-progress, succeeded, failed)
- Response để replay (mã trạng thái và thân)
Khóa nên được scope, thường là theo user (hoặc theo API token) cộng endpoint. Nếu cùng khóa được dùng với payload khác, từ chối với lỗi rõ ràng. Điều này ngăn va chạm vô tình khi một client buggy gửi số tiền mới dùng khóa cũ.
Trên replay, trả lại cùng kết quả như lần thành công đầu tiên. Điều đó nghĩa là cùng mã trạng thái HTTP và cùng thân response, không phải đọc mới có thể đã thay đổi.
Bảng dedup trong PostgreSQL: một mẫu đơn giản, đáng tin cậy
Một bảng dedup chuyên dụng là cách đơn giản để hiện thực idempotency. Request đầu tiên tạo một dòng cho idempotency key. Mọi retry sẽ đọc cùng dòng và trả lại kết quả đã lưu.
Lưu gì vào bảng
Giữ bảng nhỏ và tập trung. Cấu trúc phổ biến:
key: idempotency key (text)owner: ai sở hữu khóa (user_id, account_id, hoặc API client ID)request_hash: hash của các trường request quan trọngresponse: payload response cuối cùng (thường JSON) hoặc con trỏ tới kết quả đã lưucreated_at: khi khóa được thấy lần đầu
Ràng buộc unique là cốt lõi. Áp ràng buộc trên (owner, key) để một client không tạo trùng lặp, và hai client khác không va chạm.
Cũng lưu request_hash để phát hiện lạm dụng khóa. Nếu retry tới cùng khóa nhưng hash khác, trả lỗi thay vì trộn hai thao tác.
Retention và indexing
Dòng dedup không nên tồn tại mãi. Giữ đủ lâu cho cửa sổ retry thực tế, rồi dọn dẹp.
Để nhanh khi tải cao:
- Index unique trên
(owner, key)để insert hoặc lookup nhanh - Index trên
created_atđể dọn dẹp hiệu quả
Nếu response lớn, lưu con trỏ (ví dụ result ID) và giữ payload đầy đủ ở nơi khác. Điều này giảm bloat bảng trong khi giữ hành vi retry nhất quán.
Bước theo bước: luồng handler an toàn với retry trong Go
Một handler an toàn với retry cần hai thứ: cách xác định “cùng request” ổn định, và nơi bền để lưu kết quả đầu tiên để replay.
Luồng thực tế cho payments, imports, và webhook ingestion:
-
Validate request, sau đó suy ra ba giá trị: idempotency key (từ header hoặc field client), owner (tenant hoặc user ID), và request hash (hash các trường quan trọng).
-
Bắt một transaction DB và thử tạo bản ghi dedup. Đặt unique trên
(owner, key). Lưurequest_hash, trạng thái (started, completed), và chỗ trống cho response. -
Nếu insert bị conflict, load dòng có sẵn. Nếu đã completed, trả response đã lưu. Nếu đang started, hoặc chờ ngắn (polling đơn giản) hoặc trả 409/202 để client thử lại sau.
-
Chỉ khi bạn thực sự “sở hữu” được dòng dedup, chạy business logic một lần. Viết side effect trong cùng transaction khi có thể. Persist kết quả nghiệp vụ cộng với HTTP response (mã trạng thái và thân).
-
Commit, và ghi log với idempotency key và owner để bộ phận hỗ trợ có thể truy vết các bản sao.
Mẫu bảng tối thiểu:
create table idempotency_keys (
owner_id text not null,
idem_key text not null,
request_hash text not null,
status text not null,
response_code int,
response_body jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (owner_id, idem_key)
);
Ví dụ: endpoint “Create payout” timeout sau khi đã charge. Client retry cùng key. Handler của bạn vấp conflict, thấy record completed, và trả payout ID ban đầu mà không charge lại.
Thanh toán: charge đúng một lần, ngay cả khi timeout
Thanh toán là nơi idempotency không còn là tùy chọn. Mạng thất bại, app mobile retry, và gateway đôi khi timeout sau khi đã tạo charge.
Quy tắc thực tế: idempotency key bảo vệ việc tạo charge, và provider ID (charge/intent ID) trở thành nguồn tin cậy sau đó. Khi bạn lưu provider ID, đừng tạo charge mới cho cùng request.
Một mẫu xử lý retry và bất định từ gateway:
- Đọc và validate idempotency key.
- Trong transaction DB, tạo hoặc fetch một payment row keyed bằng
(merchant_id, idempotency_key). Nếu đã cóprovider_id, trả kết quả đã lưu. - Nếu chưa có
provider_id, gọi gateway để tạo PaymentIntent/Charge. - Nếu gateway trả thành công, persist
provider_idvà đánh dấu payment là “succeeded” (hoặc “requires_action”). - Nếu gateway timeout hoặc trả kết quả không rõ, lưu trạng thái “pending” và trả response nhất quán thông báo client rằng có thể retry an toàn.
Chi tiết quan trọng là cách bạn xử lý timeout: đừng mặc định thất bại. Đánh dấu payment là pending, sau đó xác nhận lại với gateway sau (hoặc bằng webhook) khi bạn có provider ID.
Error response nên dự đoán được. Client xây logic retry dựa trên những gì bạn trả, nên giữ mã trạng thái và hình dạng lỗi ổn định.
Import và endpoint batch: dedup mà không mất tiến độ
Import là nơi bản sao gây thiệt hại nhiều nhất. Người dùng upload CSV, server timeout ở 95%, họ retry. Nếu không có kế hoạch, bạn sẽ tạo bản ghi trùng hoặc buộc họ làm lại.
Với công việc theo lô, nghĩ ở hai lớp: import job và các item bên trong. Idempotency ở cấp job ngăn cùng request tạo nhiều job. Idempotency ở cấp item ngăn cùng dòng được áp dụng hai lần.
Mẫu cấp job là yêu cầu idempotency key cho mỗi request import (hoặc suy ra từ request hash ổn định cộng user ID). Lưu nó trong bản ghi import_job và trả cùng job ID khi retry. Handler nên trả: “Tôi đã thấy job này, đây trạng thái hiện tại,” thay vì “bắt đầu lại.”
Với dedup cấp item, dựa vào khóa tự nhiên đã có trong dữ liệu. Ví dụ mỗi dòng có external_id từ hệ thống nguồn, hoặc combo ổn định như (account_id, email). Áp unique constraint trong PostgreSQL và dùng upsert để retry không tạo bản sao.
Trước khi phát hành, quyết định replay sẽ làm gì khi một dòng đã tồn tại. Hãy rõ ràng: bỏ qua, cập nhật trường cụ thể, hoặc fail. Tránh “merge” trừ khi bạn có quy tắc rõ ràng.
Thành công một phần là bình thường. Thay vì trả một “ok” hoặc “failed” tổng quát, lưu kết quả từng dòng gắn vào job: số dòng, natural key, trạng thái (created, updated, skipped, error), và thông báo lỗi. Khi retry, bạn có thể chạy lại an toàn trong khi giữ kết quả cho các dòng đã hoàn thành.
Để import có thể restart, thêm checkpoint. Xử lý theo trang (ví dụ 500 dòng một lần), lưu con trỏ đã xử lý cuối cùng (index dòng hoặc source cursor), và cập nhật sau mỗi trang commit. Nếu quá trình crash, lần thử sau sẽ tiếp tục từ checkpoint cuối cùng.
Webhook ingestion: dedup, validate, rồi xử lý an toàn
Webhook sender retry. Họ cũng gửi sự kiện lệch thứ tự. Nếu handler của bạn cập nhật trạng thái trên mọi lần delivery, cuối cùng bạn sẽ tạo bản ghi trùng, gửi email hai lần, hoặc charge hai lần.
Bắt đầu bằng việc chọn khóa dedup tốt nhất. Nếu provider cho event ID duy nhất, dùng nó. Chỉ fallback sang hash payload khi không có event ID.
Bảo mật là ưu tiên: verify signature trước khi chấp nhận. Nếu signature thất bại, từ chối request và đừng ghi record dedup. Nếu không, kẻ tấn công có thể “reserve” event ID và chặn event thật sau này.
Luồng an toàn khi retry:
- Xác thực signature và kiểm tra hình dạng cơ bản (header cần thiết, event ID).
- Insert event ID vào bảng dedup với ràng buộc unique.
- Nếu insert bị lỗi duplicate, trả 200 ngay.
- Lưu payload thô (và header) khi cần cho audit và debug.
- Enqueue xử lý và trả 200 nhanh.
Phản hồi nhanh quan trọng vì nhiều provider có timeout ngắn. Làm công việc nhỏ nhất có thể trong request: verify, dedup, persist. Sau đó xử lý bất đồng bộ (worker, queue, background job). Nếu không thể async, giữ xử lý idempotent bằng cách key các side effect nội bộ bằng cùng event ID.
Out-of-order delivery là chuyện bình thường. Đừng giả định “created” tới trước “updated.” Ưu tiên upsert theo external object ID và theo dõi timestamp hoặc version của event đã xử lý.
Lưu payload thô hữu ích khi khách hàng nói “chúng tôi không nhận được cập nhật.” Bạn có thể chạy lại xử lý từ payload đã lưu sau khi sửa bug, mà không cần nhờ provider gửi lại.
Đồng thời: giữ đúng trong điều kiện request song song
Retries phức tạp khi hai request với cùng idempotency key đến cùng lúc. Nếu cả hai handler đều thực hiện bước “thực hiện công việc” trước khi bất kỳ ai lưu kết quả, bạn vẫn có thể trừ hai lần, tạo bản ghi trùng, hoặc enqueue hai lần.
Điểm phối hợp đơn giản nhất là transaction của database. Làm bước đầu tiên là “claim key” và để database quyết định ai thắng. Các lựa chọn phổ biến:
- Insert duy nhất vào bảng dedup (database bắt ai thắng)
SELECT ... FOR UPDATEsau khi tạo (hoặc tìm) dòng dedup- Khóa advisory ở cấp transaction theo hash của idempotency key
- Ràng buộc unique trên bản ghi nghiệp vụ như biện pháp cuối cùng
Với công việc chạy lâu, tránh giữ row lock trong khi gọi hệ thống ngoài hoặc chạy import kéo dài vài phút. Thay vào đó, lưu một state machine nhỏ trong dòng dedup để request khác có thể thoát nhanh.
Các trạng thái thực tế:
in_progresskèmstarted_atcompletedkèm response cachedfailedkèm mã lỗi (tùy theo chính sách retry)expires_at(để dọn dẹp)
Ví dụ: hai instance app nhận cùng request thanh toán. Instance A insert key và đánh dấu in_progress, rồi gọi provider. Instance B vấp path conflict, đọc dòng dedup, thấy in_progress, và trả nhanh “đang xử lý” (hoặc đợi ngắn rồi kiểm tra lại). Khi A xong, nó cập nhật thành completed và lưu thân response để các retry sau nhận được cùng output chính xác.
Sai lầm phổ biến làm hỏng idempotency
Hầu hết lỗi idempotency không phải về locking phức tạp. Chúng là những lựa chọn “gần đúng” mà thất bại khi retry, timeout, hoặc hai user làm hành động tương tự.
Bẫy phổ biến là coi idempotency key là duy nhất toàn cục. Nếu bạn không scope nó (theo user, account, hoặc endpoint), hai client khác nhau có thể va chạm và một người sẽ nhận kết quả của người kia.
Vấn đề khác là chấp nhận cùng khóa với body khác. Nếu lần đầu $10 và replay là $100, bạn không nên im lặng trả kết quả lần đầu. Lưu request hash (hoặc các trường chính), so sánh trên replay, và trả lỗi conflict rõ ràng.
Client cũng bối rối khi replay trả hình dạng response khác hoặc mã trạng thái khác. Nếu lần đầu trả 201 kèm JSON, replay nên trả cùng body và mã trạng thái. Thay đổi hành vi replay bắt buộc client phải đoán.
Những sai lầm thường gây trùng:
- Chỉ dựa vào map trong bộ nhớ hoặc cache, rồi mất state dedup khi restart.
- Dùng khóa không có scope (va chạm giữa user hoặc endpoint).
- Không validate mismatch payload cho cùng khóa.
- Thực hiện side effect trước rồi mới ghi record dedup.
- Trả ID sinh mới mỗi retry thay vì replay ID ban đầu.
Cache có thể tăng tốc đọc, nhưng nguồn chân lý nên bền (thường PostgreSQL). Nếu không, retry sau deploy có thể tạo bản sao.
Cũng lên kế hoạch dọn dẹp. Nếu bạn lưu mọi khóa mãi mãi, bảng tăng kích thước và index chậm. Đặt cửa sổ retention theo hành vi retry thực tế, xóa dòng cũ, và giữ index nhỏ.
Checklist nhanh và bước tiếp theo
Xem idempotency như một phần của hợp đồng API. Mỗi endpoint có thể bị retry bởi client, queue, hoặc gateway cần quy tắc rõ ràng về “request giống nhau” và “kết quả giống nhau”.
Checklist trước khi ship:
- Với mỗi endpoint có thể retry, phạm vi idempotency đã được định nghĩa (theo user, account, order, event ngoài) và được ghi lại chưa?
- Dedup có được ép bằng database (unique constraint trên idempotency key và scope), không chỉ “kiểm tra bằng code”?
- Khi replay, bạn có trả cùng mã trạng thái và thân response (hoặc một tập con được ghi chép) chứ không trả một object mới hoặc timestamp mới?
- Với thanh toán, bạn xử lý kết quả không rõ an toàn (timeout sau submit, gateway trả “processing”) mà không charge hai lần?
- Logs và metrics có làm rõ khi request được thấy lần đầu so với khi nó bị replay không?
Nếu bất cứ mục nào là “có thể”, sửa ngay. Hầu hết lỗi xuất hiện dưới tải lớn: retry song song, mạng chậm, và outage cục bộ.
Nếu bạn xây công cụ nội bộ hoặc app cho khách hàng trên AppMaster (appmaster.io), tốt nhất hãy thiết kế idempotency key và bảng dedup PostgreSQL sớm. Như vậy, ngay cả khi nền tảng tái sinh mã backend Go khi yêu cầu thay đổi, hành vi retry của bạn vẫn giữ nhất quán.
Câu hỏi thường gặp
Retries là chuyện bình thường vì mạng và client vẫn thất bại theo những cách thông thường. Một request có thể đã được xử lý thành công trên server nhưng response không đến được client, nên client sẽ thử lại và bạn có thể thực hiện cùng hành động hai lần trừ khi server nhận ra và replay kết quả ban đầu.
Gửi cùng một khóa cho mọi lần retry của cùng một hành động. Nên do client tạo — một chuỗi ngẫu nhiên, khó đoán (ví dụ UUID) — và không dùng lại khóa đó cho hành động khác.
Phạm vi khóa nên khớp với quy tắc nghiệp vụ của bạn, thường là theo endpoint cộng với thực thể gọi (user, account, tenant, hoặc API token). Điều này ngăn hai khách hàng khác nhau va chạm và nhận kết quả của nhau.
Trả về cùng kết quả như lần gọi thành công đầu tiên. Thực tế là replay cùng mã trạng thái HTTP và cùng thân response, hoặc ít nhất cùng ID tài nguyên và trạng thái, để client có thể retry an toàn mà không gây ra side effect thứ hai.
Từ chối với lỗi kiểu conflict rõ ràng thay vì đoán. Lưu và so sánh hash của các trường quan trọng trong request; nếu khóa trùng nhưng payload khác, trả về lỗi thay vì trộn hai thao tác khác nhau dưới cùng một khóa.
Giữ khóa đủ lâu để bao phủ các retry thực tế, rồi xóa chúng. Mặc định hay dùng: 24–72 giờ cho thanh toán, một tuần cho import; với webhook, khớp chính sách retry của bên gửi để các retry muộn vẫn được dedupe đúng.
Một bảng dedup riêng hoạt động tốt vì database có thể ép ràng buộc khóa duy nhất và tồn tại qua khởi động lại. Lưu phạm vi owner, khóa, request hash, trạng thái và response để replay; rồi đặt (owner, key) là duy nhất để chỉ một request “thắng”.
Claim (chiếm) khóa bên trong transaction trước, rồi chỉ thực hiện side effect nếu bạn thành công chiếm được. Nếu request song song khác tới, nó sẽ vấp phải ràng buộc duy nhất, đọc thấy trạng thái in_progress hoặc completed, và trả về thông báo chờ/replay thay vì chạy logic hai lần.
Xử lý timeout như “không biết” chứ không phải “thất bại”. Ghi trạng thái pending và, nếu có provider ID, dùng nó làm nguồn tin cậy để các retry trả về cùng kết quả thanh toán thay vì tạo charge mới.
Tách dedup ở hai cấp: job-level và item-level. Trả về cùng job ID khi retry, và bắt khóa tự nhiên cho các dòng (external ID hoặc (account_id, email)) với unique constraint hoặc upsert để tái xử lý không sinh bản sao.


