Idempotency key: để một đơn không bị trừ tiền hai lần
Dùng idempotency key trên SQL Server, ASP.NET Core và cổng thanh toán để một lần app timeout rồi thử lại không trừ tiền khách hai lần.
10:47 một sáng cao điểm, khách 1234 bấm "Thanh toán" cho đơn 10063, 1.250.000 đ. App chờ 10 giây không thấy phản hồi và tự gửi lại. Cổng thanh toán đã trừ tiền ở lần đầu, chỉ trả lời chậm. Lần gửi lại được xử lý như yêu cầu mới, và khách bị trừ hai lần cho một đơn. Bài này dựng cách chặn lỗi đó trên BanHang, từ bảng SQL Server, endpoint ASP.NET Core đến cách gọi cổng, kèm một mô phỏng đếm số lần trừ tiền.
Đọc nhanh
- Client không phân biệt được "server chưa nhận" với "server đã xong nhưng phản hồi bị mất". Thử lại là không tránh được, nên lệnh trừ tiền phải chịu được việc bị gửi nhiều lần.
- App sinh một UUID cho mỗi lần thanh toán và gửi lại đúng UUID đó ở mọi lần thử. Server lưu khóa, vân tay body, trạng thái và phản hồi: trùng thì trả phản hồi cũ, body khác trả
422, đang xử lý trả409. - Giành khóa bằng một
INSERTnguyên tử trước khi gọi cổng. Mô phỏng 1.000 đơn: không khóa trừ 1.089 lần, "tra rồi mới lưu" trừ 1.053 lần, giành khóa trước trừ đúng 1.000 lần. - Truyền khóa xuống cổng, như header
Idempotency-Keycủa Stripe. Không biết kết quả thì thử lại với cùng khóa hoặc tra trạng thái, không bao giờ trừ với khóa mới.
1. Một lần bấm, hai lần trừ
Diễn biến của sự cố, giờ giấc là minh họa:
10:47:03,0 App gửi POST /api/don-hang/10063/thanh-toan, chờ tối đa 10 s
10:47:03,1 API gọi cổng thanh toán. Cổng đang chậm
10:47:13,0 App hết 10 s, hủy request, tự gửi lại cùng body
10:47:13,1 API coi đây là yêu cầu mới, gọi cổng lần hai
10:47:14,9 Cổng trừ 1.250.000 cho lần gọi thứ nhất, 11,8 s sau khi nhận
10:47:15,4 Cổng trừ 1.250.000 cho lần gọi thứ hai. App nhận 200, báo "thành công"
Không thành phần nào hỏng theo nghĩa thông thường. App thử lại vì mạng di động hay rớt. API và cổng làm đúng việc được yêu cầu, hai lần. Không ai biết hai request là cùng một ý định trả tiền.
BanHang có khoảng 13.000 đơn mỗi ngày. Giả sử 0,3% lần thanh toán vượt 10 giây trong các đợt cổng chậm (minh họa), tức khoảng 39 lần mỗi ngày. Nếu cổng hoàn tất cả các lần đó, app tự thử lại biến chúng thành khoảng 1.170 khoản trừ trùng mỗi tháng (ước lượng từ số minh họa trên), mỗi khoản là một lần hoàn tiền.
2. Vì sao không bỏ được việc thử lại
Một request có thể hỏng trên đường đi, trong lúc server xử lý, hoặc trên đường về sau khi server đã commit. Ở cả ba chỗ, client thấy cùng một thứ: hết giờ mà không có phản hồi. Trường hợp đầu phải gửi lại. Trường hợp cuối mà gửi lại thì trừ hai lần. Client không có thông tin để phân biệt. Tắt tự thử lại trong app cũng không giúp gì: khách thấy báo lỗi thì bấm lại, và đó cũng là một lần thử lại.
Theo bài toán hai vị tướng, qua một kênh có thể mất tin, không giao thức nào bảo đảm "gửi đúng một lần". Cách làm thực tế là gửi ít nhất một lần, và làm cho phía nhận xử lý lần thứ hai mà không đổi kết quả. RFC 9110 gọi một method là idempotent khi nhiều request giống nhau có cùng tác động lên server như một request. GET, PUT, DELETE có tính chất này. POST tạo khoản trừ thì không. Idempotency key biến một POST cụ thể thành idempotent.
3. Thiết kế khóa
Khóa do client sinh, vì chỉ client biết hai request là cùng một ý định. Đơn vị của khóa là một lần thanh toán, không phải một lần bấm hay một đơn. App sinh UUID v4 khi khách xác nhận, lưu xuống máy cùng nội dung request, và gửi trong header Idempotency-Key ở mọi lần thử, kể cả sau khi app bị tắt rồi mở lại. Thẻ bị từ chối và khách đổi thẻ khác là một lần thanh toán mới, với khóa mới.
Server lưu theo khóa: vân tay của body (SHA-256), trạng thái đang xử lý hay đã xong, mã HTTP và body đã trả lần đầu. Khóa chính là (KhachHangId, IdemKey), nên khóa của khách này không bao giờ trả về phản hồi của khách khác.
| Request đến | Server làm gì | Trả về |
|---|---|---|
| Khóa chưa có | Giành khóa, gọi cổng, lưu phản hồi | Kết quả thật |
| Cùng khóa, cùng body, đã xong | Không gọi cổng, đọc phản hồi đã lưu | Đúng mã và body lần đầu |
| Cùng khóa, cùng body, đang xử lý | Không chạy lần thứ hai song song | 409, app chờ rồi hỏi lại |
| Cùng khóa, body khác | Từ chối: lỗi ở phía client | 422 |
| Thiếu khóa hoặc không phải UUID | Từ chối | 400 |
Ba mã lỗi theo bản nháp IETF draft-ietf-httpapi-idempotency-key-header (bản 07, 15/10/2025, chưa thành RFC và đã hết hạn ngày 18/04/2026). Server cũng có thể giữ request trùng lại chờ lần đầu xong. Khi đó kết nối của app mở suốt thời gian chờ cổng và app vẫn có thể hết giờ lần nữa, nên BanHang trả 409 ngay.
Vân tay bắt lỗi của chính client, ví dụ một bản app dùng lại khóa cũ cho số tiền khác. Không có nó, server trả phản hồi cũ và app tưởng lần mới đã xong. Vân tay tính trên đối tượng đã parse rồi serialize lại, không tính trên byte thô, vì hai bản app có thể khác khoảng trắng hoặc thứ tự trường.
Khóa không giữ mãi. Stripe ghi rằng khóa có thể bị xóa khi đã ít nhất 24 giờ, và khóa gửi lại sau đó được coi là request mới. BanHang giữ 7 ngày, khoảng 13.000 × 7 = 91.000 dòng. Job dọn theo lô chỉ xóa dòng đã xong: DELETE TOP (5000) FROM dbo.IdemThanhToan WHERE TrangThai = 2 AND TaoLuc < DATEADD(DAY, -7, SYSUTCDATETIME());. Dòng đang xử lý thuộc về job đối soát ở mục 7.
4. Giành khóa nguyên tử trong SQL Server
Thứ tự sai hay gặp: tra khóa, không thấy thì gọi cổng, gọi xong mới lưu khóa. Hai request cùng khóa đến trong lúc cổng đang xử lý đều tra không thấy, và đều gọi cổng. Thứ tự đúng: giành khóa bằng một lệnh ghi nguyên tử, commit, rồi mới gọi cổng.
CREATE TABLE dbo.IdemThanhToan (
KhachHangId int NOT NULL,
IdemKey uniqueidentifier NOT NULL,
VanTay binary(32) NOT NULL, -- SHA-256 của body đã chuẩn hóa
DonHangId bigint NOT NULL,
TrangThai tinyint NOT NULL, -- 1 đang xử lý, 2 xong
MaPhanHoi smallint NULL, -- mã HTTP đã trả lần đầu
PhanHoi nvarchar(max) NULL, -- body JSON đã trả lần đầu
TaoLuc datetime2(3) NOT NULL CONSTRAINT DF_IdemThanhToan_TaoLuc DEFAULT SYSUTCDATETIME(),
XongLuc datetime2(3) NULL,
CONSTRAINT PK_IdemThanhToan PRIMARY KEY CLUSTERED (KhachHangId, IdemKey)
);
GO
CREATE OR ALTER PROCEDURE dbo.usp_IdemThanhToan_Gianh
@KhachHangId int, @IdemKey uniqueidentifier, @VanTay binary(32), @DonHangId bigint
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @LaMoi bit;
BEGIN TRANSACTION;
INSERT INTO dbo.IdemThanhToan (KhachHangId, IdemKey, VanTay, DonHangId, TrangThai)
SELECT @KhachHangId, @IdemKey, @VanTay, @DonHangId, 1
WHERE NOT EXISTS (
SELECT 1 FROM dbo.IdemThanhToan WITH (UPDLOCK, HOLDLOCK)
WHERE KhachHangId = @KhachHangId AND IdemKey = @IdemKey);
SET @LaMoi = @@ROWCOUNT;
SELECT @LaMoi AS LaMoi, VanTay, DonHangId, TrangThai, MaPhanHoi, PhanHoi
FROM dbo.IdemThanhToan
WHERE KhachHangId = @KhachHangId AND IdemKey = @IdemKey;
COMMIT TRANSACTION;
END;
GO
Đây là mẫu upsert ở mục 7 của Transaction, khóa và isolation. HOLDLOCK cho câu dò ngữ nghĩa SERIALIZABLE: khi dòng chưa có, nó khóa cả khoảng trống nơi dòng sẽ nằm. UPDLOCK đặt khóa đó ở chế độ U, không tương thích với chính nó, nên phiên thứ hai xếp hàng ngay ở câu dò. Phiên thắng nhận LaMoi = 1. Phiên đến sau nhận LaMoi = 0 cùng trạng thái và phản hồi đã lưu.
Kiểm trên SQL Server 2019 CU27 (LocalDB 15.0.4382.1): phiên A giành khóa rồi cố ý giữ giao dịch thêm 4 giây, trong lúc đó giữ RangeS-U trên khoảng trống và X trên khóa vừa chèn. Phiên B gọi sau 1 giây, chờ 3.657 ms, rồi nhận LaMoi = 0. Trong thủ tục thật, giao dịch chỉ dài bằng một INSERT.
Khóa chính mới là lớp bảo vệ cuối. Bỏ hai gợi ý khóa, phép thử 1.500 request ở mục 6 vẫn chỉ trừ 300 lần, vì INSERT trùng vi phạm PK_IdemThanhToan (lỗi 2627). Nhưng ở ba lần chạy, 16, 24 và 11 request nhận lỗi 500 thay vì 409 hay phản hồi đã lưu. Gợi ý khóa đổi cuộc đua thành một khoảng chờ ngắn.
Gọi cổng nằm ngoài giao dịch
Giao dịch giành khóa commit trước khi gọi cổng. Giữ giao dịch mở trong 10 giây chờ cổng là giữ khóa phạm vi 10 giây, và mọi request có khóa rơi vào cùng khoảng trống của chỉ mục phải xếp hàng theo.
5. Mô phỏng: đếm số lần trừ tiền
Mô phỏng chạy 1.000 đơn qua ba cách xử lý phía API, thời gian thu nhỏ: app chờ tối đa 0,5 s, cổng trả lời sau 0,1 s. Mỗi đơn có xác suất 5% gặp cổng chậm 1,0 s, app gửi lại khi API còn đang chạy. Xác suất 3% mất phản hồi, app gửi lại sau khi API đã xong. Cổng giả không tự chống trùng. Bảng sqlite3 trong bộ nhớ có khóa chính đóng vai PK_IdemThanhToan. Cần Python 3.11 trở lên, chỉ dùng thư viện chuẩn, hạt giống 42.
import hashlib, json, random, sqlite3, sys, threading, time, uuid
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, wait
SO_DON, TIMEOUT, CONG_NHANH, CONG_CHAM = 1000, 0.5, 0.1, 1.0 # giây, thời gian thu nhỏ
random.seed(42)
SU_CO = random.choices(["ok", "cham", "mat"], weights=[92, 5, 3], k=SO_DON)
APP, API = ThreadPoolExecutor(100), ThreadPoolExecutor(300) # luồng phía app, phía API
for pool, n in ((APP, 100), (API, 300)):
list(pool.map(time.sleep, [0.1] * n)) # tạo sẵn luồng trước khi đo
KHOA_CONG, KHOA_DB = threading.Lock(), threading.Lock()
def tru_tien(don, so_tien, cham): # cổng thanh toán giả, không tự chống trùng
time.sleep(CONG_CHAM if cham else CONG_NHANH)
with KHOA_CONG:
LAN_TRU[don] += 1
return {"don": don, "so_tien": so_tien, "ma_gd": f"GD{don}-{LAN_TRU[don]}"}
def sql(cau, *ts): # KHOA_DB chỉ để các thread dùng chung một connection
with KHOA_DB, DB:
return DB.execute(cau, ts).fetchone()
def xu_ly(che_do, khoa, body, cham): # phía API của BanHang
if che_do == "khong_key":
return 200, tru_tien(body["don"], body["so_tien"], cham)
van_tay = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
if che_do == "tra_roi_luu": # tra trước, trừ tiền, lưu sau
dong = sql("SELECT phan_hoi FROM idem WHERE khoa = ?", khoa)
if dong:
return 200, json.loads(dong[0])
kq = tru_tien(body["don"], body["so_tien"], cham)
sql("INSERT OR REPLACE INTO idem VALUES (?, ?, 'xong', ?)", khoa, van_tay, json.dumps(kq))
return 200, kq
try: # gianh_truoc: INSERT giành khóa rồi mới trừ tiền
sql("INSERT INTO idem VALUES (?, ?, 'dang_xu_ly', NULL)", khoa, van_tay)
except sqlite3.IntegrityError:
vt, trang_thai, phan_hoi = sql("SELECT * FROM idem WHERE khoa = ?", khoa)[1:]
if vt != van_tay:
return 422, None
if trang_thai == "dang_xu_ly":
return 409, None
return 200, json.loads(phan_hoi)
kq = tru_tien(body["don"], body["so_tien"], cham)
sql("UPDATE idem SET trang_thai = 'xong', phan_hoi = ? WHERE khoa = ?", json.dumps(kq), khoa)
return 200, kq
def goi(che_do, khoa, body, cham, mat): # app gửi một request, chờ tối đa TIMEOUT
viec = API.submit(xu_ly, che_do, khoa, body, cham)
VIEC.append(viec)
try:
kq = viec.result(timeout=TIMEOUT)
except TimeoutError:
return None # hết giờ, API có thể vẫn đang chạy
if mat:
time.sleep(TIMEOUT) # API đã xong nhưng phản hồi mất trên đường về
return None
return kq
def khach(che_do, don):
khoa = str(uuid.uuid4()) # sinh MỘT lần cho một lần thanh toán
body = {"don": don, "so_tien": 1_250_000}
for lan in range(8):
kq = goi(che_do, khoa, body, SU_CO[don] == "cham" and lan == 0, SU_CO[don] == "mat" and lan == 0)
if kq is None:
continue # thử lại ngay, cùng khóa, cùng body
if kq[0] == 409:
time.sleep(0.3) # yêu cầu trước còn chạy: chờ rồi hỏi lại
continue
return kq
return None
print(f"Python {sys.version.split()[0]}, {SO_DON} đơn, sự cố:", dict(Counter(SU_CO)))
print(f"{'che_do':<12} {'request':>8} {'lan_tru':>8} {'tru_2_lan':>10} {'chua_tru':>9} {'app_bo_cuoc':>12}")
for che_do in ["khong_key", "tra_roi_luu", "gianh_truoc"]:
LAN_TRU, VIEC = Counter(), []
DB = sqlite3.connect(":memory:", check_same_thread=False)
DB.execute("CREATE TABLE idem (khoa TEXT PRIMARY KEY, van_tay TEXT, trang_thai TEXT, phan_hoi TEXT)")
ket_qua = list(APP.map(lambda d: khach(che_do, d), range(SO_DON)))
wait(VIEC) # chờ cả các request mà app đã bỏ chờ
print(f"{che_do:<12} {len(VIEC):>8} {LAN_TRU.total():>8} {sum(v >= 2 for v in LAN_TRU.values()):>10}"
f" {SO_DON - len(LAN_TRU):>9} {ket_qua.count(None):>12}")
Kết quả trên Python 3.14.3, Windows 11, Intel Core Ultra 5 125U, chạy hết khoảng 9 giây:
Python 3.14.3, 1000 đơn, sự cố: {'ok': 911, 'cham': 53, 'mat': 36}
che_do request lan_tru tru_2_lan chua_tru app_bo_cuoc
khong_key 1089 1089 89 0 0
tra_roi_luu 1089 1053 53 0 0
gianh_truoc 1195 1000 0 0 0
Không có khóa, mọi request thành một lần trừ: 89 đơn trừ hai lần, bằng 53 đơn cổng chậm cộng 36 đơn mất phản hồi. "Tra rồi mới lưu" chặn được 36 đơn mất phản hồi, vì lần gửi lại đến sau khi phản hồi đã lưu. Nó không chặn được 53 đơn cổng chậm: lần gửi lại tra bảng khi lần đầu còn chờ cổng. Giành khóa trước trừ đúng 1.000 lần, với 1.000 + 36 + 53 × 3 = 1.195 request: mỗi đơn cổng chậm nhận hai lần 409 rồi một phản hồi đã lưu.
Cột chua_tru và app_bo_cuoc bằng 0 ở cả ba dòng: chặn trùng không làm mất lần thanh toán nào. Ba lần chạy cho cùng các con số. Thời gian chạy đổi theo máy, còn số đếm ổn định vì các mốc 0,1 s, 0,5 s và 1,0 s cách nhau xa hơn nhiều so với độ trễ lập lịch thread, trừ khi máy quá tải nặng.
6. Endpoint ASP.NET Core
Đoạn dưới thuộc Program.cs của API (.NET 8): using ở đầu file, MapPost sau builder.Build(), record ở cuối file. Cần thêm một ICongThanhToan đăng ký trong DI và middleware xác thực đặt claim ClaimTypes.NameIdentifier là mã khách.
using System.Data;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
app.MapPost("/api/don-hang/{donHangId:long}/thanh-toan", async (long donHangId, ThanhToanBody body,
HttpRequest req, ClaimsPrincipal user, IConfiguration cfg, ICongThanhToan cong) =>
{
if (!Guid.TryParse(req.Headers["Idempotency-Key"], out Guid khoa))
return Results.Problem("Thiếu Idempotency-Key dạng UUID", statusCode: 400);
int khachHangId = int.Parse(user.FindFirst(ClaimTypes.NameIdentifier)!.Value);
byte[] vanTay = SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new { donHangId, body }));
// Kết nối đang đóng: Dapper tự mở rồi đóng quanh từng lệnh, không giữ kết nối khi chờ cổng.
await using var conn = new SqlConnection(cfg.GetConnectionString("BanHang"));
var dong = await conn.QuerySingleAsync<IdemDong>("dbo.usp_IdemThanhToan_Gianh",
new { KhachHangId = khachHangId, IdemKey = khoa, VanTay = vanTay, DonHangId = donHangId },
commandType: CommandType.StoredProcedure);
if (!dong.LaMoi)
{
if (!dong.VanTay.AsSpan().SequenceEqual(vanTay))
return Results.Problem("Khóa này đã dùng cho một yêu cầu khác", statusCode: 422);
if (dong.TrangThai == 1)
return Results.Problem("Yêu cầu trước với khóa này đang xử lý", statusCode: 409);
return Results.Content(dong.PhanHoi, "application/json", statusCode: dong.MaPhanHoi);
}
// Không truyền HttpContext.RequestAborted: app ngắt kết nối không được hủy lệnh trừ tiền giữa chừng.
// Lỗi mạng khi gọi cổng: dòng giữ TrangThai = 1 để job đối soát xử lý (mục 7).
KetQuaThanhToan kq = await cong.TruTienAsync(donHangId, body.SoTien, body.PhuongThuc, khoa.ToString());
string json = JsonSerializer.Serialize(kq);
await conn.ExecuteAsync("""
UPDATE dbo.IdemThanhToan
SET TrangThai = 2, MaPhanHoi = 200, PhanHoi = @json, XongLuc = SYSUTCDATETIME()
WHERE KhachHangId = @khachHangId AND IdemKey = @khoa AND TrangThai = 1;
""", new { json, khachHangId, khoa });
return Results.Content(json, "application/json", statusCode: 200);
});
public sealed record ThanhToanBody(decimal SoTien, string PhuongThuc);
public sealed record KetQuaThanhToan(string MaGiaoDich, string TrangThai);
public sealed record IdemDong(bool LaMoi, byte[] VanTay, long DonHangId, byte TrangThai,
short? MaPhanHoi, string? PhanHoi);
public interface ICongThanhToan
{
Task<KetQuaThanhToan> TruTienAsync(long donHangId, decimal soTien, string phuongThuc, string idempotencyKey);
}
Hai chi tiết dễ bỏ sót. Trong minimal API, tham số CancellationToken gắn với HttpContext.RequestAborted, bị hủy khi app hết giờ và đóng kết nối. Truyền nó vào lệnh gọi cổng là tự tạo ra trường hợp "không biết đã trừ hay chưa". Và Dapper gặp kết nối đang đóng thì mở trước lệnh, đóng sau lệnh, nên lúc chờ cổng kết nối đã về pool. Pool mặc định tối đa 100 kết nối: giữ kết nối suốt 10 giây chờ cổng thì 10 lần thanh toán mỗi giây là hết pool.
Đoạn code đã chạy trên ASP.NET Core 8.0.31, Dapper 2.1.89, Microsoft.Data.SqlClient 7.1.1 và LocalDB ở mục 4, với một cổng giả trả lời sau 300 ms. Tỉ lệ 200 và 409 và thời gian chạy đổi theo lần chạy, tùy request trùng đến trước hay sau khi lần đầu xong. Số lần cổng trừ tiền thì không đổi: đúng 1 lần ở phép thử đầu và đúng 300 lần ở phép thử cuối, ở cả sáu lần chạy hết phép thử.
20 request song song, cùng khóa: 200 x 1, 409 x 19; cổng trừ 1 lần
gửi lại sau khi xong: 200 {"MaGiaoDich":"GD-10063","TrangThai":"da_tru"}
cùng khóa, đổi số tiền: 422
thiếu khóa: 400
300 khóa x 5 request song song: 200 x 648, 409 x 852; cổng trừ 300 lần; 5632 ms
7. Gọi cổng thanh toán và khi không biết kết quả
Chặng API đến cổng có đúng vấn đề của chặng app đến API: HttpClient của API hết giờ thì API cũng không biết tiền đã bị trừ hay chưa. Thử lại mà không có khóa ở chặng này là trừ trùng ở tầng cổng.
Stripe nhận khóa qua header Idempotency-Key cho mọi request POST (tài liệu đọc ngày 2026-10-01). Stripe lưu mã trạng thái và body của request đầu tiên với mỗi khóa, thành công hay thất bại, kể cả lỗi 500, rồi trả lại đúng kết quả đó cho request sau kèm header Idempotent-Replayed: true. Khóa dài tối đa 255 ký tự, Stripe gợi ý UUID v4. Tham số khác request gốc thì báo lỗi. Request đụng một request đang chạy không được lưu kết quả và gửi lại được. Bảng mã lỗi của Stripe dùng 409 Conflict cho xung đột này.
BanHang truyền chính UUID của app xuống cổng qua tham số idempotencyKey, nên một lần thanh toán có một khóa ở cả hai chặng. Cổng không nhận idempotency key thì dùng một mã tham chiếu ổn định, như DonHangId cộng số thứ tự lần thanh toán, và tra trạng thái giao dịch theo mã đó trước mọi lần gọi lại.
Khi không biết kết quả: thử lại với cùng khóa hoặc tra trạng thái, không bao giờ trừ với khóa mới. Stripe coi phản hồi 500 là chưa xác định và khuyên không đổi khóa, vì khóa cũ có thể đã gây tác động. Trong endpoint ở mục 6, lỗi mạng tới cổng để lại dòng ở TrangThai = 1. App gửi lại chỉ nhận 409, và sau vài lần app hiện "đang xác nhận thanh toán".
Job đối soát chạy mỗi vài phút, lấy các dòng TrangThai = 1 cũ hơn 2 phút, gọi lại cổng bằng đúng khóa đó hoặc tra trạng thái. Cổng xác nhận đã trừ thì job lưu phản hồi và chuyển dòng sang TrangThai = 2. Cổng xác nhận không có giao dịch thì job xóa dòng để lần gửi sau chạy như mới. Job phải xong trong cửa sổ nhớ khóa của cổng: với Stripe, khóa có thể bị xóa sau 24 giờ, và gọi lại sau đó thành một lần trừ mới. Stripe cũng gợi ý gửi mã nội bộ trong metadata để đối chiếu các đối tượng tạo về sau qua webhook.
Những chỗ hay hiểu sai
- "Server tự sinh khóa cho mỗi request là đủ." Server không phân biệt được lần gửi lại với một ý định mới. Khóa phải do client sinh trước lần gửi đầu.
- "Mỗi lần thử lại nên sinh khóa mới." Khóa mới là yêu cầu mới, tức đúng lỗi trừ hai lần. Chỉ sinh khóa mới khi khách bắt đầu một lần thanh toán mới, ví dụ đổi thẻ.
- "Tra bảng trước, xong việc mới lưu khóa là đủ." Hai request đồng thời cùng tra không thấy: mô phỏng ở mục 5 có 53 đơn trừ hai lần theo cách này. Giành khóa bằng
INSERTtrước khi gọi cổng. - "Giữ khóa vĩnh viễn cho an toàn." Bảng lớn mãi, còn cổng chỉ nhớ khóa trong cửa sổ của nó. Đặt thời hạn rõ, dài hơn thời gian client còn thử lại, và để trạng thái đơn hàng làm lớp chặn sau cùng.
- "API đã idempotent thì không cần khóa ở cổng." Chặng API đến cổng cũng timeout. Truyền khóa xuống, hoặc tra trạng thái trước khi gọi lại.
Đọc tiếp
- Bài toán hai vị tướng và giới hạn của "gửi đúng một lần": vì sao không giao thức nào bảo đảm gửi đúng một lần qua mạng.
- Transaction, khóa và isolation: upsert với
UPDLOCK, HOLDLOCKvà vòng thử lạidbo.usp_DonHang_Taovới mã đơn cố định. - Kiểu dữ liệu, collation và khóa chính: chi phí của khóa GUID như
IdemKey, và sequence sinh mã đơn trước lần thử đầu.
Nguồn
- Stripe API Reference, Idempotent requests.
- Stripe Docs, Advanced error handling: mục Idempotency, Network errors, Server errors.
- IETF, The Idempotency-Key HTTP Header Field, draft-ietf-httpapi-idempotency-key-header-07.
- RFC 9110, HTTP Semantics, mục 9.2.2 Idempotent Methods.
- Microsoft Learn, Table hints (Transact-SQL):
HOLDLOCK,UPDLOCK. - Microsoft Learn, Parameter binding in Minimal API apps:
CancellationTokengắn vớiHttpContext.RequestAborted. - Microsoft Learn, SQL Server connection pooling with Microsoft.Data.SqlClient:
Max Pool Sizemặc định 100. - Dapper, SqlMapper.Async.cs: mở và đóng kết nối khi kết nối đang đóng.