Photo by Ksenia Kudelkina, from Unsplash.com

ลองนึกภาพว่าเรามีระบบที่ถูกออกแบบเป็น Service-Oriented Architecture (SOA)

ในบรรดาเซอร์วิซทั้งหมด จะมีบางเซอร์วิซทีเป็น Dependency ของเซอร์วิซอื่นๆ ผมจะเรียกเซอร์วิซนี้ว่าเซอร์วิซ A

ถ้าเซอร์วิซ A พังขึ้นมา เซอร์วิซอื่นที่ต้องเรียกใช้ข้อมูลนี้ทั้งหมดก็จะทำงานไม่ได้

ตัวอย่างเช่นในภาพข้างล่าง หากเซอร์วิซ A พัง เซอร์วิซ B, และ C จะทำงานไม่ได้

เซอร์วิซ A เป็น Dependency ของ B และ C
เซอร์วิซ A เป็น Dependency ของ B และ C

วันดีคืนดี มีคนเขียนโปรแกรมในระบบ B ผิดพลาด ใส่ Infinite Loop เข้าไป ทำให้พยายามยิง Request เพื่อดึงข้อมูลจากระบบ A รัวๆไม่หยุด

ผลคือระบบ A รับการทำงานไม่ไหว Request อื่นๆที่ส่งมาก็จะโดน Time Out Error ตลอด

เซอร์วิซ B ถล่มเซอร์วิซ A ด้วยการ Request จำนวนมากรัวๆ
เซอร์วิซ B ถล่มเซอร์วิซ A ด้วยการ Request จำนวนมากรัวๆ

ประเด็นคือ เซอร์วิซ C ซึ่งไม่รู้อิโหน่อิเหน่อะไรด้วยก็ติดร่างแหไปด้วย ทั้งๆที่ออกแบบมาอย่างดี เทสต์มาอย่างดีทุกอย่าง ดันมาตายน้ำตื้นเพราะดันมีอีกเซอร์วิซเขียน Infinite Loop ซะงั้น

เราจะป้องกันปัญหานี้ยังไงดี?

ผู้ใช้มักมาก

หากผู้ใช้เรียกใช้เซอร์วิซเป็นจำนวนมากในระยะเวลาสั้นๆ เป็นหน้าที่ของเซอร์วิซ A ที่จะต้องทำการปกป้องทรัพยากร (Resource) ของตน ไม่ให้ถูกใช้จนหมดผ่านผู้ใช้คนเดียว

ผมขอเรียกผู้ใช้จำพวกนี้ว่า “ผู้ใช้มักมาก” ก็แล้วกัน

ตัวอย่างที่พบในชีวิตจริงก็เช่น

  • ผู้ใช้อยู่ดีๆก็ยิง Request มากขึ้นแบบพุ่งพรวด (จาก 10 Request/วินาที เป็น 1,000 Request ต่อวินาที) เพราะเว็บโดนแชร์ในโซเชียล
  • ผู้ใช้เขียนโปรแกรมผิดพลาด เหมือนกรณี Infinite Loop ข้างต้น
  • ผู้ใช้พยายามเขียนบอตเพื่อดึงข้อมูล แล้วไม่รอก่อนยิงคำสั่งถัดไป (กรณี REST interface สำหรับเว็บไซต์)

คำว่า “ผู้ใช้” ในที่นี้ อาจจะเป็นเซอร์วิซของทีมข้างๆ หรืออาจเป็นใครที่อยู่ภายนอกบริษัทที่เราควบคุมไม่ได้เลย

ผมเลือกใช้คำว่า “มักมาก” เพราะเค้าไม่ได้ประสงค์ร้ายที่จะพังเซอร์วิซ A เค้าแค่อยากจะส่ง Request ปริมาณมากๆเท่านั้น

เหมือนกับบางครั้ง เราไปซื้อซาลาเปาไส้ครีมที่เซเว่น แล้วคนก่อนหน้าเราดันสั่งที 5 โหล เราเลยอดกิน (ไม่ก็รออุ่นกันจนตาเหลือก)

ซึ่งถามว่าเค้าเจตนาแกล้งเราหรือแกล้งเซเว่นไหม ก็เปล่า

แต่เราอดกิน เสียความรู้สึกกับเซเว่นสุดๆ

ดังนั้น เซเว่นน่าจะช่วยอะไรเราหน่อย

ลองมาคิดดูกันครับ ว่าถ้าเราเป็นเซเว่น เราทำอะไรได้บ้าง?

  • จำกัดปริมาณซาลาเปาที่แต่ละคนซื้อได้ในแต่ละวัน
  • จำกัดปริมาณการซื้อแต่ละที ให้ได้แค่คนละสามลูก หากซื้ออยากได้มากกว่าสามลูก ต้องกลับไปเริ่มต่อคิวใหม่
  • แบ่งคิวให้คนที่ซื้อต่ำกว่าสามลูกคิวนึง คนที่ซื้อเกินสามลูกแยกไปอีกคิวนึง
  • ซื้อเครื่องอบเพิ่ม ทำให้อบรวดเดียวห้าโหลได้

ฯลฯ

ซึ่งวิธีการที่พูดมาทั้งหมดนี้ เราสามารถเอามาใช้ในการออกแบบเซอร์วิซของเราได้

ในบทความนี้ เราจะมาหนึ่งในวิธีข้างต้น คือการจำกัดปริมาณซาลาเปา เอ้ย ปริมาณ Request ซึ่งมีชื่อเรียกว่า Throttling (บางที่ก็จะเรียกว่า Rate Limiting)

ซึ่งไอเดียก็คือ เราจะจำกัดให้ผู้ใช้แต่ละคนเรียกใช้เซอร์วิซเราได้ในจำนวนที่จำกัดในช่วงระยะเวลาหนึ่งๆ หากเรียกมาเกินกว่านั้น ก็ให้ปฏิเสธ Request ไป

ตัวอย่างเช่น เราอาจจะกำหนดให้ผู้ใช้แต่ละคน สามารถส่ง Request ได้ไม่เกิน 100 ครั้งต่อวินาที หากเซอร์วิซ B ส่ง Request มา 150 Requests ในหนึ่งวินาที ส่วนที่เกิน (50 Requests) จะได้รับค่า Error 429 (Too Many Requests) กลับไป

เซอร์วิซ B โดน Thottled ซึ่งทำให้เซอร์วิซ C ไม่ได้รับผลกระทบ
เซอร์วิซ B โดน Thottled ซึ่งทำให้เซอร์วิซ C ไม่ได้รับผลกระทบ

ความซับซ้อนในการกำหนด Throttling

ในทางปฏิบัติ เราควรจะใช้ไลบรารี่ที่คนอื่นทำไว้แล้ว เพราะการเขียนและทดสอบ Throttling นี่ยากมาก อย่าไปเขียนใช้เองนะครับ บั้กกระจายแน่นอน

พอใช้ไลบรารี่ นอกจากค่า Limit ที่ส่งได้แล้ว เราต้องเลือกค่า Key ในที่ใช้ในการ Throttle ด้วย

วิธีที่ง่ายที่สุดคือให้ Key ตาม Username ของผู้ใช้ ตัวอย่างเช่น

Key จำนวน Request ในช่วงวินาทีที่ผ่านมา
user1 57
user2 30
user3 100

ถ้าเรากำหนด Limit ไว้ที่ 100 Requests/วินาที ณ จุดนี้ user3 จะโดนปฏิเสธ Request ถัดๆไป

ในบางกรณี เราจะไม่ได้ Username แต่จะได้มาเป็นพวก Access Token อันนี้ก็ใช้แทนได้ ไม่ต่างกัน

แต่บางครั้ง Request ที่ส่งมาก็ไม่รู้ว่ามาจาก User ไหนเลย ยกตัวอย่างเช่น ระบบ B อาจจะเป็นเว็บไซต์ เวลาได้ Request ที่ต้องการดึงข้อมูลจากระบบ A คำสั่งก็จะมาในนามของระบบ B ตรงๆ โดยที่ A ไม่มีข้อมูลเลยว่ามาจาก User คนไหน เพราะข้อมูล User ถูกเก็บไว้ในระบบ B ไม่ได้ส่งมาด้วย

กรณีนี้ก็ต้องใช้เป็น Access Token ของระบบนั้นๆไป โดยอาจปรับเรทให้สูงหน่อย เพราะอาจจะมาจาก User หลายๆคน

ถ้าคิดว่าจะใช้ IP ก็ต้องยอมรับว่า IP มันเปลี่ยนได้เรื่อยๆ ผลการ Throttling จึงอาจไม่ได้เป้ะ ยิ่งถ้าเซอร์วิซ B มีหลายเซอร์เวอร์ Request อาจจะมาจากหลาย IP ได้

ถ้าโอเคกับ IP ลองเช็คค่าคอนฟิคใน Web Server หรือ Load Balancer ดูก่อนนะครับ อาจจะทำได้อยู่แล้ว ไม่ต้องเขียนโค้ดเพิ่ม

สร้าง Config file สำหรับ Throttling

พอถึงจุดนี้ เราจะค้นพบว่าเซอร์วิซ B และ C อาจจะต้องยิง Request ในอัตราที่ต่างกัน ดังนั้น เราจะ Throttle ด้วยค่าเดียวกันไม่ได้

เราจึงต้องกำหนด Rate ที่ต่างกันสำหรับแต่ละ Key โดยแยกเก็บ Config ไว้ในไฟล์แยกจากโค้ด เพื่อง่ายต่อการจัดการ

ยกตัวอย่าง ในรูปแบบ JSON เราอาจเก็บเป็นแบบนี้

1
2
3
4
5
ratePerSecond = {
  serviceB: 100, // key = serviceB, 100 request/second
  serviceC: 50,
  other: 10
}

โดยเราอาจจะให้แอพพลิเคชั่นของเราโหลด Config File ตอนเริ่มต้น Application แล้วเก็บไว้ใน Memory ตลอดการทำงาน

หรืออาจจะทำการโหลดซ้ำทุกๆนาที หากเราต้องมีการเปลี่ยนค่านี้บ่อยๆ จะได้ไม่ต้อง Restart Application ทุกครั้งที่เปลี่ยน

ถ้าเอาให้ละเอียดลงไปอีก เราอาจจะต้องใส่ชนิดของ Operation ลงไปใน Key ด้วย เช่น A อาจจะอ่านข้อมูลได้เยอะ แต่เขียนข้อมูลได้ช้ามาก เราก็ต้องอาจจะกำหนดให้การเขียนทำได้แค่ 1 ใน 10 ของการอ่าน

ตัว Config File ก็จะเป็นดังนี้

1
2
3
4
5
6
7
8
ratePerSecond = {
  serviceB_read: 100,
  serviceB_write: 10,
  serviceC_read: 50,
  serviceC_write: 5,
  other_read: 10,
  other_write: 1
}

ตัว Key อาจจะซับซ้อนขึ้นไปอีกโดยมีมิติมากกว่าแค่ Operation อันนี้ก็แล้วแต่ออกแบบ

แต่แนะนำว่าพยายามให้ง่าย (Simple) ที่สุด จะได้ดูแลได้ง่าย ถ้าไม่จำเป็นจริงๆก็อย่าใส่อะไรเพิ่มเข้าไป แค่ หนึ่งค่าต่อเซอร์วิซก็พอแล้ว

Throttle ก่อนจะทำงานที่กิน Resource

การเรียกเช็ค Throttling ควรทำเป็นลำดับแรกๆ ไม่ใช่ไปดึงข้อมูลจาก DB ก่อน แล้วค่อยมาเช็คว่าต้อง Throttle รึเปล่า

ถ้าเราไปทำงานที่กิน Resource ก่อน Throttling แบบนี้ ผลลัพธ์ก็แทบจะไม่ต่างกับไม่มี Throttling เลย ถ้าโดนเรียกมากๆ DB ก็จะเดี้ยงไปก่อน เซอร์วิซก็ตายอยู่ดี

ข้อนี้เขียนเตือนไว้ กันตกม้าตาย เพราะบางครั้งโค้ดก็เขียนไว้งงมาก เราต้องเช็คให้ดีก่อนว่าไม่ได้มีการทำงานหนักๆก่อนตำแหน่งที่เช็คThrottling ไม่งั้นอาจเหนื่อยฟรีได้

ส่งข้อมูลของ Rate กลับไปให้ผู้ใช้

ถ้าเซอร์วิซ B มีหลายเซอร์เวอร์ บางเซอร์เวอร์อาจจะส่ง Request เยอะมาก จนชน Rate Limit ไปเรียบร้อยแล้ว

ส่วนอีกเซอร์เวอร์นึง พึ่งเรียกครั้งแรก ก็เจอะ Error 429 (Too Many Requests) ทันทีเลย

สิ่งที่เซอร์เวอร์นี้ต้องการรู้คือ ควรจะรออีกนานเท่าไร ถึงค่อยส่ง Request ถัดไป

ซึ่งไม่ง่ายเลย เพราะมันไม่รู้ว่าค่า Rate Config ใน A เป็นอะไร และเซอร์เวอร์อื่นๆของ B ส่ง Request ไปเมื่อไรบ้าง

ดังนั้น เพื่อให้ง่ายต่อผู้ใช้ เซอร์วิซ A สามารถส่งค่าพวกนี้กลับไปทาง Response

ที่เห็นใช้กันบ่อยก็คือการใส่ค่ากลับไปใน Response Header โดยมักจะส่งค่าสามค่านี้กัน:

  • X-Rate-Limit-Limit : 100 (จำนวน Request ที่ส่งได้ในช่วงเวลาที่กำหนด ในกรณีนี้เป็น 100 Request/second)
  • X-Rate-Limit-Remaining : 45 (จำนวน Request ที่เหลือยังส่งได้ ในกรณีนี้คือส่งไปแล้ว 55 Requests ก็ยังเหลือ 45 Request)
  • X-Rate-Limit-Reset : 500 (เวลาก่อนที่ก่อนที่ค่า Remaining จะถูกเซ็ตกลับเป็นค่าเดิม ในกรณีนี้ อีก 500 ms, Remaining จะกลับเป็น 100)

วิธีนี้ นอกจากจะทำให้ฝั่ง B เขียนโค้ดได้ง่ายขึ้น ยังลดโอกาสที่ผู้ใช้จะยังส่ง Request มาแล้วเจอ 429 อีกรอบ เป็นผลดีต่อฝั่ง A ด้วย

หากเซอร์วิซ A มีหลายเซอร์เวอร์

ปัญหาการ Throttling จะบานปลาย กลายเป็นปัญหา Distributed System ในทันที เพราะเซอร์เวอร์ใน A ต้องคุยกันให้รู้ว่ายอดปัจจุบันเป็นเท่าไรแล้ว

สมมติว่าเรามีเซอร์เวอร์อยู่ 3 ตัว เราต้องการกำหนด Rate ให้อยู่ที่ 100 Request ต่อวินาที จะทำยังไง?

อันนี้ก็ต้องพึ่งไลบรารี่ของท่านแล้วล่ะครับ เพราะเขียนเองยากมาก อย่าลืมส่งเมลล์ไปขอบคุณคนเขียนด้วย ที่ทำให้ชีวิตง่ายขึ้น

แต่หากหาไลบรารี่ไม่ได้ อาจจะมีวิธีหลบอยู่บ้าง เช่น

  1. ทำ Throttling ตั้งแต่ก่อนกระจาย Request โดยสร้างเซอร์เวอร์ตัวหนึ่งมากั้นกลางเหมือน Reverse Proxy เพื่อรับ Request ที่เข้ามาทั้งหมดก่อน แล้วทำการนับที่เซอร์เวอร์นี้อย่างเดียว ถ้าผ่าน Limit Rate ถึงค่อยส่ง Request ไปต่อ
  2. เอาไปเก็บไว้ใน Database กลาง วิธีนี้อาจจะทำได้ในกรณีที่ Request กระจายตัว ไม่ได้เข้ามาถี่มาก เพราะการอ่านจาก Database นั้นช้ากว่ามาก แม้จะใช้ In-Memory DB ก็ตาม
  3. ยอมรับความคาดเคลื่อน โดยเซ็ตค่าไปที่ 34 Request/วินาที ในแต่ละเซอร์เวอร์ ซึ่งแน่นอนว่า Request จะไม่กระจายเท่าๆกันเป๊ะ ดังนั้น Request มีโอกาสจะเข้า Server ตัวแรกบ่อยๆ และอาจจะโดนบล็อคก่อนครบ 102 Request

สรุป

ในเซอร์วิซที่มีคนใช้เยอะมาก เรามักจะเจอปัญหาว่าผู้ใช้บางคน"มักมาก" โดยส่ง Request มาเยอะๆในช่วงเวลาสั้นๆ จนกินทรัพยากรของเซอร์วิซไปจนหมด ส่งผลให้ผู้ใช้คนอื่นไม่สามารถใช้งานได้

เราสามารถป้องกันเหตุการณ์นี้ได้โดยการจำกัดจำนวน Request ซึ่งเราเรียกเทคนิคนี้ว่า Throttling (Rate Limiter)

โดยการใช้เทคนิคนี้ มีรายละเอียดที่ต้องคิดอยู่เยอะพอสมควร ได้แก่

  • เราจะกำหนด Key อย่างไร และจัดการกับ Config file ยังไง
  • เราต้องทำการ Throttling ก่อนการทำงานที่กิน Resource เยอะๆ
  • เราอาจส่งข้อมูลของ Rate กลับไปให้ผู้ใช้ ผู้ใช้ได้จะได้รู้ว่าต้องหยุดรอนานเท่าไร ก่อนจะส่ง Request มาอีก
  • หากเซอร์วิซของเรามีหลายเซอร์เวอร์ ปัญหาจะยากขึ้นมาก เราอาจจะต้องยอม Trade-off ความเร็วและความซับซ้อนของเซอร์วิซ กับความแม่นยำในการคำนวน Rate

ในทางปฏิบัติ เราไม่จำเป็นต้องทำทุกอย่างให้ครบและเพอร์เฟ็คตั้งแต่แรกนะครับ เพราะบางอย่างก็อาจจะไม่จำเป็นสำหรับเซอร์วิซของเรา แต่หากเราพอรู้ Requirement ของระบบตั้งแต่แรก การออกแบบ Throttling ให้เหมาะสมตั้งแต่แรก จะทำให้เสียเวลามาแก้หรือเพิ่มอะไรทีหลังน้อยลงครับ

ตอนหน้าผมจะเขียนถึงอีกเทคนิคนึงที่เรียกว่า Bulkhead ซึ่งเหมาะสมมากกว่ากับกรณีที่ระบบ B หรือ C มีระบบหนึ่งที่ Critical มากกว่า และเรายอมให้ล่มไม่ได้ ใครอยากติดตามก็ไป Follow ได้ที่เพจ Not About Code ครับ