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) ทำได้ไม่ยากและนำไปใช้ได้ทันที
6 ความคิดเห็น
นานๆ ทีจะมีบทความที่ให้ความรู้สึกเป็นงานพัฒนาจริงๆ โผล่ขึ้นมานะ
ฉันเองก็ไม่ได้เห็นบทความแบบนี้มานานแล้ว เลยให้ความรู้สึกที่ต่างออกไป
ดูเหมือนว่าจะเหนื่อยล้ากับ AI มากเกินไปแล้ว
เห็นด้วยเลย 555 ยุคนี้ได้เห็นบทความแบบนี้แล้วรู้สึกยินดีจริง ๆ!
ดูเหมือนว่าผมจะไม่ใช่คนเดียวที่เบื่อโพสต์โปรโมตแนว AI Slop 555
เยี่ยมมาก
Postgresql หรือ oracle เองก็มี
skip lockedมาตั้งนานแล้วไม่ใช่เหรอ? ก็คิดแบบนั้นอยู่เหมือนกันแต่ Oracle นี่ค่าไลเซนส์ก็นับว่าเป็นภาระก่อนเลย
ส่วน Pg ก็คาดว่าเพราะการทำงานของ MVCC น่าจะมีภาระจาก
updateค่อนข้างมาก เลยเดาว่าเป็นเหตุผลที่นำมาใช้หลังจาก MySQL 8 มีฟีเจอร์นี้แล้ว