6 คะแนน โดย GN⁺ 5 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ระบบจองสต็อก เป็นโครงสร้างพื้นฐานสำคัญที่ใช้ป้องกันการขายซ้ำเกินจำนวนจริงระหว่างขั้นตอนชำระเงิน โดย 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_quantities INSERT → reservation_units DELETE
    • claim: reserved_quantities DELETE
  • วิธีแก้คือให้ reserve ทำ DELETE ที่ตาราง units ก่อนเสมอ แล้วค่อย reserved_quantities INSERT ทีหลัง เพื่อ ทำลำดับให้เป็นมาตรฐานเดียวกัน → ตัดปัญหา 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 ความคิดเห็น

 
hso2341 29 분 전

นานๆ ทีจะมีบทความที่ให้ความรู้สึกเป็นงานพัฒนาจริงๆ โผล่ขึ้นมานะ