Tranh chấp tài nguyên
Chống bán vượt và đặt trùng khi nhiều request cùng giành một tài nguyên: khóa bi quan, optimistic locking, UPDATE có điều kiện, distributed lock, hàng đợi theo key và giữ chỗ có hạn.
Bài toán: hai người cùng giành một thứ
Còn đúng một ghế A12 cho buổi concert. Hai người bấm "Đặt" cách nhau 5 ms. Hai request rơi vào hai instance API khác nhau, cùng đọc thấy ghế còn trống, cùng ghi "đã bán". Kết quả là hai vé cho một ghế, một khách hàng tức giận và một đội CSKH phải xử lý.
Dạng lỗi này xuất hiện ở khắp nơi:
- Overselling: kho còn 10 chiếc, flash sale bán ra 13 đơn.
- Double booking: một phòng khách sạn, một khung giờ bác sĩ, một tài xế bị gán cho hai chuyến.
- Mất cập nhật (lost update): hai người cùng sửa một bản ghi, bản ghi sau đè bản ghi trước mà không ai biết. Microservices Patterns §4.3 định nghĩa lost update là một tiến trình ghi đè lên cập nhật của tiến trình khác mà không đọc thay đổi đó (ví dụ saga tạo đơn duyệt đơn vừa bị saga khác huỷ).1
- Trừ tiền hai lần: số dư 100k, hai giao dịch 80k cùng qua bước kiểm tra.
Grokking ch.9 nêu cùng loại vấn đề: dữ liệu bị nhiều người đọc ghi cùng lúc cần được điều phối để tránh bất nhất, ví dụ đặt trùng phòng khách sạn, và cơ sở dữ liệu quan hệ xử lý bằng truy cập có transaction.2 Điểm chung: có một bất biến (số ghế ≥ 0, mỗi ghế một chủ, số dư ≥ 0) và nhiều tiến trình đồng thời cùng đọc rồi ghi. Pattern này là bộ công cụ để giữ bất biến đó.
Cách ngây thơ: đọc, kiểm tra, rồi ghi
async function bookSeat(seatId: string, userId: string) {
const seat = await db.query("SELECT status FROM seats WHERE id = $1", [seatId]);
if (seat.status !== "AVAILABLE") throw new Error("Đã có người đặt");
// khoảng hở: request khác có thể chen vào đúng chỗ này
await db.query("UPDATE seats SET status = 'SOLD', user_id = $2 WHERE id = $1", [seatId, userId]);
}
Code trông đúng và chạy đúng khi test một mình. Lỗi nằm ở khoảng thời gian giữa SELECT và UPDATE. Đây là kiểu race condition check-then-act.
Vài cách sửa sai hay gặp:
- Khóa trong bộ nhớ (
mutextrong process Node.js). Chỉ có tác dụng trong một process. Có 2 instance trở lên là vô dụng. - Bọc trong transaction với mức isolation mặc định. Microservices Patterns §4.3 ghi nhận mức isolation mặc định của RDBMS thường yếu hơn isolation đầy đủ (serializable), nên các bất thường vẫn có thể xảy ra.3 Ví dụ Postgres mặc định là READ COMMITTED: hai transaction vẫn cùng đọc thấy AVAILABLE. Transaction không tự động khóa những gì bạn đọc.4
- Kiểm tra ở frontend (disable nút sau khi bấm). Chỉ chặn được double-click của chính một người.
Giải pháp 1: Khóa bi quan với SELECT FOR UPDATE
Khi tài nguyên nằm trong một database quan hệ, cách thẳng nhất là bảo database khóa dòng đó lại. Redis in Action gọi việc khóa thật sự mà database quan hệ thực hiện là khóa bi quan (pessimistic), đối lập với optimistic locking.5 Cú pháp SELECT ... FOR UPDATE cụ thể của Postgres chưa có nguồn trong thư viện.4
BEGIN;
SELECT status FROM seats WHERE id = 'A12' FOR UPDATE; -- request thứ hai chờ ở đây
-- ứng dụng kiểm tra status = 'AVAILABLE'
UPDATE seats SET status = 'SOLD', user_id = 42 WHERE id = 'A12';
COMMIT; -- nhả khóa
Request B bị chặn ở FOR UPDATE cho tới khi A commit, sau đó đọc thấy SOLD và từ chối.
Trade-off:
- Đúng và dễ hiểu. Database lo hết.
- Khóa giữ suốt transaction. Nếu trong transaction bạn gọi API thanh toán mất 2 s, mọi người muốn ghế đó xếp hàng 2 s. Không bao giờ gọi mạng ra ngoài khi đang giữ khóa.
- Khóa nhiều dòng theo thứ tự khác nhau dẫn tới deadlock. Luôn khóa theo thứ tự cố định (ví dụ sắp xếp seat id).
- Với một dòng cực nóng (một SKU flash sale), mọi request xếp hàng tuần tự trên một dòng. Throughput chỉ còn khoảng 1 / thời gian giữ khóa. Giữ 5 ms nghĩa là tối đa khoảng 200 lần ghi/s cho dòng đó (phép tính theo giả định của khoá học, cần đo).6
SELECT ... FOR UPDATE SKIP LOCKED là biến thể hữu ích khi bạn cần "lấy bất kỳ một cái còn trống" (một voucher, một job trong hàng đợi): request bỏ qua dòng đang bị khóa và lấy dòng kế tiếp thay vì chờ.
Giải pháp 2: Optimistic locking và UPDATE có điều kiện
Ý tưởng: không khóa khi đọc. Khi ghi, kiểm tra xem dữ liệu có bị ai đổi trong lúc đó không. Nếu có thì thất bại và thử lại.
UPDATE có điều kiện
Với nhiều bài toán, gộp kiểm tra và ghi thành một câu lệnh nguyên tử là đủ:
-- đặt ghế: chỉ thành công nếu ghế còn trống
UPDATE seats SET status = 'SOLD', user_id = 42
WHERE id = 'A12' AND status = 'AVAILABLE';
-- rowCount = 1: thành công; rowCount = 0: đã có người lấy
-- trừ tồn kho: không bao giờ âm
UPDATE inventory SET stock = stock - 1
WHERE sku = 'IP15-128' AND stock >= 1;
Database đảm bảo một câu UPDATE trên một dòng là nguyên tử (MongoDB: Definitive Guide cũng nói cập nhật một document là nguyên tử, hai cập nhật cùng lúc thì cái đến server trước được áp dụng trước).7 Không có khoảng hở check-then-act. Sách MongoDB minh hoạ đúng kỹ thuật này: để nhiều tiến trình không lấy trùng một việc, cập nhật status từ READY sang RUNNING với điều kiện status vẫn là READY rồi kiểm tra số document bị ảnh hưởng (n == 1 nghĩa là mình lấy được), hoặc dùng findAndModify cho thao tác kiểu get-and-set nguyên tử.8 Đây là cách nên nghĩ tới đầu tiên vì đơn giản, nhanh, và không giữ khóa lâu hơn thời gian chạy một câu lệnh (nhận định của khoá học).
Version column
Khi logic kiểm tra phức tạp và nằm ở tầng ứng dụng (đọc aggregate, áp nhiều luật nghiệp vụ, rồi ghi lại), thêm cột version. Microservices Patterns §6.1.3 dùng đúng kỹ thuật này để xử lý cập nhật đồng thời trên aggregate: cột VERSION tăng mỗi lần cập nhật, câu UPDATE chỉ thành công nếu version chưa đổi từ lúc đọc, nên giao dịch thứ hai sẽ thất bại thay vì đè lên thay đổi của giao dịch đầu.9
const order = await repo.load(orderId); // version = 7
order.cancel(); // áp luật nghiệp vụ trong bộ nhớ
const r = await db.query(
`UPDATE orders SET state = $1, version = version + 1
WHERE id = $2 AND version = $3`,
[order.state, orderId, 7]);
if (r.rowCount === 0) throw new ConcurrentUpdateError(); // đọc lại và thử lại
Trade-off:
- Không chờ khóa, rất tốt khi xung đột hiếm (sửa hồ sơ, cập nhật giỏ hàng). Redis in Action nói tương tự về WATCH: client không bao giờ chờ người giữ khóa đầu tiên, mà thử lại nếu client khác nhanh hơn.5
- Khi xung đột dày đặc (1.000 người cùng mua 1 SKU), phần lớn request thất bại và retry, lãng phí. Lúc đó UPDATE có điều kiện với
stock >= 1hoặc hàng đợi theo key tốt hơn. - Cần giới hạn số lần retry và trả lỗi rõ cho người dùng.
Mẹo: MongoDB có update với điều kiện trong query và findAndModify,8 Redis có WATCH/MULTI/EXEC (Redis in Action §4.4 dùng chúng để mua bán vật phẩm trong marketplace mà không bị người khác sửa dữ liệu giữa chừng).10 DynamoDB ConditionExpression chưa có nguồn trong thư viện.11 Mọi kho dữ liệu nghiêm túc đều có dạng "ghi nếu điều kiện còn đúng". Hãy dùng nó thay vì tự đọc rồi ghi.
Giải pháp 3: Distributed lock với TTL
Có những lúc tài nguyên không nằm gọn trong một dòng database: thao tác chạm nhiều hệ thống, hoặc bạn muốn chặn trùng một tác vụ nặng (ví dụ chỉ một worker được tạo báo cáo cho user 42). Lúc đó dùng khóa phân tán.
Redis in Action §6.2 xây khóa bằng Redis: lấy khóa bằng SETNX (chỉ ghi khi key chưa tồn tại) với giá trị là một định danh ngẫu nhiên 128-bit, thử lại tới khi hết thời gian chờ, và khi nhả khóa phải kiểm tra giá trị vẫn là định danh của mình rồi mới xoá.12 Cách viết gọn bằng SET với NX và PX dưới đây là biến thể hiện đại không có trong sách (bản sách dùng SETNX kèm EXPIRE):13
const token = crypto.randomUUID();
const ok = await redis.set(`lock:seat:A12`, token, "NX", "PX", 10_000); // giữ tối đa 10 s
if (!ok) throw new Error("Đang có người xử lý");
try {
await doCriticalWork();
} finally {
// chỉ xóa nếu khóa vẫn là của mình
await redis.eval(
`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end return 0`,
1, `lock:seat:A12`, token);
}
Ba chi tiết quan trọng:
- TTL bắt buộc. Sách giải thích khóa đơn giản không xử lý trường hợp người giữ khóa bị crash mà không nhả, nên phải thêm timeout (
EXPIRE) để khóa tự hết hạn.14 Hệ thống không treo vĩnh viễn. - Token riêng. Nếu xử lý chậm quá TTL, khóa đã sang tay người khác. Redis in Action nhấn mạnh khi nhả khóa phải kiểm tra giá trị vẫn là của mình, vì giữa lúc lấy và nhả có thể có người can thiệp. Xóa không kiểm tra token sẽ xóa nhầm khóa của họ.12
- Khóa có thể hết hạn khi bạn vẫn đang chạy (GC pause, mạng chậm). Redis in Action cũng thừa nhận một khóa timeout có thể khiến nhiều tiến trình cùng nghĩ mình giữ khóa, và nêu cả trường hợp khóa hết hạn trong lúc người giữ vẫn đang xử lý.15 Vì vậy khóa phân tán chỉ là lớp giảm xung đột. Bất biến thật vẫn phải giữ ở tầng lưu trữ, bằng UPDATE có điều kiện. Fencing token (một số tăng dần gửi kèm mỗi lần ghi, storage từ chối số cũ hơn số đã thấy) chưa có nguồn trong thư viện.16
Trade-off: thêm một phụ thuộc (Redis) vào đường đi quan trọng, thêm một vòng mạng (~0,5–1 ms, ước lượng của khoá học6), và dễ tạo cảm giác an toàn giả. Dùng khi cần điều phối giữa nhiều hệ thống. Nếu mọi thứ nằm trong một database, khóa của chính database tốt hơn.
Giải pháp 4: Hàng đợi theo key và giữ chỗ có hạn
Tuần tự hóa theo key
Thay vì để 10.000 request cùng tranh một dòng, đưa mọi yêu cầu cho cùng một tài nguyên vào một hàng đợi duy nhất và để một consumer xử lý tuần tự. Microservices Patterns §3.3.5 mô tả đúng cơ chế: kênh được chia thành nhiều shard (partition), người gửi đặt shard key trong header (ví dụ orderId), broker gán message vào shard theo hash của key, và mỗi shard chỉ giao cho một receiver trong nhóm (consumer group của Kafka), nên các message cùng key được xử lý tuần tự và đúng thứ tự.17 Với Kafka, dùng eventId hoặc sku làm partition key. Grokking ch.17 cũng nêu ý "tuần tự hoá yêu cầu" ở hai đầu hàng đợi để tránh race condition mà không cần khóa.18
Consumer giữ số tồn trong bộ nhớ hoặc ghi lô xuống database, không còn tranh chấp vì chỉ có một người ghi cho mỗi key. Đổi lại, API trở thành bất đồng bộ: trả về "đang xử lý", client chờ kết quả qua polling hoặc push. Hợp với flash sale và đấu giá, nơi một key nhận lượng ghi cực lớn (nhận định của khoá học). Lưu ý tin nhắn có thể bị giao lặp: Microservices Patterns §3.3.6 khuyên viết message handler idempotent, vì khi đó message trùng không gây hại.19
Giữ chỗ có thời hạn
Đặt vé thường có hai bước: chọn ghế, rồi thanh toán trong vài phút. Nếu chỉ khóa ghế khi thanh toán xong, người dùng sẽ thanh toán rồi mới biết ghế đã mất. Nếu khóa vĩnh viễn khi chọn, người bỏ dở giữ ghế mãi.
Giải pháp là reservation có hạn:
UPDATE seats
SET status = 'HELD', held_by = 42, hold_expires_at = now() + interval '10 minutes'
WHERE id = 'A12'
AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < now()));
- Giữ chỗ chính là một UPDATE có điều kiện, nên vẫn nguyên tử.
- Ghế hết hạn được coi là trống ngay trong điều kiện, không cần chờ job dọn dẹp. Vẫn nên có job định kỳ trả các ghế hết hạn về AVAILABLE để báo cáo cho đúng.
- Thanh toán xong:
UPDATE ... SET status = 'SOLD' WHERE id = 'A12' AND held_by = 42 AND hold_expires_at > now(). Nếu thất bại vì hết hạn, hoàn tiền. - Có thể đặt khóa giữ chỗ trong Redis với TTL 10 phút để giảm tải database, nhưng trạng thái SOLD cuối cùng phải ở database.
Trạng thái HELD giống ý tưởng semantic lock trong Microservices Patterns §4.3: bản ghi mang một cờ cho biết chưa commit và có thể còn đổi (như trạng thái APPROVAL_PENDING của Order), và các thao tác khác tôn trọng cờ đó.20 Thời hạn 10 phút và cách viết điều kiện hết hạn trong SQL là thiết kế của khoá học.6
Tổng hợp
Bảng này là tổng hợp của khoá học dựa trên các nguồn ở trên.6
| Kỹ thuật | Hợp khi | Điểm yếu |
|---|---|---|
| SELECT FOR UPDATE | logic phức tạp trong một DB, xung đột vừa phải | khóa giữ lâu, deadlock, nghẽn ở dòng nóng |
| UPDATE có điều kiện | bất biến diễn tả được trong một câu SQL | khó với luật nhiều bảng |
| Version column | xung đột hiếm, sửa aggregate | retry nhiều khi xung đột dày |
| Distributed lock | điều phối giữa nhiều hệ thống | TTL hết khi đang chạy, cần fencing |
| Hàng đợi theo key | một key nhận ghi cực lớn | bất đồng bộ, thêm hạ tầng |
| Reservation có hạn | quy trình nhiều bước có người dùng tham gia | cần xử lý hết hạn và hoàn tiền |
Scale theo quy mô
Các mốc người dùng và con số trong phần này là giả định của khoá học, không phải số trong sách.6
~10k user. Một Postgres là đủ. UPDATE có điều kiện cho tồn kho và đặt chỗ, version column cho chỉnh sửa. Thêm unique constraint làm lưới an toàn cuối cùng: UNIQUE (event_id, seat_id) trên bảng vé thì dù code sai cũng không thể có hai vé cùng ghế.
~1M user. Tải đặt chỗ bình thường vẫn nằm gọn trong một primary Postgres 4 vCPU · 16 GB (vài nghìn query đơn giản có index mỗi giây, ước tính). Vấn đề là điểm nóng: một buổi concert mở bán, 200.000 người vào cùng phút. Thêm:
- Hàng chờ ảo (virtual waiting room) trước trang chọn ghế, cho vào theo nhịp mà hệ thống chịu được.
- Giữ chỗ trong Redis với
SET NX PXđể lọc phần lớn xung đột trước khi chạm database (Redis khoảng 100.000 thao tác/s mỗi node trên phần cứng gần đây theo Redis in Action).21 - Idempotency key cho mỗi lần đặt để retry không tạo đơn trùng.
50M+ user. Shard dữ liệu theo tài nguyên (event id, sku, hotel id) để mỗi shard tự giữ bất biến của nó, không cần khóa xuyên shard. Sharding chia dữ liệu ra nhiều node, mỗi node giữ một phần.22 Với SKU siêu nóng:
- Chia tồn kho thành nhiều "xô" (ví dụ 10.000 chiếc thành 20 xô × 500), mỗi xô một dòng hoặc một key. Request chọn ngẫu nhiên một xô còn hàng. Giảm tranh chấp 20 lần, đổi lại phức tạp hơn khi gần hết hàng.
- Hoặc tuần tự hóa qua Kafka partition theo sku, một consumer giữ số đếm trong bộ nhớ, ghi lô xuống DB. Một partition xử lý hàng nghìn message/s (ước tính của khoá học), quá đủ cho một SKU.
- Chấp nhận trả "hết hàng" sớm khi bộ đếm trong Redis về 0, không cần hỏi database.
Phần chia xô tồn kho và bộ đếm Redis là kỹ thuật của khoá học (chưa có sách riêng).23
Trong phỏng vấn: Nêu bất biến trước, rồi chỉ ra chỗ nó được bảo vệ. "Bất biến là mỗi ghế tối đa một vé. Mình giữ nó bằng UPDATE có điều kiện trên status và unique constraint. Redis lock chỉ để giảm tải, không phải nguồn sự thật."
Bài nào dùng pattern này
- Đặt vé sự kiện: giữ chỗ 10 phút, UPDATE có điều kiện, hàng chờ ảo khi mở bán.
- Flash sale: tồn kho chia xô, bộ đếm Redis, tuần tự hóa theo sku.
- Đấu giá online: giá đặt mới chỉ hợp lệ nếu lớn hơn giá hiện tại, UPDATE có điều kiện hoặc một consumer cho mỗi phiên đấu giá.
- Gọi xe và Giao hàng nội thành: một tài xế chỉ nhận một chuyến, khóa ngắn hạn khi gửi lời mời.
- Hệ thống thanh toán: không trừ tiền hai lần, idempotency key và version.
- Giao dịch chứng khoán: sổ lệnh một luồng ghi cho mỗi mã.
- Lập lịch tác vụ:
SKIP LOCKEDđể nhiều worker nhận job không trùng.
Phần ghép bài với kỹ thuật là gợi ý của khoá học; các kỹ thuật nền (UPDATE có điều kiện, khóa Redis, hàng đợi theo shard key, semantic lock) có nguồn ở các phần trên.2012
Đọc thêm: Redis, PostgreSQL, Quy trình nhiều bước cho phần thanh toán sau khi giữ chỗ.
Nguồn trích dẫn
-
Microservices Patterns (Chris Richardson) (file .zip, tải về giải nén) · ch.4 Managing transactions with sagas · §4.3 Handling the lack of isolation › §4.3.1 Overview of anomalies, mục "Lost updates" (tr. 128). ↩
-
Grokking Modern System Design · ch.9 Databases · bài "Types of Databases", mục "Why relational databases?" › "Concurrency". ↩
-
Microservices Patterns (file .zip, tải về giải nén) · ch.4 · §4.3 Handling the lack of isolation (đoạn về mức isolation mặc định của RDBMS, tr. 127). ↩
-
Chưa có nguồn trong thư viện: thư viện không có sách về PostgreSQL; mức isolation mặc định và cú pháp SELECT FOR UPDATE, SKIP LOCKED, deadlock khi khóa nhiều dòng là kiến thức chung, giữ lại vì cần cho bài toán. ↩ ↩2
-
Redis in Action · ch.4 Keeping data safe and ensuring performance · §4.4 Redis transactions (đoạn về WATCH là optimistic locking, khóa của RDBMS có thể xem là pessimistic). ↩ ↩2
-
Quy tắc ước lượng của khoá học, xem bài Chọn server. Không phải số liệu trích từ sách; cần load test với ứng dụng thật. ↩ ↩2 ↩3 ↩4 ↩5
-
MongoDB: The Definitive Guide 2e (file .zip, tải về giải nén) · ch.3 Creating, Updating, and Deleting Documents · mục "Updating Documents" (tr. 32). ↩
-
MongoDB: The Definitive Guide 2e (file .zip, tải về giải nén) · ch.3 · mục "Returning Updated Documents" (tr. 48–50; ví dụ cập nhật status READY thành RUNNING và lệnh findAndModify). ↩ ↩2
-
Microservices Patterns (file .zip, tải về giải nén) · ch.6 Developing business logic with event sourcing · §6.1.3 Handling concurrent updates using optimistic locking (tr. 193–194). ↩
-
Redis in Action · ch.4 · §4.4 Redis transactions › §4.4.2 Listing items in the marketplace và §4.4.3 Purchasing items. ↩
-
Chưa có nguồn trong thư viện: thư viện không có sách về DynamoDB. ↩
-
Redis in Action · ch.6 · §6.2.3 Building a lock in Redis (acquire_lock, release_lock; tr. 120–122). ↩ ↩2 ↩3
-
Chưa có nguồn trong thư viện: lệnh SET với NX và PX là cú pháp Redis mới hơn bản trong sách. ↩
-
Redis in Action · ch.6 · §6.2.5 Locks with timeouts (tr. 126). ↩
-
Redis in Action · ch.6 Application components in Redis · §6.2 Distributed locking › §6.2.2 Simple locks (các tình huống khóa hết hạn khiến nhiều tiến trình cùng giữ khóa, tr. 120). ↩
-
Chưa có nguồn trong thư viện: fencing token không được nêu trong các sách của thư viện; chỉ giữ lại ý cơ bản. ↩
-
Microservices Patterns (file .zip, tải về giải nén) · ch.3 Interprocess communication in a microservice architecture · §3.3.5 Competing receivers and message ordering (tr. 94–95). ↩
-
Grokking Modern System Design · ch.17 Distributed Messaging Queue · bài "Design considerations", mục "Managing concurrency" (đoạn "Avoiding race conditions"). ↩
-
Microservices Patterns (file .zip, tải về giải nén) · ch.3 · §3.3.6 Handling duplicate messages, mục "Writing idempotent message handlers" (tr. 95–96). ↩
-
Microservices Patterns (file .zip, tải về giải nén) · ch.4 · §4.3.2 Countermeasures for handling the lack of isolation › "Countermeasure: Semantic lock" (tr. 129–131). ↩ ↩2
-
Redis in Action · ch.6 · §6.2.2 Simple locks (tr. 120–121; Redis thực hiện 100.000 thao tác/s trên phần cứng gần đây). ↩
-
Grokking Modern System Design · ch.9 Databases · bài "Data Partitioning", mục "Why do we partition data?" và "Sharding". ↩
-
Chưa có nguồn trong thư viện: kỹ thuật chia tồn kho thành xô và hàng chờ ảo (virtual waiting room) không có trong sách của thư viện; chỉ nêu ý cơ bản. ↩