สภาวะการชน (race conditions) ในแอป CRUD: สาเหตุและการแก้ไขเชิงปฏิบัติ
สภาวะการแข่งขัน (race conditions) ในแอป CRUD อาจทำให้คำสั่งซื้อซ้ำ ยอดผิดพลาด และสถานะสลับไปมา เรียนรู้จุดชนทั่วไปและวิธีแก้ด้วยข้อจำกัดฐานข้อมูล ล็อกธุรกรรม และกลไกป้องกันฝั่ง UI

รูปลักษณ์ของ race condition ในแอป CRUD
สภาวะการชน (race condition) เกิดเมื่อคำขอสองคำขอ (หรือมากกว่า) อัพเดตข้อมูลชิ้นเดียวกันเกือบพร้อมกัน และผลลัพธ์สุดท้ายขึ้นกับจังหวะเวลา คำขอแต่ละอันดูถูกต้องเมื่อแยกกัน แต่เมื่อมารวมกันแล้วกลับให้ผลที่ผิดพลาด
ตัวอย่างง่ายๆ: สองคนคลิกบันทึกบนเร็กคอร์ดลูกค้าชุดเดียวกันภายในเวลาไม่กี่วินาที คนหนึ่งอัพเดตอีเมล อีกคนอัพเดตเบอร์โทร ถ้าคำขอทั้งสองส่งเร็กคอร์ดเต็ม การเขียนครั้งที่สองอาจเขียนทับการเปลี่ยนแปลงครั้งแรก และการเปลี่ยนแปลงหนึ่งอันหายไปโดยไม่มีข้อผิดพลาด
คุณจะเห็นปัญหานี้บ่อยในแอปที่เร็ว เพราะผู้ใช้สามารถกระตุ้นการกระทำได้หลายครั้งต่อนาที และจะพุ่งขึ้นในช่วงเวลาคับคั่ง: งานลดราคาชั่วคราว รายงานสิ้นเดือน แคมเปญอีเมลขนาดใหญ่ หรือเมื่อคำขอจำนวนมากชนกันบนแถวเดียวกัน
ผู้ใช้ไม่ค่อยรายงานว่า “เกิด race condition” พวกเขารายงานอาการ: คำสั่งซื้อหรือคอมเมนต์ซ้ำ อัพเดตหาย ("ฉันบันทึกแล้ว แต่มันกลับไปเหมือนเดิม") ยอดผิดปกติ (สต็อกติดลบ เคาน์เตอร์ถอยหลัง) หรือสถานะที่พลิกไปมา (อนุมัติแล้วกลับเป็นรอดำเนินการ)
การรีทรายทำให้แย่ลง ผู้คนดับเบิลคลิก รีเฟรชหากตอบสนองช้า ส่งฟอร์มจากแท็บสองแท็บ หรือเครือข่ายไม่เสถียรที่ทำให้เบราว์เซอร์และแอปลอกคำขอใหม่ ถ้าเซิร์ฟเวอร์ถือว่าทุกคำขอเป็นการเขียนใหม่ทั้งหมด คุณอาจได้การสร้างสองครั้ง เก็บเงินสองครั้ง หรือการเปลี่ยนสถานะที่ควรเกิดครั้งเดียวกลับเกิดสองครั้ง
ทำไม race condition เกิดบ่อยกว่าที่คิด
แอป CRUD ส่วนใหญ่ดูเรียบง่าย: อ่านแถว แก้ฟิลด์ แล้วบันทึก ปัญหาคือแอปของคุณไม่ได้ควบคุมจังหวะเวลา ฐานข้อมูล เครือข่าย การรีทราย งานพื้นหลัง และพฤติกรรมผู้ใช้ล้วนทับซ้อนกัน
ทริกเกอร์ทั่วไปคือคนสองคนแก้ไขเร็กคอร์ดเดียวกัน ทั้งคู่โหลดค่าปัจจุบันเดียวกันและทำการเปลี่ยนแปลงที่ถูกต้อง และการบันทึกครั้งหลังสุดจะเขียนทับการเปลี่ยนแปลงครั้งแรก ไม่มีใครผิด แต่การอัพเดตหนึ่งอันหายไป
มันยังเกิดได้กับคนเดียวด้วย การดับเบิลคลิกปุ่มบันทึก แตะซ้ำเพราะการเชื่อมต่อช้า หรือการกดส่งอีกครั้งถ้าหน้าช้า ถ้า endpoint ไม่เป็น idempotent คุณอาจได้เร็กคอร์ดซ้ำ ชาร์จซ้ำ หรือเลื่อนสถานะไปสองขั้น
การใช้งานสมัยใหม่เพิ่มการทับซ้อนมากขึ้น แท็บหรืออุปกรณ์หลายชิ้นที่ล็อกอินบัญชีเดียวกันอาจยิงอัพเดตขัดแย้งกัน งานพื้นหลัง (อีเมล การเรียกเก็บเงิน ซิงค์ ทำความสะอาด) สามารถแตะแถวเดียวกับคำขอเว็บ การรีทรายแบบอัตโนมัติที่ฝั่งไคลเอนต์ โหลดบาลานเซอร์ หรือรันเนอร์งานสามารถส่งคำขอซ้ำที่สำเร็จแล้ว
ถ้าคุณปล่อยฟีเจอร์เร็ว เร็กคอร์ดเดียวมักถูกอัพเดตจากหลายจุดมากกว่าที่ใครจะจำได้ ถ้าคุณใช้ตัวสร้างแบบแชทอย่าง Koder.ai แอปจะเติบโตเร็วขึ้นอีก ดังนั้นควรถือว่าความขนานเป็นพฤติกรรมปกติ ไม่ใช่กรณีขอบ
จุดชนกันที่ควรเฝ้าระวัง
race condition ไม่ค่อยปรากฏในการสาธิต "สร้างเร็กคอร์ด" แต่จะปรากฏเมื่อคำขอสองคำขอแตะชิ้นความจริงเดียวกันเกือบพร้อมกัน การรู้จุดร้อนช่วยออกแบบการเขียนที่ปลอดภัยตั้งแต่ต้น
เคาน์เตอร์และฟิลด์ "หมายเลขถัดไป"
อะไรที่รู้สึกว่า "เพิ่ม 1" อาจพังเมื่อโหลดสูง: ไลก์ จำนวนวิว ยอดรวม หมายเลขใบแจ้งหนี้ หมายเลขบัตร ตั๋ว รูปแบบเสี่ยงคืออ่านค่า เพิ่ม แล้วเขียนกลับ คำขอสองอันอาจอ่านค่าเริ่มต้นเดียวกันแล้วเขียนทับกัน
การเปลี่ยนสถานะ
เวิร์กโฟลว์เช่น Draft -> Submitted -> Approved -> Paid ดูเรียบง่าย แต่การชนกันเกิดบ่อย ปัญหาเริ่มเมื่อสองการกระทำเป็นไปได้พร้อมกัน (อนุมัติและแก้ไข ยกเลิกและชำระเงิน) ถ้าไม่มีการป้องกัน คุณอาจได้เร็กคอร์ดที่ข้ามขั้น พลิกกลับ หรือแสดงสถานะต่างกันในตารางต่างกัน
ปฏิบัติต่อการเปลี่ยนสถานะเหมือนสัญญา: อนุญาตเฉพาะก้าวที่ถูกต้องถัดไป และปฏิเสธอย่างอื่น
สต็อก ความจุ และช่องจอง
ที่นั่งที่เหลือ จำนวนสินค้า ช่องนัดหมาย และฟิลด์ "ความจุที่เหลือ" สร้างปัญหา oversell แบบคลาสสิก ผู้ซื้อสองคนเช็คเอาต์พร้อมกัน เห็นว่ายังมีสินค้า ทั้งคู่สำเร็จ ถ้าฐานข้อมูลไม่ใช่ผู้ตัดสินสุดท้าย ก็จะขายเกินจำนวนได้ในที่สุด
กฎความเป็นเอกลักษณ์และ "มีสิ่งที่ใช้งานได้เพียงอันเดียว"
กฎบางอย่างเป็นข้อบังคับ: อีเมลหนึ่งบัญชี หนึ่งการสมัครใช้งานที่ใช้งานต่อผู้ใช้ หนึ่งตะกร้ากำลังเปิดต่อผู้ใช้ มักล้มเหลวเมื่อคุณเช็คก่อน ("มีอยู่หรือไม่?") แล้วค่อยแทรก ภายใต้ความขนานคำขอทั้งสองอาจผ่านการตรวจได้
ถ้าคุณสร้างฟลอว์ CRUD อย่างรวดเร็ว (เช่นโดยคุยสร้างแอปบน Koder.ai) จดจุดร้อนเหล่านี้ตั้งแต่ต้นและหนุนด้วยข้อจำกัดและการเขียนที่ปลอดภัย ไม่ใช่แค่การตรวจฝั่ง UI
การส่งซ้ำและคำขอซ้ำจาก UI
หลาย race condition เริ่มจากสิ่งน่าเบื่อ: การกระทำเดียวกันถูกส่งสองครั้ง ผู้ใช้ดับเบิลคลิก เครือข่ายช้าเลยคลิกอีกครั้ง โทรศัพท์ลงทะเบียนสองครั้ง บางครั้งไม่ตั้งใจก็เกิดขึ้น: หน้ารีเฟรชหลัง POST และเบราว์เซอร์เสนอให้ส่งซ้ำ
เมื่อนั้น backend ของคุณอาจรันการสร้างหรืออัพเดตสองครั้งพร้อมกัน ถ้าทั้งสองสำเร็จ คุณจะได้เร็กคอร์ดซ้ำ ยอดผิด หรือการเปลี่ยนสถานะที่เกิดสองครั้ง มันดูสุ่มเพราะขึ้นกับจังหวะเวลา
แนวทางที่ปลอดภัยที่สุดคือการป้องกันทั้งชั้น ฟิก UI แต่สมมติว่า UI อาจล้มเหลว
การเปลี่ยนแปลงเชิงปฏิบัติที่ใช้ได้กับ flow การเขียนส่วนใหญ่:
- ปิดปุ่มส่งเมื่อคลิกครั้งแรกและแสดงสถานะ "กำลังบันทึก"
- เปลี่ยนเส้นทางหลัง POST สำเร็จเพื่อให้การรีเฟรชไม่ส่งฟอร์มซ้ำ
- ใช้ idempotency key: โทเค็นเฉพาะสำหรับแต่ละการกระทำของผู้ใช้ที่เซิร์ฟเวอร์เก็บและยอมรับเพียงครั้งเดียว
- หนุนด้วยกฎความเป็นเอกลักษณ์ในฐานข้อมูล เพื่อให้การแทรกที่เหมือนกันสองครั้งไม่สามารถชนะทั้งคู่ได้
ตัวอย่าง: ผู้ใช้กด "ชำระเงิน" สองครั้งบนมือถือ UI ควรบล็อกการแตะที่สอง เซิร์ฟเวอร์ควรปฏิเสธคำขอที่สองเมื่อเห็น idempotency key เดิม และคืนผลลัพธ์ความสำเร็จเดิมแทนการเก็บเงินซ้ำ
การเปลี่ยนสถานะที่ไม่สอดคล้อง
ฟิลด์สถานะดูเรียบง่ายจนกระทั่งสองสิ่งพยายามเปลี่ยนมันพร้อมกัน ผู้ใช้คลิกอนุมัติในขณะที่งานอัตโนมัติทำเครื่องหมายว่า Expired ทั้งสองอัพเดตอาจสำเร็จ แต่สถานะสุดท้ายขึ้นกับจังหวะเวลา ไม่ใช่กฎของคุณ
ปฏิบัติต่อสถานะเหมือนเครื่องสถานะขนาดเล็ก เก็บตารางสั้นๆ ของการย้ายที่อนุญาต (เช่น: Draft -> Submitted -> Approved และ Submitted -> Rejected) แล้วทุกการเขียนให้ตรวจสอบ: "การย้ายนี้อนุญาตจากสถานะปัจจุบันหรือไม่?" ถ้าไม่ ให้ปฏิเสธแทนที่จะเงียบๆ เขียนทับ
การล็อกแบบ optimistic ช่วยจับการอัพเดตที่ล้าสมัยโดยไม่บล็อกผู้ใช้อื่น เพิ่มหมายเลขเวอร์ชัน (หรือ updated_at) แล้วบังคับให้ตรงเมื่อบันทึก ถ้ามีคนอื่นเปลี่ยนแถวหลังจากคุณโหลดมา การอัพเดตของคุณจะไม่มีแถวใดถูกกระทบและคุณสามารถแสดงข้อความชัดเจนเช่น "รายการนี้มีการเปลี่ยนแปลง กรุณารีเฟรชแล้วลองใหม่"
แพตเทิร์นเรียบง่ายสำหรับการอัพเดตสถานะคือ:
- อ่านสถานะปัจจุบันและเวอร์ชัน
- ตรวจสอบการเปลี่ยนผ่าน
- อัพเดตด้วยการป้องกัน (WHERE status = ? AND version = ?)
- เพิ่มหมายเลขเวอร์ชัน
นอกจากนี้ ให้เก็บการเปลี่ยนสถานะไว้ที่เดียว ถ้าการอัพเดตกระจัดกระจายทั้งหน้าจอ งานพื้นหลัง และ webhook คุณจะพลาดกฎ วางไว้หลังฟังก์ชันหรือ endpoint เดียวที่บังคับใช้การตรวจเหมือนกันทุกครั้ง
เคาน์เตอร์และยอดรวมที่เลื่อนไปตามเวลา
บั๊กเคาน์เตอร์ที่พบบ่อยสุดดูไม่เป็นพิษเป็นภัย: แอปอ่านค่า เพิ่ม 1 แล้วเขียนกลับ ภายใต้โหลด คำขอสองอันอาจอ่านเลขเดียวกันและทั้งคู่เขียนค่าเดียวกัน ดังนั้นหนึ่งการเพิ่มหายไป มันง่ายพลาดเพราะ "ส่วนใหญ่ใช้ได้" ในการทดสอบ
ควรใช้การอัพเดตแบบอะตอมิกแทนการอ่านแล้วเขียน
ถ้าค่าถูกเพิ่มหรือลด ให้ฐานข้อมูลทำในคำสั่งเดียว แล้วฐานข้อมูลจะประยุกต์การเปลี่ยนแปลงอย่างปลอดภัยแม้มีคำขอหลายครั้งพร้อมกัน
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
แนวคิดเดียวกันใช้กับสต็อก จำนวนวิว ตัวนับรีทราย และสิ่งที่สามารถเขียนเป็น "ใหม่ = เก่า + ส่วนต่าง" ได้
ยอดรวมเอนไปเมื่อคุณเก็บสิ่งที่คำนวณได้
ยอดรวมมักพังเมื่อคุณเก็บตัวเลขที่ได้จากการคำนวณ (order_total, account_balance, project_hours) แล้วอัพเดตจากหลายที่ ถ้าคุณสามารถคำนวณยอดจากแถวแหล่งข้อมูล (รายการสินค้า ระเบียนบัญชี ชั่วโมงงาน) คุณจะหลีกเลี่ยงบั๊กการเลื่อนทั้งประเภทได้
เมื่อจำเป็นต้องเก็บยอดเพื่อความเร็ว ให้ถือมันเป็นการเขียนที่สำคัญ เก็บการอัพเดตของแถวแหล่งข้อมูลและยอดรวมที่เก็บไว้ในธุรกรรมเดียวกัน ตรวจสอบให้มีผู้เขียนเพียงคนเดียวสามารถอัพเดตยอดเดียวกันได้ในเวลาเดียวกัน (การล็อก การอัพเดตที่มีเงื่อนไข หรือเส้นทางเจ้าของเดียว) เพิ่มข้อจำกัดที่ป้องกันค่าที่เป็นไปไม่ได้ (เช่น สต็อก < 0) แล้วทำการตรวจสอบความถูกต้องเป็นระยะโดยแบ็กกราวด์ที่คำนวณใหม่และส่งสัญญาณเมื่อไม่ตรงกัน
ตัวอย่างชัดเจน: สองคนเพิ่มสินค้าลงตะกร้าเดียวกันพร้อมกัน ถ้าคำขอแต่ละอันอ่าน cart_total เพิ่มราคาสินค้า แล้วเขียนกลับ การเพิ่มหนึ่งอันอาจหายไป หากคุณอัพเดตรายการตะกร้าและ cart total พร้อมกันในธุรกรรมหนึ่ง ยอดจะถูกต้องแม้มีการคลิกพร้อมกันหนาแน่น
ใช้ข้อจำกัดฐานข้อมูลก่อนเพื่อลดการชน
ถ้าต้องการลด race condition ให้เริ่มที่ฐานข้อมูล โค้ดแอปอาจรีทราย หมดเวลา หรือรันสองครั้ง ข้อจำกัดในฐานข้อมูลคือประตูสุดท้ายที่ถูกต้องแม้คำขอสองคำขอกระทบพร้อมกัน
ข้อจำกัดความเป็นเอกลักษณ์หยุดการซ้ำที่ "ไม่ควรเกิด" แต่เกิดได้: ที่อยู่อีเมล หมายเลขคำสั่ง หมายเลขใบแจ้งหนี้ หรือกฎ "มีการสมัครใช้งานที่ใช้งานได้หนึ่งรายการต่อผู้ใช้" เมื่อสองการลงทะเบียนมาพร้อมกัน ฐานข้อมูลจะยอมรับแถวหนึ่งและปฏิเสธอีกอัน
foreign keys ป้องกันการอ้างอิงที่พัง ถ้าไม่มี พ่อแม่ถูกลบขณะที่อีกคำขอสร้างลูกที่ชี้ไปยังสิ่งที่ไม่อยู่ จะทิ้งแถวเด็กที่ไม่มีที่มาให้แก้ไขยาก
check constraints คงค่าภายในช่วงปลอดภัยและบังคับกฎสถานะง่ายๆ เช่น quantity >= 0 rating ระหว่าง 1 ถึง 5 หรือสถานะจำกัดให้อยู่ในชุดที่อนุญาต
มองว่าการผิดพลาดจากข้อจำกัดเป็นผลลัพธ์ที่คาดได้ ไม่ใช่ "ข้อผิดพลาดเซิร์ฟเวอร์" จับการละเมิด unique foreign key และ check แล้วคืนข้อความชัดเจนเช่น "อีเมลนี้ถูกใช้งานแล้ว" และบันทึกรายละเอียดเพื่อตรวจโดยไม่รั่วไหลข้อมูลภายใน
ตัวอย่าง: สองคนคลิก "สร้างคำสั่งซื้อ" สองครั้งในช่วงหน่วง หากมี unique constraint บน (user_id, cart_id) คุณจะไม่ได้คำสั่งซื้อสองรายการ แต่ได้คำสั่งซื้อหนึ่งรายการและการปฏิเสธที่อธิบายได้สำหรับอีกคำขอ
ใช้ธุรกรรมและล็อกเมื่อการเปลี่ยนต้องชนะเพียงหนึ่งเดียว
การเขียนบางอย่างไม่ใช่คำสั่งเดียว คุณอ่านแถว ตรวจสอบกฎ อัพเดตสถานะ และอาจแทรกบันทึกตรวจสอบ ถ้าคำขอสองอันทำแบบนั้นพร้อมกัน ทั้งคู่อาจผ่านการตรวจและทั้งคู่เขียน นี่คือรูปแบบความล้มเหลวแบบคลาสสิก
ห่อการเขียนหลายขั้นตอนในธุรกรรมเดียวเพื่อให้ทุกขั้นตอนสำเร็จพร้อมกันหรือไม่เลย ยิ่งสำคัญ ธุรกรรมยังเป็นที่ควบคุมว่าใครเปลี่ยนข้อมูลเดียวกันได้พร้อมกัน
เมื่อมีผู้ทำหน้าที่เดียวเท่านั้นที่สามารถแก้ไขเร็กคอร์ดได้ในครั้งเดียว ให้ใช้การล็อกแถวระดับแถว เช่น ล็อกแถวคำสั่ง ซื้อ ยืนยันว่ายังคงเป็นสถานะ "รอดำเนินการ" แล้วเปลี่ยนเป็น "อนุมัติ" และเขียนบันทึกตรวจสอบ คำขอที่สองจะรอ แล้วตรวจสถานะใหม่และหยุด
การล็อกเชิง optimistic กับ pessimistic
เลือกตามความถี่ที่เกิดการชน:
- การล็อกแบบ optimistic: เพิ่มหมายเลขเวอร์ชัน (หรือ updated_at) และอัพเดตเฉพาะเมื่อตรง
- การล็อกแบบ pessimistic: ล็อกแถวก่อน แล้วอ่านและอัพเดตภายในธุรกรรมเดียวกัน
ให้เวลาในการถือล็อกสั้น ทำงานน้อยที่สุดขณะถือมัน: ห้ามเรียก API ภายนอก โต้ตอบไฟล์ช้า หรือลูปใหญ่ หากคุณสร้างฟลอว์ในเครื่องมือเช่น Koder.ai ให้เก็บธุรกรรมแค่ขั้นตอนฐานข้อมูล แล้วทำส่วนที่เหลือหลัง commit
ทีละขั้น: เสริมความแข็งแกร่งให้การเขียนหนึ่งเส้นทางตั้งแต่ต้นจนจบ
เลือก flow หนึ่งที่การชนอาจทำให้เสียเงินหรือความเชื่อถือได้ ตัวอย่างทั่วไปคือ: สร้างคำสั่งซื้อ สำรองสต็อก แล้วตั้งสถานะคำสั่งซื้อเป็นยืนยัน
เขียนขั้นตอนที่โค้ดคุณทำตอนนี้ทีละข้อ ระบุชัดเจนว่าอ่านอะไร เขียนอะไร และ "สำเร็จ" หมายถึงอะไร การชนซ่อนตัวในช่องว่างระหว่างการอ่านและการเขียนภายหลัง
เส้นทางเสริมความแข็งที่ใช้ได้ในสแต็กส่วนใหญ่:
- กำหนดผลลบที่คุณต้องปิดกั้น (เช่น: สต็อกติดลบ หรือการสำรองซ้ำสำหรับบรรทัดคำสั่งเดียว)
- เพิ่มกฎฐานข้อมูลที่ทำให้ผลนั้นเป็นไปไม่ได้ (เช่น: CHECK constraint ป้องกัน stock < 0 และ UNIQUE constraint บน (order_id, sku) สำหรับการสำรอง)
- ห่อทั้ง flow ในธุรกรรมเดียว และล็อกแถวที่ตัดสินผล (เช่น: ล็อกแถวสินค้าก่อนหักสต็อก)
- ทำการอัพเดตเป็นคำสั่งเดียวเมื่อทำได้ (เช่น: "update stock where stock >= qty" เพื่อให้ล้มเหลวอย่างปลอดภัย)
- จัดการความขัดแย้งอย่างสะอาด: รีทรายไม่กี่ครั้ง หรือคืนข้อความ "สินค้าหมด กรุณารีเฟรช" ชัดเจน
เพิ่มเทสต์หนึ่งตัวที่พิสูจน์การแก้ไข รันคำขอสองคำขอพร้อมกันกับสินค้าและปริมาณเดียวกัน ยืนยันว่าเพียงคำสั่งซื้อเดียวเท่านั้นที่ยืนยัน และอีกคำขอล้มเหลวอย่างควบคุม (ไม่มีสต็อกติดลบ ไม่มีแถวการสำรองซ้ำ)
ถ้าคุณสร้างแอปอย่างรวดเร็ว (รวมถึงด้วยแพลตฟอร์มอย่าง Koder.ai) เช็คลิสต์นี้ยังคุ้มค่ากับการทำในเส้นทางการเขียนที่สำคัญไม่กี่เส้น
ข้อผิดพลาดทั่วไปที่ทำให้ race condition ไม่หายไป
สาเหตุใหญ่หนึ่งคือเชื่อใจ UI เกินไป ปุ่มที่ปิดและการตรวจฝั่งไคลเอนต์ช่วยได้ แต่ผู้ใช้ดับเบิลคลิก รีเฟรช เปิดสองแท็บ หรือเล่นคำขอกลับได้ หากเซิร์ฟเวอร์ไม่เป็น idempotent การซ้ำจะเล็ดรอด
บั๊กเงียบอีกอย่าง: คุณจับข้อผิดพลาดฐานข้อมูล (เช่น unique constraint violation) แต่ยังคงดำเนินเวิร์กโฟลว์ต่อ นั่นมักกลายเป็น "สร้างล้มเหลว แต่เรายังส่งอีเมล" หรือ "ชำระเงินล้มเหลว แต่เรายังทำเครื่องหมายคำสั่งเป็นชำระแล้ว" เมื่อผลด้านข้างเกิดขึ้น ยากจะย้อนกลับ
ธุรกรรมยาวๆ ก็เป็นกับดัก ถ้าคุณเปิดธุรกรรมขณะเรียกอีเมล การชำระเงิน หรือ API ภายนอก คุณจะถือล็อกนานเกินจำเป็น เพิ่มการรอและเวลาหมด และโอกาสที่คำขอจะบล็อกกัน
การผสมงานพื้นหลังกับการกระทำผู้ใช้โดยไม่มีแหล่งความจริงเดียวสร้างสภาวะ split-brain งานรีทรายแล้วอัพเดตแถวในขณะที่ผู้ใช้กำลังแก้ไข และทั้งสองคิดว่าตัวเองเป็นคนแก้ล่าสุด
"การแก้" บางอย่างที่ไม่ใช่การแก้จริง:
- การป้องกันการส่งซ้ำเฉพาะฝั่ง UI
- กลืนข้อผิดพลาดแล้วดำเนินต่อเหมือนไม่มีอะไรเกิดขึ้น
- ทำการเรียกเครือข่ายช้าๆ ภายในธุรกรรม
- ให้งานพื้นหลังและคำขอผู้ใช้อัพเดตสถานะเดียวกันโดยไม่มีแหล่งอำนาจเดียว
- อัพเดตเคาน์เตอร์ด้วยอ่าน-แก้-เขียนแทนการอัพเดตอะตอมิก
ถ้าคุณสร้างด้วยเครื่องมือคุยเป็นแอปอย่าง Koder.ai กฎเดียวกันใช้ได้: ขอให้มีข้อจำกัดฝั่งเซิร์ฟเวอร์และขอบเขตธุรกรรมที่ชัดเจน ไม่ใช่แค่การป้องกัน UI ที่ดูดี
ตรวจสอบด่วนก่อนปล่อย
race condition มักปรากฏเฉพาะภายใต้ทราฟฟิกจริง การผ่านตรวจก่อนปล่อยสามารถจับจุดชนกันทั่วไปโดยไม่ต้องรื้อระบบทั้งแอป
เริ่มจากฐานข้อมูล ถ้าบางอย่างต้องเป็นเอกลักษณ์ (อีเมล หมายเลขใบแจ้งหนี้ หนึ่งกฎที่ต้องเป็นเอกลักษณ์) ให้ทำเป็น unique constraint แทนการเช็คในแอป แล้วตรวจว่ารหัสของคุณคาดหวังว่าข้อจำกัดจะล้มบางครั้งและคืนการตอบกลับที่ชัดเจนและปลอดภัย
ถัดมา ดูสถานะ การเปลี่ยนสถานะใดๆ ควรได้รับการตรวจสอบกับชุดการเปลี่ยนผ่านที่ชัดเจน หากสองคำขอพยายามย้ายเร็กคอร์ดเดียวกัน คำขอที่สองควรถูกปฏิเสธหรือกลายเป็น no-op ไม่ใช่สร้างสถานะอยู่กลางทาง
เช็กลิสต์ปฏิบัติ:
- ยืนยันว่ามี unique constraints สำหรับกฎต้อง-เป็น-เอกลักษณ์ทุกอัน รวมถึงแบบคอมโพสิต
- ตรวจสอบให้แต่ละ endpoint เขียนปลอดภัยเมื่อรีพีท (idempotency keys, request IDs หรือ idempotency ทางธรรมชาติ)
- ห่อการเขียนหลายขั้นในธุรกรรม โดยเฉพาะเมื่ออ่านแล้วเขียนตามที่อ่าน
- ใช้ล็อกเฉพาะที่การเปลี่ยนต้องมีผู้ชนะเพียงคนเดียว (เช่น อนุมัติหนึ่งครั้งต่อคำสั่ง)
- บันทึกสัญญาณความขัดแย้ง (duplicate key errors, serialization failures, lock timeouts) แล้วแจ้งเตือนเมื่อมีสปाइक
ถ้าคุณสร้าง flow บน Koder.ai ให้ถือสิ่งเหล่านี้เป็นเกณฑ์ยอมรับ: แอปที่สร้างควรล้มอย่างปลอดภัยเมื่อมีการรีพีทและความขนาน ไม่ใช่แค่ผ่านเส้นทางปกติ
ตัวอย่างสถานการณ์: ป้องกันการอนุมัติซ้ำ
พนักงานสองคนเปิดคำขอซื้อเดียวกัน ทั้งคู่คลิกอนุมัติภายในไม่กี่วินาที คำขอทั้งสองมาถึงเซิร์ฟเวอร์
สิ่งที่อาจผิดเกิดขึ้นได้หลายอย่าง: คำขอถูก "อนุมัติ" สองครั้ง การแจ้งเตือนส่งสองครั้ง และยอดที่ผูกกับการอนุมัติ (งบประมาณที่ใช้ จำนวนการอนุมัติต่อวัน) กระโดดขึ้นสอง ทั้งสองอัพเดตถูกต้องเมื่อแยกกัน แต่ชนกันเมื่อรวมกัน
นี่คือแผนแก้ที่ใช้ได้ดีกับฐานข้อมูลสไตล์ PostgreSQL
1) ทำให้การอนุมัติซ้ำเป็นไปไม่ได้ในฐานข้อมูล
เพิ่มกฎที่รับประกันว่ามีเร็กคอร์ดการอนุมัติเดียวเท่านั้นสำหรับคำขอ เช่น เก็บการอนุมัติไว้ในตารางแยกและบังคับ unique constraint บน request_id ตอนนี้การแทรกครั้งที่สองจะล้มแม้โค้ดแอปมีบั๊ก
2) ห่อการเปลี่ยนสถานะในธุรกรรม
เมื่ออนุมัติ ให้ทำการเปลี่ยนทั้งหมวดในธุรกรรมเดียว:
- ล็อกแถวคำขอ (หรืออัพเดตโดยมีเงื่อนไขเช่น WHERE status = 'pending')
- ยืนยันว่ายังคงเป็น pending
- เขียนเร็กคอร์ดการอนุมัติ
- อัพเดตสถานะคำขอเป็น approved
ถ้าพนักงานคนที่สองมาตอนหลัง เขาจะเห็นว่าจำนวนแถวที่อัพเดตเป็น 0 หรือเกิด unique-constraint error ไม่ว่าแบบไหนก็มีเพียงการเปลี่ยนเดียวที่ชนะ
3) ให้ฟีดแบ็ก UI ชัดเจน
หลังแก้ ไมล์แรกจะเห็นว่า Approved และได้การยืนยันตามปกติ คนที่สองจะเห็นข้อความเป็นมิตรเช่น: "คำขอนี้ได้รับการอนุมัติแล้วโดยคนอื่น กรุณารีเฟรชเพื่อดูสถานะล่าสุด" ไม่มีการหมุน ไม่แจ้งซ้ำ ไม่มีความล้มเหลวเงียบ
ถ้าคุณสร้าง CRUD flow ด้วยแพลตฟอร์มอย่าง Koder.ai (backend เป็น Go กับ PostgreSQL) คุณสามารถฝังเช็คนี้ไว้ใน action อนุมัติครั้งเดียวและนำแพตเทิร์นนี้ไปใช้กับการกระทำ "ผู้ชนะหนึ่งเดียว" อื่นๆ
ขั้นตอนต่อไป: ทำให้การแก้ race เป็นส่วนหนึ่งของกระบวนการสร้าง
race condition แก้ได้ง่ายเมื่อคุณทำให้เป็นกิจวัตรที่ทำซ้ำได้ ไม่ใช่การล่าบั๊กครั้งเดียว ให้โฟกัสที่เส้นทางการเขียนไม่กี่เส้นที่สำคัญที่สุด และทำให้พวกมันถูกต้องก่อนจะขัดเกลาอย่างอื่น
เริ่มจากตั้งชื่อจุดชนสูงสุดของคุณ ในหลายแอป CRUD มันคือสามสิ่งเดียวกัน: เคาน์เตอร์ (ไลก์ สต็อก ยอดเงิน) การเปลี่ยนสถานะ (Draft -> Submitted -> Approved) และการส่งซ้ำ (ดับเบิลคลิก รีทราย เครือข่ายช้า)
รูทีนที่ทนทานได้:
- เขียนกฎจริงเพียงข้อเดียวสำหรับแต่ละ flow (เช่น: คำสั่งซื้ออนุมัติได้ครั้งเดียว และเฉพาะจาก pending)
- เพิ่มข้อจำกัดฐานข้อมูลก่อน (unique keys, foreign keys, check constraints) เพื่อให้ฐานข้อมูลปฏิเสธสถานะที่เป็นไปไม่ได้
- ใช้ล็อกธุรกรรมเฉพาะที่การเปลี่ยนต้องมีผู้ชนะเพียงคนเดียว (เช่น ล็อกแถวระหว่างอนุมัติ)
- เพิ่มเทสต์ความขนานเล็กหนึ่งตัวต่อเวิร์กโฟลว์สำคัญ มุ่งที่ความล้มเหลวที่คุณกลัว (สองการอนุมัติ สองการเพิ่ม สองการส่งฟอร์ม)
- ทบทวนการจัดการข้อผิดพลาดเพื่อให้ UI แสดงข้อความชัดเจนเมื่อฐานข้อมูลปฏิเสธการเขียนที่ขัดแย้ง
ถ้าคุณสร้างบน Koder.ai, Planning Mode เป็นที่เหมาะสำหรับแมปแต่ละ flow เป็นขั้นตอนและกฎก่อนสร้างโค้ดจริง Snapshots และ rollback ก็มีประโยชน์เมื่อปล่อยข้อจำกัดหรือพฤติกรรมล็อกใหม่และต้องการทางกลับที่รวดเร็วเมื่อเจอกรณีขอบ
เมื่อเวลาผ่านไป นี่จะกลายเป็นนิสัย: ทุกฟีเจอร์การเขียนใหม่มี constraint ธุรกรรม และเทสต์ความขนาน นั่นแหละคือวิธีที่ race condition ในแอป CRUD หยุดเป็นเรื่องเซอร์ไพรส์
คำถามที่พบบ่อย
Race condition ในแอป CRUD คืออะไร?
Race condition เกิดขึ้นเมื่อคำขอสองรายการเปลี่ยนข้อมูลเดียวกันในเวลาไล่เลี่ยกัน และจังหวะเวลาจะเป็นตัวตัดสินผลลัพธ์ การบันทึกที่ถูกต้องรายการหนึ่งอาจเขียนทับอีกรายการ สร้างข้อมูลซ้ำ หรือเปลี่ยนสถานะซ้ำสองครั้งได้
ฟีเจอร์ CRUD ใดมีแนวโน้มเกิด race condition มากที่สุด?
ควรระวังตัวนับ สินค้าคงคลัง ช่วงเวลาการจอง ฟิลด์สถานะ และระเบียนที่มีกฎว่าต้องไม่ซ้ำ พื้นที่เหล่านี้มักใช้รูปแบบอ่านแล้วเขียน ซึ่งจะมีปัญหาเมื่อคำขอซ้อนทับกัน
จะป้องกันไม่ให้ผู้ใช้ส่งฟอร์มซ้ำสองครั้งได้อย่างไร?
ปิดใช้งานปุ่มหลังคลิกครั้งแรก แต่ต้องป้องกันที่เซิร์ฟเวอร์ด้วย ส่ง idempotency key หนึ่งค่าต่อหนึ่งการดำเนินการ แล้วจัดเก็บไว้ เพื่อให้คำขอซ้ำส่งคืนผลลัพธ์เดิมแทนการสร้างระเบียนที่สอง
ควรอัปเดตตัวนับอย่างปลอดภัยอย่างไร?
ใช้คำสั่งอัปเดตฐานข้อมูลที่เปลี่ยนค่าโดยตรง เช่น เพิ่ม 1 ในคำสั่ง SQL อย่าอ่านค่าตัวนับเข้ามาในแอป เปลี่ยนค่าที่นั่น แล้วเขียนกลับไป
จะป้องกันไม่ให้การเปลี่ยนสถานะขัดแย้งกันได้อย่างไร?
กำหนดให้การเปลี่ยนสถานะผ่านกฎฝั่งเซิร์ฟเวอร์ข้อเดียวที่ตรวจสอบสถานะปัจจุบัน และอนุญาตเฉพาะขั้นตอนถัดไปที่ถูกต้อง ปฏิเสธการเปลี่ยนสถานะที่ล้าสมัยหรือไม่ถูกต้องพร้อมข้อความที่ชัดเจน
Optimistic locking คืออะไร?
Optimistic locking จะเก็บเวอร์ชันหรือเวลาประทับไว้กับระเบียน การอัปเดตจะสำเร็จต่อเมื่อค่านั้นยังตรงกันอยู่ จึงทำให้แอปแจ้งผู้ใช้ได้ว่ามีคนอื่นเปลี่ยนระเบียนไปก่อนแล้ว
ควรใช้ database lock เมื่อใด?
ใช้ transaction พร้อม row lock เมื่อคำขอหนึ่งต้องตัดสินใจก่อนเปลี่ยนหลายระเบียน เช่น จองสินค้าในสต็อกและยืนยันคำสั่งซื้อ ทำงานขณะล็อกให้สั้น และหลีกเลี่ยงการเรียก API ภายนอกภายใน transaction
เหตุใด database constraint จึงสำคัญต่อ race condition?
เพิ่ม unique constraint สำหรับกฎ เช่น หนึ่งอีเมลต่อหนึ่งบัญชี หรือหนึ่งการอนุมัติต่อหนึ่งคำขอ เพิ่ม foreign key และ check constraint เพื่อบังคับการอ้างอิงและค่าที่ถูกต้อง ฐานข้อมูลจะปฏิเสธสถานะที่เป็นไปไม่ได้ แม้คำขอสองรายการจะมาถึงพร้อมกัน
จะป้องกันไม่ให้สินค้าคงคลังติดลบได้อย่างไร?
กำหนดเงื่อนไขให้การอัปเดตสต็อกเกิดใน transaction เดียว เช่น หักจำนวนเฉพาะเมื่อยังมีสินค้าเพียงพอ หากการอัปเดตไม่กระทบแถวใด ให้แจ้งผู้ซื้อว่าสินค้าไม่พร้อมจำหน่าย แทนการยืนยันคำสั่งซื้อที่ขายเกินสต็อก
จะทดสอบ write flow เพื่อหารือเรื่อง race condition ได้อย่างไร?
ส่งคำขอสองรายการพร้อมกันไปยังระเบียนหรือจำนวนเดียวกัน แล้วตรวจสอบผลลัพธ์ ทดสอบว่าคำขอหนึ่งรายการชนะเมื่อจำเป็น กรณีขัดแย้งส่งคืนการตอบกลับที่ควบคุมได้ และฐานข้อมูลไม่มีข้อมูลซ้ำหรือไม่ถูกต้อง