Shopify เปลี่ยนระบบจองสต็อกจาก Redis ไปเป็น MySQL
(shopify.engineering)- ระบบจองสต็อก เป็นโครงสร้างพื้นฐานสำคัญที่ใช้ป้องกันการขายซ้ำเกินจำนวนจริงระหว่างขั้นตอนชำระเงิน โดย Shopify ใช้งานบน Redis มานานหลายปี
- ใช้ความสามารถ
SKIP LOCKEDของ MySQL 8 เพื่อออกแบบใหม่จากโครงสร้างคอลัมน์จำนวนต่อสินค้า ไปเป็นโครงสร้าง 1 แถวต่อ 1 หน่วยขาย ทำให้รองรับประสิทธิภาพสูงได้โดยไม่ต้องพึ่ง Redis - ผสานเทคนิคเพิ่มประสิทธิภาพของ MySQL เช่น composite primary key, ระดับ isolation แบบ
READ COMMITTED, ลำดับการล็อกที่สม่ำเสมอ, และการประมวลผลแบบแบตช์ด้วยUNION ALLเพื่อแก้ปัญหาการแย่งล็อกและ deadlock - คอขวดที่แท้จริงไม่ใช่ query สำหรับการจอง แต่เป็น การยึดครอง connection และหลังจากทำ instrumentation ตลอดเส้นทาง checkout ก็ลดการอ่านจาก DB ลง 50% และลด transaction ลง 33%
- ที่จุดพีกของ Black Friday ปี 2025 ระบบรองรับยอดขาย 5.1 ล้านดอลลาร์ต่อนาที โดยยังคงรักษา writer CPU ไว้ต่ำกว่า 50% และ reader CPU ต่ำกว่า 16% พร้อมทำได้เกินเป้าหมาย throughput
เบื้องหลัง: ข้อกำหนดของระบบป้องกันการขายเกินจำนวน
- ต้องมีระบบ Oversell Protection ที่รับประกันได้ว่า เมื่อ checkout เสร็จแล้ว สินค้ายังคงมีสต็อกอยู่จริง
- Reserve: เมื่อเริ่มชำระเงิน จะล็อกสินค้านั้นไว้ชั่วคราวเป็นเวลาหลายนาที
- Claim: เมื่อชำระเงินสำเร็จ จะตัดจำนวนออกจากสมุดบัญชีสต็อกอย่างถาวร
- ทั้งสองทางนี้ยอมให้ผิดพลาดไม่ได้
- หากผิด อาจเกิดกรณีลูกค้าสองคนซื้อสินค้าชิ้นเดียวกันได้ หรือระบบมองว่าของหมดทั้งที่ยังมีสต็อก ทำให้เสียรายได้
- ข้อกำหนดด้านสเกล: Shopify รองรับมากกว่า 14% ของอีคอมเมิร์ซในสหรัฐฯ และใน Black Friday ปี 2025 มียอดขาย 5.1 ล้านดอลลาร์ต่อนาที เพิ่มขึ้น 11% จากปีก่อน
- ข้อกำหนดสำคัญคือรองรับสต็อกหลายสถานที่ (Multi-location inventory), การรับประกันแบบ ACID, throughput สูง และให้ความถูกต้องมาก่อนเสมอ
ข้อจำกัดของโมเดล Redis เดิม
- ใน Redis สินค้าแต่ละรายการจะมีคีย์เก็บจำนวน โดยการจองใช้
DECRและการคืนใช้INCR - ปัญหาหลัก: ข้อมูลการจอง (Redis) กับสมุดบัญชีสต็อก (MySQL) อยู่กันคนละระบบ
- ในขั้นตอน Claim ไม่สามารถรวมการอัปเดต MySQL กับการล้างข้อมูลใน Redis ให้อยู่ใน transaction แบบ atomic เดียวกันได้
- ขึ้นอยู่กับลำดับการทำงาน อาจเกิด oversell (ขายสินค้าไปแล้วแต่ยังไม่ตัดจาก ledger) หรือ undersell (ตัดจาก ledger แล้วแต่ยังคงอยู่ในสถานะจอง)
- ไม่รองรับการรับรู้สต็อกหลายสถานที่ และยังมีภาระต้นทุนในการดูแล Redis cluster แยกต่างหาก
แนวทางแก้หลัก: ออกแบบ MySQL ใหม่บนพื้นฐานของ SKIP LOCKED
โครงสร้างพื้นฐาน: 1 แถวต่อ 1 หน่วย (One Row Per Unit)
- แทนที่จะใช้คอลัมน์จำนวนต่อสินค้า ก็เปลี่ยนเป็นโครงสร้าง 1 แถวต่อ 1 หน่วยที่ขายได้
- สินค้าที่มีสต็อก 10 ชิ้น → 10 แถว; หากจอง 3 ชิ้น ก็เลือกและย้าย 3 แถวภายใน transaction เดียว
- เก็บทั้งการจองและสมุดบัญชีสต็อกไว้ใน MySQL DB เดียวกัน ทำให้ reserve และ claim อยู่ใน ACID transaction เดียว ลดบั๊กแบบที่เคยเกิดกับ Redis
SKIP LOCKED: ข้ามแถวที่ถูกล็อกโดย transaction อื่น และคืนเฉพาะแถวที่พร้อมใช้งานทันที → ลดการแย่งกันรอแถวเดิม
จำกัดขนาดพูล: สูงสุด 1,000 แถวต่อสถานที่
- จำกัดจำนวนแถวที่พร้อมใช้ต่อคู่ item/location ไว้สูงสุด 1,000 แถว เพื่อควบคุมขนาดตารางและประสิทธิภาพการสแกน
- ตัวอย่าง: ป้องกันสถานการณ์ที่สต็อก 50,000 ชิ้น × 10 สถานที่ กลายเป็น 500,000 แถว
- เมื่อพูลใกล้หมด จะ trigger การ replenishment แบบ inline; มีการล็อกเพื่อให้มีเพียง transaction เดียวที่เติมข้อมูล ป้องกัน thundering herd จากหลาย transaction ที่พยายาม insert พร้อมกัน
- แม้พูลจะว่างเปล่าทั้งหมด ก็จะเกิดความหน่วงเฉพาะกับการจองนั้น และจะไม่ทำให้ลูกค้าที่ควรซื้อได้จริงถูกมองว่าสินค้าหมด
4 การตัดสินใจทางเทคนิคสำคัญ
1. ลดจำนวนล็อกด้วย composite primary key
- ใน prototype แรก เมื่อใช้งาน auto-increment ID เป็น primary key, InnoDB ต้องล็อกทั้ง secondary index และ clustered index ทำให้เกิด 2 row locks ต่อการจองหนึ่งครั้ง
- เปลี่ยนมาใช้ composite primary key ที่ประกอบด้วย
shop_id, inventory_item_id, inventory_group_id, id→ เพราะคอลัมน์ที่ใช้กรองรวมอยู่ใน primary key แล้ว จำนวนล็อกจึงลดเหลือ 1 - ในสภาพแวดล้อมที่มีการจองหลายพันครั้งต่อวินาที การออกแบบ index และ primary key ส่งผลโดยตรงต่อจำนวนล็อกและ throughput
2. ใช้ READ COMMITTED เพื่อตัด gap lock
- เมื่อรัน
SELECT ... FOR UPDATE SKIP LOCKEDบนตารางว่าง จะเกิด gap lock (รวมถึง supremum) ซึ่งไปบล็อก INSERT ของ transaction ที่ใช้เติมข้อมูล และก่อให้เกิด deadlock - เปลี่ยนระดับ isolation จากค่าเริ่มต้นของ MySQL อย่าง
REPEATABLE READไปเป็นREAD COMMITTED→ วิธีเกิด gap lock เปลี่ยนไป ทำให้ transaction สำหรับเติมข้อมูลทำงานต่อได้ตามปกติ - นี่เป็นครั้งแรกที่ codebase นี้ใช้ isolation level ที่ไม่ใช่ค่า default จึงต้องเพิ่มการรองรับใน framework เล็กน้อยเพื่อกำหนด isolation level ราย transaction
3. ป้องกัน deadlock ด้วยลำดับการล็อกที่สม่ำเสมอ
- reserve และ claim เข้าถึงสองตารางนี้ด้วย ลำดับที่ต่างกัน จึงทำให้เกิด deadlock
- reserve:
reserved_quantitiesINSERT →reservation_unitsDELETE - claim:
reserved_quantitiesDELETE
- reserve:
- วิธีแก้คือให้ reserve ทำ DELETE ที่ตาราง units ก่อนเสมอ แล้วค่อย
reserved_quantitiesINSERT ทีหลัง เพื่อ ทำลำดับให้เป็นมาตรฐานเดียวกัน → ตัดปัญหา circular wait
4. ลด round-trip ด้วยการแบตช์ผ่าน UNION ALL
- เมื่อในตะกร้ามีหลาย line item ระบบจะใช้
UNION ALLเพื่อแบตช์ query การจองให้เป็น single round-trip - เมื่อจำนวน round-trip ลดลง latency ก็ลดลงในภาวะโหลดสูง
คอขวดจริง: ไม่ใช่ query แต่เป็นการยึด connection
กระบวนการค้นหาปัญหา
- ใน production ระบบชนเพดานก่อนถึง throughput เป้าหมาย ทั้งที่ P90 latency ยังดี, CPU ยังไม่เต็ม และ query ก็ optimize แล้ว
- อาการที่สังเกตได้จากการทดสอบโหลด:
- มีการ queue thread ภายใน MySQL
- เมื่อเริ่มประมวลผลงานที่ค้างอยู่ CPU จะพุ่งสูง
- ที่ชั้น ProxySQL เกิดการหมดของ MySQL backend connection
ทำให้มองเห็นการใช้ connection
- ที่ชั้นแอปพลิเคชัน: เพิ่มคอมเมนต์ระบุ business process ลงใน SQL ทุกคำสั่งในรูปแบบ
/* conn_tag:checkout_completion */ - ที่ชั้น ProxySQL: เพิ่มการ parse tag และเก็บสถิติ เวลาที่แต่ละ caller ยึด connection
- ผลลัพธ์: มองเห็นได้ทันทีว่า process ไหนยึด connection นานเท่าไร
สิ่งที่พบและการแก้ไข
- นอกจากส่วนจองแล้ว โค้ดอื่นในเส้นทาง checkout ก็ยึด connection นานเกินจำเป็น
- ก่อนหน้านี้โค้ดเหล่านี้ไม่เคยถูกเลือกมา optimize เพราะยังไม่ชนเพดานก่อนตัวอื่น
- หลังจัดระเบียบเส้นทาง checkout: ลดการอ่านจาก primary DB 50% และลด transaction 33%
- ปรับค่า InnoDB thread concurrency ที่เคยตั้งไว้อย่างระมัดระวังเมื่อหลายปีก่อนแต่ไม่เคยทบทวน ก็ช่วยลบคอขวดเพิ่มได้อีก
- หลังปรับปรุงแล้ว ในเกณฑ์ flash sale ปริมาณสูง ระบบยังคง writer CPU ต่ำกว่า 50% และ reader CPU ต่ำกว่า 16%
วิธีการเปลี่ยนผ่าน: Shadow Mode
- ไม่ได้สลับจาก Redis ไป MySQL ทันที แต่ใช้ Shadow Mode รันสองระบบคู่ขนาน
- ทุกการจองจะถูกเขียนลงทั้ง Redis และ MySQL พร้อมกัน โดยยังคงให้ Redis เป็น source of truth
- ใช้ traffic จริงใน production เพื่อตรวจสอบความถูกต้องและประสิทธิภาพของ MySQL แบบขนาน
- สามารถสลับระบบได้โดยไม่ต้อง migrate การจองที่ยังค้างอยู่ (เพราะทั้งสองระบบยังทำงานอยู่พร้อมกัน)
- หลังเปลี่ยน source of truth ไปเป็น MySQL แล้ว ก็ยังคงมี kill switch และยังคงใช้เส้นทาง dual-write เพื่อให้ Redis อัปเดตล่าสุดอยู่เสมอ
- การ rollout ทำแบบ ค่อยเป็นค่อยไปในระดับ pod ตั้งแต่ pod ที่มี traffic ต่ำ ไปจนถึง merchant ที่มีปริมาณสูงสุด
บทเรียน
1. ควรทบทวนการตัดสินใจเก่า ๆ
- สิ่งที่ทำไม่ได้เมื่อ 5 ปีก่อน วันนี้อาจทำได้แล้วด้วยความสามารถใหม่ของ MySQL อย่าง
SKIP LOCKED - การตั้งค่าตาม "กฎจากประสบการณ์" เช่น thread limit ควรถูกทบทวนเมื่อ workload และฮาร์ดแวร์เปลี่ยนไป
- ถ้า CPU ยังต่ำแต่เกิด queueing ต้องขุดหาสาเหตุให้เจอ
2. เริ่มจากเล็ก ๆ และสังเกตให้มาก
- สร้าง prototype ขั้นต่ำด้วย Ruby script ขนาดเล็กและ MySQL โดยไม่ต้องใช้ Rails framework เต็มรูปแบบ
- การเปิดอีกหนึ่ง terminal เพื่อดูพฤติกรรมของ lock โดยตรง ให้บทเรียนมากกว่าทฤษฎีเสียอีก
- แพตเทิร์นการทำ instrumentation การยึด connection (tag ที่ชั้นแอป + การรวมสถิติที่ proxy) ทำได้ไม่ยากและนำไปใช้ได้ทันที
1 ความคิดเห็น
นานๆ ทีจะมีบทความที่ให้ความรู้สึกเป็นงานพัฒนาจริงๆ โผล่ขึ้นมานะ