Transaction isolation levels คือตัวกำหนดว่า transaction หนึ่งจะมองเห็นงานของ transaction อื่นที่รันอยู่พร้อมกันได้แค่ไหน ในโลกอุดมคติ transaction ที่รันพร้อมกันควรให้ผลลัพธ์เหมือนรันทีละตัวเรียงกัน แต่การบังคับแบบนั้นเต็มรูปแบบมีต้นทุนสูง SQL standard จึงนิยามไว้ 4 ระดับ แต่ละระดับยอมให้เกิด anomaly ต่างกัน แลกกับการรันพร้อมกันได้มากขึ้น
บั๊กในเรื่องนี้ส่วนใหญ่ไม่ได้เกิดจากการเลือก level "ผิด" แต่เกิดจากการไม่รู้ว่า level ที่ใช้อยู่ยังปล่อย anomaly อะไรผ่านไปได้บ้าง บทความนี้จะไล่ anomaly ทีละตัว ดูว่า PostgreSQL 16 ทำแต่ละ level อย่างไร (ซึ่งต่างจาก standard ในทางที่เป็นประโยชน์) และใช้พื้นที่ส่วนใหญ่กับ write skew ซึ่งเป็น anomaly ที่ row lock และ atomic update จับไม่ได้ และเป็นเหตุผลหลักที่ SERIALIZABLE ต้องมีอยู่
Anomaly ที่ transaction isolation levels แต่ละระดับยอมให้เกิด
แต่ละ level ถูกนิยามด้วย anomaly ที่มันกันได้ ไล่จากเบาไปหาซับซ้อน:
Dirty read คืออ่านเจอข้อมูลที่อีก transaction เขียนไว้แต่ยังไม่ commit ถ้า transaction นั้น rollback ทีหลัง แปลว่าเราตัดสินใจจากค่าที่ไม่เคยมีอยู่จริง
Non-repeatable read คืออ่านแถวเดิมสองครั้งใน transaction เดียวแล้วได้ค่าไม่เท่ากัน เพราะมีคนแก้แถวนั้นแล้ว commit คั่นกลาง
Phantom read คือรัน query เงื่อนไขเดิมสองครั้ง เช่น WHERE amount > 100 แล้วได้ชุดแถวไม่เหมือนกัน เพราะมีคน insert หรือ delete แถวที่เข้าเงื่อนไขในระหว่างนั้น
Lost update คือสอง transaction อ่านแถวเดียวกัน ต่างคนต่างคำนวณค่าใหม่ในโค้ดแอป แล้วเขียนกลับ ตัวที่เขียนทีหลังทับของตัวแรกไปเงียบ ๆ:
-- T1 and T2 both run this at the same time; balance starts at 100
SELECT balance FROM accounts WHERE id = 1; -- both see 100
-- app computes 100 + 50 (T1) and 100 - 30 (T2)
UPDATE accounts SET balance = 150 WHERE id = 1; -- T1
UPDATE accounts SET balance = 70 WHERE id = 1; -- T2 wins, T1's deposit is goneWrite skew คือสอง transaction อ่าน ชุด แถวเดียวกัน ต่างคนต่างตัดสินใจซึ่งถูกต้องเมื่อมองแยกกัน แล้วไปเขียน คนละแถว พอรวมกันแล้วกลับละเมิดกฎที่ไม่มีใครละเมิดคนเดียว ตัวนี้จะอธิบายละเอียดข้างล่าง
Transaction isolation levels ตาม SQL standard และใน PostgreSQL
Standard นิยาม level ตาม anomaly สามตัวแรกว่าตัวไหนห้ามเกิด ส่วน PostgreSQL เข้มกว่า standard อยู่สองจุด:
| Level | Dirty read | Non-repeatable read | Phantom read | Lost update | Write skew |
|---|---|---|---|---|---|
| Read Uncommitted | standard อนุญาต แต่ PostgreSQL ไม่เกิด | เกิดได้ | เกิดได้ | เกิดได้ | เกิดได้ |
| Read Committed (ค่า default ของ PostgreSQL) | ไม่เกิด | เกิดได้ | เกิดได้ | เกิดได้ | เกิดได้ |
| Repeatable Read | ไม่เกิด | ไม่เกิด | standard อนุญาต แต่ PostgreSQL ไม่เกิด | ไม่เกิด (error แล้ว retry) | เกิดได้ |
| Serializable | ไม่เกิด | ไม่เกิด | ไม่เกิด | ไม่เกิด | ไม่เกิด |
รายละเอียดเฉพาะของ PostgreSQL ที่ควรรู้:
Read Committed
แต่ละ statement เห็น snapshot ใหม่ของข้อมูลที่ commit แล้วก่อน statement นั้นเริ่ม ดังนั้น SELECT สองครั้งใน transaction เดียวกันอาจได้ผลไม่เท่ากัน
เมื่อ UPDATE หรือ DELETE ไปเจอแถวที่อีก transaction กำลังแก้อยู่ มันจะรอให้ transaction นั้นจบก่อน แล้วเช็คเงื่อนไข WHERE ใหม่กับ version ล่าสุดของแถว พฤติกรรมนี้เองที่ทำให้คำสั่งเดียวแบบมีเงื่อนไข อย่าง UPDATE ... SET balance = balance - 80 WHERE balance >= 80 ปลอดภัยใน level นี้ แต่การอ่านแยกแล้วค่อยเขียนแยกไม่ปลอดภัย
Repeatable Read
PostgreSQL ทำ level นี้ด้วย snapshot isolation คือ transaction จะถ่าย snapshot ครั้งเดียวตอน statement แรก (ไม่ใช่ตอน BEGIN) แล้วเห็นแค่ snapshot นั้นไปจนจบ อ่านแถวเดิมซ้ำก็ได้ค่าเดิม รัน query ซ้ำก็ได้แถวชุดเดิม เลยไม่เจอ phantom ในการอ่านด้วย
ถ้า transaction ที่เป็น Repeatable Read พยายาม update หรือ lock แถวที่ transaction อื่นแก้แล้ว commit ไปหลังจากถ่าย snapshot PostgreSQL จะไม่เขียนทับ แต่โยน error:
ERROR: could not serialize access due to concurrent updateError นี้กัน lost update ข้างบนได้ แต่แอปต้องจับ error แล้ว retry ทั้ง transaction เอง สิ่งที่ snapshot isolation กันไม่ได้ คือ write skew เพราะใน write skew สอง transaction ไม่ได้แตะแถวเดียวกันเลย
InnoDB ของ MySQL ก็ใช้ Repeatable Read เป็นค่า default เหมือนกัน แต่ความหมายไม่เหมือน PostgreSQL อย่าเดาว่าพฤติกรรมจะเหมือนกันข้าม database ให้ทดสอบจริง
Write skew: anomaly ที่ row lock จับไม่ได้
สมมติธนาคารให้ลูกค้ามีบัญชีออมทรัพย์และบัญชีกระแสรายวัน กฎคือ บัญชีไหนจะติดลบก็ได้ แต่ยอดรวมสองบัญชีห้ามต่ำกว่าศูนย์
CREATE TABLE accounts (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL,
kind text NOT NULL CHECK (kind IN ('savings', 'checking')),
balance numeric(14, 2) NOT NULL
);
-- customer 7 holds 100 in each account, 200 in totalมีคำขอถอนเงิน 200 เข้ามาพร้อมกันสองรายการ รายการหนึ่งถอนจาก savings อีกรายการถอนจาก checking แต่ละ transaction เช็คกฎก่อนแล้วค่อยถอน:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT sum(balance) FROM accounts WHERE customer_id = 7; -- 200, rule OK
UPDATE accounts SET balance = balance - 200
WHERE customer_id = 7 AND kind = 'savings'; -- the other one uses 'checking'
COMMIT;ลำดับการทำงานที่สลับกันเป็นแบบนี้:
| ขั้น | Transaction A (savings) | Transaction B (checking) |
|---|---|---|
| 1 | sum = 200, 200 - 200 >= 0 ผ่าน | |
| 2 | sum = 200, 200 - 200 >= 0 ผ่าน | |
| 3 | savings = -100 | |
| 4 | checking = -100 | |
| 5 | COMMIT สำเร็จ | COMMIT สำเร็จ |
ลูกค้าเหลือยอดรวม -200 ทั้งที่ไม่มี transaction ไหนทำผิดตามมุมมองของตัวเองเลย ต่างคนต่างอ่าน snapshot ที่ถูกต้องซึ่งยอดรวมยังเป็น 200 และตัดสินใจถูก เมื่อมองแยก แต่รวมกันแล้วผิดกฎ
ลองดูว่าอะไรช่วยไม่ได้บ้าง:
- Repeatable Read เพราะ A เขียน savings ส่วน B เขียน checking เป็นคนละแถว การเช็ค "concurrent update" จึงไม่ทำงาน
SELECT ... FOR UPDATEบนแถวที่ตัวเองจะแก้ A ล็อก savings ส่วน B ล็อก checking ล็อกไม่มีวันชนกัน- Atomic conditional update เพราะ
WHERE balance >= 200ดูได้แค่แถวเดียว แต่กฎพันสองแถว
รูปแบบเดียวกันนี้โผล่ในหลายโดเมน เช่น หมอสองคนขอออกเวรพร้อมกันเพราะต่างคนต่างเห็นว่าอีกคนยังอยู่เวร, จองห้องช่วงเวลาเดียวกันสองรายการ, หรือสมัครสมาชิกด้วย username ซ้ำกัน สองกรณีหลังหนักกว่า เพราะแถวที่ขัดแย้งกันยังไม่มีอยู่จริงตอนที่ทั้งสองฝั่งเช็ค เลยไม่มีอะไรให้ล็อกเลย
SSI ของ PostgreSQL กัน write skew ได้อย่างไร
ที่ระดับ SERIALIZABLE PostgreSQL ใช้เทคนิคชื่อ Serializable Snapshot Isolation (SSI) ในขณะที่ database หลายตัวทำ serializable ด้วยการล็อกการอ่านหนัก ๆ จนฝั่งอ่านกับฝั่งเขียนต้องรอกัน SSI ใช้วิธีต่างออกไป:
- ทุก transaction ยังรันบน snapshot ของตัวเองเหมือน Repeatable Read ทุกประการ การอ่านไม่บล็อกการเขียน และการเขียนไม่บล็อกการอ่าน
- PostgreSQL คอยจดเพิ่มว่า แต่ละ transaction อ่านอะไรไปบ้าง ผ่าน predicate lock แบบเบา (
SIReadLockในpg_locks) ซึ่งไม่บล็อกใคร เป็นแค่การทำบัญชี - เมื่อ transaction หนึ่งเขียนข้อมูลที่อีก transaction ที่รันพร้อมกันเคยอ่านไป PostgreSQL จะบันทึกความสัมพันธ์ read-write dependency ระหว่างสองตัวนั้น
- Serialization anomaly ทุกแบบจะมีรูปแบบหนึ่งอยู่ในนั้นเสมอ คือมี transaction ที่มีทั้ง read-write dependency ขาเข้าและขาออกกับ transaction ที่รันพร้อมกัน เมื่อ PostgreSQL เจอ "dangerous structure" นี้ มันจะยกเลิก transaction ตัวใดตัวหนึ่งในวงนั้นทิ้ง
ลองรันตัวอย่าง savings/checking ด้วย BEGIN ISOLATION LEVEL SERIALIZABLE ตัวแรกจะ 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.
HINT: The transaction might succeed if retried.พอ transaction ที่ล้ม retry ใหม่ มันจะเห็นการถอนจาก savings ที่ commit แล้ว ยอดรวมเหลือ 100 การเช็คกฎไม่ผ่าน และการถอนถูกปฏิเสธอย่างถูกต้อง
การตรวจนี้เป็นแบบระวังไว้ก่อน อาจ abort transaction ที่จริง ๆ ไม่มีปัญหาได้ (false positive) แต่จะไม่ปล่อย anomaly จริงผ่านไป Error อาจมาตอน statement ไหนก็ได้หรือตอน COMMIT โดยมี SQLSTATE เป็น 40001
ใช้ SERIALIZABLE ให้ถูกวิธี
- ทุก transaction ที่เกี่ยวข้องต้องรันที่
SERIALIZABLEการรับประกันครอบคลุมเฉพาะ transaction ที่เป็น serializable ด้วยกัน ถ้ามี transaction ระดับ Read Committed มาเขียนตารางเดียวกัน ก็ยังเกิด anomaly ได้ - Retry เมื่อเจอ
40001และนับ deadlock (40P01) เป็นกรณีเดียวกัน ต้อง retry ทั้ง transaction ไม่ใช่แค่ statement ที่ล้ม - ทำ transaction ให้สั้น ยิ่งรันนาน ยิ่งสะสม dependency เยอะ และยิ่งมีโอกาสโดนยกเลิก
- ประกาศงานที่อ่านอย่างเดียว
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLEทำให้ report ยาว ๆ รอ snapshot ที่ปลอดภัยก่อน แทนที่จะเสี่ยงโดนยกเลิกกลางทาง - ทำ index ให้เงื่อนไขที่ query ใช้ predicate lock ผูกกับเส้นทางที่ใช้เข้าถึงข้อมูล ถ้าเป็น sequential scan จะล็อกทั้งตาราง ทำให้ false positive เยอะขึ้น อ่านเพิ่มได้ที่ คู่มือวางกลยุทธ์ index ฐานข้อมูล
- อย่าเชื่อข้อมูลที่อ่านใน serializable transaction จนกว่าจะ commit ผ่าน transaction ที่โดนยกเลิกภายหลังอาจเคยเห็นสถานะที่ไม่สอดคล้องกันมาก่อน
ตัวอย่าง retry wrapper ใน Go 1.22 กับ pgx v5:
package txutil
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
func isRetryable(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) &&
(pgErr.Code == "40001" || pgErr.Code == "40P01")
}
// Serializable runs fn in a SERIALIZABLE transaction and retries on
// serialization failures. fn must not have side effects outside the database.
func Serializable(ctx context.Context, pool *pgxpool.Pool, fn func(pgx.Tx) error) error {
const maxAttempts = 5
opts := pgx.TxOptions{IsoLevel: pgx.Serializable}
var err error
for attempt := range maxAttempts {
err = pgx.BeginTxFunc(ctx, pool, opts, fn)
if !isRetryable(err) {
return err
}
backoff := time.Duration(1<<attempt) * 10 * time.Millisecond
jitter := time.Duration(rand.Int64N(int64(backoff)))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff + jitter):
}
}
return fmt.Errorf("serializable transaction failed after %d attempts: %w", maxAttempts, err)
}เนื่องจาก fn อาจถูกรันมากกว่าหนึ่งครั้ง อย่าใส่การเรียก HTTP การส่งอีเมล หรือการ publish เข้าคิวไว้ข้างใน ให้ทำหลัง commit สำเร็จแล้ว
ทางเลือกเมื่อ SERIALIZABLE แพงเกินไป
SERIALIZABLE มีต้นทุนทั้งการ retry, throughput ที่ลดลงบ้าง และโค้ด retry ที่ต้องมีทุกจุดที่เรียกใช้ ถ้า write skew จำกัดอยู่ที่กฎข้อเดียวที่รู้อยู่แล้ว มีวิธีที่ถูกกว่า:
- Materialize ความขัดแย้ง เก็บยอดรวมของลูกค้าไว้ในแถวเดียว แล้วบังคับให้ทุกการถอนต้อง update แถวนั้นด้วย พอสอง transaction ต้องเขียนแถวเดียวกัน row lock ปกติก็เปลี่ยน write skew ให้กลายเป็น conflict ที่ database จัดการได้อยู่แล้ว
- ล็อก parent row รัน
SELECT ... FROM customers WHERE id = $1 FOR UPDATEก่อนเช็คกฎ ทุก operation ของลูกค้าคนนั้นจะต่อคิวหลังล็อกนี้ - ให้ constraint บังคับกฎ
CHECK,UNIQUE, partial unique index และEXCLUDEconstraint ถูกตรวจโดย database เสมอ ไม่ว่า transaction จะสลับลำดับกันอย่างไร
นี่คือเครื่องมือภาคปฏิบัติสำหรับระบบที่เคลื่อนย้ายเงิน ซึ่งมีโค้ดตัวอย่างครบในบทความ ป้องกัน race condition ในระบบ payment
เลือก transaction isolation level อย่างไร
| Level | ใช้เมื่อ | ต้นทุน |
|---|---|---|
| Read Committed | ค่า default สำหรับงาน OLTP ส่วนใหญ่ ใช้คู่กับ atomic update, row lock และ constraint | ต้องคิดเรื่อง race เอง |
| Repeatable Read | report หรือการอ่านหลาย query ที่ต้องเห็น snapshot เดียวกัน | ต้อง retry เมื่อเจอ concurrent update และยังเกิด write skew ได้ |
| Serializable | กฎที่พันหลายแถวและเขียนเป็น constraint หรือ lock ได้ยาก | retry, throughput ลดเมื่อแย่งกันสูง, ต้องมีโค้ด retry ทุกที่ |
แนวทางที่พบบ่อยและสมเหตุสมผลคือใช้ Read Committed เป็นค่า default แล้วยกระดับเฉพาะ transaction ที่มีกฎข้ามหลายแถวจริง ๆ ถ้าอยากเห็นภาพว่า isolation ทำงานร่วมกับ atomicity และ row version แบบ MVCC ของ PostgreSQL อย่างไร อ่านต่อที่ ACID และ MVCC อธิบายแบบเข้าใจง่าย
คำถามที่พบบ่อย
PostgreSQL ใช้ transaction isolation level อะไรเป็นค่า default?
Read Committed เปลี่ยนได้ราย transaction ด้วย BEGIN ISOLATION LEVEL ... หรือเปลี่ยนทั้งระบบด้วย setting default_transaction_isolation
Repeatable Read ของ PostgreSQL กัน phantom read ได้ไหม?
ฝั่งการอ่านกันได้ เพราะ Repeatable Read ของ PostgreSQL ใช้ snapshot เดียวตลอด transaction รัน query ซ้ำก็ได้แถวชุดเดิม แต่ยังเกิด write skew ได้ รวมถึง write skew ที่มาจากแถวที่ transaction อื่น insert เข้ามา
Lost update กับ write skew ต่างกันอย่างไร?
Lost update คือสอง transaction เขียนแถว เดียวกัน แล้วตัวหนึ่งทับอีกตัว row lock หรือ Repeatable Read จับได้ ส่วน write skew คือเขียน คนละแถว โดยอิงจากการอ่านชุดเดียวกัน มีแค่ Serializable, การ materialize ความขัดแย้ง, การล็อก parent หรือ constraint ที่กันได้
SELECT FOR UPDATE กัน write skew ได้ไหม?
ได้เฉพาะเมื่อล็อกทุกแถวที่ใช้ตัดสินใจ หรือล็อก parent row ตัวเดียวที่ทุก transaction แบบนี้ต้องล็อกเหมือนกัน มันล็อกแถวที่ยังไม่มีอยู่ไม่ได้ จึงช่วย logic แบบ "เช็คว่าไม่มีอะไรทับซ้อน แล้วค่อย insert" ไม่ได้
SERIALIZABLE ใน PostgreSQL ช้าไหม?
มันไม่ได้ทำให้ฝั่งอ่านกับฝั่งเขียนต้องรอกัน ต้นทุนจริงคือการทำบัญชี predicate lock, transaction ที่โดนยกเลิกแล้วต้อง retry และ false positive ที่เพิ่มขึ้นเมื่อ scan ข้อมูลเยอะ ถ้า transaction สั้นและเข้าถึงข้อมูลผ่าน index ต้นทุนมักรับได้ แต่ควรวัดกับ workload ของตัวเอง
สรุปใช้งานจริง
- รู้ให้ชัดว่า level ที่ใช้อยู่ยังปล่อย anomaly อะไรผ่านได้ สำหรับค่า default ของ PostgreSQL คือทุกตัวยกเว้น dirty read
- เปลี่ยนการอ่านแล้วค่อยเขียนในโค้ดแอป ให้เป็นคำสั่งเดียวแบบมีเงื่อนไขเท่าที่ทำได้
- กฎธุรกิจทุกข้อที่พันหลายแถว ต้องตัดสินใจให้ชัดว่าจะปกป้องด้วยอะไร constraint, แถวที่ materialize ไว้, การล็อก parent หรือ
SERIALIZABLE - ถ้าใช้
SERIALIZABLEต้องให้ทุก transaction ที่เกี่ยวข้องรันที่ level นี้ และห่อด้วย retry loop ที่มี backoff
บั๊กเรื่อง concurrency แทบไม่โผล่ตอน develop แต่แพงมากเมื่อไปเจอใน production ถ้าอยากได้คนช่วยรีวิวการออกแบบ transaction ทีม Vectorkub รับออกแบบและรีวิวระบบ backend ลักษณะนี้อยู่
