Chuỗi MongoDB · Bài 6/6. Sau bài 5 về index và explain, chúng ta chuyển từ tối ưu việc đọc sang bảo vệ tính đúng đắn khi ghi dữ liệu.
Cửa hàng chỉ còn một bình giữ nhiệt. Hai khách bấm đặt hàng gần như cùng lúc. Cả hai màn hình đều thấy “còn hàng”. Nếu ứng dụng đọc tồn kho rồi mới quyết định trừ, hai khách có thể cùng được xác nhận. Một lỗi khác khó thấy hơn: tồn kho đã giảm nhưng máy chủ gặp sự cố trước khi lưu đơn hàng.
Ngay cả khi đã khắc phục hai lỗi ấy, khách vẫn có thể bấm lại vì không nhận được phản hồi. Nếu mỗi lần gửi lại tạo một đơn mới, tồn kho tiếp tục bị trừ. Bài học này giải quyết ba vấn đề đó bằng cập nhật có điều kiện, transaction và idempotency key. Mỗi cơ chế có phạm vi riêng; bạn sẽ thực hành để thấy chúng bổ sung cho nhau ra sao.
1. Mục tiêu và điều kiện đầu vào
Bạn cần biết CRUD, bộ lọc update và index. Nên đọc bài 2 về thiết kế document và dữ liệu lịch sử trước. Sau bài này, bạn sẽ viết được thao tác giữ hàng không làm âm kho, gom việc trừ kho và tạo đơn vào một transaction, kiểm tra rollback, và xử lý yêu cầu gửi lại bằng cùng một key.
Phần đầu dùng mongosh. Phần transaction dùng Node.js 22 và MongoDB Node.js Driver 6.x, với cú pháp dành cho MongoDB Server 7.0/8.0. Hai môi trường được ghi rõ: lệnh shell không được dán vào file Node.js và ngược lại.
Bạn cần một deployment hỗ trợ transaction, chẳng hạn replica set được cấu hình đúng hoặc Atlas. Một mongod standalone không hỗ trợ multi-document transaction; bật một tùy chọn trong code không tự biến standalone thành replica set [1]. Trong mongosh, chạy db.hello(): với replica set, kiểm tra setName và primary; nếu kết nối qua mongos thì kiểm tra topology sharded cluster tương ứng.
Dữ liệu giả lập nằm trong database techdata_mongo6_lab. Ví dụ là một cửa hàng, một SKU trên mỗi đơn, giá nguyên đồng Việt Nam, chưa có giảm giá, thuế, hoàn tiền hoặc thanh toán. Trạng thái đơn là reserved, nghĩa là đã giữ hàng; không có nghĩa đã thu tiền.
2. Atomicity: một thao tác trên một document là nguyên tử
“Nguyên tử” ở đây nghĩa là một thao tác ghi trên một document được áp dụng như một đơn vị. Nếu lệnh sửa nhiều trường của cùng document, các thay đổi của lệnh ấy không bị chia thành những lần ghi riêng lẻ. Nhưng một lần đọc và một lần update là hai thao tác; chúng không tự trở thành một thao tác nguyên tử [2].
Giả sử code đọc stock = 1, kiểm tra đủ hàng trong bộ nhớ ứng dụng rồi gọi update chỉ lọc theo _id. Một yêu cầu khác có thể thay đổi stock ở giữa hai bước. Bảng dưới mô tả một lịch thực thi có thể gây bán vượt kho nếu mỗi lần trừ đều dùng $inc nhưng không có điều kiện stock:
| Thời điểm | Yêu cầu A | Yêu cầu B | stock |
|---|---|---|---|
| Ban đầu | Chưa đọc | Chưa đọc | 1 |
| 1 | Đọc được 1 | Chưa đọc | 1 |
| 2 | Đã kiểm tra đủ hàng | Đọc được 1, cũng thấy đủ | 1 |
| 3 | Trừ 1, chỉ lọc _id | Chưa trừ | 0 |
| 4 | Đã xong | Tiếp tục trừ 1, chỉ lọc _id | -1 |
Không phải $inc bị lỗi. Điều kiện nghiệp vụ “chỉ trừ khi còn đủ hàng” đã được kiểm tra ở ngoài lệnh update, nên có thể trở nên cũ. Cách sửa là đưa điều kiện ấy vào chính bộ lọc của thao tác ghi.
3. Thực hành mongosh: kiểm tra đủ hàng và trừ trong cùng một lệnh
Dùng collection riêng cho ví dụ atomicity, tách khỏi các kịch bản transaction phía sau. Chạy phần nạp sau một lần:
db = db.getSiblingDB("techdata_mongo6_lab");
db.inventory_atomic.insertOne({ _id: "P001", stock: 1 });
Mở hai cửa sổ mongosh cùng kết nối một deployment, chọn cùng database rồi chạy lệnh dưới ở mỗi cửa sổ:
db = db.getSiblingDB("techdata_mongo6_lab");
db.inventory_atomic.updateOne(
{ _id: "P001", stock: { $gte: 1 } },
{ $inc: { stock: -1 } }
);
Nếu cả hai thao tác hoàn tất bình thường và mọi lần trừ kho đều tuân theo bộ lọc này, một yêu cầu khớp với matchedCount = 1; yêu cầu còn lại nhận matchedCount = 0. Kho còn 0. MongoDB kiểm tra điều kiện của thao tác cập nhật đồng thời, nên yêu cầu thứ hai không tiếp tục trừ khi điều kiện không còn đúng [2].

Khi qty là tham số, ứng dụng phải kiểm tra đó là số nguyên dương. Nếu qty âm, biểu thức $inc: { stock: -qty } có thể làm tăng kho. Cũng cần chuẩn hóa kiểu stock và ngăn các đường ghi khác bỏ qua quy tắc này. Bộ lọc đúng không bảo vệ được code khác tự ý đặt stock sai.
Tuy nhiên, thao tác trên chưa tạo đơn hàng. Nếu cập nhật inventory rồi insert vào orders bằng một lệnh riêng, lỗi giữa hai lệnh có thể làm mất liên kết nghiệp vụ. Đây là ranh giới để cân nhắc transaction.
4. Khi nào cần multi-document transaction?
Transaction cho phép nhóm nhiều thao tác ghi trên nhiều document thành một kết quả: commit các thay đổi hoặc hủy chúng khi transaction bị abort. Trong ví dụ, tồn kho giảm và đơn reserved phải được lưu cùng nhau. Khi có lỗi trước commit, không giữ lại một nửa thay đổi [3].
| Nhu cầu | Cơ chế nên xem xét |
|---|---|
| Chỉ tăng hoặc giảm một trường của một document | Update nguyên tử, ví dụ $inc |
| Chỉ trừ khi còn đủ tồn kho trên cùng document | Update có điều kiện stock >= qty |
| Cập nhật tồn kho và lưu đơn ở hai document | Multi-document transaction |
| Gửi lại cùng một hành động đặt hàng | Key ổn định, unique constraint và logic idempotency |
| Ghi MongoDB rồi gọi dịch vụ ngoài | Thiết kế trạng thái, outbox hoặc quy trình phối hợp; không chỉ transaction DB |
Nếu dữ liệu liên quan phù hợp để nhúng trong cùng document, thiết kế đó có thể giảm nhu cầu transaction. Nhưng không nên nhét toàn bộ lịch sử đơn vào document sản phẩm chỉ để tránh transaction. Hãy quay lại câu hỏi đọc, ghi và giới hạn tăng trưởng đã học ở bài 2 [3].

5. Chuẩn bị project Node.js và dữ liệu
Tạo thư mục học tập và cài driver. Dùng deployment replica set hoặc Atlas đã cấu hình; thay giá trị URI bằng kết nối của bạn. Không lưu mật khẩu kết nối vào bài viết hoặc commit vào repository.
mkdir mongodb-transaction-lab
cd mongodb-transaction-lab
npm init -y
npm install mongodb@6
export MONGODB_URI='YOUR_REPLICA_SET_OR_ATLAS_URI'
Lệnh export ở trên dành cho Linux/macOS hoặc WSL. Tạo file checkout.mjs. Ghép ba khối Node.js ở phần 5, 6 và 7 theo đúng thứ tự vào cùng file; sau đó mới chạy các lệnh kiểm thử. Đuôi .mjs cho phép dùng import và top-level await.
import { MongoClient } from 'mongodb';
import assert from 'node:assert/strict';
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error('Thiếu biến môi trường MONGODB_URI');
const client = new MongoClient(uri, {
readPreference: 'primary',
writeConcern: { w: 'majority' }
});
const db = client.db('techdata_mongo6_lab', {
readConcern: { level: 'majority' }
});
const inventory = db.collection('inventory');
const orders = db.collection('orders');
function validate(req) {
for (const key of ['requestId', 'customerId', 'sku']) {
if (typeof req[key] !== 'string' ||
req[key].trim() === '' || req[key].length > 100) {
throw new Error('INVALID_REQUEST');
}
}
if (!Number.isSafeInteger(req.qty) || req.qty < 1 || req.qty > 1000) {
throw new Error('INVALID_QUANTITY');
}
}
function sameRequest(order, req) {
if (order.customerId !== req.customerId ||
order.sku !== req.sku || order.qty !== req.qty) {
throw new Error('IDEMPOTENCY_KEY_CONFLICT');
}
return order;
}
validate() giới hạn số lượng mua và kiểm tra các định danh. sameRequest() so nội dung cốt lõi khi key đã tồn tại. Trong ứng dụng HTTP thật, customerId phải lấy từ người dùng đã xác thực, không tin trực tiếp giá trị khách tự gửi; key cần được quy ước theo phạm vi cửa hàng hoặc tài khoản phù hợp.
Client dùng primary và write concern majority. Database dùng read concern majority cho các lần đọc ngoài transaction trong chương trình. Cấu hình transaction sẽ được khai báo riêng. Write concern là mức xác nhận ghi, không phải cơ chế thay thế atomicity [4].
6. Viết transaction: giữ kho và tạo đơn reserved
Dán tiếp khối thứ hai vào checkout.mjs. Chúng ta dùng requestId làm _id của đơn trong bộ mẫu một cửa hàng. _id có unique index mặc định, nên hai đơn không thể cùng tồn tại với một requestId. Kiểm tra “đã có chưa” giúp xử lý gửi lại bình thường; unique constraint vẫn cần thiết khi hai yêu cầu cạnh tranh [5].
async function placeOrder(req, { failBeforeCommit = false } = {}) {
validate(req);
const session = client.startSession();
try {
return await session.withTransaction(async () => {
const existing = await orders.findOne(
{ _id: req.requestId }, { session }
);
if (existing) return sameRequest(existing, req);
const product = await inventory.findOneAndUpdate(
{ _id: req.sku, stock: { $gte: req.qty } },
{ $inc: { stock: -req.qty } },
{ session, returnDocument: 'after', includeResultMetadata: false }
);
if (!product) throw new Error('OUT_OF_STOCK');
const total = product.unitPrice * req.qty;
if (!Number.isSafeInteger(product.unitPrice) ||
product.unitPrice < 0 || !Number.isSafeInteger(total)) {
throw new Error('INVALID_PRICE');
}
const order = {
_id: req.requestId,
customerId: req.customerId,
sku: req.sku,
qty: req.qty,
unitPrice: product.unitPrice,
total,
status: 'reserved',
createdAt: new Date()
};
await orders.insertOne(order, { session });
if (failBeforeCommit) throw new Error('DEMO_FAILURE');
return order;
}, {
readPreference: 'primary',
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' }
});
} catch (error) {
// Hai request cùng key có thể đua nhau tới insertOne.
// Ngoài transaction, đọc lại đơn đã commit nếu gặp duplicate key.
if (error.code === 11000) {
const existing = await orders.findOne({ _id: req.requestId });
if (existing) return sameRequest(existing, req);
}
throw error;
} finally {
await session.endSession();
}
}
6.1. Đọc luồng xử lý theo ý nghĩa nghiệp vụ
Trước tiên, hàm kiểm tra yêu cầu hợp lệ và tạo session riêng. Trong callback, nó tìm đơn cùng requestId. Nếu đã có và nội dung khớp, hàm trả đơn cũ. Nếu chưa có, findOneAndUpdate chỉ giảm kho khi đủ số lượng, đồng thời lấy document sau update để chốt unitPrice. includeResultMetadata: false làm driver trả document hoặc null; vì vậy đoạn code kiểm tra product trực tiếp, không dùng product.value [6].
Giá lấy từ dữ liệu phía server, không nhận giá do người mua tự gửi. Thành tiền được tính bằng số nguyên đồng với kiểm tra miền số an toàn; nếu dữ liệu giá không hợp lệ, lỗi làm transaction abort. Đơn lưu snapshot giá để những lần đổi giá sau này không sửa lịch sử đơn.
Mỗi thao tác trong transaction đều truyền { session } và được await theo thứ tự. withTransaction trả kết quả callback sau khi xử lý commit; finally kết thúc session. Cờ failBeforeCommit chỉ để cố tình gây lỗi trong bài lab, không phải tham số nên cho khách hàng tùy chỉnh [7].
6.2. Vì sao vẫn cần điều kiện stock khi đã có transaction?
Transaction không tự biết quy tắc “stock không được âm”. Bộ lọc stock >= qty vẫn diễn đạt điều kiện nghiệp vụ. Khi hai transaction cùng sửa một sản phẩm, có thể phát sinh write conflict; cơ chế retry của API callback xử lý các lỗi được gắn nhãn phù hợp. Lần thử sau phải đọc lại và kiểm tra điều kiện, thay vì dùng kết quả kiểm tra cũ.
readConcern snapshot giúp các lần đọc trong transaction có một ảnh dữ liệu nhất quán theo cấu hình. Nó không biến mọi quy tắc nhiều document thành serializable một cách tự động. Nếu bạn kiểm tra một quy tắc tổng hợp trên nhiều document nhưng không có điểm ghi chung để điều phối, cần thiết kế thêm cách bảo vệ quy tắc ấy [8].
7. Thực hành commit, rollback và hai yêu cầu cạnh tranh
Dán khối thứ ba để hoàn thành checkout.mjs. Hàm check so tồn kho và số đơn với kết quả mong đợi. Mỗi sản phẩm thử nghiệm có mã riêng nên các kịch bản không làm lẫn dữ liệu của nhau.
async function check(sku, expectedStock, expectedOrders) {
const product = await inventory.findOne({ _id: sku });
const count = await orders.countDocuments({ sku });
assert.equal(product.stock, expectedStock);
assert.equal(count, expectedOrders);
console.log({ sku, stock: product.stock, orders: count });
}
async function runLab(mode) {
if (mode === 'seed') {
await inventory.insertMany([
{ _id: 'P-SUCCESS', stock: 2, unitPrice: 100000 },
{ _id: 'P-ROLLBACK', stock: 2, unitPrice: 100000 },
{ _id: 'P-RACE', stock: 1, unitPrice: 100000 },
{ _id: 'P-DUP', stock: 2, unitPrice: 100000 }
]);
await orders.createIndex(
{ customerId: 1, createdAt: -1 }, { name: 'customer_recent' }
);
console.log('Đã nạp dữ liệu lab');
} else if (mode === 'success') {
const req = {
requestId: 'REQ-SUCCESS', customerId: 'C01', sku: 'P-SUCCESS', qty: 1
};
const first = await placeOrder(req);
const replay = await placeOrder(req);
assert.equal(first._id, replay._id);
await assert.rejects(
placeOrder({ ...req, qty: 2 }), /IDEMPOTENCY_KEY_CONFLICT/
);
await check('P-SUCCESS', 1, 1);
} else if (mode === 'rollback') {
await assert.rejects(placeOrder({
requestId: 'REQ-ROLLBACK', customerId: 'C01', sku: 'P-ROLLBACK', qty: 1
}, { failBeforeCommit: true }), /DEMO_FAILURE/);
await check('P-ROLLBACK', 2, 0);
} else if (mode === 'race') {
const results = await Promise.allSettled([
placeOrder({ requestId: 'REQ-A', customerId: 'C01', sku: 'P-RACE', qty: 1 }),
placeOrder({ requestId: 'REQ-B', customerId: 'C02', sku: 'P-RACE', qty: 1 })
]);
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
const failed = results.find(r => r.status === 'rejected');
assert.equal(failed.reason.message, 'OUT_OF_STOCK');
await check('P-RACE', 0, 1);
} else if (mode === 'duplicate') {
const req = {
requestId: 'REQ-DUP', customerId: 'C01', sku: 'P-DUP', qty: 1
};
const [a, b] = await Promise.all([placeOrder(req), placeOrder(req)]);
assert.equal(a._id, b._id);
await check('P-DUP', 1, 1);
} else if (mode === 'inspect') {
console.log(await inventory.find().sort({ _id: 1 }).toArray());
console.log(await orders.find().sort({ _id: 1 }).toArray());
} else {
throw new Error('Chọn seed, success, rollback, race, duplicate hoặc inspect');
}
}
try {
await client.connect();
await runLab(process.argv[2] ?? 'inspect');
} finally {
await client.close();
}
Nạp dữ liệu một lần rồi chạy các trường hợp dưới đây. Seed lặp lại trên cùng dữ liệu sẽ báo duplicate key; dùng database lab mới hoặc chủ động chuẩn bị lại môi trường học. Chương trình không có lệnh xóa dữ liệu tự động.
node --check checkout.mjs
node checkout.mjs seed
node checkout.mjs success
node checkout.mjs rollback
node checkout.mjs race
node checkout.mjs duplicate
node checkout.mjs inspect
| Kịch bản | Kho ban đầu | Kho sau kiểm tra | Số đơn | Ý nghĩa |
|---|---|---|---|---|
| success | 2 | 1 | 1 | Gửi lại cùng key trả cùng đơn; đổi qty trên key cũ bị từ chối |
| rollback | 2 | 2 | 0 | Lỗi sau insert nhưng trước commit hủy cả trừ kho và tạo đơn |
| race | 1 | 0 | 1 | Hai requestId khác nhau tranh một sản phẩm; một bên hết hàng |
| duplicate | 2 | 1 | 1 | Hai lời gọi cùng key có cùng đơn kết quả, chỉ giữ kho một lần |
Kết quả trên là điều kiện kiểm tra của bài lab khi deployment hoạt động bình thường. Ở kịch bản race, không bảo đảm C01 hay C02 thắng; chỉ kiểm tra số đơn thành công và bất biến tồn kho. Mỗi lần gọi placeOrder tạo session riêng, vì vậy Promise.all ở bên ngoài chạy hai transaction độc lập; không phải chạy song song nhiều thao tác trong cùng transaction.
Nếu mạng hoặc primary gặp sự cố kéo dài, chương trình có thể kết thúc bằng lỗi thay vì đạt bảng kết quả. Khi đó cần phân loại lỗi và kiểm tra trạng thái yêu cầu; không sửa assertion để che lỗi. Các phép thử này không thay thế thử nghiệm failover, timeout và tải đồng thời trên môi trường tương tự production.
8. Idempotency: cùng một hành động gửi lại không tạo thêm đơn
Idempotency trong bài có nghĩa là nhiều lần gọi với cùng requestId và cùng nội dung đặt hàng sẽ nhận lại cùng đơn đã lưu, thay vì tiếp tục trừ kho. Client cần tạo key một lần cho hành động đó và giữ nguyên khi retry. Nếu mỗi lần bấm gửi lại tạo một key mới, hệ thống nhìn thấy các yêu cầu mới.

Trong đường đi bình thường, orders.findOne tìm thấy đơn cũ ngay đầu transaction. Trong một cuộc đua, hai transaction có thể cùng chưa thấy đơn; unique _id ngăn lưu hai đơn cùng key. Nếu nhận duplicate key, code ra ngoài transaction đã abort và đọc lại đơn đã commit, rồi so customerId, SKU và qty. Nếu chưa đọc thấy đơn, nó ném lỗi ra cho lớp gọi xử lý, không giả định thành công.
Nhánh xử lý mã 11000 phù hợp bộ mẫu có unique _id trên orders và không có thao tác insert nào khác trong transaction. Khi thêm các unique index hoặc collection khác, phải phân biệt lỗi phát sinh từ ràng buộc nào; không biến mọi duplicate key thành “đặt hàng thành công”.
Với cùng key nhưng qty hoặc khách khác, code báo IDEMPOTENCY_KEY_CONFLICT. Nếu giá sản phẩm thay đổi sau lần đầu, gửi lại cùng yêu cầu vẫn trả snapshot của đơn cũ. Trong mô hình nhiều cửa hàng, cần xác định phạm vi key và kiểm tra quyền truy cập trước khi trả dữ liệu của đơn.
Để giữ tính idempotent theo thiết kế này, không tùy tiện xóa document đơn hoặc bản ghi key khi client còn có thể gửi lại. Nếu có chính sách lưu key hữu hạn, cần công bố cửa sổ retry và hành vi khi key hết hiệu lực. Unique index một mình cũng không làm quy trình bên ngoài MongoDB thành exactly-once.
9. Retry: lỗi tạm thời khác với “không biết commit đã xong chưa”
| Tình huống | Hướng xử lý |
|---|---|
| OUT_OF_STOCK hoặc yêu cầu không hợp lệ | Lỗi nghiệp vụ; không retry mù quáng cùng điều kiện |
| TransientTransactionError | Callback API có logic thử lại transaction theo quy tắc driver |
| UnknownTransactionCommitResult | Callback API có logic thử lại commit; không kết luận ngay là rollback |
| Driver vẫn trả lỗi sau xử lý | Giữ requestId; đối chiếu trạng thái và xử lý retry có giới hạn ở lớp ứng dụng |
API callback đã có logic xử lý các nhãn lỗi transaction phổ biến. Nếu tự dùng Core API với startTransaction và commitTransaction, bạn phải chủ động xây logic tương ứng. Lỗi tạm thời có thể khiến toàn bộ callback chạy lại; lỗi chưa rõ kết quả commit cần xử lý ở bước commit, không đồng nghĩa chạy lại mọi tác dụng bên ngoài [9].
Vì callback có thể chạy lại, không gửi email, trừ tiền hoặc phát sự kiện trực tiếp trong callback. Cũng không nuốt lỗi thao tác rồi return như đã thành công. Trong ví dụ, lỗi nghiệp vụ được ném ra; nhánh duplicate key chỉ xử lý sau khi transaction đã rời callback và được abort.
retryWrites không phải công tắc thay thế transaction retry. Các lệnh ghi nằm trong transaction không được retry riêng như các retryable write độc lập. Hãy để API transaction điều phối đúng phạm vi, đồng thời giữ logic idempotency ở mức yêu cầu nghiệp vụ [10].
Nếu phản hồi commit bị mất, việc “không nhận được thành công” không chứng minh giao dịch đã thất bại. Đừng lập tức cộng trả stock hoặc tạo đơn bằng một requestId mới. Hãy tra trạng thái bằng key cũ; nếu vẫn chưa xác định được, trả trạng thái chờ xử lý hoặc lỗi có thể retry theo hợp đồng API, rồi đối soát tiếp.
10. Vì sao không gọi cổng thanh toán trong transaction?
Giả sử callback đã gọi cổng thanh toán thành công, sau đó MongoDB báo lỗi tạm thời và thử lại callback. Lần gọi ngoài có thể xảy ra lần nữa. Ngược lại, nếu database rollback thì một giao dịch tiền đã thực hiện ở hệ thống khác không tự rollback theo.
Một hướng thiết kế là lưu đơn reserved và bản ghi outbox trong cùng transaction. Worker đọc outbox sau commit, gọi dịch vụ ngoài bằng một eventId hoặc idempotency key ổn định, rồi cập nhật trạng thái theo kết quả. Worker vẫn có thể xử lý lại sự kiện, nên phía nhận hoặc logic ứng dụng phải chống trùng. Đây là hướng mở rộng kiến trúc, chưa được triển khai trong chương trình lab.
Việc hủy giữ hàng cũng cần bảo vệ riêng: chỉ chuyển một đơn reserved sang cancelled và hoàn kho một lần. Không cộng kho chỉ vì client timeout, vì có thể đơn thực tế đã được tạo. Hãy thiết kế chuyển trạng thái có điều kiện, ranh giới transaction và đối soát theo từng bước nghiệp vụ.
11. Lỗi thường gặp, giới hạn và đánh đổi
| Dấu hiệu | Nguyên nhân và cách kiểm tra |
|---|---|
| Báo transaction chỉ hỗ trợ trên replica set hoặc mongos | Kiểm tra deployment và URI; standalone không đủ |
| Kho giảm dù đơn không được lưu | Tìm thao tác bỏ quên session hoặc nằm ngoài transaction |
| product là null nhưng code vẫn đọc thuộc tính | Bộ lọc không khớp; xử lý hết hàng hoặc SKU không tồn tại |
| Đọc product.value bị undefined | Kiểm tra includeResultMetadata và phiên bản driver |
| Callback gây tác dụng ngoài lặp lại | Đưa tác dụng ngoài khỏi callback; dùng cơ chế xử lý sự kiện phù hợp |
| Hai thao tác trong cùng session chạy song song bị lỗi | Await tuần tự; không Promise.all trong một transaction |
| Kho âm dù đã có transaction | Kiểm tra qty, bộ lọc cập nhật và các đường ghi khác |
Giữ transaction ngắn, dùng index cho các đường đọc và cập nhật quan trọng. Ví dụ dùng _id cho tìm sản phẩm và tìm đơn theo key; index customer_recent được tạo ngoài transaction để phục vụ tra đơn. Với đơn nhiều SKU, hãy kiểm tra thứ tự cập nhật nhất quán và xử lý tuần tự, thay vì chờ gọi mạng bên ngoài trong lúc transaction đang mở.
Transaction dùng tài nguyên và có thể gặp giới hạn thời gian, áp lực cache hoặc write conflict. Giới hạn thời gian mặc định của server thường là dưới một phút, tùy cấu hình; không dùng transaction mở lâu để chờ người dùng xác nhận thanh toán. Việc tăng giới hạn không chữa được thiết kế giao dịch quá dài [1].
writeConcern majority và readConcern snapshot phục vụ các mục tiêu xác nhận và nhất quán khác nhau. Snapshot không thay thế bộ lọc stock, unique key không thay thế transaction, và transaction không bao trùm cổng thanh toán. Khi đọc nhiều document ở ngoài transaction bằng nhiều lệnh riêng, bạn cũng không tự có cùng một snapshot cho toàn bộ các lệnh đọc.
12. Bài tập tự luyện: chứng minh bằng bất biến nghiệp vụ
Bài 1: thêm một SKU vào đơn
Mở rộng đơn thành mảng items có hai SKU. Yêu cầu: nếu sản phẩm thứ hai hết hàng, sản phẩm thứ nhất không bị giữ kho và đơn không tồn tại. Gợi ý: cập nhật từng SKU trong cùng session, ném lỗi khi một kết quả không khớp, rồi kiểm tra lại cả hai tồn kho sau rollback. Đừng dùng Promise.all cho các thao tác trong cùng transaction.
Bài 2: đặt cùng key nhưng đổi số lượng
Chạy success rồi thử lại REQ-SUCCESS với qty = 2. Đáp án: IDEMPOTENCY_KEY_CONFLICT, tồn kho P-SUCCESS vẫn là 1 và vẫn chỉ có một đơn. Nếu bạn quyết định một yêu cầu thay đổi số lượng là hành động mới, nó cần một key mới và đi qua quy trình đặt hàng mới.
Bài 3: hết hàng hoặc SKU không tồn tại
Dùng một requestId mới với SKU không có trong inventory. Đáp án theo chương trình mẫu là OUT_OF_STOCK, không tạo đơn. Đây là lựa chọn đơn giản để tập trung vào transaction; trong API thực tế có thể phân biệt NOT_FOUND và OUT_OF_STOCK theo yêu cầu sản phẩm, nhưng không được tiếp tục insert đơn sau khi giữ hàng thất bại.
Bài 4: thử nhiều yêu cầu trên một sản phẩm tồn kho 3
Chuẩn bị một SKU riêng với stock = 3 và năm requestId khác nhau, mỗi yêu cầu qty = 1. Nếu deployment khỏe và các request hoàn tất sau retry phù hợp, kỳ vọng ba đơn reserved, hai lỗi hết hàng và stock = 0. Gửi lại một key đã thành công không được làm stock giảm tiếp. Kiểm tra tổng qty của đơn thành công bằng số hàng thực sự đã giữ.
13. Tổng kết chuỗi MongoDB: từ document đến xử lý cạnh tranh
Sáu bài đã đi qua CRUD; thiết kế embedding và references; truy vấn document lồng nhau và mảng; aggregation; index và explain; cuối cùng là atomicity, transaction và retry. Chúng kết nối thành một quy trình: mô hình đúng, truy vấn đúng, đo hiệu năng và bảo vệ các thay đổi nghiệp vụ khi có nhiều yêu cầu.
Với bài này, hãy nhớ ba bất biến: tồn kho không âm; đơn và thay đổi kho thuộc cùng transaction; một key hợp lệ chỉ đại diện cho một hành động đặt hàng. Đừng chỉ nhìn dòng log “success”; hãy kiểm tra trạng thái database sau commit, rollback, gửi lại và cạnh tranh.
Bước thực hành tiếp theo phù hợp là một project cửa hàng có catalog, inventory, orders và báo cáo doanh thu, rồi bổ sung outbox và quy trình thanh toán. Chỉ mở rộng sau khi bạn có thể giải thích vì sao từng kịch bản trong lab không làm sai tồn kho hoặc tạo trùng đơn.
14. Tài liệu tham khảo
Đối chiếu tài liệu chính thức ngày 12/11/2026. Các số liệu và tình huống trong bài là dữ liệu giảng dạy tự xây dựng. Khi triển khai, chọn tài liệu đúng phiên bản server và driver; kết quả retry còn phụ thuộc loại lỗi và trạng thái deployment.
- Production Considerations for Transactions
- Atomicity and Transactions
- Transactions trong MongoDB Server
- Write Concern
- Unique Indexes
- Node.js Driver 6.x: Compound Operations
- Node.js Driver 6.x: Transactions
- Read Concern snapshot
- Handle Transactions in Applications
- Retryable Writes
Học cùng TechData.AI: Bạn muốn thực hành MongoDB trong project Data Engineering có hướng dẫn, bài tập và review? Xem chương trình đào tạo Data & AI để chọn lộ trình phù hợp.
