ข้ามไปยังเนื้อหา

Multi-document transactions

บางครั้ง operation ครั้งเดียวจำเป็นต้องเปลี่ยนแปลงหลาย document จริง ๆ และต้องสำเร็จทั้งหมดหรือไม่สำเร็จเลยสักอย่าง กรณีในตำราเรียนคือการย้ายเงิน: หักจากบัญชีหนึ่งและเพิ่มให้อีกบัญชีหนึ่ง ถ้าการหักลงแต่การเพิ่มล้มเหลว เงินก็หายไป เมื่อ document ที่เกี่ยวข้องยุบรวมเป็นก้อนเดียวไม่ได้ เพราะเป็นคนละบัญชีหรืออยู่คนละ collection MongoDB ก็มี multi-document transaction ของจริงให้ใช้ พร้อมการรับประกัน ACID เต็มรูปแบบครอบทุก operation ข้างใน คือ commit พร้อมกันทั้งหมด หรือไม่ก็ roll back พร้อมกันทั้งหมด

ราคาของการรับประกันนั้นคือ transaction ต้องใช้ session เพื่อรัน ต้องมี replica set (standalone server รันไม่ได้) และมีต้นทุนสูงกว่าการเขียนธรรมดา ดังนั้นนี่จึงเป็นเครื่องมือที่ใช้อย่างจงใจ ไม่ใช่ค่าเริ่มต้น

ขั้นตอนเป็นแบบเดิมเสมอ คุณเริ่ม session จากนั้นรัน operation ของคุณภายใน transaction ที่ผูกกับ session นั้น helper ของ driver ชื่อ withTransaction ห่อทุกอย่างให้: เริ่ม transaction รัน callback ของคุณ แล้ว commit ถ้า callback จบโดยไม่มี error หรือ abort (roll back ทั้งหมด) ถ้ามีการ throw นอกจากนี้ยัง retry commit ให้อัตโนมัติเมื่อเจอ transient error บางประเภท เพราะแบบนี้จึงควรใช้ helper ตัวนี้แทนการ start และ commit เอง

flowchart TD
  S["startSession"] --> B["Begin transaction"]
  B --> O1["Debit the source account"]
  O1 --> O2["Credit the destination account"]
  O2 --> C{"Both succeeded?"}
  C -->|"Yes"| COMMIT["Commit — both changes become visible together"]
  C -->|"No"| ABORT["Abort — every change is rolled back"]
transaction เริ่มใน session รัน operation ทั้งชุด แล้ว commit การเปลี่ยนแปลงพร้อมกัน หรือไม่ก็ abort แล้ว roll back กลับทั้งหมด

ตรงนี้เราย้ายค่าสามหน่วยจาก Ada ไปยัง Linus ข้ามสอง document ใน collection accounts ทุก operation ต้องส่ง session เข้าไปด้วย เพราะนั่นคือสิ่งที่ผูก operation เข้ากับ transaction ถ้าการอัปเดตใดล้มเหลว — สมมติว่าต้นทางมียอดคงเหลือไม่พอแล้วเรา throw — การโอนทั้งหมดจะถูก roll back และยอดคงเหลือทั้งสองไม่เปลี่ยนแปลง:

const session = db.getMongo().startSession();
session.withTransaction(() => {
const accounts = session.getDatabase("library").accounts;
accounts.updateOne({ name: "Ada" }, { $inc: { balance: -3 } });
accounts.updateOne({ name: "Linus" }, { $inc: { balance: 3 } });
});
session.endSession();

ก่อนการโอน บัญชีทั้งสองมีสภาพดังนี้:

[
{ "_id": 1, "name": "Ada", "balance": 10 },
{ "_id": 2, "name": "Linus", "balance": 4 }
]

หลังจาก commit สำเร็จ ค่าสามหน่วยได้ย้ายไปแล้วและยอดรวมยังคงเดิม ไม่มี reader คนใดเคยเห็นช่วงเวลาที่ค่าสามหน่วยของ Ada ออกไปจากเธอแล้วแต่ยังมาไม่ถึง Linus:

[
{ "_id": 1, "name": "Ada", "balance": 7 },
{ "_id": 2, "name": "Linus", "balance": 7 }
]

ใน Compass: Compass สั่ง transaction จาก document editor ไม่ได้ แต่คุณสามารถเปิดแท็บ mongosh ที่ฝังอยู่ด้านล่างของหน้าต่าง แล้วรันสคริปต์ที่อิง session ตรงนั้นกับ connection เดียวกันได้

  • transaction ต้องใช้ replica set mongod แบบ standalone ตัวเดียวรันไม่ได้ การตั้งค่า Docker บนเครื่องในคอร์สนี้รัน replica set แบบหนึ่ง node อยู่แล้ว เพื่อให้คุณลองตัวอย่างเหล่านี้ได้
  • ทำให้ transaction สั้น transaction จองทรัพยากรไว้ตลอดอายุ และมีลิมิตเวลาตั้งไว้เป็นค่า default ให้เก็บเฉพาะการเขียนที่ต้อง atomic ไว้ข้างใน ส่วนงานช้า ๆ อย่างการเรียกผ่านเครือข่ายหรือคำนวณหนัก ให้ย้ายออกไปทำข้างนอก
  • มีต้นทุนด้านประสิทธิภาพจริง transaction ช้ากว่าการเขียน single-document ที่ให้ผลเท่ากัน แถม abort ได้เมื่อเจอ write conflict แล้วต้อง retry ใช้เฉพาะตอนที่ความถูกต้องบังคับ ไม่ใช่ใช้เพื่อความสบายใจ
  • ส่ง session ผ่านไปทุกที่ operation ภายใน callback ที่ลืมส่ง session จะรัน นอก transaction และจะไม่ถูก roll back
ตัวเลือกBenefitCost
ใช้ multi-document transactionACID เต็มรูปแบบข้าม document และ collection รับประกันว่า commit หรือ abort พร้อมกันทั้งหมดต้อง lock resource ไว้ตลอดอายุ transaction เพิ่ม latency และกิน WiredTiger cache มากขึ้นเพราะต้องเก็บ snapshot ไว้จนกว่าจะ commit
หลีกเลี่ยง transaction ด้วยการออกแบบ document ใหม่เร็วกว่า ไม่มี lock ยืดยาว ไม่กิน cache เพิ่มต้องยอมรับข้อจำกัดของโมเดลข้อมูล เช่น duplication หรือ eventual consistency ระหว่าง collection
  • ไม่ handle transient transaction error ด้วยการ retry เอง — เชื่อว่า withTransaction จัดการ retry ให้ทุกกรณี แต่ถ้าเขียน transaction แบบ manual โดยไม่ดักจับ error label อย่าง TransientTransactionError หรือ UnknownTransactionCommitResult แล้วลองใหม่ transaction จะล้มเหลวเงียบ ๆ ภายใต้ write conflict ปกติที่ควรลองใหม่ได้สำเร็จ
  • ปล่อยให้ transaction รันนานเกินขีดจำกัดเวลาเริ่มต้น (60 วินาที) — ใส่ logic ที่ต้องเรียก API ภายนอกหรือคำนวณหนักไว้ในตัว transaction ทำให้ transaction ค้างนาน ชน transactionLifetimeLimitSeconds แล้วโดน abort อัตโนมัติ ทั้งที่งานหนักนั้นควรทำให้เสร็จก่อนแล้วค่อยเปิด transaction สั้น ๆ
  • คว้า transaction ก่อนตรวจว่า embedding แก้ปัญหาได้อยู่แล้วหรือไม่ — เห็น operation ที่แตะสอง document ก็เปิด transaction ทันที ทั้งที่ข้อมูลสองก้อนนั้น redesign ให้อยู่ document เดียวได้ ทำให้เสียต้นทุนเรื่อง lock และ latency ไปฟรี ๆ

💡 ตัวอย่างจากของจริง

ธนาคาร/fintech ทั่วไป — ใช้ multi-document transaction สำหรับการโอนเงินระหว่างบัญชี หัก balance จากบัญชีต้นทางและเพิ่มให้บัญชีปลายทางต้อง commit พร้อมกันเสมอ เพื่อไม่ให้เงิน “หาย” หรือ “งอก” ระหว่างทาง

Coinbase — ใช้ transaction ที่ผูกกับ session เพื่อรักษาความถูกต้องของยอดเงินดิจิทัลข้าม ledger document หลายฉบับ เพราะข้อผิดพลาดด้าน consistency ในระบบการเงินมีต้นทุนสูงกว่าการแลก latency ที่เพิ่มขึ้นจาก transaction มาก

อะไรผูก operation เข้ากับ multi-document transaction?
withTransaction ทำอะไรเมื่อ callback throw?
ต้องใช้การ deploy แบบใดในการรัน transaction?
คำแนะนำที่ดีสำหรับเนื้อตัวของ transaction คืออะไร?