CPU ของฐานข้อมูลของคุณพุ่งสูงถึง 100% ตอนตีสาม ทั้งที่ปริมาณทราฟฟิกดูปกติ จำนวนคำขอ (requests) ก็ไม่ได้สูงผิดปกติ แต่ฐานข้อมูลหลักของคุณกลับกำลังจะพัง เพราะทุกๆ คำขอเหล่านั้นกำลังถามหาข้อมูลอย่างเดียวกัน ในเวลาเดียวกันเป๊ะ
นี่คือปัญหา thundering herd
ลองนึกภาพสนามกีฬาหลังจบการแสดงคอนเสิร์ต ในช่วงเวลาหนึ่งชั่วโมง ประตูทางออกบานเดียวสามารถปล่อยให้ทุกคนออกไปได้อย่างราบรื่น แต่ถ้าฝูงชนทั้งหมดตัดสินใจออกทางประตูบานนั้นพร้อมกันภายในเวลาเพียงสิบวินาที ประตูไม่ได้พัง แต่มันแค่ไม่สามารถรองรับการทำงานพร้อมกัน (concurrency) ได้ขนาดนั้น แรงเบียดเสียดนั่นแหละคือบั๊ก ไม่ใช่ตัวประตู
จุดที่ฝูงสัตว์เริ่มก่อตัว
รูปแบบนี้จะปรากฏขึ้นเมื่อมีหลายโปรเซสทำงานพร้อมกันโดยอิงจากเหตุการณ์เดียวกัน คุณไม่จำเป็นต้องมีทราฟฟิกที่มุ่งร้าย (malicious traffic) ระบบปกติก็สามารถสร้างปัญหานี้ขึ้นมาเองได้
Cache หมดอายุ (Cache expiration): คีย์ในแคชที่ถูกใช้งานบ่อย (hot cache key) หมดอายุลง อาจจะเป็นแคตตาล็อกสินค้า, feature flag หรือรายการสิทธิ์ของผู้ใช้ เซิร์ฟเวอร์แอปพลิเคชันหลายพันเครื่องสังเกตเห็นช่องว่างที่ว่างเปล่าพร้อมๆ กัน แต่ละเซิร์ฟเวอร์ต่างก็ทำหน้าที่ของตนด้วยความหวังดี โดยการเปิดการเชื่อมต่อฐานข้อมูลของตัวเองและรันคิวรี (query) ชุดเดิมที่หนักหน่วงเพื่อสร้างค่าใหม่ขึ้นมา ฐานข้อมูลไม่ได้ถูกตั้งค่ามาเพื่อตอบคำถามราคาแพงคำถามเดิมซ้ำๆ ถึงสองพันครั้งพร้อมกัน แค่คีย์เดียวที่หมดอายุก็สามารถทำให้ทั้งคลัสเตอร์ล่มได้
การปลุก Connection pool (Connection pool wakeups): ในบางสถาปัตยกรรม โปรเซสทำงาน (worker processes) จำนวนมากจะรอ (block) อยู่ที่เงื่อนไขใดเงื่อนไขหนึ่งเพื่อรอรับงาน เมื่อเงื่อนไขนั้นเกิดขึ้น ระบบปฏิบัติการจะปลุก worker ทุกตัวที่หลับอยู่ขึ้นมา แต่มีเพียง worker ตัวเดียวเท่านั้นที่ได้รับ connection หรือได้รับงานจริงๆ ส่วนที่เหลือตื่นขึ้นมาแล้วพบว่าตัวเองแพ้ในการแข่งขัน (lost the race) จึงต้องกลับไปนอนต่อ วงจรนี้จะเผาผลาญ CPU ไปกับการสลับบริบท (context switches) และอาจทำให้งานที่มีประสิทธิภาพจริงๆ ไม่สามารถทำงานได้
พายุการลองใหม่ (Retry storms): บริการปลายทาง (downstream service) เกิดขัดข้องชั่วคราว ลูกค้า (client) ทุกรายตรวจพบ timeout และรอเป็นช่วงเวลาที่กำหนดไว้ เช่น หนึ่งวินาทีพอดี ก่อนจะลองใหม่ เมื่อบริการนั้นกลับมาใช้งานได้และเริ่มรับทราฟฟิกอีกครั้ง ลูกค้าทุกรายก็จะรุมเข้าหาพร้อมกันในทันที บริการที่ยังไม่พร้อมและกำลังอยู่ในช่วงฟื้นตัวจึงล่มลงอีกครั้ง และวงจรนี้ก็จะเกิดขึ้นซ้ำๆ
การชนกันของ Cron (Cron collisions): หากคุณตั้งเวลาการบำรุงรักษา, การสร้างรายงาน หรือการอุ่นแคช (cache warming) ให้ทำงานที่เวลา 00:00 UTC ตรงกันเป๊ะในทุกอินสแตนซ์ คุณกำลังสร้างการพุ่งสูงของทราฟฟิก (traffic spike) ด้วยความแม่นยำเหมือนนาฬิกา แม้โหลดจะคาดเดาได้ แต่ความหนาแน่นของมันนั้นรุนแรงถึงขั้นทำลายระบบได้
การรวมคำขอ (Request Coalescing)
วิธีแก้ไขที่มีประสิทธิภาพที่สุดสำหรับปัญหา cache stampedes คือการหยุดตอบคำถามเดิมซ้ำมากกว่าหนึ่งครั้ง เมื่อเกิด cache miss คุณไม่ต้องการให้ thread ทั้ง 2,500 ตัวรันคิวรีฐานข้อมูลแยกกัน แต่คุณต้องการให้มี thread เดียวที่รันคิวรี ในขณะที่อีก 2,499 thread รอผลลัพธ์นั้น
รูปแบบนี้มักถูกเรียกว่า request coalescing ในภาษา Go แพ็กเกจ singleflight ใน golang.org/x/sync มีการนำรูปแบบนี้มาใช้งานอย่างเป็นมาตรฐาน โดย goroutine แรกที่เรียก Do สำหรับคีย์ที่กำหนดจะเป็นผู้เริ่มทำงาน ส่วนผู้เรียกคนต่อๆ มาด้วยคีย์เดียวกันจะถูกบล็อก (block) อยู่ที่การเรียก Do นั้น เมื่อการทำงานเสร็จสิ้น ผู้ที่รออยู่ทั้งหมดจะได้รับผลลัพธ์พร้อมกัน สรุปคือคุณรันคิวรีเพียงครั้งเดียวแต่ให้บริการได้ถึง 2,500 คำขอ
คุณสามารถสร้างพฤติกรรมที่คล้ายกันได้ด้วยการใช้ in-memory concurrent map ของ promises, futures หรือ channels เคล็ดลับคือการตรวจสอบและลงทะเบียนคำขอที่กำลังดำเนินการอยู่ (in-flight request) แบบอะตอมมิก (atomically) หากคำขอล้มเหลว ทุกคนจะได้รับ error ซึ่งมักจะเป็นพฤติกรรมที่ถูกต้อง แต่ถ้าสำเร็จ ทุกคนจะได้รับค่าที่แคชไว้ และคำขอต่อๆ ไปจะเข้าถึงแคชโดยตรง โหลดของฐานข้อมูลจะเปลี่ยนจากกราฟที่พุ่งสูงเป็นเส้นตรงราบเรียบ
การหมดอายุล่วงหน้าแบบความน่าจะเป็น (Probabilistic Early Expiration)
ในบางครั้ง คุณอาจต้องการหลีกเลี่ยงการเกิด stampede ไปเลย แทนที่จะจัดการกับมัน ซึ่งนั่นก็นำไปสู่แนวคิดการหมดอายุล่วงหน้าแบบความน่าจะเป็น (probabilistic early expiration) ที่ถูกนำมาใช้ผ่านอัลกอริทึมอย่าง XFetch
แทนที่จะใช้เวลาหมดอายุแบบตายตัว (hard expiration) แต่ละค่าที่แคชไว้จะมีช่วงเวลาหมดอายุแบบยืดหยุ่น (soft expiration window) เมื่อมีคำขอเข้ามา แอปพลิเคชันจะสร้างตัวเลขสุ่มและใช้การตรวจสอบความน่าจะเป็นแบบง่ายๆ หากผลการสุ่มบอกว่า "ใช่" คำขอนั้นจะทำการรีเฟรชแคชก่อนกำหนด แต่ถ้าไม่ใช่ คำขอนั้นจะให้บริการค่าที่เก่าไปเล็กน้อย (stale value) แทน
เนื่องจากการสุ่มของแต่ละคำขอไม่เหมือนกัน การรีเฟรชจึงกระจายตัวออกไปตลอดช่วงเวลาแบบยืดหยุ่นนั้น ลูกค้าคนหนึ่งอาจรีเฟรชที่ 40 วินาทีก่อนการหมดอายุจริง อีกคนอาจรีเฟรชที่ 12 วินาที และคนอื่นๆ ส่วนใหญ่ก็อาจจะไม่ได้รีเฟรชเลย ภาระงานจะเปลี่ยนจากการพุ่งสูงพร้อมกันเพียงจุดเดียว ไปสู่ความนุ่มนวล
