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

write skew — anomaly ที่ snapshot isolation มอง​ไม่​เห็น

ไร​เด​อร์สอง​คน​อยู่​เวร​ใน​โซน​เดียวกัน กฎ​ที่​ทั้ง​ระบบ​ต้อง​รักษา​ไว้​มี​ข้อ​เดียว ต้อง​มี​คน​อยู่​เวร​อย่าง​น้อยหนึ่ง​คน​เสมอ

ทั้ง​คู่​กด​ถอน​ตัว​พร้อม​กัน ต่าง​คน​ต่าง​เปิด​ดู​ก่อน​ว่า​อีก​ฝ่าย​ยัง​อยู่​เวร​ไหม เห็น​ว่า​ยัง​อยู่ ก็​เลย​ถอน​ตัวเอง

ไม่มี​ใคร​ทำ​ผิด​สัก​คน ไม่มี error สัก​ตัว ไม่มี transaction ไหน​ถูก abort และ​หลัง​จาก​นั้น โซน​นั้น​ก็​ไม่​เหลือ​ไร​เด​อร์อยู่​เลย

สี่บท​ที่​ผ่าน​มาสร้าง​ของ​ครบ​ทุก​ชิ้น​ที่​ต้อง​ใช้​เพื่อ​กัน​เรื่อง​แบบ​นี้ แล้ว​มัน​ก็​ยัง​กัน​ไม่​อยู่

snapshot isolation ให้​อะไร​ก่อน แล้ว​ค่อย​ถาม​ว่า​มัน​ไม่​ให้​อะไร

หัวข้อ​ที่​มีชื่อ​ว่า “snapshot isolation ให้​อะไร​ก่อน แล้ว​ค่อย​ถาม​ว่า​มัน​ไม่​ให้​อะไร”

หัวข้อ​นี้​ให้​นิยาม​ของ snapshot isolation ตาม​ต้นฉบับ​ปี 1995 และ​สรุป​ว่า​มัน​ปิด​ปัญหา​อะไร​ไป​แล้ว​บ้าง

Snapshot IsolationSnapshot Isolationisolation level ที่​ให้​ทุก transaction อ่าน​จาก snapshot ของ​ตัวเอง จึง​ไม่มี dirty read และ​ไม่มี non-repeatable read แต่​ยัง​ปล่อย​ให้ write skew เกิด​ได้ ไม่ใช่​ระดับ​ที่​มาตรฐาน ANSI SQL ระบุ​ไว้ มัน​ถูก​ตั้ง​ชื่อ​และ​ให้​นิยาม ใน​บทความ A Critique of ANSI SQL Isolation Levels ของ Berenson และ​คณะ เมื่อ​ปี 1995 ที่​หัวข้อ 4.2

นิยาม​ของ​มัน​สั้น​กว่า​ที่​ชื่อ​ทำให้​คิด ทุก transaction อ่าน​ข้อมูล​จาก SnapshotSnapshotภาพ​ของ​ฐาน​ข้อมูล ณ จุด​เวลา​หนึ่ง​ที่ transaction หนึ่ง​มอง​เห็น​ตลอด​อายุ​ของ​มัน ต่อ​ให้​คน​อื่น commit อะไร​เพิ่ม​ระหว่าง​นั้น ภาพ​นี้​ก็​ไม่​เปลี่ยน ของ​ค่าที่ commit ไป​แล้ว ณ เวลา​ที่​มัน​เริ่ม ต้นฉบับ​เรียก​เวลา​นั้น​ว่า Start-Timestamp

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

กติกา​ข้อ​เดียว​นี้​ปิด​ปัญหา​สอง​อย่าง​ไป​พร้อม​กัน dirty read เกิด​ไม่​ได้​เพราะ​ค่าที่​ยัง​ไม่ commit ไม่​เคย​เข้า​มา​อยู่​ใน​ภาพ ส่วน non-repeatable read เกิด​ไม่​ได้​เพราะ​ภาพ​ไม่​ขยับ​ตลอด​อายุ​ของ transaction

ต้นฉบับ​ยัง​ใส่​กติกา​ข้อ​ที่​สอง​ไว้​ด้วย ชื่อ​ว่า first-committer-wins ถ้า​สอง transaction เขียน​คีย์ เดียวกัน โดยที่​ช่วง​เวลา​ทำงาน​ของ​ทั้ง​คู่​ทับ​กัน คน​ที่ commit ทีหลัง​ต้อง​แพ้ เพื่อ​ไม่​ให้​ใคร​เขียน​ทับ​งาน​ของ​อีก​ฝ่าย​แบบ​เงียบๆ

ข้อความ “ช่วง​เวลา​ทับ​กัน” ตัด​ทิ้ง​ไม่​ได้ ถ้า​ตัด​ไป กติกา​นี้​จะ​ไป​ปฏิเสธ transaction ที่​เปิด​ขึ้น​มา หลัง อีก​ฝ่าย commit จบ​ไป​แล้ว ซึ่ง​ไม่ใช่ snapshot isolation อีก​ต่อ​ไป

🔁 ของ​ที่​บท​ที่ 4 สร้าง​ไว้​คือ​กลไก​ของ​บท​นี้​ทั้งหมด

snapshot ที่​พูด​ถึง​ใน​หัวข้อ​นี้​ไม่ใช่​ของ​ใหม่ มัน​คือ​สำเนา​ทะเบียน committed ที่ txn::begin จับ​ไว้​ตอน​เปิด บวก​กับ​กฎ​การ​มอง​เห็น​สอง​ข้อ​ที่​บท​ที่ 4 ไล่​ที​ละ​บรรทัด​ไป​แล้ว

บท​นี้​จึง​ไม่​ได้​เพิ่ม code ให้​เอนจิน​เลย​สัก​บรรทัด มัน​แค่​เอา​ของ​เดิม​ไป​วาง​ใน​สถานการณ์​ที่​กฎ สอง​ข้อ​นั้น​ไม่​พอ

กฎ​หนึ่ง​ข้อ สอง​คน​อ่าน​ครบ​ทั้ง​สอง​แถว แล้ว​เขียน​คนละ​แถว

หัวข้อ​ที่​มีชื่อ​ว่า “กฎ​หนึ่ง​ข้อ สอง​คน​อ่าน​ครบ​ทั้ง​สอง​แถว แล้ว​เขียน​คนละ​แถว”

หัวข้อ​นี้​วาง scenario ที่​ทำให้​กฎ​พัง และ​ชี้​ว่า​จุด​ชี้ขาด​อยู่​ที่​ทั้ง​คู่​เขียน​คนละ​คีย์

Write SkewWrite Skewanomaly ที่ transaction สอง​ตัว​อ่าน​สิ่ง​เดียวกัน ตัดสิน​ใจ​ถูกต้อง​ตาม​ที่​ตัวเอง​เห็น แล้ว​เขียน​คนละ​แถว จน​กฎ​ที่​ทั้ง​คู่​ตั้งใจ​รักษา​ไว้​พัง​พร้อม​กัน snapshot isolation มอง​ไม่​เห็น anomaly ชนิด​นี้ คือ​กรณี​ที่ transaction สอง​ตัว​อ่าน​ข้อมูล​ชุด​เดียวกัน ตัดสิน​ใจ​ถูกต้อง​ตาม​ภาพ​ที่ ตัวเอง​เห็น แล้ว​เขียน​คนละ​แถว จน​กฎ​ที่​ทั้ง​คู่​ตั้งใจ​รักษา​ไว้​พัง​พร้อม​กัน

ต้นฉบับ​ปี 1995 ตั้ง​รหัส​ให้​มัน​ว่า A5B และ​ยก​ตัวอย่าง​เป็น​บัญชี​ธนาคาร ที่​ยอด​ของ​แต่ละ​บัญชี ติดลบ​ได้ ตราบ​ใด​ที่​ผล​รวม​ของ​ทั้ง​คู่​ยัง​ไม่​ติดลบ

โดเมน​ของ​ไซต์​นี้​ใช้​ไร​เด​อร์แทน​บัญชี ค่า 1 แปล​ว่า​อยู่​เวร ค่า 0 แปล​ว่า​ไม่​อยู่ และ​กฎ​คือ ผล​รวม​ของ​ทั้ง​สอง​แถว​ต้อง​ไม่​เป็น​ศูนย์

src/demos.rs · scenario ที่​ทำให้​กฎ​พัง​โดย​ไม่มี​ใคร​เขียน​ชน​กัน
/// ไรเดอร์สองคนอยู่เวร แต่ละคนอ่าน *ทั้งสองแถว* เห็นว่าอีกคนอยู่เวร แล้วถอนตัวเอง
///
/// ทั้งคู่ตัดสินใจถูกต้องตามภาพที่ตัวเองเห็น และทั้งคู่เขียนคนละแถว
/// ด่านที่ตรวจแค่ "เขียนชนกันไหม" จึงไม่มีอะไรให้จับเลย
pub fn write_skew(level: Level) -> Skew {
let mut s = Store::new();
let mut setup = txn::begin(&mut s, Level::Snapshot);
setup.write(&mut s, ANAN, 1);
setup.write(&mut s, BUNMA, 1);
setup.commit(&mut s, &[]);
let mut t1 = txn::begin(&mut s, level);
let mut t2 = txn::begin(&mut s, level);
let seen1: i64 = t1.read_all(&s).values().sum();
let seen2: i64 = t2.read_all(&s).values().sum();
if seen1 >= 2 {
t1.write(&mut s, ANAN, 0);
}
if seen2 >= 2 {
t2.write(&mut s, BUNMA, 0);
}
let ok1 = t1.commit(&mut s, &[&t2]);
let ok2 = t2.commit(&mut s, &[&t1]);

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

สอง​บรรทัด​ที่​ต้อง​อ่าน​ช้าๆ คือ t1.write(&mut s, ANAN, 0) กับ t2.write(&mut s, BUNMA, 0) — t1 แตะ​แถว​ของ anan อย่าง​เดียว ส่วน t2 แตะ​แถว​ของ bunma อย่าง​เดียว

flowchart TD
  R1["แถว anan<br/>อยู่เวร = 1"]
  R2["แถว bunma<br/>อยู่เวร = 1"]
  T1["t1 · snapshot ของตัวเอง<br/>อ่านทั้งสองแถว ผลรวม = 2"]
  T2["t2 · snapshot ของตัวเอง<br/>อ่านทั้งสองแถว ผลรวม = 2"]
  W1["t1 เขียน anan = 0"]
  W2["t2 เขียน bunma = 0"]
  RULE["กฎ ต้องมีคนอยู่เวรอย่างน้อย 1 คน<br/>ผลจริง ไม่เหลือสักคน"]
  R1 ==> T1
  R2 ==> T1
  R1 --> T2
  R2 --> T2
  T1 ==> W1
  T2 --> W2
  W1 ==> RULE
  W2 --> RULE

คำ​บรรยาย​ภาพ: ทั้ง​คู่​อ่าน​ครบ​ทั้ง​สอง​แถว แต่​เขียน​คนละ​แถว เส้น​เขียน​สอง​เส้น​จึง​ไม่​เคย​ชน​กัน​เลย

หัวข้อ​นี้​แสดง​ผล​รัน​ที่​ระดับ snapshot แล้ว​ชี้ตัวเลข​สาม​ตัว​ที่​เป็น​ประเด็น​ของ​ทั้ง​บท

cargo run --quiet -- 05
== บทที่ 5 write skew — anomaly ที่ snapshot isolation มองไม่เห็น ==
กฎ ต้องมีไรเดอร์อยู่เวรอย่างน้อย 1 คน · เริ่มต้นอยู่เวรทั้ง 2 คน
ระดับ snapshot
commit ได้ ["t1", "t2"]
abort 0
ไรเดอร์เหลืออยู่เวร 0 คน
กฎยังอยู่ไหม **พัง**
version ที่ค้าง 4
ไม่มีใครเขียนทับคีย์เดียวกันเลย ทั้งคู่จึง commit ผ่านทั้งคู่
แต่ละคนตัดสินใจถูกตามภาพที่ตัวเองเห็น ผลรวมกลับผิด

สอง​บรรทัด​บน​สุด​ของ​รายงาน​บอกว่า​ทั้ง t1 และ t2 ผ่าน​ทั้ง​คู่ และ abort 0 ครั้ง เอนจิน​ไม่​ได้ ปฏิเสธ​งาน​ของ​ใคร​สัก​ชิ้น​เดียว

บรรทัด​ที่​ควร​หยุด​อ่าน​คือ​บรรทัด​ถัด​มา ไม่​เหลือ​ไร​เด​อร์อยู่​เวร​สัก​คน กฎ​ที่​ทั้ง​ระบบ​ต้อง​รักษา​ไว้ พัง​ไป​แล้ว โดย​ไม่มี​อะไร​ส่งเสียง​สัก​ครั้ง

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

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

หัวข้อ​นี้​อธิบาย​ว่า​ด่าน​ที่​ถาม​เรื่อง​การ​เขียน​ทับ​คีย์​เดียวกัน ไม่มี​อะไร​ให้​ตรวจ​ใน​การ​รัน​นี้

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

ใน​การ​รัน​นี้​คำ​ตอบ​คือ​ไม่มี​สัก​คีย์ t1 เขียน​แถว​ของ anan ส่วน t2 เขียน​แถว​ของ bunma รายการ​คีย์​ที่​ทั้ง​คู่​เขียน​ไม่​ซ้อน​กัน​แม้แต่​ตัว​เดียว

first-committer-wins จึง​ไม่มี​อะไร​ให้​จับ ไม่ใช่​เพราะ​กติกา​นั้น​หลวม​เกิน​ไป แต่​เพราะ​ไม่มี การ​ชน​เกิด​ขึ้น​ให้​มัน​เห็น​ตั้งแต่​ต้น

เอนจินของ​เรา​ที่​ระดับ snapshot ยัง​ไม่มี​ด่าน​นั้น​ด้วย​ซ้ำ ด่าน commit ทั้ง​ด่าน​มี​เงื่อนไข​เดียว และ​เงื่อนไข​นั้น​ทำงาน​เฉพาะ​ที่​ระดับ serializable

src/txn.rs · ด่าน commit ที่​ระดับ snapshot ไม่​ตรวจ​อะไร​เลย
/// พยายาม commit · คืน `false` เมื่อถูก abort
///
/// ที่ระดับ snapshot ด่านนี้ไม่ตรวจอะไรเลย ทุก transaction ผ่านหมด
/// ที่ระดับ serializable เราตรวจ read-write antidependency กับตัวที่ commit ไปแล้ว
/// รายละเอียดของด่านนั้นอยู่ใน `ssi.rs`
pub fn commit(&self, store: &mut Store, concurrent: &[&Txn]) -> bool {
if self.level == Level::Serializable && crate::ssi::conflicts(self, store, concurrent) {
store.aborts += 1;
return false;
}
store.committed.insert(self.xid);
true
}

ที่​ระดับ snapshot ทุก transaction เดิน​ตรง​ไป​เข้า​ทะเบียน committed นั่น​คือ​เหตุผล​ที่​รายงาน บอกว่า abort 0 ครั้ง

ประเด็น​ที่​สำคัญ​กว่า​คือ ต่อ​ให้​เติม first-committer-wins เข้าไป​เต็ม​รูป​ตาม​นิยาม​ของ Berenson ตัวเลข​ทุก​ตัว​ก็​ไม่​ขยับ เพราะ​สิ่ง​ที่​กติกา​นั้น​เปรียบเทียบ​คือ​รายการ​คีย์​ที่​เขียน และ​รายการ​นั้น ไม่​ซ้อน​กัน

ไม่มี​ใคร​ตัดสิน​ใจ​ผิด​สัก​คน ความ​ผิด​อยู่​ที่​ผล​รวม

หัวข้อ​ที่​มีชื่อ​ว่า “ไม่มี​ใคร​ตัดสิน​ใจ​ผิด​สัก​คน ความ​ผิด​อยู่​ที่​ผล​รวม”

หัวข้อ​นี้​เทียบ​ผล​ของ​การ​รัน​พร้อม​กัน​กับ​ผล​ของ​การ​รัน​เรียง​ที​ละ​ตัว แล้ว​เรียก​ชื่อ​อาการ​ที่​ได้

ลอง​เรียง​ให้ t1 ทำงาน​จบ​ก่อน มัน​เห็น​ว่า​ทั้ง​คู่​ยัง​อยู่​เวร ผล​รวม​เป็น 2 มัน​จึง​ถอน​ตัวเอง

พอ​ถึง​คิว​ของ t2 ภาพ​ที่​มัน​เปิด​ขึ้น​มา​มี​คน​อยู่​เวร​เหลือ​คน​เดียว เงื่อนไข seen2 >= 2 ไม่​ผ่าน มัน​จึง​ไม่​เขียน​อะไร​ลง​ไป​เลย

สลับ​ลำดับ​ก็ได้​ผล​แบบ​เดียวกัน ไม่​ว่า​จะ​เรียง​ทาง​ไหน ผลลัพธ์​คือ​ยัง​มี​ไร​เด​อร์อยู่​เวร​หนึ่ง​คน​เสมอ

การ​รัน​พร้อม​กัน​ให้​ผล​ที่​ไม่​ตรง​กับ​ลำดับ​ใด​เลย​สัก​ลำดับ เอกสาร​ของ PostgreSQL เรียก​อาการ​นี้ ตรงๆ ว่า serialization anomaly คือ​ผล​ของ​กลุ่ม transaction ที่ commit สำเร็จ ไม่​สอดคล้อง กับ​ลำดับ​การ​รัน​ที​ละ​ตัว​แบบ​ใด​เลย

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

ความ​ผิดพลาด​ไม่​ได้​อยู่​ใน​ตัว​ใคร​สัก​คน มัน​มี​อยู่​แค่​ใน​ผล​รวม​เท่านั้น และ​นั่น​คือ​เหตุผล​ที่​หา bug ตัว​นี้​จาก log ของ transaction ที​ละ​ตัว​ไม่​เจอ

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

คำถาม “เขียน​ชน​กัน​ไหม” ใช้​กับ​อาการ​นี้​ไม่​ได้​แล้ว คำถาม​ที่​ยัง​ใช้ได้​คือ มี​ใคร​เขียน​ทับ​ค่าที่​ฉัน​อ่าน​ไป​แล้ว​หรือ​เปล่า

ใน​การ​รัน​นี้​มี​ครบ​ทั้ง​สอง​ทาง t1 เขียน​ทับ​แถว​ที่ t2 อ่าน​ไป และ t2 เขียน​ทับ​แถว​ที่ t1 อ่าน​ไป

ข้อมูล​ที่​ต้อง​ใช้​ตอบ​คำถาม​นั้น​ถูก​เก็บ​ไว้​ตั้งแต่​บท​ที่ 4 แล้ว ทุก​ครั้ง​ที่ read ถูก​เรียก คีย์​นั้น ถูก​จด​ลง​ใน​รายการ​ของ transaction เอง บท​ที่ 6 แค่​หยิบ​สอง​รายการ​นั้น​มา​เทียบ​กัน

ด่าน​นั้น​จับ write skew ได้​จริง แต่ สิ่ง​ที่​มัน​คืน​กลับ​มา​ไม่ใช่​คำ​ตอบ​ที่​ถูก มัน​คือ​การ​ปฏิเสธ​งาน และ​เมื่อ​เอา​ไป​ใช้​กับ anomaly ที่​บท​ที่ 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) หัวข้อ 4.2 ให้​นิยาม snapshot isolation ด้วย Start-Timestamp และ​หัวข้อ​เดียวกัน​ตั้ง​รหัส A5B ให้ write skew พร้อม​ตัวอย่าง​บัญชี​ธนาคาร​ที่​ยอด​รวม​ของ​สอง​บัญชี​ห้าม​ติดลบ
  • PostgreSQL 18 Documentation — 13.2 Transaction Isolation (ตรวจ​แล้ว 2026-08-13) — ระบุ​ว่า Repeatable Read ของ PostgreSQL ทำ​ด้วย​เทคนิค​ที่ วง​วิชาการ​เรียก​ว่า snapshot isolation และ​กัน​ได้​ทุก​ปรากฏการณ์​ใน​ตาราง​ของ​หน้า​นั้น ยกเว้น serialization anomaly

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

ข้อ 1 / 3

ผลรันของบทนี้บอกว่าที่ระดับ snapshot ทั้ง t1 และ t2 commit ผ่านทั้งคู่ และไม่มีการ abort เลย ข้อใดอธิบายได้ถูกต้องว่าทำไมไม่มีด่านไหนจับได้