- Vấn đề Trùng lặp Giao dịch và Yêu cầu Idempotency trong Hệ thống E-commerce
- Kiến trúc Hoạt động của Idempotency Key
- Thiết kế Bảng Dữ liệu Idempotency Keys với Prisma Schema
- Xây dựng Idempotency Middleware trong ExpressJS
- Xử lý Race Condition khi Nhiều Request Đến Đồng Thời
- Chiến lược Dọn dẹp Bản ghi và Tối ưu Hiệu năng Database
- Tổng kết
Vấn đề Trùng lặp Giao dịch và Yêu cầu Idempotency trong Hệ thống E-commerce
Trong các ứng dụng thương mại điện tử, tình trạng khách hàng bấm gửi đơn hàng nhiều lần do mạng chập chờn hoặc hệ thống thanh toán tự động retry khi gặp lỗi timeout là kịch bản rất phổ biến. Nếu Backend không có giải pháp xử lý triệt để, hệ thống sẽ tạo ra nhiều đơn hàng trùng lặp, trừ tiền tài khoản nhiều lần và làm sai lệch số lượng tồn kho trong database.
Về mặt kỹ thuật, các phương thức HTTP như GET, PUT, DELETE theo định nghĩa chuẩn của RESTful API phải có tính idempotent, nghĩa là dù thực thi một lần hay nhiều lần thì trạng thái cuối cùng của tài nguyên trên máy chủ vẫn không thay đổi. Tuy nhiên, POST lại là phương thức non-idempotent. Mỗi lần client gửi request POST tạo đơn hàng thành công, một bản ghi mới sẽ được ghi vào database. Do đó, kỹ thuật Idempotency Key ra đời nhằm biến các endpoint POST xử lý nhạy cảm trở thành idempotent, đảm bảo an toàn tuyệt đối cho các giao dịch tài chính và khởi tạo dữ liệu.
Kiến trúc Hoạt động của Idempotency Key
Ý tưởng cốt lõi của giải pháp là yêu cầu client đính kèm một định danh duy nhất thông qua HTTP Header, thông thường là Idempotency-Key, chứa một chuỗi ngẫu nhiên dạng UUID phiên bản 4 cho mỗi hành động độc lập của người dùng.
Khi ExpressJS tiếp nhận request, một Middleware chuyên trách sẽ kiểm tra xem key này đã tồn tại trong database hay chưa. Luồng xử lý diễn ra theo ba kịch bản cụ thể:
- Kịch bản 1 (Request mới): Key chưa từng xuất hiện. Server tạo một bản ghi tạm thời ghi nhận trạng thái đang xử lý, tiếp tục chuyển giao request cho Controller hoàn tất quy trình nghiệp vụ, sau đó cập nhật kết quả trả về vào bản ghi này trước khi gửi response cho client.
- Kịch bản 2 (Request đang xử lý dở dang): Key đã tồn tại nhưng bản ghi đang mang trạng thái xử lý chưa hoàn tất. Tình huống này xảy ra khi request trước đó vẫn đang chạy trong pipeline. Server sẽ từ chối xử lý tiếp và trả về HTTP status 409 Conflict.
- Kịch bản 3 (Request đã xử lý xong): Key đã tồn tại và trạng thái ghi nhận hoàn tất. Server lập tức lấy lại toàn bộ HTTP status code và payload đã lưu từ database để phản hồi trực tiếp cho client mà không chạy lại bất kỳ logic nghiệp vụ nào bên dưới.
Thiết kế Bảng Dữ liệu Idempotency Keys với Prisma Schema
Để lưu trữ trạng thái của các request, chúng ta thiết kế một bảng riêng biệt trong MySQL thông qua Prisma Schema. Bảng này không chỉ lưu kết quả phản hồi mà còn phải lưu chuỗi hash của request body để phát hiện hành vi dùng cùng một key nhưng gửi payload khác nhau.
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum IdempotencyStatus {
PENDING
SUCCESS
FAILED
}
model IdempotencyRecord {
id String @id @default(uuid())
key String @unique @db.VarChar(128)
endpoint String @db.VarChar(255)
requestHash String @db.Char(64)
status IdempotencyStatus @default(PENDING)
statusCode Int?
responseBody Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([createdAt])
@@map("idempotency_records")
}Cột key được đánh chỉ mục unique để tận dụng triệt để cơ chế khóa dòng của MySQL, chặn đứng xung đột khi có nhiều request đồng thời gửi lên cùng một giá trị key.
Xây dựng Idempotency Middleware trong ExpressJS
Chúng ta triển khai một Middleware có khả năng can thiệp vào vòng đời của request và ghi đè phương thức res.json để tự động ghi lại payload phản hồi trước khi dữ liệu được gửi về cho client.
import { Request, Response, NextFunction } from "express";
import crypto from "crypto";
import { PrismaClient, IdempotencyStatus } from "@prisma/client";
const prisma = new PrismaClient();
const calculateHash = (data: unknown): string => {
const content = typeof data === "string" ? data : JSON.stringify(data || {});
return crypto.createHash("sha256").update(content).digest("hex");
};
export const idempotencyMiddleware = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const idempotencyKey = req.header("Idempotency-Key");
if (!idempotencyKey) {
next();
return;
}
const endpoint = `${req.method} ${req.originalUrl}`;
const requestHash = calculateHash(req.body);
try {
const existingRecord = await prisma.idempotencyRecord.findUnique({
where: { key: idempotencyKey }
});
if (existingRecord) {
if (existingRecord.requestHash !== requestHash) {
res.status(422).json({
error: "Idempotency Key đã được sử dụng với payload khác. Vui lòng kiểm tra lại dữ liệu gửi lên."
});
return;
}
if (existingRecord.status === IdempotencyStatus.PENDING) {
res.status(409).json({
error: "Yêu cầu tương ứng đang trong tiến trình xử lý, vui lòng không gửi lại liên tục."
});
return;
}
res.status(existingRecord.statusCode || 200).json(existingRecord.responseBody);
return;
}
await prisma.idempotencyRecord.create({
data: {
key: idempotencyKey,
endpoint,
requestHash,
status: IdempotencyStatus.PENDING
}
});
const originalJson = res.json.bind(res);
res.json = (body: unknown): Response => {
const statusCode = res.statusCode;
const isSuccess = statusCode >= 200 && statusCode < 300;
prisma.idempotencyRecord.update({
where: { key: idempotencyKey },
data: {
statusCode,
responseBody: body as object,
status: isSuccess ? IdempotencyStatus.SUCCESS : IdempotencyStatus.FAILED
}
}).catch((err) => {
console.error("Lỗi khi cập nhật trạng thái Idempotency Record:", err);
});
return originalJson(body);
};
next();
} catch (error) {
next(error);
}
};Xử lý Race Condition khi Nhiều Request Đến Đồng Thời
Một bài toán hóc búa phát sinh khi hai request mang cùng một Idempotency-Key cập bến máy chủ tại cùng một mili giây. Cả hai luồng xử lý đều gọi hàm findUnique và nhận về giá trị rỗng, dẫn đến cả hai cùng thực thi thao tác create.
MySQL giải quyết vấn đề này ở tầng lưu trữ nhờ ràng buộc duy nhất trên cột key. Khi luồng thứ hai cố gắng thực thi câu lệnh insert, MySQL sẽ kích hoạt lỗi trùng lặp khóa chính. Prisma sẽ bắt lỗi này và ném ra ngoại lệ với mã lỗi P2002. Chúng ta xử lý ngoại lệ này như sau:
import { Prisma } from "@prisma/client";
export const handleConcurrencyInsert = async (
key: string,
endpoint: string,
requestHash: string
) => {
try {
return await prisma.idempotencyRecord.create({
data: {
key,
endpoint,
requestHash,
status: IdempotencyStatus.PENDING
}
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return null;
}
throw error;
}
};Nếu kết quả trả về là null, Middleware sẽ ngay lập tức trả về status 409 Conflict, qua đó bảo vệ database khỏi tình trạng race condition mà không cần phải sử dụng thêm các giải pháp distributed lock phức tạp.
Chiến lược Dọn dẹp Bản ghi và Tối ưu Hiệu năng Database
Bảng idempotency_records sẽ phình to rất nhanh theo thời gian nếu hệ thống xử lý hàng trăm nghìn đơn hàng mỗi ngày. Tuy nhiên, tính hợp lệ của một Idempotency Key chỉ nên duy trì trong một khoảng thời gian nhất định, thường là 24 giờ đến 7 ngày tùy thuộc vào chính sách thanh toán.
Giải pháp hiệu quả là tạo một tác vụ định kỳ chạy mỗi ngày vào giờ thấp điểm để xóa sạch các bản ghi đã hết hạn sử dụng. Bạn có thể sử dụng câu lệnh SQL trực tiếp thông qua Event Scheduler của MySQL:
CREATE EVENT IF NOT EXISTS purge_expired_idempotency_records ON SCHEDULE EVERY 1 DAY STARTS (TIMESTAMP(CURRENT_DATE) + INTERVAL 1 DAY + INTERVAL 2 HOUR) DO DELETE FROM idempotency_records WHERE createdAt < NOW() - INTERVAL 7 DAY;
Việc này đảm bảo kích thước bảng luôn được giữ ở mức kiểm soát được, chỉ mục B-tree hoạt động tối ưu và không làm suy giảm hiệu năng của các tác vụ ghi đơn hàng mới.
Tổng kết
Idempotency là một nguyên tắc thiết kế sống còn đối với các hệ thống backend thương mại điện tử chuyên nghiệp. Bằng việc kết hợp chặt chẽ giữa Middleware xử lý của ExpressJS, ràng buộc toàn vẹn của Prisma và khả năng khóa bản ghi của MySQL, chúng ta xây dựng được một hàng rào bảo vệ vững chắc chống lại lỗi trùng lặp giao dịch và race condition.
Để làm chủ toàn diện các kỹ thuật lập trình backend, tối ưu hóa cơ sở dữ liệu và xây dựng hoàn chỉnh một ứng dụng dashboard thương mại điện tử thực tế chuẩn production, bạn có thể tham gia lộ trình thực chiến chuyên sâu. Tham khảo khóa học "[Full Course] Ecommerce Dashboard Fullstack Clone" tại đây.







Bình luận 0
Chia sẻ ý kiến hoặc đặt câu hỏi cùng cộng đồng