Animated Architecture Patterns

Transaction Isolation Levels

transaction ที่รันพร้อมกันเห็นงานของกันและกันได้แค่ไหน: anomaly ที่แต่ละ isolation level ยอมให้เกิด และค่า default ที่แต่ละ engine ใช้

หรือที่เรียกกันว่า Isolation levels, Read Committed, Repeatable Read, Serializable, Snapshot isolation, Write skew

BEGIN ISOLATION LEVEL READ UNCOMMITTED READ COMMITTED REPEATABLE READ SERIALIZABLE PostgreSQL’s default runs as READ COMMITTED here standard: no dirty reads + no non-repeatable reads + no phantoms, serial result new snapshot per statement one snapshot per transaction snapshot + SSI conflict checks MySQL: allows dirty reads PostgreSQL, SQL Server default MySQL InnoDB default the SQL standard’s default Afinance reportCnew orderBrefund order 495179 · customer 42 ASELECT total_thb FROM orders WHERE id = 495179→ 51,179.03 ASELECT count(*) FROM orders WHERE customer_id = 42→ 179 BUPDATE orders SET total_thb = total_thb - 3985.43 B→ UPDATE 1WHERE id = 495179refund line 3: 1 × 3,985.43 ASELECT total_thb …→ 51,179.03 B has not committed yet no dirty read: B not committed BCOMMITtotal_thb is now 47,193.60 C→ 500001INSERT INTO orders (customer_id, …) VALUES (42, …) ASELECT total_thb … → 47,193.60 changed inside one transaction non-repeatable read → 51,179.03 same snapshot as the first read ASELECT count(*) … → 180 order 500001 appeared phantom → 179 no phantom either Astaff 1Bstaff 2 stock 2892 qty1098 ASELECT qty FROM stock WHERE product_id = 2892→ 10 B→ 10SELECT qty FROM stock WHERE product_id = 2892 AUPDATE stock SET qty = 9 WHERE product_id = 2892→ UPDATE 1 BUPDATE stock SET qty = 9 …waits for A’s row lock ACOMMIT BCOMMITre-checks the row, writes 9 too → UPDATE 1 · two sales, but the stock fell from 10 to 9: one decrement is gone lost update: two sales, stock 10 → 9 BERROR: could not serialize access due to concurrent update 40001BROLLBACK · retry: reads 9 · sets qty = 8 · COMMIT AAnan signs offBMalee signs off 1 Anan2 Malee SELECT count(*) FROM shift_leads WHERE on_duty SELECT count(*) FROM shift_leads WHERE on_duty UPDATE shift_leads SET on_duty = false WHERE id = 1 UPDATE shift_leads SET on_duty = false WHERE id = 2 COMMIT COMMIT Rule: at least one lead stays on duty. Runs in step 2. ASELECT count(*) FROM shift_leads WHERE on_duty→ 2 B→ 2SELECT count(*) FROM shift_leads WHERE on_duty AUPDATE shift_leads SET on_duty = false WHERE id = 1→ UPDATE 1 B→ UPDATE 1UPDATE shift_leads SET on_duty = false WHERE id = 2 ACOMMIT BCOMMIT both committed: 0 leads on duty, the rule is broken · write skew REPEATABLE READ: both commit too, 0 on duty · write skew B→ ERROR: could not serialize access due to read/writeCOMMIT dependencies among transactions 40001B retries: count(*) is now 1, so Malee stays on duty Sessions are separate psql processes; a script runs them in this exact order. Write skew: each saw 2 on duty and changed a different row, so nothing blocked. Retry loop: on SQLSTATE 40001, roll back and rerun the whole transaction. Long transactions keep their snapshot longer, so they meet more 40001s. Every transaction: BEGIN ISOLATION LEVEL <the highlighted level>. PostgreSQL never shows uncommitted data, even at READ UNCOMMITTED. Retry deadlocks (40P01) too, and back off between attempts. SERIALIZABLE READ ONLY DEFERRABLE: waits once, then can’t fail with 40001. WHAT EACH RUN SAW · POSTGRESQL 18.6 AT EACH ENGINE’S DEFAULT LEVEL READ COMMITTED REPEATABLE READ SERIALIZABLE PostgreSQL · RC MySQL 8.4 · RR SQL Server · RC dirty read non-repeatable read phantom lost update write skew no seen: 47,193.60 seen: 180 seen: qty 9 seen: 0 on duty no no: 51,179.03 no: 179 40001, retried: 8 seen: 0 on duty no no no 40001, retried 40001, retried no no no seen no: 51,179.03 possible seen no * possible seen seen: qty 9 possible seen seen possible Red: the anomaly happened in our run. Green: it did not. 40001: one aborted. Dashed: the SQL standard forbids it at that level. MySQL: our runs on 8.4. SQL Server: from its documentation. * plain SELECTs 8 sessions sell product 2892, 10 spgbench, median of 3 runs READ COMMITTED, read then write8,537 sales, stock down only 1,090 REPEATABLE READ, up to 10 tries2,707 sales · 17,059 retries · 1,841 gave up READ COMMITTED + SELECT … FOR UPDATE6,902 sales · B waits, no retries UPDATE stock SET qty = qty - 19,718 sales Retries redo work; FOR UPDATE waits; a single UPDATE locks the row briefly. stock 2892 qty1098 PostgreSQL · READ COMMITTED · locking read ASELECT qty FROM stock WHERE product_id = 2892 FOR UPDATE→ 10 BSELECT qty … FOR UPDATEwaits for A’s row lock AUPDATE stock SET qty = 9 WHERE product_id = 2892→ UPDATE 1 ACOMMIT B→ 9reads A’s committed 9 B→ UPDATE 1UPDATE stock SET qty = 8 WHERE product_id = 2892 BCOMMITstock 8 after two sales, no retry Astaff 1Bstaff 2MySQL stock qty109 ASELECT qty FROM stock WHERE product_id = 2892→ 10 B→ 10SELECT qty FROM stock WHERE product_id = 2892 AUPDATE stock SET qty = 9 WHERE product_id = 2892→ Changed: 1 BUPDATE stock SET qty = 9 …waits for A’s row lock ACOMMIT BCOMMITsets 9 again → Rows matched: 1, Changed: 0 · stock 9 after two sales: a lost update, and no error at REPEATABLE READ With FOR UPDATE: 8. At SERIALIZABLE: ERROR 1213 (40001), a deadlock. MySQL 8.4 InnoDB · REPEATABLE READ, its default

ขั้นที่ 1 · รันสองชุดที่ READ COMMITTED ที่ level default ของ PostgreSQL คือ READ COMMITTED แต่ละ statement จะเห็นสิ่งที่ commit ไปแล้วก่อน statement นั้นเริ่ม report การเงินของ Acme (A) อ่านยอดรวมของ order 495179 และจำนวน order ของลูกค้า 42 ระหว่างที่ refund (B) กับ order ใหม่ (C) commit ทำให้ใน transaction เดียวกัน มันอ่านได้ 51,179.03 แล้วก็ 47,193.60 และ 179 แล้วก็ 180 order ส่วนพนักงานสองคนที่ขาย product 2892 อ่าน stock ได้ 10 ทั้งคู่ แล้วเขียน 9: UPDATE ของ B รอ row lock ของ A แล้วก็เขียน 9 เหมือนกัน ขายไปสองครั้ง แต่ stock ลดจาก 10 เหลือแค่ 9

ขั้นที่ 2 · ตั้งชื่อให้ anomaly PostgreSQL ไม่เคยให้เห็นข้อมูลที่ยังไม่ commit: การอ่านที่ทำก่อน B commit ยังได้ 51,179.03 อยู่ และ READ UNCOMMITTED ก็ทำงานเป็น READ COMMITTED ยอดรวมที่เปลี่ยนไปคือ non-repeatable read ส่วน order ใหม่คือ phantom และ stock คือ lost update ส่วนตอนที่หัวหน้ากะสองคนต่างเห็นว่ามีคนอยู่เวร 2 คน แล้วต่างคนต่างลงจากเวร จนไม่เหลือใครเลย แบบนั้นคือ write skew มาตรฐาน SQL นิยาม level ของมันด้วยสามตัวแรก: READ COMMITTED ห้าม dirty read ส่วน REPEATABLE READ ห้าม non-repeatable read ด้วย และ SERIALIZABLE ต้องให้ผลที่การรันเรียงทีละตัวสักลำดับหนึ่งให้ได้ (ช่องเส้นประ)

ขั้นที่ 3 · level ที่เข้มกว่า กับการ retry REPEATABLE READ อ่านจาก snapshot เดียวที่ถ่ายไว้ตอน statement แรกของ transaction: report เห็น 51,179.03 และ 179 ตลอด ส่วน UPDATE stock ของ B ก็ fail ด้วย could not serialize access due to concurrent update หรือ SQLSTATE 40001 แล้ว B ก็ retry โดยอ่านได้ 9 แล้วเขียน 8 ส่วน write skew ยัง commit ได้ เพราะหัวหน้ากะสองคนเปลี่ยนคนละ row ตัว SERIALIZABLE (serializable snapshot isolation) ติดตามด้วยว่าแต่ละ transaction อ่านอะไรไปบ้าง แล้วทำให้ COMMIT ของ B fail ด้วย 40001 (read/write dependencies among transactions) พอ retry ก็เห็นว่ามีคนอยู่เวร 1 คน Malee เลยอยู่เวรต่อ

ขั้นที่ 4 · ต้นทุน และ engine อื่น session แปดตัวขาย product ตัวเดียวกันนาน 10 s (pgbench, median ของการรัน 3 ครั้งบน laptop): การอ่านแล้วเขียนที่ READ COMMITTED บันทึกการขายไว้ 8,537 ครั้ง แต่ stock ลดลงแค่ 1,090 ส่วน REPEATABLE READ ที่ลองได้สูงสุด 10 ครั้งขายได้ 2,707 ครั้ง ต้อง retry 17,059 ครั้ง และยอมแพ้ไป 1,841 ครั้ง ส่วน SELECT … FOR UPDATE ขายได้ 6,902 ครั้งด้วยการรอแทน และ UPDATE … SET qty = qty - 1 ตัวเดียวขายได้ 9,718 ครั้ง ตัว PostgreSQL กับ SQL Server ใช้ READ COMMITTED เป็น default ส่วน InnoDB ของ MySQL ใช้ REPEATABLE READ: การอ่านธรรมดาของมันใช้ snapshot เดียวตลอด แต่การขายแบบเดียวกันบน MySQL 8.4 ก็ยังทำ update หายไปโดยไม่ขึ้น error เลย

ปัญหา

database ของ Acme Shop รันหลาย transaction พร้อมกันตลอด: checkout, refund, update stock, report โดยแต่ละตัวถูกเขียนมาเหมือนว่ามันรันอยู่ตัวเดียว และตอนเทสก็มักจะเป็นแบบนั้นจริง ๆ แต่บน production มันซ้อนทับกัน และ transaction หนึ่งจะเห็นงานของอีกตัวแค่ไหนก็ขึ้นกับ isolation level ที่มันรันอยู่ ถ้า transaction ไม่ได้ขอ level ไว้ ก็จะได้ค่า default ของ engine และบน PostgreSQL ค่า default นั้นคือ READ COMMITTED

diagram เล่นการรันสลับกันสามแบบซ้ำบน PostgreSQL 18.6 โดยแต่ละ session เป็น process psql แยกกัน และมี script ส่ง statement ตามลำดับที่กำหนดไว้ ทุกค่าที่เห็นเลยเป็นค่าที่ session ได้รับจริง:

  • report การเงิน session A อ่าน total_thb ของ order 495179 (51,179.03) และจำนวน order ของลูกค้า 42 (179) ระหว่างนั้น B refund line 3 ของ order นั้น (3,985.43) แล้ว commit ส่วน C สร้าง order ใหม่ให้ลูกค้า 42 (id 500001) ที่ READ COMMITTED ตัว report ที่ยังอยู่ใน transaction เดียวกันก็อ่านได้ 47,193.60 และ 180: report เดียวกันมียอดรวมสองค่าและจำนวน order สองค่า
  • stock พนักงานสองคนขาย product 2892 ที่มี stock 10 ชิ้น แต่ละคนอ่าน qty แล้วคำนวณ 9 ในแอป แล้วเขียนค่านั้นลงไป ส่วน UPDATE ของ B ก็รอ row lock ของ A พอ A commit ตัว B ก็เช็ก row ใหม่อีกรอบแล้วเขียน 9 เหมือนกัน ขายไปสองครั้ง แต่ stock ลดจาก 10 เหลือแค่ 9
  • กฎการอยู่เวร หัวหน้ากะของคลังสินค้าสองคนคือ Anan กับ Malee ต้องมีอย่างน้อยหนึ่งคนอยู่เวร แต่ละคนลงจากเวรผ่านแอป ที่นับก่อนว่ามีหัวหน้ากะอยู่เวรกี่คน (2 คน เลยลงได้) แล้วค่อย update row ของตัวเอง ทั้งคู่ commit ได้ และไม่เหลือใครอยู่เวรเลย

ไม่มีเคสไหนขึ้น error เลย แต่ละ transaction ถูกต้องเมื่อดูแยกกัน ส่วน bug อยู่ในช่วงที่มันซ้อนกัน มันขึ้นกับจังหวะเวลา และแทบไม่โผล่ตอนเทส

ทำงานยัง⁠ไง

anomaly ต่าง ๆ แต่ละตัวคือแบบหนึ่งที่ transaction จะเห็นงานของ transaction ที่รันพร้อมกัน หรือโดนมันทำให้เสียหายได้:

Anomaly เกิดอะไรขึ้น ในการรันของ Acme (READ COMMITTED)
Dirty read transaction หนึ่งอ่านการเปลี่ยนแปลงที่อีกตัวยังไม่ commit และอาจยัง rollback ได้ ไม่เกิดเลยใน PostgreSQL: ตอน refund ยังไม่ commit ตัว report ก็ยังอ่านได้ 51,179.03
Non-repeatable read row เดียวกันที่อ่านสองครั้งเปลี่ยนไป เพราะอีก transaction commit ในระหว่างนั้น ยอดรวมเปลี่ยนจาก 51,179.03 เป็น 47,193.60
Phantom การค้นแบบเดียวกันที่รันสองครั้งได้ชุด row ไม่เหมือนกัน เพราะอีก transaction commit row ที่ตรงเงื่อนไขเข้ามา จำนวน order ของลูกค้า 42 เปลี่ยนจาก 179 เป็น 180
Lost update สอง transaction อ่านค่าหนึ่ง แล้วต่างก็เขียนค่าที่คำนวณจากค่านั้น ทำให้การเขียนครั้งที่สองลบการเขียนครั้งแรกทิ้ง ขายสองครั้ง stock 10 → 9
Write skew สอง transaction อ่านข้อมูลที่ทับซ้อนกัน แล้วต่างคนต่างเขียนคนละ row แต่ละตัวไม่มีปัญหาเมื่อรันเดี่ยว ๆ แต่พอรวมกันกลับทำให้กฎพัง หัวหน้ากะทั้งคู่ลงจากเวร: อยู่เวร 0 คน

มาตรฐาน SQL บังคับอะไร มาตรฐานนิยาม level ไว้สี่ระดับด้วย anomaly สามตัวแรก (มาตรฐานเรียกมันว่า phenomena) แล้วโยนที่เหลือทั้งหมดให้ SERIALIZABLE ที่ต้องให้ผลที่การรัน transaction ชุดเดียวกันทีละตัวสักลำดับหนึ่งให้ได้ ตัว SERIALIZABLE ยังเป็น level default ของมาตรฐานด้วย

Level Dirty read Non-repeatable read Phantom PostgreSQL 18
READ UNCOMMITTED เกิดได้ เกิดได้ เกิดได้ ทำงานเป็น READ COMMITTED: ไม่มี dirty read
READ COMMITTED ห้าม เกิดได้ เกิดได้ ตามมาตรฐาน
REPEATABLE READ ห้าม ห้าม เกิดได้ phantom ก็เกิดไม่ได้
SERIALIZABLE ห้าม ห้าม ห้าม กัน anomaly ได้ทุกแบบ

มาตรฐานกำหนดแค่ขั้นต่ำที่แต่ละ level ต้องกันได้ engine จะทำได้มากกว่านั้นก็ได้ อย่างที่ PostgreSQL ทำที่ READ UNCOMMITTED และ REPEATABLE READ ส่วนใน A Critique of ANSI SQL Isolation Levels (SIGMOD 1995) ทาง Berenson, Bernstein, Gray, Melton, O'Neil และ O'Neil แสดงให้เห็นว่า phenomena ถูกเขียนไว้กำกวม และใช้อธิบาย level ที่ระบบจริงใช้กันอยู่ไม่ได้ รวมถึง level แบบ lock คลาสสิกด้วย พวกเขาเสนอให้ตีความแต่ละ phenomenon แบบกว้าง เพิ่มตัวที่มาตรฐานไม่ได้พูดถึง (เช่น dirty write, lost update, read skew และ write skew) และนิยาม snapshot isolation: ทุก transaction อ่านจาก snapshot ของข้อมูล ณ ตอนที่มันเริ่ม และถ้าสอง transaction ที่รันพร้อมกันเขียน item เดียวกัน ก็มีแค่ตัวที่ commit ก่อนที่สำเร็จ ตัว snapshot isolation กัน anomaly สามตัวของมาตรฐานได้ถ้าตีความแบบเข้ม แต่มันก็ยังไม่ serializable เพราะมันยอมให้เกิด write skew ตัว paper นี้ยังแสดงด้วยว่า REPEATABLE READ แบบใช้ lock กับ snapshot isolation จัดอันดับกันไม่ได้: ตัวแรกยอมให้เกิด phantom ที่ snapshot isolation กันได้ และกัน write skew ที่ snapshot isolation ยอมให้เกิด นี่คือเหตุผลที่ "REPEATABLE READ" ให้การรับประกันไม่เหมือนกันในแต่ละ engine

READ COMMITTED ให้ทุก statement ได้ snapshot ใหม่ของสิ่งที่ commit แล้ว ณ ตอนที่ statement นั้นเริ่ม เรื่องของ report ก็มีแค่นี้: การอ่านยอดรวมครั้งที่สองตอนที่ refund ของ B ยังไม่ commit ยังเห็น 51,179.03 ส่วนการอ่านครั้งที่สามเริ่มหลัง B commit แล้วเลยเห็น 47,193.60 การเขียนที่เจอว่า row เป้าหมายถูก transaction ที่ยังไม่จบเปลี่ยนไปจะรอ transaction นั้น ถ้า transaction นั้น commit ตัว PostgreSQL จะเช็ก WHERE clause กับ version ใหม่ของ row อีกรอบ แล้วเขียนลงไปที่ version นั้น นี่คือเหตุผลที่ UPDATE stock SET qty = 9 ของ B ผ่านไปได้หลัง A commit: row ยังตรงเงื่อนไขอยู่ แล้วเลข 9 ที่ B คำนวณจากการอ่านที่ค้างเก่าก็ไปทับเลข 9 ของ A

REPEATABLE READ คือ snapshot isolation ของ PostgreSQL ตัว transaction ถ่าย snapshot ครั้งเดียวตอน statement แรก (ไม่ใช่ตอน BEGIN) แล้วอ่านจากมันจนจบ transaction ทำให้ report อ่านได้ 51,179.03 และ 179 ทุกครั้ง ทั้งที่ refund กับ order ใหม่ commit อยู่รอบ ๆ ส่วนการเขียนจะเช็ก row เป้าหมายเทียบกับ snapshot: UPDATE ของ B รอ row lock ของ A และพอ A commit การเปลี่ยนแปลงที่ snapshot ของ B มองไม่เห็น ตัว B ก็ fail ด้วย ERROR: could not serialize access due to concurrent update ทุก serialization failure มี SQLSTATE 40001 แอปจะ rollback แล้วรันทั้ง transaction ใหม่ ที่คราวนี้อ่านได้ 9 แล้วเขียน 8 ส่วนกฎการอยู่เวรก็ยังพัง: Anan กับ Malee ต่างคน update คนละ row เลยไม่มีการเขียนที่ชนกันให้ตรวจเจอ และทั้งคู่ก็ commit ได้

SERIALIZABLE คือ snapshot isolation บวกการเช็ก serialization anomaly ที่เรียกว่า Serializable Snapshot Isolation (SSI) เทคนิคนี้เสนอโดย Cahill, Röhm และ Fekete (SIGMOD 2008) แล้ว PostgreSQL 9.1 ก็เป็น release ของ database สำหรับ production ตัวแรกที่ทำมันออกมา ตามที่ Ports และ Grittner อธิบายไว้ (VLDB 2012) แต่ละ serializable transaction บันทึกสิ่งที่มันอ่านไว้เป็น predicate lock ที่ pg_locks แสดงด้วย mode SIReadLock โดย lock พวกนี้ไม่เคย block ใคร ในการรันเรื่องการอยู่เวร แต่ละ session ถือไว้สองตัว: ตัวหนึ่งบนทั้ง table shift_leads เพราะการนับใช้ sequential scan และอีกตัวบน page 1 ของ shift_leads_pkey ที่ UPDATE … WHERE id = … ของมันอ่าน พอ transaction หนึ่งเขียนข้อมูลที่อีกตัวที่รันพร้อมกันอ่านไป ตัว PostgreSQL ก็บันทึก read/write conflict ระหว่างสองตัวนั้นไว้ ทุก anomaly ที่ snapshot isolation ยอมให้เกิดจะมี "dangerous structure" อยู่ข้างใน: คือ transaction หนึ่งตัวที่เป็น pivot ที่มี read/write conflict ทั้งขาเข้าหนึ่งตัวและขาออกอีกหนึ่งตัว ตรงนี้ B อ่าน row ของ Anan ที่ A เปลี่ยนทีหลัง และ A อ่าน row ของ Malee ที่ B เปลี่ยนทีหลัง ทำให้ B เป็น pivot ตัว A commit ก่อน แล้ว COMMIT ของ B ก็ fail ด้วย could not serialize access due to read/write dependencies among transactions พร้อม detail Canceled on identification as a pivot, during commit attempt พอ retry ตัว B นับได้ว่ามีหัวหน้ากะอยู่เวร 1 คน แอปเลยให้ Malee อยู่เวรต่อ การเช็กนี้อาจรายงาน failure ในจุดที่ไม่มี anomaly จริงก็ได้ แบบนั้นเสียแค่การ retry เพิ่มอีกรอบ แต่ไม่มีทางให้ผลที่ผิด

สิ่งที่การรันของเราเห็นบน PostgreSQL 18.6 READ COMMITTED REPEATABLE READ SERIALIZABLE
Dirty read ไม่เกิด ไม่เกิด ไม่เกิด
Non-repeatable read เกิด: 47,193.60 ไม่เกิด ไม่เกิด
Phantom เกิด: 180 ไม่เกิด ไม่เกิด
Lost update เกิด: stock 9 40001 แล้ว retry: 8 40001 แล้ว retry: 8
Write skew เกิด: อยู่เวร 0 คน เกิด: อยู่เวร 0 คน 40001 แล้ว retry: Malee อยู่ต่อ

ลองรันดู

ข้อมูลตัวอย่างมาจาก samples/acme-shop ที่เป็น script สร้าง template database acme บน PostgreSQL 18 ในเวลาราวหนึ่งนาที

รันใน psql โดยต่อเข้าไปเป็น superuser ตัว session ของ psql คือ A ส่วน session B และ C คือ connection ที่สองและที่สามที่เปิดด้วย extension dblink ที่มากับ PostgreSQL อยู่แล้ว ตัว server เปิด connection พวกนั้นเองผ่าน local socket โดยไม่ใช้ password แบบที่ Docker image ทางการยอมให้ทำได้ ถ้ารันที่อื่นให้เพิ่ม host และ password ลงใน connection string ตัว dblink_send_query เริ่ม UPDATE ของ B โดยไม่รอให้มันเสร็จ เพื่อให้ A commit ได้ระหว่างที่ B รอ lock ของ A อยู่ ตัว comment แสดงผลของการรันหนึ่งรอบ ส่วน level ที่เขียนไว้ใน comment ตรง BEGIN แต่ละตัวจะให้ผลอื่น ๆ ใน table ข้างบน

CREATE DATABASE isolation_levels_try TEMPLATE acme STRATEGY FILE_COPY;
\c isolation_levels_try
CREATE EXTENSION dblink;
CREATE TABLE stock (product_id bigint PRIMARY KEY REFERENCES products, qty int NOT NULL);
INSERT INTO stock VALUES (2892, 10);
CREATE TABLE shift_leads (id int PRIMARY KEY, name text NOT NULL, on_duty boolean NOT NULL);
INSERT INTO shift_leads VALUES (1, 'Anan', true), (2, 'Malee', true);
SELECT dblink_connect('b', 'dbname=isolation_levels_try');   -- session B
SELECT dblink_connect('c', 'dbname=isolation_levels_try');   -- session C
\set tot 'SELECT total_thb FROM orders WHERE id = 495179'
\set cnt 'SELECT count(*) FROM orders WHERE customer_id = 42'

-- 1. The report (this session, A) while B refunds and C places an order
BEGIN ISOLATION LEVEL READ COMMITTED;   -- then run it again with REPEATABLE READ
:tot;                                                       -- 51179.03
:cnt;                                                       -- 179
SELECT dblink_exec('b', 'BEGIN');
SELECT dblink_exec('b', 'UPDATE orders SET total_thb = total_thb - 3985.43 WHERE id = 495179');
:tot;                                                       -- 51179.03  (B not committed)
SELECT dblink_exec('b', 'COMMIT');
SELECT * FROM dblink('c', $$INSERT INTO orders (customer_id, status, shipping_country, created_at, total_thb)
  VALUES (42, 'pending', 'TH', '2026-10-02 18:00+00', 1290.00) RETURNING id$$) AS t(id bigint);   -- 500001
:tot;                                                       -- 47193.60  REPEATABLE READ: 51179.03
:cnt;                                                       -- 180       REPEATABLE READ: 179
COMMIT;
UPDATE orders SET total_thb = 51179.03 WHERE id = 495179;   -- undo, for the next run
DELETE FROM orders WHERE id > 500000;
SELECT setval(pg_get_serial_sequence('orders', 'id'), 500000);

-- 2. Two staff sell product 2892: read the stock, then write the new value
BEGIN ISOLATION LEVEL REPEATABLE READ;  -- READ COMMITTED: B's UPDATE returns UPDATE 1; committed, it leaves 9
SELECT qty FROM stock WHERE product_id = 2892;              -- 10
SELECT dblink_exec('b', 'BEGIN ISOLATION LEVEL REPEATABLE READ');
SELECT * FROM dblink('b', 'SELECT qty FROM stock WHERE product_id = 2892') AS t(qty int);   -- 10
UPDATE stock SET qty = 9 WHERE product_id = 2892;
SELECT dblink_send_query('b', 'UPDATE stock SET qty = 9 WHERE product_id = 2892');   -- B waits for A
SELECT pg_sleep(0.5);
COMMIT;
SELECT * FROM dblink_get_result('b', false) AS t(status text);
-- NOTICE:  could not serialize access due to concurrent update        (SQLSTATE 40001)
SELECT * FROM dblink_get_result('b') AS t(status text);    -- (0 rows): B's connection is free
SELECT dblink_exec('b', 'ROLLBACK');                        -- B retries in a new transaction
SELECT dblink_exec('b', 'BEGIN ISOLATION LEVEL REPEATABLE READ');
SELECT * FROM dblink('b', 'SELECT qty FROM stock WHERE product_id = 2892') AS t(qty int);   -- 9
SELECT dblink_exec('b', 'UPDATE stock SET qty = 8 WHERE product_id = 2892');
SELECT dblink_exec('b', 'COMMIT');
SELECT qty FROM stock WHERE product_id = 2892;              -- 8

-- 3. Write skew: at least one shift lead must stay on duty
BEGIN ISOLATION LEVEL SERIALIZABLE;     -- with REPEATABLE READ, both commit and 0 stay on duty
SELECT count(*) FROM shift_leads WHERE on_duty;             -- 2
SELECT dblink_exec('b', 'BEGIN ISOLATION LEVEL SERIALIZABLE');
SELECT * FROM dblink('b', 'SELECT count(*) FROM shift_leads WHERE on_duty') AS t(n bigint);   -- 2
UPDATE shift_leads SET on_duty = false WHERE id = 1;
SELECT dblink_exec('b', 'UPDATE shift_leads SET on_duty = false WHERE id = 2');
SELECT mode, locktype, relation::regclass, page FROM pg_locks WHERE mode = 'SIReadLock' ORDER BY pid, 2;
--     mode    | locktype |     relation     | page    (one row like these per session)
--  SIReadLock | page     | shift_leads_pkey |    1
--  SIReadLock | relation | shift_leads      |
COMMIT;
SELECT dblink_exec('b', 'COMMIT');
-- ERROR:  could not serialize access due to read/write dependencies among transactions
-- DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
SELECT name, on_duty FROM shift_leads ORDER BY id;          -- Anan f, Malee t

ใช้ตอนไหนดี

เลือก level ทีละ transaction ตามสิ่งที่ transaction นั้นต้องการ:

  • READ COMMITTED เหมาะกับ transaction ส่วนใหญ่ที่ทำงานจบใน statement เดียว: UPDATE stock SET qty = qty - 1 WHERE product_id = 2892 AND qty > 0 อ่านและเขียน row ปัจจุบันในขั้นเดียว การขายที่เกิดพร้อมกันเลยต่อคิวกันที่ row lock และไม่มีตัวไหนหาย แต่มันเป็น level ที่ผิดสำหรับการอ่าน แล้วตัดสินใจในแอป แล้วค่อยเขียนจากผลการอ่านนั้น ยกเว้นการอ่านนั้น lock row ที่มันพึ่งพาไว้ (ดูข้างล่าง)
  • REPEATABLE READ เหมาะกับ report และ export ที่รันหลาย query แล้วต้องการให้ผลตรงกัน อย่าง report การเงิน ที่ level นี้ transaction ที่อ่านอย่างเดียวจะไม่ fail ด้วย serialization error เลย ส่วน transaction ที่อ่าน ตัดสินใจ แล้วเขียน row เดียวกัน จะได้ 40001 แทนที่จะเกิด lost update และต้อง retry
  • SERIALIZABLE เหมาะกับกฎที่ครอบหลาย row หรือหลาย table และไม่มี constraint ไหนเขียนแทนได้: มีหัวหน้ากะอยู่เวรอย่างน้อยหนึ่งคน, ไม่จองห้องซ้อนกัน, วงเงินเครดิตที่รวมทุกบัญชีของลูกค้า แต่ละ transaction แค่ต้องถูกต้องเมื่อรันเดี่ยว ๆ แล้ว database จะ rollback ตัวไหนก็ตามที่ผลของการรันพร้อมกันอาจต่างจากการรันทีละตัว ทุก transaction ที่เกี่ยวข้อง รวมถึงตัวที่อ่านอย่างเดียว ต้องมี retry loop
  • lock แบบ explicit คือทางเลือกที่ READ COMMITTED ถ้ารู้ว่าการตัดสินใจพึ่งพา row ไหน SELECT qty FROM stock WHERE product_id = 2892 FOR UPDATE ทำให้ B รอตั้งแต่ตอนอ่าน แล้ว B ก็อ่านได้ 9 และเขียน 8 โดยไม่ต้อง retry ส่วนกฎการอยู่เวร คำสั่ง SELECT id, name FROM shift_leads WHERE on_duty FOR UPDATE จะ lock row ของหัวหน้ากะทั้งสองคน: B รอ A แล้วได้แค่ row ของ Malee เพราะ row ของ Anan ไม่ตรงเงื่อนไขอีกต่อไป ส่วน lock ปกป้อง row ที่ยังไม่มีอยู่ไม่ได้ ถ้าเป็น phantom insert ต้องใช้ SERIALIZABLE, unique constraint หรือ exclusion constraint หรือ lock บน row แม่
  • constraint ดีกว่าทุกตัวข้างบนในจุดที่ใช้ได้: CHECK (qty >= 0) คู่กับ UPDATE แบบ statement เดียว, unique index, exclusion constraint ที่กันการจองที่ทับซ้อนกัน

ได้อะไร เสียอะไร

  • retry กิน throughput ตอนที่แย่งกันเยอะ pgbench แปด session ขาย product 2892 นาน 10 วินาทีต่อแบบ (median ของการรันสามครั้งบน laptop ที่เป็นเครื่องใช้ร่วมกัน ให้มองตัวเลขเป็นค่าคร่าว ๆ):

    แต่ละการขาย update stock ยัง⁠ไง ขายได้ใน 10 s Retry ยอมแพ้หลังลอง 10 ครั้ง stock ที่ลดลง
    READ COMMITTED อ่านแล้วเขียน 8,537 0 0 1,090 (หายไป 7,447 ครั้ง)
    REPEATABLE READ อ่านแล้วเขียน 2,707 17,059 1,841 2,707
    READ COMMITTED, SELECT … FOR UPDATE 6,902 0 0 6,902
    READ COMMITTED, UPDATE … SET qty = qty - 1 9,718 0 0 9,718

    pgbench แค่รัน transaction ที่ fail ซ้ำหลัง rollback ส่วนโค้ดจริงควรรอระหว่างแต่ละรอบด้วย backoff และ jitter และจำกัดจำนวนรอบไว้ด้วย ส่วน conflict แบบนี้เกิดแค่ระหว่าง transaction ที่เขียน row เดียวกัน row ร้อนตัวเดียวเลยเป็นกรณีที่แย่ที่สุด ถ้ากระจายไปหลาย product โหลดเท่ากันจะชนกันน้อยกว่ามาก

  • retry ทั้ง transaction และเฉพาะตัว transaction เท่านั้น รันทุกอย่างใหม่ตั้งแต่ BEGIN รวมถึงการอ่านและ logic ในแอปที่เลือกค่าต่าง ๆ เพราะข้อมูลที่มันใช้ตัดสินใจเปลี่ยนไปแล้ว ตัว PostgreSQL ไม่ retry ให้เอง ต้อง retry 40001 เสมอ และ error deadlock 40P01 ด้วย (ดู Locks & Deadlocks) เก็บ side effect อย่าง email, payment และ message ไว้นอกส่วนที่ retry หรือทำให้มันรันซ้ำได้อย่างปลอดภัยด้วย idempotency key (Idempotent Consumer)

  • transaction ที่ยาวเจ็บหนักกว่าที่ level สูง transaction แบบ REPEATABLE READ หรือ SERIALIZABLE เก็บ snapshot ของมันไว้จนจบ ยิ่งรันนาน ก็ยิ่งมีการเปลี่ยนแปลงที่รันพร้อมกันให้ชนได้มากขึ้น ที่ SERIALIZABLE ตัว SIRead lock ของมันมักอยู่นานกว่า commit จนกว่า transaction แบบอ่านเขียนที่รันซ้อนกับมันจะจบ ส่วน snapshot ที่เปิดค้างอยู่ก็ทำให้การเก็บกวาด row version เก่าต้องรอด้วย (MVCC) ให้ transaction สั้นเข้าไว้ รัน report ยาว ๆ เป็น SERIALIZABLE READ ONLY DEFERRABLE (มันอาจรอ snapshot ที่ปลอดภัยหนึ่งครั้ง แล้วหลังจากนั้นก็ไม่มีทาง fail และไม่ทำให้ตัวอื่น fail ด้วย serialization error) และตั้ง idle_in_transaction_session_timeout หรือ transaction_timeout (PostgreSQL 17 ขึ้นไป)

  • SSI ต้องดูแลนิดหน่อย sequential scan จะถือ predicate lock บนทั้ง table ทำให้มีโอกาสเกิด false-positive failure มากขึ้น ส่วน index ช่วยให้ lock แคบลง พอ table ของ predicate lock มี memory ไม่พอ ตัว PostgreSQL จะรวม lock เป็นตัวที่หยาบขึ้น ทำให้ failure เพิ่มขึ้นอีก (max_pred_locks_per_transaction และค่าที่คล้ายกันใช้เพิ่ม limit ได้) ประกาศ transaction ที่อ่านอย่างเดียวเป็น READ ONLY ใช้ pool คุมจำนวน connection ที่ active ไว้ให้น้อย และเอา lock แบบ explicit ที่ไม่จำเป็นแล้วเมื่อใช้ SERIALIZABLE ออกไป

  • lock แบบ explicit แลก retry กับการรอ session ที่รออยู่ก็ถือ connection ไว้ และสอง transaction ที่ lock row ชุดเดียวกันคนละลำดับจะเกิด deadlock

  • isolation จบอยู่ที่ database business process ที่กระจายไปหลาย service และหลาย database ไม่ได้ isolation ระหว่าง local transaction ของมันเลย saga ต้องออกแบบรับสถานะระหว่างทางและชดเชยแทน ส่วน optimistic concurrency ที่ใช้ version column ช่วยปกป้อง record ในช่วงที่ user กำลังคิด ช่วงที่ไม่มี transaction ไหนเปิดค้างไว้ได้

ข้อควรรู้ตอนลงมือทำ

  • ตั้งค่า level ใช้ BEGIN ISOLATION LEVEL … หรือ SET TRANSACTION ISOLATION LEVEL … ก่อน query แรกของ transaction ส่วน default_transaction_isolation เปลี่ยนค่า default ของ session, role (ALTER ROLE … SET) หรือ database ได้ และ driver ส่วนใหญ่ก็ให้ตั้ง level บน connection หรือ transaction object ให้เช็กว่า driver ของคุณส่งค่านี้ไปจริง อย่าเดาเอา
  • error code serialization failure มี SQLSTATE 40001 (serialization_failure) เสมอ ไม่ว่าข้อความจะเป็นอะไร ส่วน deadlock มี 40P01 ตัว unique violation (23505) ก็อาจเป็นปัญหา serialization ที่แฝงมาได้ เช่น สอง transaction ที่ต่างก็เช็กแล้วว่า key ยังว่าง แล้วก็ insert key นั้น เอกสารของ PostgreSQL แนะนำให้ retry เคสพวกนี้เฉพาะตอนที่น่าจะเกิดจากสาเหตุนี้ เพราะมันอาจเป็น error ถาวรได้ด้วย
  • MySQL 8.4 (InnoDB) ใช้ REPEATABLE READ เป็น default ตัว SELECT ธรรมดาเป็น consistent read จาก snapshot ที่ถ่ายไว้ตอนการอ่านครั้งแรกของ transaction ทำให้บน MySQL ตัว report เห็น 51,179.03 ตลอด ส่วน locking read (FOR SHARE, FOR UPDATE), UPDATE และ DELETE ทำงานกับ row ล่าสุดที่ commit แล้วแทน: มัน lock สิ่งที่มันเจอ และถ้าเป็น range ก็ lock ช่องว่างระหว่าง index record ด้วย (next-key lock) ทำให้ session อื่น insert เข้ามาใน range นั้นไม่ได้ SELECT count(*) … FOR SHARE บนลูกค้า 42 ทำให้ insert ของลูกค้า 42 ที่เกิดพร้อมกันต้องรอ ส่วน InnoDB ต่างจาก REPEATABLE READ ของ PostgreSQL ตรงที่มันไม่ทำให้การเขียน row ที่เปลี่ยนไปหลัง snapshot fail: การขายบน MySQL ทำ update หายไปโดยไม่มี error ตัว UPDATE ของ B รายงาน Rows matched: 1, Changed: 0 และ stock จบที่ 9 แต่ถ้าใส่ FOR UPDATE ให้การอ่าน stock ก็จะจบที่ 8 ที่ SERIALIZABLE ตัว InnoDB จะเปลี่ยน SELECT ธรรมดาเป็น SELECT … FOR SHARE ตอนที่ปิด autocommit ไว้ ทำให้ทั้งสอง session ถือ shared lock ไว้ แล้ว UPDATE ของ B ก็เจอ deadlock (ERROR 1213 (40001)) และถูก rollback ส่วนการรัน write skew ก็ fail แบบเดียวกัน ส่วน READ UNCOMMITTED บน InnoDB อ่านข้อมูลที่ยังไม่ commit จริง ๆ: report ของเราอ่านได้ 47,193.60 ตอนที่ refund ยังเปิดอยู่
  • SQL Server ใช้ READ COMMITTED เป็น default ถ้าปิด database option READ_COMMITTED_SNAPSHOT ไว้ (ค่า default ของ SQL Server และ Azure SQL Managed Instance) การอ่านจะถือ shared lock และรอ writer ถ้าเปิดไว้ (ค่า default บน Azure SQL Database) แต่ละ statement จะอ่านจาก snapshot ของ row version แทน ไม่ว่าแบบไหนก็เป็นระดับ statement ทั้งนั้น anomaly ของ report, lost update และ write skew เลยยังเกิดได้ ส่วน level SNAPSHOT (หลังเปิด ALLOW_SNAPSHOT_ISOLATION ON) เป็น snapshot isolation ระดับ transaction ที่จบ transaction ด้วย error 3960 เมื่อ update ชนกัน ส่วน SERIALIZABLE ของมันใช้ key-range lock ที่ถือไว้จนจบ transaction
  • Oracle Database ใช้ READ COMMITTED เป็น default และมี SERIALIZABLE กับ READ ONLY ให้ใช้ ตัว SERIALIZABLE ของมันอ่านจาก snapshot ที่ถ่ายไว้ตอน transaction เริ่ม แล้ว fail ด้วย ORA-08177 ตอนพยายามเปลี่ยน row ที่ transaction อื่นเปลี่ยนและ commit หลังจุดนั้น คล้าย REPEATABLE READ ของ PostgreSQL มาก
  • managed service บน Amazon RDS & Aurora คุณรันตัว engine เองพร้อม level ของมัน มีความต่างหนึ่งข้อที่มีในเอกสาร: writer ของ cluster Aurora MySQL มี level สี่ระดับเหมือน RDS for MySQL แต่ read-only replica ของมันใช้ REPEATABLE READ เสมอและไม่สน SET TRANSACTION ISOLATION LEVEL ยกเว้น session จะเปิด aurora_read_replica_read_committed เพื่อรัน query ยาว ๆ ที่ READ COMMITTED ส่วน Amazon DynamoDB ไม่มี level ให้เลือก: TransactWriteItems และ TransactGetItems เป็น serializable เมื่อเทียบกับการเขียนอื่นและกับ GetItem ส่วน Query, Scan และ BatchGetItem ทั้งก้อนเป็นแค่ read-committed เมื่อเทียบกับ transaction ถ้าเป็นการอ่านแล้วเขียน item เดียว conditional write ที่เช็ก version attribute จะตรวจเจอ conflict แล้วแอปก็ retry (Optimistic Concurrency Control)
  • การรันใน diagram ใช้สำเนาของข้อมูลตัวอย่างบน PostgreSQL 18.6 และ server MySQL 8.4.11 แต่ละ session เป็น client process แยกกัน ที่มี script ป้อน statement ให้ตามลำดับที่แสดง โดยหยุดครึ่งวินาทีถึงหนึ่งวินาทีทุกครั้งที่ session หนึ่งต้องรอ lock ของอีกตัว