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

transaction สัญญา​อะไร — และ​ไม่​ได้​สัญญา​อะไร

ระบบ​ที่​มี​ผู้​ใช้​คน​เดียว​ไม่​ต้องการ​อะไร​ใน​คอร์ส​นี้​เลย​สัก​บรรทัด

อ่าน​ค่า คิด เขียน​กลับ ไม่มี​ใคร​มา​แทรก​กลาง​ทาง ผล​ที่​ได้​คือ​ผล​ที่​ควร​ได้​เสมอ

ปัญหา​ทั้งหมด​เริ่ม​ตอน​มี​คน​ที่​สอง และ​สิ่ง​ที่​ทำให้​มัน​ร้ายก​ว่าที่​ควร​คือ มัน​ไม่​ส่งเสียง ยอด​ที่​ผิด​ไม่​ได้​โยน error ออก​มา มัน​แค่​ผิด แล้ว​ระบบ​ก็​เดิน​ต่อ​ไป​บน​ตัวเลข​นั้น

📦 code ของ​บท​นี้

ทั้ง​คอร์ส​เขียน​ด้วย Rust standard library ล้วน ไม่มี external crate สัก​ตัว ตรวจ​ได้​จาก scripts/transaction-engine/Cargo.toml ที่​ไม่มี​หัวข้อ [dependencies] อยู่​เลย

ทุก​บล็อก code ใน​บทเรียน​นี้​ถูก​คัด​มา​จาก scripts/transaction-engine/src/ ทีละ byte และ​ผล​รัน​ทุก​บรรทัด​คัด​มา​จาก expected/ ที่​รัน​จริง ถ้า​แก้​ใน​บทเรียน​อย่าง​เดียว​โดย​ไม่​แก้​ที่​ซอร์ส เทสต์จะ​แดง​ทันที

คำ​สัญญา​สอง​ข้อ​ที่​คน​มัก​รวม​เป็น​ก้อน​เดียว

หัวข้อ​ที่​มีชื่อ​ว่า “คำ​สัญญา​สอง​ข้อ​ที่​คน​มัก​รวม​เป็น​ก้อน​เดียว”

หัวข้อ​นี้​แยก atomicity กับ isolation ออก​จาก​กัน และ​แสดง​ว่า​ระบบ​ที่​มี atomicity ครบ​ยัง​ตอบ​ผิด​ได้

TransactionTransactionกลุ่ม​คำ​สั่ง​ที่​ฐาน​ข้อมูล​ปฏิบัติ​ต่อ​มัน​เหมือน​เป็น​คำ​สั่ง​เดียว — สำเร็จ​ทั้ง​ก้อน​หรือ​ไม่​เกิด​อะไร​ขึ้น​เลย ไม่มี​สภาพครึ่งๆ กลางๆ ให้​ใคร​เห็น มัก​ถูก​อธิบาย​ด้วย​ประโยค​เดียว​ว่า “สำเร็จ​ทั้งหมด​หรือ​ไม่​เกิด​อะไร​เลย” ประโยค​นั้น​ถูก แต่​มัน​ตอบ​แค่​ครึ่ง​เดียว​ของ​เรื่อง

AtomicityAtomicityคำ​สัญญา​ว่า transaction จะ commit ทั้ง​ก้อน​หรือ rollback ทั้ง​ก้อน เป็น​คนละ​เรื่อง​กับ isolation ซึ่ง​ตอบ​ว่า​ระหว่าง​ที่​มัน​ยัง​ไม่​จบ คน​อื่น​เห็น​อะไร​บ้าง ตอบ​คำถาม​เกี่ยว​กับ ตัว​ฉัน​เอง — งาน​ของ​ฉัน​จะ​ลง​ครบ​ทุก​ชิ้น​หรือ​ไม่​ลง​เลย มัน​ไม่​ได้​พูด​ถึง​คน​อื่น​สัก​คำ

คำถาม​ที่​เหลือ​คือ ระหว่าง​ที่​งาน​ของ​ฉัน​ยัง​ไม่​จบ คน​อื่น​เห็น​อะไร​บ้าง และ​คำถาม​นั้น atomicity ไม่​ตอบ​ให้​เลย

นั่น​คือ​งาน​ของ isolation และ​มัน​เป็น​คนละ​กลไก​กัน​คนละ​ตัว ระบบ​ที่​มี atomicity ครบถ้วน ยัง​ให้​คำ​ตอบ​ผิด​ได้​ทุก​วัน ถ้า isolation ของ​มัน​หลวม

หัวข้อ​นี้​แสดง​ว่าการ​แก้​ค่า​ใน​เอนจิน​นี้​คือ​การ​ต่อ​ของ​ใหม่ ไม่ใช่​การ​ทับ​ของ​เก่า

ถ้า​เขียน​ทับ​ค่า​เดิมทิ้งไปตรงๆ transaction ที่​เปิด​ค้าง​อยู่​จะ​ไม่มี​ทาง​อ่าน​ภาพ​เดิม​ได้​อีก เพราะ​ภาพ​เดิม​หาย​ไป​แล้วจริงๆ

เอนจิน​นี้​จึง​ไม่​เขียน​ทับ​สัก​ครั้ง มัน​เก็บ​ทุก​ค่าที่​เคย​มี​ไว้​ทั้งหมด แล้ว​ติด​ป้าย​กำกับ​ว่า ใคร​สร้าง​และ​ใคร​ปิด

นี่​ไม่ใช่​ทาง​เดียว​ที่​ทำได้ Oracle กับ InnoDB เขียน​ทับที่​เดิม​แล้ว​ประกอบ​ภาพ​เก่า​คืน​จาก undo log แทน คอร์ส​นี้​เลือก​ทาง​ที่​ต่อ​ของ​ใหม่ เพราะ​ทุก​อย่าง​ที่​เกิด​ขึ้น​เห็น​ได้​ด้วย​ตา

src/store.rs · หนึ่ง version ของ​หนึ่ง​คีย์
/// หนึ่ง version ของหนึ่งคีย์
///
/// `begin` คือ transaction ที่สร้างมันขึ้นมา ส่วน `end` คือ transaction ที่ปิดมัน
/// `end == None` แปลว่า version นี้ยังเป็นตัวล่าสุดอยู่
#[derive(Debug, Clone)]
pub struct Version {
pub begin: Xid,
pub end: Option<Xid>,
pub value: Value,
}

สอง​ฟิลด์นี้​พอ​สำหรับ​ทุก​อย่าง​ที่​เหลือ​ใน​คอร์ส การ​แก้​ค่า​คือ​การ​ปิด version เดิม​แล้ว​ต่อ​ตัว​ใหม่ ส่วน​การ​ลบ​คือ​การ​ปิด​โดย​ไม่​ต่อ​อะไร​เลย

หัวข้อ​นี้​แสดง​กฎ​การ​มอง​เห็น ซึ่ง​เป็น​สิ่ง​เดียว​ที่​กัน​ไม่​ให้​ใคร​อ่าน​ค่าที่​ยัง​ไม่ commit

เมื่อ​ของ​เก่า​ไม่​ถูกลบ​ทิ้ง คำถาม​ที่​ตาม​มา​ทันที​คือ แล้ว​ใคร​ควร​เห็น version ไหน

คำ​ตอบ​อยู่​ใน function เดียว และ​มัน​สั้น​กว่า​ที่​คน​ส่วน​ใหญ่​คาด

src/txn.rs · กฎ​การ​มอง​เห็น
pub fn visible(v: &Version, snapshot: &BTreeSet<Xid>, me: Xid) -> bool {
if v.begin != me && !snapshot.contains(&v.begin) {
return false;
}
match v.end {
Some(end) => end != me && !snapshot.contains(&end),
None => true,
}
}

snapshot คือ​สำเนา​ของ​ทะเบียน​ว่า ตอน​ที่​ฉัน​เปิด มี​ใคร commit ไป​แล้ว​บ้าง มัน​เป็น​สำเนา ไม่ใช่​การ​อ้างอิง จึง​ไม่​ขยับ​ตาม​ใคร​อีก​เลย​ตลอด​อายุ​ของ transaction

กฎ​สอง​ข้อ​ที่​เหลือ​อ่านได้ตรงๆ จาก code

  • ข้อ​แรก ถ้า​คน​สร้าง version นี้​ยัง​ไม่ commit ก่อน​ที่​ฉัน​จะ​เปิด ฉัน​ไม่​เห็น​มัน ยกเว้น​คน​สร้าง​คือ​ฉัน​เอง เพราะ​ทุก​คน​คาด​หวัง​ว่า​เขียน​แล้ว​อ่าน​กลับ​มา​ต้อง​เจอ
  • ข้อ​ที่​สอง ถ้า​คน​ที่​ปิด​มัน commit ไป​ก่อน​ฉัน​เปิด​แล้ว ฉัน​ก็​ไม่​เห็น​มัน​เช่น​กัน

ข้อ​ที่​สอง​ดูเหมือน​ของ​แถม แต่​บท​ที่ 4 จะ​แสดง​ว่า​มัน​คือ​สิ่ง​เดียว​ที่​ทำให้การ​ลบ​ทำงาน​ได้

🔁 การ​ยกเลิก​ใน​เอนจิน​นี้​ไม่มี​คำ​สั่ง​ของ​ตัวเอง

เอนจิน​นี้​ไม่มี abort() ให้​เรียก การ​ยกเลิก​คือ​การ​ที่​หมายเลข​ของ transaction นั้น ไม่​เคย​เข้าไป​อยู่​ใน​ทะเบียน committed เท่านั้น

version ที่​มัน​ทิ้ง​ไว้​จึง​ตก​กฎ​ข้อ​แรก​ตลอด​กาล โดย​ไม่​ต้อง​มี​ใคร​ไป​ตาม​ลบ​ให้ การ​ยกเลิก จึง​ไม่​ต้อง​เขียน​ข้อมูล​กลับ​เป็น​ค่า​เดิม​สัก​แถว​เดียว

แต่​มัน​ไม่​ได้​ฟรี​ทั้งหมด หมายเลข​ของ​ตัว​ที่​ยกเลิก​ยัง​ค้าง​อยู่​ใน​ช่อง end ของ version เดิม คน​ที่มา​ทีหลัง​จึง​ต้อง​เปิด​ทะเบียน​ดู​ทุก​ครั้ง​ว่า​หมายเลข​นั้น​นับ​หรือ​ไม่​นับ นี่​คือ​หน้าที่​ของ สิ่ง​ที่ PostgreSQL เรียก​ว่า commit log กับ hint bit

flowchart TD
  S["setup · xid 1<br/>เขียน 100 แล้ว commit"]
  G["ghost · xid 2<br/>เขียน 999 ไม่ commit"]
  R["reader · xid 3<br/>snapshot มีแค่ xid 1"]
  V1["version · begin 1 · end 2<br/>value 100"]
  V2["version · begin 2 · ยังเปิดอยู่<br/>value 999"]
  S --> V1
  G --> V2
  G -.->|ปิด version เดิม| V1
  R ==>|เห็น| V1
  R -.->|ไม่เห็น เพราะ xid 2 ไม่อยู่ใน snapshot| V2

คำ​บรรยาย​ภาพ: ghost ปิด version เดิม​ไป​แล้ว แต่ reader ยัง​เห็น​ค่า 100 อยู่ เพราะ​คน​ที่​ปิด​มัน​ยัง​ไม่ commit

หัวข้อ​นี้​แสดง​ผล​รัน​จริง และ​ชี้​ว่า​บรรทัด​ไหน​คือ​ประเด็น​ของ​ทั้ง​บท

cargo run --quiet -- 01
== บทที่ 1 transaction สัญญาอะไร ==
หลัง setup commit ยอด = 100 · version ที่ค้าง = 1
ghost เขียน 999 แล้วไม่ commit
คนอื่นอ่านได้ 100
version ที่ค้างในที่เก็บ 2
transaction ที่ commit แล้ว 1

สอง​บรรทัด​กลาง​คือ​สิ่ง​ที่​คาด​ไว้ ghost เขียน 999 ลง​ไป​จริง และ​ไม่มี​ใคร​เห็น​มัน​เลย

บรรทัด​ที่​ควร​หยุด​อ่าน​คือ​บรรทัด​ถัด​มา version ที่​ค้าง​ใน​ที่​เก็บ​มี​สอง​ตัว ทั้ง​ที่​มี transaction commit สำเร็จ​แค่​ตัว​เดียว

ค่าที่​ไม่มี​ใคร​มอง​เห็น​นั้น​ไม่​ได้​หาย​ไป​ไหน มัน​ยัง​กิน​ที่​อยู่​จริง สิ่ง​ที่​กัน​ไม่​ให้​ใคร​เห็น​มัน​คือ กฎ​การ​มอง​เห็น ไม่ใช่​การ​ลบ​ทิ้ง

นี่​คือ​เหตุผล​ที่​ฐาน​ข้อมูล​ซึ่ง​เก็บ​หลาย version ต้อง​มี garbage collection คอย​กวาด​ของ​ที่ ไม่มี​ใคร​ใช้​แล้ว​ออก​ไป ใน PostgreSQL งาน​นี้​คือ​คำ​สั่ง VACUUM

มัน​คือ​ราคา​ที่​วิธี​นี้​จ่าย​เพื่อ​แลก​กับ​การ​ที่​ผู้​อ่าน​ไม่​ต้อง​รอ​ผู้​เขียน เอนจินที่​เลือก​จ่าย​ด้วย วิธี​อื่น​ก็​มี บท​ที่ 8 จะ​แสดง​ว่า SQLite เลือก​ทาง​ที่​ไม่​ต้อง​เก็บกวาด​แบบ​นี้​เลย

หัวข้อ​นี้​บอกว่า​คำ​ที่​เหลือ​ของ​คอร์ส​จะ​เข้า​มา​เมื่อไร และ​บท​นี้​ยัง​ไม่​ตอบ​อะไร​เกี่ยว​กับ​มัน

ทุก​อย่าง​ใน​บท​นี้​ใช้​กติกา​ชุด​เดียว คือ​ทุก​คน​อ่าน​จาก snapshot ของ​ตัวเอง

Isolation LevelIsolation Levelระดับ​ที่​กำหนด​ว่า transaction ที่​รัน​พร้อม​กัน​จะ​กัน​กัน​แค่​ไหน ระดับ​ที่​สูง​ขึ้น​ไม่​ได้​แปล​ว่า​คำ​ตอบ​ถูก​ขึ้น แต่​แปล​ว่า​เอนจิน​ยอม​ปฏิเสธ​งาน​บ่อย​ขึ้น คือ​ชื่อ​ของ​กติกา​ที่​เลือก​ได้​ว่า​จะ​เข้ม​แค่​ไหน และ​คำถาม​ที่​ทั้ง​คอร์ส​จะ​ตอบ​คือ ระดับ​ที่​ต่าง​กัน ซื้อ​อะไร และ จ่าย​ด้วย​อะไร

คำ​ตอบ​ที่​คน​ส่วน​ใหญ่​คาด​ไว้​คือ​ระดับ​ที่​สูง​ขึ้น​ให้​คำ​ตอบ​ที่​ถูกต้อง​กว่า คำ​ตอบ​นั้น​ไม่​ตรง​กับ​ที่​วัด​ได้ และ​บท​ที่ 6 จะ​แสดง​ตัวเลข​ที่​ขัด​กับมันตรงๆ

บท​ที่ 2 จะ​เริ่ม​จาก​การ​ทำให้​ความ​ผิดพลาด​แต่ละ​ชนิด​เกิด​ขึ้น​ซ้ำ​ได้​ทุก​ครั้ง​ก่อน เพราะ​สิ่ง​ที่​ทำซ้ำ ไม่​ได้ ก็​เถียง​กัน​ด้วย​ตัวเลข​ไม่​ได้


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • A Critique of ANSI SQL Isolation Levels — Berenson, Bernstein, Gray, Melton, O’Neil, O’Neil (ACM SIGMOD 1995, รายงาน MSR-TR-95-51 ของ Microsoft Research — ตรวจ​แล้ว 2026-08-13) ต้นทาง​ของ​การ​แยก​ปรากฏการณ์​แต่ละ​ชนิด​ออก​จาก​กัน​อย่าง​ชัดเจน และ​เป็น​ที่มา​ของ​นิยาม snapshot isolation ที่​คอร์ส​นี้​จะ​ไป​ถึง​ใน​บท​ที่ 5
  • PostgreSQL 18 Documentation — 13.2 Transaction Isolation (ตรวจ​แล้ว 2026-08-13) — ตาราง​ที่​ระบุ​ว่า​ปรากฏการณ์​ชนิด​ไหน​เกิด​ได้ที่​ระดับ​ไหน ซึ่ง​บท​ที่ 8 จะ​เอา​มา​เทียบ​กับ​ผล​รัน​ของ​เอนจินที่​เรา​สร้าง​เอง

เช็กความเข้าใจ — บทที่ 1

ข้อ 1 / 3

ระบบหนึ่งรับประกัน atomicity ครบถ้วน ทุก transaction ลงครบทุกชิ้นหรือไม่ลงเลย ข้อใดอธิบายได้ถูกต้องว่าระบบนี้ยังให้คำตอบผิดได้อย่างไร