Cache เร็ว… จนกว่าจะหมดอายุ
ว่าด้วยเรื่อง Cache Stampede – ปัญหาที่ซ่อนอยู่หลังตัวเลข Response Time สวย ๆ
แต่วินาทีที่ Cache หมดอายุ ระบบเร็วที่สุดของคุณอาจกลายเป็นระบบที่ช้าที่สุดได้ในพริบตา
5 มิลลิวินาที
นั่นคือ Response Time ของ API หน้าสินค้า หลังจากทีมใส่ Cache เข้าไป จากเดิมที่ Query ฐานข้อมูลทีละ 400 มิลลิวินาที ตอนนี้เร็วขึ้นถึง 80 เท่า Load Test ผ่านฉลุย กราฟเรียบเป็นเส้นตรง ทุกคนพอใจ แล้วระบบก็ขึ้น Production
แต่มีคำถามข้อหนึ่งที่แทบไม่มีใครถามในห้องประชุมวันนั้น
“แล้ว 5 มิลลิวินาทีนี้ จะเป็นยังไง ในวินาทีที่ Cache หมดอายุ?”
015 นาทีในชีวิตของ Cache หนึ่งตัว
สมมติว่า Cache ของหน้าสินค้ายอดนิยมตั้งอายุไว้ 5 นาที และมีคนเปิดหน้านี้ 500 ครั้งต่อวินาที เรามาลองไล่ดูทีละช่วงเวลากัน
- นาทีที่ 00:00 — Request แรกวิ่งไปฐานข้อมูล ใช้เวลา 400 มิลลิวินาที ได้ข้อมูลกลับมาแล้วเก็บลง Cache
- นาทีที่ 00:01 ถึง 04:59 — ช่วงเวลาทอง ทุก Request ได้ Response จาก Cache ภายในไม่กี่มิลลิวินาที ฐานข้อมูลแทบไม่ต้องทำอะไรเลย นี่คือช่วงที่ Load Test ส่วนใหญ่วัดได้
- นาทีที่ 05:00 — Cache หมดอายุ Request ถัดไปหาข้อมูลไม่เจอ จึงวิ่งไปถามฐานข้อมูล
- อีก 400 มิลลิวินาทีถัดมา — ระหว่างที่ Request แรกยังรอ Response มี Request ใหม่เข้ามาอีกราว 200 รายการ ทุกรายการหา Cache ไม่เจอเหมือนกัน ทุกรายการจึงวิ่งไปถามฐานข้อมูลด้วยคำถามเดียวกันพร้อม ๆ กัน
- วินาทีต่อจากนั้น — ฐานข้อมูลรับงานซ้ำซ้อนหลายร้อยงานจนตอบช้าลง ยิ่งช้า Request ที่ค้างก็ยิ่งกองสูง Connection Pool เริ่มเต็ม บาง Request Timeout แล้ว Retry ซ้ำ ลูกค้าเห็นหน้าเว็บหมุนค้าง
แล้วทุกอย่างก็วนกลับไปเริ่มใหม่ในอีก 5 นาทีข้างหน้า
อาการนี้มีชื่อว่า Cache Stampede หรือ Thundering Herd เหมือนฝูงสัตว์ที่วิ่งกรูไปทางเดียวกันพร้อมกัน บนกราฟจะเห็นเป็นยอดแหลมพุ่งขึ้นเป็นจังหวะ ตรงกับรอบหมดอายุพอดี
ในโลก .NET เมธอด GetOrCreateAsync ของ IMemoryCache ที่ใช้กันแพร่หลาย ไม่ได้กันปัญหานี้ให้ ถ้าหลาย Request เข้ามาพร้อมกันตอน Cache ว่าง โค้ดดึงข้อมูลก็อาจถูกเรียกซ้ำพร้อมกันได้หลายครั้ง ชื่อเมธอดดูเหมือนจะจัดการให้ครบ แต่ความเป็นจริงไม่ใช่แบบนั้น
02Check List: Load Test ของคุณมองเห็น Stampede ไหม?
ลองติ๊กดูว่าการทดสอบรอบล่าสุดของทีมตรงกับข้อไหนบ้าง ยิ่งติ๊กเยอะ ยิ่งมีโอกาสที่ปัญหานี้ซ่อนอยู่โดยที่ไม่มีใครเห็น
- Data Test ชุดเล็กๆ และวนซ้ำ — Script ใช้รหัสสินค้าไม่กี่สิบตัวหมุนไปเรื่อย ๆ ไม่กี่วินาทีทุกอย่างก็อยู่ใน Cache หมด ที่เหลือคือการวัดความเร็วของ Cache ไม่ใช่ของระบบ
- รอบรันสั้นกว่ารอบหมดอายุ Cache — อาจจะถูกตั้งให้หมดอายุทุก 180 นาที แต่ Load Test รันแค่ 60 นาที เท่ากับไม่เคยเห็นช่วงหมดอายุเลยสักครั้ง
- ตัดช่วง Warm-up ทิ้ง — ช่วยให้ตัวเลขนิ่งก็จริง แต่ช่วงที่ตัดทิ้งคือช่วงที่ Cache ว่างเปล่าพอดี
- ดูแค่ค่าเฉลี่ยหรือตัวเลขสรุป — ยอดแหลมไม่กี่วินาทีจะหายไปเมื่อเฉลี่ยรวมกับ Request เร็ว ๆ อีกเป็นแสนรายการ
- ไม่ได้ดูฝั่งฐานข้อมูลระหว่างทดสอบ — วัดแค่ Response Time ที่ปลายทาง ไม่ได้ดูจำนวน Query และ Connection Pool
033 วิธีรับมือ เทียบกันให้เห็นชัด ๆ
ปัญหานี้มีวิธีแก้ที่รู้จักกันดี แต่ละวิธีมีข้อแลกเปลี่ยนต่างกัน ทีมส่วนใหญ่ใช้หลายวิธีร่วมกัน
| วิธี | หลักการ | ถ้าเป็นร้านชา | ข้อแลกเปลี่ยน |
|---|---|---|---|
| Single-flight (ล็อกต่อ Key) |
เมื่อ Cache หมดอายุ ให้ Request แรกไปดึงข้อมูลใหม่คนเดียว Request อื่นรอรับคำตอบชุดเดียวกัน ใน .NET ใช้ได้ผ่าน package HybridCache (เปิดตัวในยุค .NET 9 และรองรับ .NET รุ่นเก่าด้วย) | ให้พนักงานคนเดียวต้มเหยือกใหม่ คนอื่นรอเทให้ลูกค้า | ลูกค้าที่มาช่วงนั้นยังต้องรอหนึ่งรอบ และกลไกนี้ทำงานแยกในแต่ละ Server ถ้ามี 10 Server ฐานข้อมูลยังโดนได้สูงสุด 10 Query ต่อ Key ซึ่งดีกว่า 200 มาก แต่ไม่ใช่ 1 |
| Background Refresh / Stale-While-Revalidate |
รีเฟรช Cache ล่วงหน้าก่อนหมดอายุ หรือส่งข้อมูลเก่าไปก่อนระหว่างที่ดึงข้อมูลใหม่ | ต้มเหยือกใหม่ตั้งแต่เหยือกเก่ายังเหลือครึ่ง | ข้อมูลอาจเก่ากว่าเดิมไม่กี่วินาที ต้องดูว่าธุรกิจยอมรับได้ไหม และ HybridCache ไม่ได้ทำส่วนนี้ให้อัตโนมัติ ต้องเขียนเองหรือใช้ไลบรารีอย่าง FusionCache |
| Jitter (สุ่มเวลาหมดอายุ) |
บวกลบเวลาหมดอายุเล็กน้อย ไม่ให้ Cache ทุกตัวหมดอายุในวินาทีเดียวกัน | ไม่ให้ทุกเหยือกหมดพร้อมกันตอน 8 โมงตรง | ไม่ได้แก้ Stampede ของ Key ยอดนิยมตัวเดียว แค่กระจายภาระโดยรวม |
ไม่ว่าเลือกวิธีไหน ขั้นตอนที่ข้ามไม่ได้คือ ทดสอบให้เห็นกับตาว่ามันได้ผลจริง ไม่ใช่แค่เชื่อว่าโค้ดเขียนไว้ถูกแล้ว
044 Scenario ที่ควรเพิ่มในการทดสอบรอบหน้า
ไม่ต้องเปลี่ยนเครื่องมือ k6, JMeter, NeoLoad หรือ LoadRunner ก็ยังทำได้ทั้งหมด สิ่งที่เปลี่ยนคือการออกแบบ Scenario
- Cold Start — ล้าง Cache ก่อนเริ่ม แล้วยิงโหลดเต็มทันทีโดยไม่มีช่วงอุ่นเครื่อง จำลองวินาทีหลัง Deploy หรือหลัง Server Restart
- Expiry Window — รันให้ยาวกว่ารอบหมดอายุอย่างน้อยสองถึงสามรอบ แล้วดูกราฟ Response Time ตามเวลา ถ้ามียอดแหลมเกิดเป็นจังหวะสม่ำเสมอ นั่นคือสัญญาณ
- Realistic Data Mix — ใช้ Data Test ที่มีทั้งสินค้ายอดนิยมและสินค้าที่นาน ๆ ทีจะมีคนเปิด ให้ได้ Cache Hit และ Cache Miss ในสัดส่วนใกล้ของจริง
- Hot Key Burst — ยิง Request จำนวนมากเข้า Key เดียวกันพร้อมกันในจังหวะที่ Key นั้นเพิ่งหมดอายุ เพื่อพิสูจน์ว่ากลไกป้องกันในตารางด้านบนทำงานจริง
ระหว่างรันทุก Scenario ให้เฝ้าดู Cache Hit Ratio, จำนวน Query ที่เข้าฐานข้อมูล และ Connection Pool ไปพร้อมกับ Response Time ถ้า Query พุ่งขึ้นหลายร้อยรายการในวินาทีเดียว คุณเจอ Stampede แล้ว
ถ้าอยากเห็นผลเร็ว ให้ลดอายุ Cache ใน Test Environment ลงชั่วคราว เช่น จาก 30 นาทีเหลือ 1 นาที จะได้เห็นหลายรอบหมดอายุภายใน Load Test ที่สั้นลง แค่อย่าลืมว่าตัวเลขที่ได้เป็นการจำลอง ไม่ใช่ค่าจริงของ Production
05คำถามที่ควรถามในประชุมครั้งหน้า
กลับไปที่ห้องประชุมวันที่ทุกคนปรบมือให้ตัวเลข 5 มิลลิวินาที ถ้าย้อนเวลาได้ คำถามที่ควรถามคือ
- Cache ของเราหมดอายุทุกกี่นาที และ Load Test รันนานกว่านั้นหรือเปล่า?
- ถ้า Cache ว่างทั้งหมดในวินาทีเดียว ฐานข้อมูลรับได้กี่ Request พร้อมกัน?
- เรามีกลไกอะไรกันไม่ให้หลายร้อย Request วิ่งไปถามเรื่องเดียวกัน และเคยพิสูจน์แล้วหรือยังว่ามันทำงาน?
Cache เป็นทางลัดที่ดีมาก และระบบส่วนใหญ่ขาดมันไม่ได้ แต่ทางลัดไม่ได้ทำให้ถนนเส้นหลักแข็งแรงขึ้น มันแค่ทำให้เราไม่ต้องใช้ถนนเส้นนั้นบ่อย จนถึงวันที่ทางลัดปิด
“ตัวเลขที่เร็วที่สุดใน Load Test ไม่ได้บอกว่าระบบแข็งแรงแค่ไหน ตัวเลขในวินาทีที่ทางลัดหายไปต่างหากที่บอก“
ถ้าบทความนี้ทำให้นึกถึงระบบไหนขึ้นมา ลองส่งต่อให้ทีมที่ดูแลระบบนั้น แล้วตั้งคำถามสามข้อด้านบนในการประชุมรอบหน้าดู


