ราคาของ transaction — ขนาดของมันคุมจำนวนหน้าที่เขียน
หกบทก่อนหน้าเกิดขึ้นในหน่วยความจำทั้งหมด เครื่องดับเมื่อไรก็หายเกลี้ยง
บทนี้ถามคำถามที่เหลืออยู่ข้อเดียว คือ การ commit หนึ่งครั้งต้องเขียนอะไรลง disk บ้าง
คำตอบขึ้นกับตัวแปรที่คนส่วนใหญ่ไม่นับว่าเป็นตัวแปร คือ ขนาด ของ transaction และมันคุมแรงกว่าที่คาดไว้มาก
ตัวเลขของเอนจินในบทนี้มาจาก แบบจำลอง ที่เราเขียนเองใน
scripts/transaction-engine/src/wal.rs มันนับตามกฎที่เราประกาศไว้เอง
ไม่ได้ไปวัดฐานข้อมูลจริงสักตัว
ตัวเลขของ SQLite มาจาก เครื่องมือคนละตัว คือ scripts/write-amp/probe.py ซึ่งเขียนด้วย
Python เพราะ crate ของคอร์สนี้เป็น standard library ล้วน มันจึงคุยกับ SQLite ไม่ได้
สองชุดนี้จะไม่ถูกจับใส่ตารางเดียวกันในบทนี้ วิธีวัดคนละวิธี ฐานคนละฐาน
log ที่ต้องไปถึงก่อน การแก้ถึงจะนับว่าเกิดขึ้น
หัวข้อที่มีชื่อว่า “log ที่ต้องไปถึงก่อน การแก้ถึงจะนับว่าเกิดขึ้น”หัวข้อนี้บอกว่า write-ahead log บังคับลำดับอะไร และ checkpoint คือขั้นตอนไหนของเรื่อง
ถ้าแก้ file ฐานข้อมูลหลักตรงๆ แล้วเครื่องดับกลางการเขียนหนึ่งหน้า ของที่เหลืออยู่บน disk คือหน้าที่เขียนไปได้ครึ่งเดียว ไม่ใช่ของเก่าและไม่ใช่ของใหม่
Write-Ahead LogWrite-Ahead Log (WAL)log ที่ต้องเขียนลง disk ให้เสร็จก่อนที่การแก้ฐานข้อมูลจริงจะถูกยอมรับ ทำให้กู้สภาพกลับมาได้หลังเครื่องดับกลางคัน แก้ปัญหานี้ด้วยกฎเรื่อง ลำดับ ข้อเดียว คือเขียนคำอธิบายการแก้ลง log ให้เสร็จก่อน แล้วการแก้นั้นถึงจะนับว่า commit แล้ว
ชื่อของมันคือกฎของมันเอง log ไปก่อน (write-ahead) ส่วน file ฐานข้อมูลหลักตามไปทีหลัง เมื่อไรก็ได้ เพราะทุกอย่างที่ต้องใช้ประกอบสภาพกลับคืนอยู่ใน log ครบแล้ว
แต่ log โตขึ้นเรื่อยๆ จึงต้องมีจังหวะย้ายของกลับ CheckpointCheckpointจังหวะที่ระบบย้ายสิ่งที่ค้างอยู่ใน log กลับเข้า file ฐานข้อมูลหลัก แล้วตัดส่วนของ log ที่ไม่ต้องใช้แล้วทิ้ง คือจังหวะนั้น มันย้ายสิ่งที่จอดอยู่ใน log กลับเข้า file ฐานข้อมูลหลัก แล้วส่วนของ log ที่ไม่ต้องใช้แล้ว ก็ตัดทิ้งได้
เอกสารของ SQLite เขียนไว้ว่าผู้เขียน sync WAL ทุกครั้งที่ commit เมื่อ PRAGMA synchronous
เป็น FULL แต่ข้าม sync นั้นเมื่อตั้งเป็น NORMAL คำว่า “ทนต่อไฟดับ” จึงมีปุ่มปรับอยู่
เครื่องมือวัดของบทนี้ตรึง synchronous=OFF ไว้โดยตั้งใจ เพราะมันนับ หน้าที่ถูกเขียน
ไม่ได้นับจำนวนครั้งที่เรียก sync
นั่นคือเหตุผลที่หน่วยของบทนี้เป็นจำนวนนับล้วนๆ ไม่ใช่เวลา เวลาวัดเครื่องที่รัน ส่วนจำนวนหน้าวัดสิ่งที่เรากำลังอธิบาย
หน่วยที่นับได้คือ frame
หัวข้อที่มีชื่อว่า “หน่วยที่นับได้คือ frame”หัวข้อนี้นิยาม frame แล้วแสดงค่าคงที่สองตัวที่แบบจำลองของเราใช้นับ
หนึ่ง frame คือหนึ่งหน้าที่ถูกเขียนลง log หนึ่งครั้ง หน้าเดิมที่ถูกเขียนซ้ำนับใหม่ทุกครั้ง
ตรงคำว่า “นับใหม่ทุกครั้ง” นี่เองคือ Write AmplificationWrite Amplificationอัตราส่วนระหว่างจำนวนที่ระบบเขียนลง disk จริง กับจำนวนที่โปรแกรมสั่งเขียน ตัวเลขนี้แกว่งตามขนาดของ transaction ได้หลายสิบเท่า คืออัตราส่วนระหว่างจำนวนที่ระบบ เขียนจริง กับจำนวนที่โปรแกรมสั่งเขียน
แบบจำลองของบทนี้มีค่าคงที่แค่สองตัว และทั้งสองตัวอ่านออกได้จาก code ตรงๆ
/// จำนวนแถวที่ใส่ได้ในหนึ่งหน้าpub const ROWS_PER_PAGE: usize = 4;
/// ค่าคงที่ต่อหนึ่ง commit — หนึ่ง frame สำหรับระเบียนที่บอกว่า transaction นี้จบแล้วpub const COMMIT_FRAMES: usize = 1;ROWS_PER_PAGE เล็กเกินจริงโดยตั้งใจ หนึ่งหน้าของจริงใส่ได้เป็นร้อยแถว แต่เลข 4
ทำให้ผลของการซอย transaction มองเห็นได้ด้วยตาเปล่า
COMMIT_FRAMES คือค่าคงที่ที่ทั้งบทหมุนรอบมัน ทุก commit ต้องเขียนระเบียนที่บอกว่า
transaction นี้จบแล้ว เพิ่มอีกหนึ่ง frame เสมอ ไม่ว่าจะแก้ไปกี่แถวก็ตาม
สูตรนับทั้งหมดอยู่ใน function เดียว
/// นับ frame ที่ต้องเขียน เมื่อใส่ `rows` แถว โดย commit ทุกๆ `commit_every` แถว////// แถวถูกใส่เรียงกันไป แถวที่ `i` ตกอยู่บนหน้าที่ `i / ROWS_PER_PAGE` เสมอ/// หนึ่ง batch จึงแตะหน้าตั้งแต่หน้าของแถวแรกไปจนถึงหน้าของแถวสุดท้ายของ batch นั้น/// **หน้าที่คาบเกี่ยวสอง batch ถูกเขียนสองครั้ง และนับสองครั้ง** ซึ่งตรงกับที่เกิดจริงpub fn cost(rows: usize, commit_every: usize) -> Cost { let mut frames = 0; let mut commits = 0; let mut start = 0; while start < rows { let end = (start + commit_every).min(rows); let first_page = start / ROWS_PER_PAGE; let last_page = (end - 1) / ROWS_PER_PAGE; frames += (last_page - first_page + 1) + COMMIT_FRAMES; commits += 1; start = end; } Cost { commit_every, commits, frames }}บรรทัดที่สำคัญที่สุดคือ (last_page - first_page + 1) มันนับ หน้าที่ batch นี้แตะ
ไม่ใช่จำนวนแถว หนึ่ง batch ที่ยาวสี่แถวพอดีจึงเสีย 1 frame ของหน้า ไม่ใช่สี่
และหน้าที่คาบเกี่ยวสอง batch ถูกนับสองครั้ง เพราะมันถูกเขียนลง log สองครั้งจริงๆ ครั้งหนึ่งตอน commit แรก อีกครั้งตอน commit ถัดมา
รันดูเอง แถวเท่ากันทุกบรรทัด ต่างกันแค่ขนาดของ transaction
หัวข้อที่มีชื่อว่า “รันดูเอง แถวเท่ากันทุกบรรทัด ต่างกันแค่ขนาดของ transaction”หัวข้อนี้แสดงผลรันของแบบจำลอง แล้วชี้ว่าเส้นราคาชันตรงไหนและแบนตรงไหน
== บทที่ 7 ราคาของ transaction == ใส่ 1000 แถว · 4 แถวต่อหน้า · commit เพิ่มอีก 1 frame ต่อครั้ง commit ทุกๆ ครั้งที่ commit frame เทียบกับ commit ทีเดียว 1 1000 2000 7.97x 10 100 400 1.59x 100 10 260 1.04x 1000 1 251 1.00x แถวเท่ากันทุกบรรทัด ต่างกันแค่ *ขนาดของ transaction* อย่างเดียวทั้งสี่บรรทัดใส่ 1000 แถวเท่ากัน ต่างกันอย่างเดียวคือซอยเป็น transaction ขนาดเท่าไร
บรรทัดบนสุดคือ commit ทุกแถว มันจ่ายไป 2000 frame ส่วนบรรทัดล่างสุดคือ commit ทีเดียว จ่ายไป 251 frame ต่างกัน 7.97 เท่า จากงานที่เท่ากันทุกประการ
แต่สองบรรทัดกลางสำคัญกว่านั้น commit ทุก 10 แถวลงมาเหลือ 1.59x แล้ว และ commit ทุก 100 แถวลงมาเหลือ 1.04x
เส้นราคานี้ ชันมากตอนต้น แล้วแบนเกือบสนิทตอนท้าย ก้าวแรกที่ออกจาก “commit ทุกแถว” ซื้อส่วนลดไปเกือบทั้งหมดแล้ว การขยาย transaction ให้ใหญ่กว่านั้นซื้อเพิ่มได้อีกน้อยมาก
จำรูปของเส้นนี้ไว้ หัวข้อสุดท้ายจะกลับมาใช้มัน เพราะมันคือเหตุผลที่บทนี้ไม่ได้ขัดกับกฎ “transaction ควรเล็ก” ที่ไซต์นี้สอนไว้แล้ว
ค่าคงที่ต่อหนึ่ง commit ไม่ได้หายไปไหน
หัวข้อที่มีชื่อว่า “ค่าคงที่ต่อหนึ่ง commit ไม่ได้หายไปไหน”หัวข้อนี้แยกตัวเลขของบรรทัดบนสุดออกเป็นสองก้อน แล้วแสดงว่าก้อนไหนโตตามจำนวนครั้งที่ commit
แยก 2000 frame ของบรรทัดบนสุดออกมาดู มันเป็นสองก้อนเท่าๆ กันพอดี
- 1000 frame ของหน้า commit ทุกแถวแปลว่าหนึ่ง commit แตะหน้าเดียว และสี่แถวที่อยู่ หน้าเดียวกันตกไปอยู่คนละ commit หน้าเดิมจึงถูกเขียนลง log สี่รอบ
- 1000 frame ของ commit คือค่าคงที่ตัวละหนึ่ง frame คูณด้วยจำนวนครั้งที่หยุด commit
ฝั่ง commit ทีเดียวแยกได้เหมือนกัน คือ 250 หน้าที่แตะจริง บวกค่าคงที่อีกหนึ่ง frame รวมเป็น 251 frame
ก้อนแรกคือ write amplification ล้วนๆ หน้า 250 หน้ากลายเป็นการเขียน 1000 ครั้ง เพราะเราซอย transaction ถี่กว่าจังหวะที่หน้าจะเต็ม
ก้อนที่สองคือค่าคงที่ที่ไม่มีทางหายไป มันไม่ได้แพงขึ้นตามขนาดของงาน มันแพงขึ้นตาม จำนวนครั้งที่เราหยุดเพื่อ commit เท่านั้น
flowchart TD R["ใส่ 1000 แถว · 4 แถวต่อหน้า<br/>งานเท่ากันทั้งสองทาง"] R --> A["commit ทุกแถว<br/>commit 1000 ครั้ง"] R --> B["commit ทีเดียวตอนจบ<br/>commit 1 ครั้ง"] A --> AP["หน้าที่เขียนลง log<br/>1000 frame · หน้าเดิมซ้ำสี่รอบ"] A --> AC["ค่าคงที่ของ commit<br/>1000 frame"] B --> BP["หน้าที่เขียนลง log<br/>250 frame · หน้าละครั้งเดียว"] B --> BC["ค่าคงที่ของ commit<br/>1 frame"] AP --> AT["รวม 2000 frame<br/>7.97x ของฐาน"] AC --> AT BP --> BT["รวม 251 frame<br/>ฐานเทียบ 1.00x"] BC --> BT
คำบรรยายภาพ: งานเท่ากันทั้งสองทาง ต่างกันแค่จำนวนครั้งที่หยุด commit · ก้อนซ้ายบวมขึ้นสองทางพร้อมกัน คือหน้าเดิมถูกเขียนซ้ำสี่รอบ และค่าคงที่ต่อหนึ่ง commit ถูกจ่ายไป 1000 รอบ
ตัวเลขของ SQLite จริง จากเครื่องมือคนละตัวกับ crate
หัวข้อที่มีชื่อว่า “ตัวเลขของ SQLite จริง จากเครื่องมือคนละตัวกับ crate”หัวข้อนี้แสดงว่าเมื่อวัดกับฐานข้อมูลจริง ราคาของลำดับที่คีย์มาถึงแกว่งจาก +0.5 % ถึง +2,948.0 % ตามขนาดของ commit
ทุกตัวเลขข้างบนมาจากแบบจำลองที่เราเขียนเอง มันนับตามกฎที่เราประกาศไว้เอง จึงพิสูจน์ได้ แค่ว่าสูตรของเราให้ผลอย่างไร ไม่ได้พิสูจน์ว่าฐานข้อมูลจริงทำแบบนั้น
ตัวเลขข้างล่างมาจากคนละที่ คือ scripts/write-amp/probe.py ซึ่งเปิด SQLite ของจริง
ใส่ 200,000 แถว แล้วอ่านขนาด file -wal ตอนที่ connection ยังเปิดอยู่
มันหารด้วยขนาดที่เอกสารรูปแบบ file ของ SQLite ระบุไว้ คือ header ของ file WAL ขนาด 32 B และ header ของแต่ละ frame ขนาด 24 B ตามด้วยหน้าข้อมูลขนาด 4096 B
clustered (WITHOUT ROWID) commit ทุก ๆ เรียง uuid4 uuid7 uuid4 vs uuid7 vs 1 272,944 312,927 272,822 +14.6 % -0.0 % 100 16,092 283,526 16,058 +1,661.9 % -0.2 % 1,000 6,283 191,503 6,275 +2,948.0 % -0.1 % ทั้งชุด 4,703 4,728 4,702 +0.5 % -0.0 %ทั้งสามช่องซ้ายเก็บ payload ชุดเดียวกันเป๊ะ และคีย์ยาว 16 B เท่ากันทุกช่อง ต่างกันแค่
ลำดับที่คีย์มาถึง คือ เรียง มาถึงเรียงลำดับ uuid4 มาถึงแบบสุ่ม ส่วน uuid7 มาถึงเรียงตามเวลา
สองช่องขวาเป็นเปอร์เซ็นต์เทียบกับช่อง เรียง ของแถวเดียวกัน เท่านั้น เทียบข้ามแถวไม่ได้
เพราะฐานคนละตัว
บรรทัดล่างสุดคือ commit ทีเดียวทั้งชุด คีย์แบบสุ่มแพงกว่าคีย์เรียงแค่ +0.5 %
บรรทัดเหนือมันคือ commit ทุก 1,000 แถว คีย์แบบสุ่มตัวเดียวกันแพงกว่า +2,948.0 %
payload เท่ากัน คีย์เท่ากัน ต่างกันแค่ขนาดของ commit แล้วราคาของคำว่า “คีย์สุ่ม” เปลี่ยนจากแทบไม่มีอยู่ ไปเป็นเกือบสามสิบเท่า
เหตุผลอ่านออกได้จากตารางเอง ระหว่างสอง commit หน้าที่สกปรกจะสะสมอยู่ในหน่วยความจำก่อน พอ commit ทีเดียวทั้งชุด หน้าที่ถูกแก้สิบรอบก็ยังถูกเขียนลง log แค่รอบเดียว
คีย์แบบสุ่มกระจายแถวไปทั่วทั้ง b-tree แต่ละ batch ขนาด 1,000 แถวจึงแตะหน้าคนละกลุ่มกัน แล้วเขียนหน้าทั้งกลุ่มนั้นลง log ทุกครั้งที่ commit
ส่วนคีย์เรียงเติมต่อท้ายของเดิมไปเรื่อยๆ หนึ่ง batch จึงแตะหน้าไม่กี่หน้า ซึ่งเป็นกลไก
เดียวกับที่แบบจำลองของเราเขียนไว้ตรงบรรทัด (last_page - first_page + 1)
SQLite เขียน WAL เป็นหน้าเต็มทุกครั้งที่ commit ส่วน PostgreSQL กับ SQL Server เขียน log ระดับ record แล้วให้ checkpointer ยุบให้ทีหลัง
รูปของเส้นราคาจึงคล้ายกัน แต่ตัวคูณไม่ใช่ตัวเดียวกัน ผลรันของ probe เขียนข้อห้ามนี้ไว้เอง ในบรรทัดสุดท้ายของมัน
แล้วกฎที่ว่า transaction ควรเล็ก ขัดกับบทนี้ไหม
หัวข้อที่มีชื่อว่า “แล้วกฎที่ว่า transaction ควรเล็ก ขัดกับบทนี้ไหม”หัวข้อนี้ตอบว่าไม่ขัด เพราะกฎหนึ่งเลือกขอบเขตความถูกต้อง อีกกฎวัดต้นทุน I/O ภายในขอบเขตนั้น
ไซต์นี้สอนไว้แล้วในคอร์ส EF Core บทที่ 6 ว่า SaveChanges หนึ่งครั้งควรครอบ aggregate
เดียวเท่านั้น ส่วนบทนี้เพิ่งแสดงว่า transaction ใหญ่เขียนน้อยกว่ามาก
อ่านเร็วๆ เหมือนบทเรียนสองบทบนไซต์เดียวกันชี้คนละทาง แต่สองข้อนี้ตอบคนละคำถาม
กฎ 1 aggregate ต่อ 1 transaction ตอบว่า อะไรต้องสำเร็จหรือล้มพร้อมกัน คำตอบมาจาก invariant ของโดเมน ไม่ได้มาจากราคาของ I/O เลยสักนิดเดียว
บทนี้ตอบว่า งานที่เป็นหน่วยเดียวกันอยู่แล้ว ควรถูกซอยเป็นกี่ commit คำถามนี้เกิดขึ้น หลัง จากที่ขอบเขตถูกเลือกไปแล้วเท่านั้น
1000 แถวในผลรันข้างบนไม่ใช่ 1000 aggregate มันคืองานนำเข้าข้อมูลก้อนเดียว ที่หน่วย ความถูกต้องของมันคือทั้งก้อน การซอยมันเป็น 1000 commit ไม่ได้ทำให้ถูกต้องขึ้น มันแค่จ่ายค่าคงที่ 1000 รอบ
กฎสองข้อที่ตามมาจึงไม่มีวันชนกัน
- ห้ามขยาย transaction เพื่อประหยัดการเขียน การมัดสอง aggregate ไว้ด้วยกันซื้อ frame มาได้จริง แต่จ่ายด้วย lock ที่ถือนานขึ้นตามบทที่ 3 version ที่ค้างมากขึ้น และงานที่ถูกทิ้ง มากขึ้นเมื่อโดน abort ตามบทที่ 6
- ห้ามซอย transaction ให้เล็กกว่าขอบเขตความถูกต้อง การผ่างานก้อนเดียวเป็นหลาย commit ไม่ได้ปลอดภัยขึ้น มันแค่แพงขึ้น และเปิดสภาพครึ่งๆ กลางๆ ที่ขอบเขตนั้นสัญญาว่าจะไม่มีใครเห็น
ที่สำคัญกว่านั้นคือรูปของเส้นราคา มันแบนตั้งแต่ commit ทุก 100 แถวแล้ว งานนำเข้าข้อมูล จึงแทบไม่ต้องเลือกระหว่างสองกฎนี้เลย ซอยเป็นก้อนพอประมาณก็ได้ส่วนลดไปเกือบหมด
และบทที่ 6 ของคอร์ส EF Core เองก็ชี้มาที่จุดเดียวกัน มันบอกว่าจังหวะที่ต้องเปิด
BeginTransaction เองคืองานบำรุงรักษาหรือย้ายข้อมูล ที่ต้องรัน SaveChanges หลายรอบ
ให้ atomic กัน ซึ่งเป็นทรงเดียวกับงานนำเข้าข้อมูลในบทนี้พอดี
Transactions & Unit of Work ของคอร์ส EF Core วางขอบเขตความถูกต้องไว้ที่ 1 aggregate ต่อ 1 transaction ส่วนบทนี้วัดต้นทุน I/O ที่เกิดขึ้น ภายใน ขอบเขตนั้น
อ่านคู่กันแล้วได้กฎที่ใช้ได้จริงข้อเดียว คือ ให้ความถูกต้องเลือกขอบเขต แล้วให้ต้นทุน เลือกขนาดของ batch ภายในขอบเขตนั้น ไม่ใช่กลับกัน
บทที่ 8 จะเอาทุกอย่างที่สร้างมาไปเทียบกับเอนจินจริง และจะพบว่า SQLite เลือกจ่ายราคา ด้วยวิธีที่คอร์สนี้ยังไม่ได้พูดถึงเลยสักครั้ง
- SQLite — Write-Ahead Logging (ตรวจแล้ว 2026-08-13)
ต้นทางของนิยาม checkpoint ตรงๆ “Moving the WAL file transactions back into the database
is called a ‘checkpoint’” และเป็นที่มาของข้อที่ว่าการ sync ทุก commit เป็นค่าที่ตั้งได้
ผ่าน
PRAGMA synchronous - SQLite — Database File Format (ตรวจแล้ว 2026-08-13)
หัวข้อ WAL ระบุขนาดที่สูตรนับ frame ของ
scripts/write-amp/probe.pyใช้ตรงๆ คือ “The WAL header is 32 bytes in size” และ “Each frame consists of a 24-byte frame-header followed by a page-size bytes of page data”
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3ผลรันของแบบจำลองบอกว่า commit ทุกแถวจ่ายไป 2000 frame ส่วน commit ทีเดียวจ่ายไป 251 frame จากแถวจำนวนเท่ากัน ข้อใดอธิบายส่วนต่างนี้ได้ถูกต้อง