Thundering Herd: เมื่อคีย์ทองคำหมดอายุเพียง 1 วินาที ทำไมคำขอหลักหมื่นถึงกรูลงไปรุมเหยียบ DB จนแบนติดดิน?
⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 30 วินาที
หน้าแรกของแอปช้อปปิ้งออนไลน์ มีแบนเนอร์แสดงโปรโมชัน Flash Sale ที่ทุกคนต้องเปิดดู... คีย์นี้ถูกแคชไว้ใน Redis สวยงาม มีผู้ใช้งานเปิดดูพร้อมกัน 15,000 ครั้งต่อวินาที
ทุกอย่างราบรื่น Hit Rate แตะ 100% จนกระทั่งถึงเวลา 12:00:00 น. ซึ่งเป็นเวลาที่แคชคีย์นี้หมดอายุ (TTL Expire) ลงตามที่ตั้งไว้...
ในเสี้ยววินาทีถัดมา: แทนที่จะมีแค่ 1 คำขอแอบเดินลงไปดึงข้อมูลใหม่จาก Database เงียบๆ... คำขอทั้ง 15,000 คนที่วิ่งมาไม่เจอแคชพร้อมกัน ดันพร้อมใจกันแปลงร่างเป็น "ฝูงควายป่าแตกตื่น" วิ่งกรูทะลุลงไป Query หน้าตารางเดียวกันที่ Database ในมิลลิวินาทีเดียวกันเป๊ะ!
CPU ของฐานข้อมูลพุ่งกระแทกเพดานทันที Connection Pool ตันสนิท และหน้าเว็บทั้งระบบล่มสลายในพริบตา!
🐃 ฝูงสัตว์แตกตื่น: The Thundering Herd Problem
คำว่า Thundering Herd (ฝูงสัตว์แตกตื่น) เดิมทีมาจากโลกของระบบปฏิบัติการ UNIX เมื่อมี Process จำนวนมากหลับรอคอย Event ชิ้นเดียวกัน พองานมาถึง OS กลับส่งสัญญาณปลุกทุกคนพร้อมกัน ทำให้ Process ทั้งหมดตื่นขึ้นมาแย่งของชิ้นเดียวจนกินแรง CPU เปล่าประโยชน์
ในโลกของ Web Architecture ยุคใหม่ ปรากฏการณ์นี้เกิดขึ้นเมื่อ:
"ทรัพยากรยอดนิยม (Hot Key) หมดอายุลงกะทันหัน ส่งผลให้ทราฟฟิกมหาศาลที่เคยถูกดูดซับด้วย Cache กรูลงไปยังบริการหลังบ้าน (Origin/Database) พร้อมเพรียงกันโดยไม่มีใครยอมเบรก!"
สิ่งที่น่าเจ็บปวดคือ: ทั้ง 15,000 คำขอต้องการผลลัพธ์หน้าตาเหมือนกันเป๊ะทุกไบต์! แต่ระบบกลับสั่งให้ Database ทำงานซ้ำเดิมหมื่นห้าพันรอบในเสี้ยววินาทีเดียว
🛡️ 3 คาถาสยบฝูงสัตว์: Singleflight, Probabilistic Early Expiration & Lock
1. ไม้ตายของ Go: golang.org/x/sync/singleflight (รวมงานเป็นคำขอเดียว):
Singleflight คือกลไกขั้นเทพที่ทำงานในระดับ Application Memory: ถ้ามี 10,000 Goroutine พยายามขอดึงคีย์ banner:flashsale ในจังหวะเดียวกัน...
- Singleflight จะอนุญาตให้ Goroutine แรกเพียงตัวเดียว วิ่งลงไป Query ที่ Database
- อีก 9,999 ตัวที่เหลือจะถูกกล่อมให้ยืนรอผลลัพธ์ชั่วคราว
- เมื่อตัวแรกได้ผลลัพธ์กลับมา มันจะ กระจายคำตอบเดียวกันนั้นให้ทุกคนทันที! Database ทำงานแค่ 1 ครั้งถ้วน!
var requestGroup singleflight.Group
val, err, _ := requestGroup.Do("banner:flashsale", func() (interface{}, error) {
// คำสั่งนี้จะถูกรันแค่ 1 ครั้งเท่านั้น ไม่ว่าจะมีคนเรียกพร้อมกันกี่หมื่นคน!
return fetchBannerFromDatabase()
})
2. ชิงชาร์จพลังก่อนหมดเวลา (XFetch / Probabilistic Early Expiration):
แทนที่จะรอให้คีย์หมดอายุแบบกะทันหัน อัลกอริทึมอย่าง XFetch จะใช้หลักความน่าจะเป็น: ยิ่งเวลาใกล้หมดอายุมากเท่าไหร่ และคีย์นั้นมีคนเรียกอ่านบ่อยเท่าไหร่ โอกาสที่ Request สุ่มตัวหนึ่งจะ "แอบไปคำนวณและอัปเดตแคชล่วงหน้าเงียบๆ ก่อนที่มันจะตายจริง" จะยิ่งสูงขึ้น ทำให้คีย์นี้ ไม่มีวันว่างเปล่า ต่อหน้าผู้ใช้ทั่วไปเลย
3. ปิดกั้นด้วย Distributed Mutex:
ในภาษาอย่าง Java หรือ Node.js ให้ใช้ Redis SET NX EX เพื่อให้เฉพาะ Worker ที่แย่งกุญแจได้ตัวแรกเป็นคนลงไปดึงข้อมูล ส่วนคนอื่นที่ไม่ได้กุญแจ ให้ยอมส่ง Stale Cache (ข้อมูลเก่าที่ค้างอยู่) กลับไปให้ผู้ใช้ชั่วคราว ดีกว่าปล่อยให้หน้าเว็บค้าง
💡 5-Min Takeaway
"งานที่ซ้ำซ้อนกันในเสี้ยววินาทีเดียวกัน ไม่ควรถูกทำซ้ำเป็นครั้งที่สอง" เมื่อระบบมี Hot Key ที่คนเปิดดูมหาศาล การมีแค่ Cache เพียงอย่างเดียวไม่พอ ต้องมีกลไก Request Collapsing / Singleflight หรือการทำ Background Refresh คอยคุมบังเหียนเสมอ เพื่อป้องกันไม่ให้คลื่นมนุษย์หลุดเข้าไปเหยียบฐานข้อมูลจนล้มทั้งระบบ