Race condition ในระบบ payment เกิดขึ้นเมื่อสอง request อ่านยอดเงินก้อนเดียวกันในเวลาเดียวกัน ต่างฝ่ายต่างตัดสินว่าเงินพอ แล้วต่างฝ่ายต่างเขียน ผลคือเงินงอก เงินหาย หรือยอดติดลบทั้งที่กฎบอกว่าห้าม ทางแก้แทบไม่เคยเป็นการใส่ mutex ในโค้ดแอป แต่คือการให้ database เป็นคนตัดสินในจุดเดียวว่าการเปลี่ยนแปลงแต่ละครั้งทำได้หรือไม่
บทความนี้เป็นคู่มือภาคปฏิบัติ ไล่ตั้งแต่บั๊ก double-spend แบบคลาสสิก วิธีแก้ระดับ database เรียงตามลำดับที่ควรเลือกใช้ ไปจนถึง idempotency key สำหรับ request ที่เข้ามาซ้ำ ตัวอย่างใช้ PostgreSQL 16 และ Go กับ pgx v5
Race condition แบบ double-spend ใน payment flow
บั๊กกลุ่มนี้ส่วนใหญ่มาจากรูปแบบเดียวกัน คืออ่าน เช็คในแอป แล้วค่อยเขียน:
SELECT balance FROM accounts WHERE id = $1; -- app: if balance < amount, reject
UPDATE accounts SET balance = $2 WHERE id = $1; -- $2 = balance - amount, computed in the appบัญชีมีเงิน 100 แล้วมีคำขอถอน 80 สองรายการเข้ามาห่างกันไม่กี่มิลลิวินาที:
| ขั้น | Request A | Request B |
|---|---|---|
| 1 | อ่าน balance = 100 | |
| 2 | อ่าน balance = 100 | |
| 3 | 100 >= 80 ผ่าน | 100 >= 80 ผ่าน |
| 4 | เขียน balance = 20 | |
| 5 | เขียน balance = 20 |
ลูกค้าได้เงินไป 160 แต่บัญชียังแสดง 20 ถ้าเปลี่ยนไปเขียนแบบ balance = balance - $2 บัญชีก็จะเหลือ -60 แทน ไม่ว่าแบบไหน การเช็คใช้ค่าที่เก่าไปแล้วตอนที่เขียนจริง
แค่ครอบด้วย transaction ก็ไม่ช่วยที่ระดับ Read Committed ซึ่งเป็นค่า default เพราะ SELECT ธรรมดาไม่ได้ล็อกอะไร ส่วน mutex ในโปรเซสก็ใช้ไม่ได้ทันทีที่รัน service สอง instance สิ่งเดียวที่ทุก instance ใช้ร่วมกันคือ database การตัดสินใจจึงต้องอยู่ที่นั่น
วิธีที่ 1: atomic conditional UPDATE
ยุบการอ่าน เช็ค และเขียนให้เหลือคำสั่งเดียว แล้วให้ database ประเมินเงื่อนไขในขณะที่ถือ row lock อยู่:
UPDATE accounts
SET balance = balance - $2,
updated_at = now()
WHERE id = $1
AND balance >= $2
RETURNING balance;ถ้าไม่มีแถวกลับมา แปลว่าเงินไม่พอ (ใน pgx Scan จะคืน pgx.ErrNoRows)
ทำไมวิธีนี้ได้ผลที่ Read Committed: UPDATE ตัวแรกล็อกแถวไว้ ตัวที่สองต้องรอ พอตัวแรก commit แล้ว PostgreSQL จะประเมิน balance >= $2 ใหม่กับ version ล่าสุดของแถว (ซึ่งตอนนี้เหลือ 20) เงื่อนไขไม่ผ่าน จึง update ไป 0 แถว ไม่ต้องมี retry loop เลย (ถ้าเป็น Repeatable Read คำสั่งที่สองจะได้ serialization error แทน ดังนั้นใช้ pattern นี้ที่ Read Committed)
เพิ่ม constraint ไว้เป็นตาข่ายรองรับ เผื่อมีโค้ดเส้นทางไหนลืมใส่เงื่อนไข:
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);เก็บเงินเป็นหน่วยย่อยแบบจำนวนเต็ม (เช่น สตางค์) หรือ numeric ห้ามใช้ floating point และเขียน ledger entry ใน transaction เดียวกัน ใช้วิธีนี้ทุกครั้งที่กฎใส่ลงใน WHERE ของแถวเดียวได้ ซึ่งครอบคลุมการตัดเงินเกือบทั้งหมด
วิธีที่ 2: pessimistic locking ด้วย SELECT ... FOR UPDATE
เมื่อการตัดสินใจซับซ้อนกว่าการบวกลบ เช่น ต้องคิดค่าธรรมเนียม วงเงินรายวัน หรือกฎความเสี่ยง ให้ล็อกแถวก่อน:
BEGIN;
SELECT balance, daily_limit, spent_today
FROM accounts
WHERE id = $1
FOR UPDATE; -- other writers to this row now wait
-- application logic: fees, limits, risk rules
UPDATE accounts SET balance = $2, spent_today = $3 WHERE id = $1;
INSERT INTO ledger_entries (account_id, amount, kind) VALUES ($1, $4, 'debit');
COMMIT;สำหรับการโอนเงิน ให้ล็อกทั้งสองบัญชี ตามลำดับที่คงที่เสมอ เพื่อไม่ให้การโอนสวนทางกันสองรายการเกิด deadlock:
SELECT id, balance FROM accounts
WHERE id IN ($1, $2)
ORDER BY id
FOR UPDATE;ทำช่วงที่ถือล็อกให้สั้น ห้ามเรียก payment gateway หรือ service อื่นระหว่างถือล็อก และรัน SET LOCAL lock_timeout = '2s' เพื่อให้ transaction ที่ค้างล้มเร็ว แทนที่จะปล่อยให้ connection กองพะเนิน
วิธีที่ 3: optimistic locking ด้วย version column
ถ้า conflict เกิดไม่บ่อย หรือการอ่านกับการเขียนอยู่คนละ request (เช่น ผู้ใช้แก้การตั้งค่าการถอนเงินผ่านฟอร์ม) ไม่ต้องถือล็อก ให้ตรวจจับ conflict ตอนเขียนแทน:
ALTER TABLE wallets ADD COLUMN version bigint NOT NULL DEFAULT 0;
-- read
SELECT balance, version FROM wallets WHERE id = $1;
-- write only if nobody changed the row since we read it
UPDATE wallets
SET balance = $2, version = version + 1
WHERE id = $1 AND version = $3;ถ้า update ได้ 0 แถว แปลว่ามีคนแก้ไปก่อน ให้โหลดใหม่แล้ว retry หรือตอบ 409 Conflict กลับไป แต่ถ้าแย่งแถวเดียวกันหนัก ๆ วิธีนี้จะกลายเป็นพายุ retry ตอนนั้นวิธีที่ 1 หรือ 2 เหมาะกว่า
วิธีที่ 4: กฎที่พันหลายแถว
วิธีข้างบนทั้งหมดปกป้องได้แค่ แถวเดียว แต่บางกฎเกี่ยวกับหลายแถว เช่น "ยอด savings รวมกับ checking ห้ามต่ำกว่าศูนย์" สอง transaction อาจต่างคนต่างเช็คกฎผ่าน แล้วไปเขียนคนละแถว รวมกันกลับผิดกฎ anomaly นี้เรียกว่า write skew ทฤษฎีเบื้องหลังและวิธีที่ระดับ Serializable ของ PostgreSQL ตรวจจับมันอยู่ในบทความ transaction isolation levels และ write skew ในทางปฏิบัติมีทางเลือกสี่ทาง
Materialize ความขัดแย้งให้มาอยู่ในแถวเดียว เก็บยอดรวมระดับลูกค้าไว้ในแถวของมันเอง แล้วบังคับให้ทุกการถอนต้อง update แถวนั้นแบบมีเงื่อนไข พอทั้งสอง transaction ชนแถวเดียวกัน วิธีที่ 1 ก็กลับมาใช้ได้:
UPDATE customer_balances
SET total = total - $2
WHERE customer_id = $1 AND total >= $2;
-- 0 rows: reject. Otherwise update the individual account in the same transaction.ล็อก parent row ล็อกแถวลูกค้าก่อนอ่านอะไรทั้งนั้น เพื่อให้ทุก operation ของลูกค้าคนนั้นเข้าคิวทีละรายการ:
BEGIN; -- Read Committed
SELECT 1 FROM customers WHERE id = $1 FOR UPDATE;
SELECT sum(balance) FROM accounts WHERE customer_id = $1;
-- check the rule, then update one account
COMMIT;วิธีนี้พึ่ง Read Committed เพราะแต่ละ statement หลังได้ล็อกจะถ่าย snapshot ใหม่ sum จึงเห็นสิ่งที่คนถือล็อกก่อนหน้า commit ไว้แล้ว ถ้าเป็น Repeatable Read snapshot จะมาจาก statement แรก และยอดรวมอาจเป็นค่าเก่า
ใช้ constraint ถ้ากฎเขียนแบบ declarative ได้ database จะบังคับให้เองไม่ว่าจังหวะเวลาจะเป็นอย่างไร:
-- at most one pending payout per account
CREATE UNIQUE INDEX one_pending_payout
ON payouts (account_id)
WHERE status = 'pending';ใช้ SERIALIZABLE กับทุก transaction ที่เกี่ยวข้อง พร้อม retry loop เมื่อเจอ SQLSTATE 40001 เป็นทางเลือกที่ครอบคลุมที่สุด และมีต้นทุนตอนรันสูงที่สุดด้วย
Idempotency key: กันการตัดเงินซ้ำ
ทุกอย่างข้างบนกันไม่ให้สอง request ที่ต่างกัน ถอนเงินเกินบัญชี แต่ไม่ได้กัน request เดิม ที่ถูก retry หลัง timeout, ผู้ใช้กดสองครั้ง หรือ broker ส่งซ้ำ ไม่ให้ถูกทำซ้ำสองรอบ
ทางแก้คือ idempotency key ฝั่ง client สร้าง key ไม่ซ้ำหนึ่งตัวต่อหนึ่งธุรกรรม (เช่น UUID) ส่งมาทุกครั้งที่ลอง และฝั่ง server บันทึก key นั้น ใน transaction เดียวกัน กับการเคลื่อนย้ายเงิน:
CREATE TABLE idempotency_keys (
client_id bigint NOT NULL,
key text NOT NULL,
request_hash text NOT NULL,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (client_id, key)
);func (s *Service) Withdraw(ctx context.Context, req WithdrawRequest) (Result, error) {
var res Result
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
INSERT INTO idempotency_keys (client_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (client_id, key) DO NOTHING`,
req.ClientID, req.IdempotencyKey, req.Hash())
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errAlreadyProcessed
}
err = tx.QueryRow(ctx, `
UPDATE accounts SET balance = balance - $2
WHERE id = $1 AND balance >= $2
RETURNING balance`, req.AccountID, req.Amount).Scan(&res.Balance)
if errors.Is(err, pgx.ErrNoRows) {
return ErrInsufficientFunds
}
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
UPDATE idempotency_keys SET response = $3
WHERE client_id = $1 AND key = $2`,
req.ClientID, req.IdempotencyKey, res)
return err
})
if errors.Is(err, errAlreadyProcessed) {
return s.storedResult(ctx, req) // compare request_hash, return saved response
}
return res, err
}รายละเอียดที่สำคัญ:
- request ซ้ำที่มาพร้อมกัน primary key จัดการให้
INSERTตัวที่สองจะรอ transaction แรกจบก่อน แล้วค่อยเห็นว่าชนกัน - เทียบ request hash key เดิมแต่จำนวนเงินต่างไป คือบั๊กฝั่ง client ให้ปฏิเสธ
- กรณีเงินไม่พอจะ rollback ไปพร้อม transaction ในตัวอย่างนี้ retry ครั้งถัดไปจึงถูกประเมินใหม่ ถ้าต้องการให้ตอบผลปฏิเสธเดิมซ้ำได้ด้วย ให้บันทึกผลนั้นแยกเป็นการเขียนที่ commit ต่างหาก
- ส่งต่อ key ไปยังปลายทาง payment provider ส่วนใหญ่รับ idempotency key ได้ ให้ใช้ key เดิมต่อการ charge หนึ่งครั้งทุกครั้งที่ retry ส่วนฝั่งที่รับ webhook หรือ message จากคิวก็ควร dedupe ด้วย event ID
- ลบ key เก่า ได้ก็ต่อเมื่อพ้นช่วงเวลา retry ของทุก client ไปแล้ว
เรื่อง retry ในระดับ network อ่านต่อได้ที่ การสื่อสารระหว่าง microservice ให้ทนทาน
เลือกวิธีป้องกัน race condition ในระบบ payment
| สถานการณ์ | วิธี | ข้อแลกเปลี่ยน |
|---|---|---|
| กฎอยู่ในแถวเดียว (ยอดเงินพอไหม) | atomic conditional UPDATE + CHECK | ง่ายและเร็วที่สุด แต่ logic ต้องเขียนเป็น SQL ได้ |
| logic ซับซ้อนบนหนึ่งหรือสองแถว | SELECT ... FOR UPDATE ล็อกตามลำดับคงที่ | บล็อกคนเขียนคนอื่น ต้องทำให้สั้น |
| แย่งกันน้อย อ่านกับเขียนอยู่คนละ request | optimistic locking ด้วย version | retry เยอะเมื่อแย่งกันหนัก |
| กฎพันหลายแถว มี parent ตัวเดียว | ยอดรวมที่ materialize ไว้ หรือล็อก parent row | operation ของ parent นั้นต้องต่อคิว |
| กฎเขียนแบบ declarative ได้ | UNIQUE, partial index, EXCLUDE, CHECK | ใช้ได้กับบางกฎเท่านั้น |
| กฎซับซ้อนพันหลายแถว | SERIALIZABLE + retry | ต้องมีโค้ด retry, throughput ลดลง |
| request เดิมเข้ามาสองครั้ง | idempotency key ใน transaction เดียวกัน | ต้องมีตารางเพิ่ม และ client ต้องร่วมมือ |
คำถามที่พบบ่อย
Race condition ในระบบ payment คืออะไร?
คือเมื่อหลาย operation ที่รันพร้อมกันอ่านยอดเงินเดียวกัน ต่างฝ่ายตัดสินใจจากค่านั้น แล้วผลการเขียนรวมกันออกมาผิด เช่น ถอนเงินซ้ำสองครั้ง
ป้องกัน double spending ใน database อย่างไร?
ทำให้การเช็คกับการเขียนเป็น operation เดียวแบบ atomic เช่น UPDATE ... WHERE balance >= amount, ใช้ row lock ด้วย SELECT ... FOR UPDATE หรือเช็ค version แล้วเพิ่ม CHECK constraint กับ idempotency key เพื่อไม่ให้การ retry ตัดเงินซ้ำ
Pessimistic หรือ optimistic locking ดีกว่าสำหรับระบบ payment?
สำหรับยอดเงินที่ถูกแตะบ่อย ใช้ atomic update หรือ pessimistic lock เพราะ optimistic locking จะ retry เยอะเมื่อแย่งกันหนัก ส่วน optimistic locking เหมาะกับข้อมูลที่ไม่ค่อยมีใครแย่งกัน
ระบบ payment ต้องใช้ isolation ระดับ SERIALIZABLE ไหม?
ไม่จำเป็นสำหรับกฎที่อยู่ในแถวเดียว atomic update และ row lock ที่ Read Committed ก็พอ ให้พิจารณา Serializable สำหรับกฎที่พันหลายแถวซึ่งแก้ด้วยแถวที่ materialize ไว้ การล็อก parent หรือ constraint ไม่ได้
Idempotency key กัน race condition ได้ไหม?
กันได้แค่ไม่ให้ request เดิม ถูกทำซ้ำ แต่ไม่ได้กันสอง request ที่ต่างกัน ถอนเงินเกินบัญชี ต้องมีทั้งสองอย่าง
Checklist
- ไม่มีการอ่าน-เช็ค-เขียนยอดเงินในโค้ดแอป
- การตัดเงินทุกครั้งเป็น atomic conditional
UPDATEหรือทำภายใต้ row lock - มี
CHECK (balance >= 0)หรือ constraint ที่เทียบเท่า - การโอนล็อกแถวตามลำดับคงที่ และไม่มีการเรียกระบบภายนอกระหว่างถือล็อก
- กฎที่พันหลายแถวทุกข้อมีกลไกป้องกันที่ระบุชัดเจน
- ทุก endpoint ที่เคลื่อนย้ายเงินต้องมี idempotency key ที่บันทึกใน transaction เดียวกัน
ถ้ากำลังสร้างหรือตรวจสอบ payment flow แล้วอยากได้ทีมที่มีประสบการณ์ช่วย Vectorkub รับออกแบบและพัฒนาระบบ backend ลักษณะนี้
