Rate Limiting and Throttling
ทุกบริการมีเพดาน instance หนึ่งสามารถรับ request ได้เพียงจำนวนหนึ่งต่อวินาทีก่อนที่ CPU จะอิ่มตัว connection pool เต็ม หรือหน่วยความจำพุ่งสูง ส่วนใหญ่คุณทำงานอยู่ต่ำกว่าเพดานนั้นอย่างสบาย ๆ แต่ traffic ไม่ราบเรียบ: อีเมลการตลาดถูกส่งออกไป client ยอดนิยม deploy retry loop ที่แน่นเกินไป scraper ค้นพบ API ของคุณ หรือเหตุล่มที่ต้นทางจู่ ๆ เปลี่ยนทิศทาง request จำนวนมหาศาลมาทางคุณ
pattern ก่อนหน้าในโมดูลนี้ปกป้องคุณจากการที่ dependency ของคุณ ล้มเหลว rate limiting ปกป้องคุณจาก ผู้เรียกของคุณ — และจากความสำเร็จของคุณเอง
พอ demand เกินกำลัง service ไม่ได้ค่อย ๆ ทรุดลงอย่างนุ่มนวล แต่คว่ำทั้งยวง เมื่อ request เข้ามาเร็วกว่าที่ทำเสร็จ คิวก็ยาวขึ้น latency พุ่ง timeout เริ่มทำงาน แล้ว client ก็ retry ซึ่งยิ่งเพิ่มภาระให้ service ที่แบกไม่ไหวอยู่แล้ว พ้นจุดพลิกผันไป throughput ไม่ได้แค่นิ่ง แต่ดิ่งลงเลย เพราะ server หมดเวลาไปกับการไล่เคลียร์ backlog ที่ไม่มีวันหมด service ที่รับได้สบาย ๆ 1,000 request ต่อวินาที อาจเหลือ ศูนย์ เมื่อเจอ 5,000 request เพราะทุก request timeout ก่อนจะทำเสร็จ
แรงที่ขัดกันจึงเป็นแบบนี้ คุณมีกำลังจำกัด demand พุ่งเกินได้แบบคาดเดาไม่ได้ และการรับ ทุกอย่างแต่ทำได้แย่ ย่อมแย่กว่าการรับ ส่วนใหญ่แล้วทำได้ดี คุณจึงต้องรักษาให้ service ทำงานอยู่ในกรอบที่ปลอดภัย แม้ตอนที่งานเข้ามามากกว่าที่รับไหว
วิธีแก้
หัวข้อที่มีชื่อว่า “วิธีแก้”rate limiting จำกัดปริมาณงานที่ service จะรับต่อหน่วยเวลา เพื่อคุมให้อยู่ในกรอบที่ปลอดภัย พอ request เข้ามาเร็วกว่าขีดจำกัดที่ตั้งไว้ ส่วนเกินจะโดน shed คือปฏิเสธทันที ปกติด้วย HTTP 429 Too Many Requests หรือ เข้าคิว ชั่วครู่เพื่อรีดช่วง burst สั้น ๆ ให้ราบ ไม่ว่าทางไหน งานที่รับเข้ามาแล้วจะได้รับบริการอย่างดี
กลไกที่ใช้กันมากที่สุดคือ token bucket ถังหนึ่งใบเก็บ token ได้สูงสุด capacity token และเติมกลับในอัตราคงที่ refillPerSecond token ทุก request ต้องหยิบ token หนึ่งตัวถึงจะทำงานได้ ถ้าถังว่างก็โดนปฏิเสธ เพราะถังสะสม token สำรองไว้ได้ จึงยอมให้เกิด burst สั้น ๆ โดยใช้ token ที่สะสมไว้ พร้อมกับยังคุมอัตราเฉลี่ยระยะยาวไว้ที่อัตรา refill การผสมสองอย่างนี้ คือยืดหยุ่นในระดับเล็กแต่มีเพดานในระดับใหญ่ ตรงกับพฤติกรรมจริงของ traffic พอดี
flowchart LR
REF["Refill: +r tokens / second"] --> B
subgraph B[Token bucket • capacity C]
T[(tokens)]
end
REQ[Incoming request] --> CHK{token<br/>available?}
B --> CHK
CHK -- yes --> TAKE[take 1 token → allow]
CHK -- no --> DROP[shed → HTTP 429] ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”ตัวอย่างนี้คือ limiter แบบ token bucket ที่ refill แบบ lazy ตามเวลาที่ผ่านไป แล้วรับ request ก็ต่อเมื่อมี token เหลือ แต่ละตัวอย่างรันได้ด้วยตัวเองและเขียนตามสไตล์ของภาษานั้น ๆ
class TokenBucket { private tokens: number; private last: number = Date.now();
constructor( private readonly capacity: number, private readonly refillPerSec: number, ) { this.tokens = capacity; }
private refill(): void { const now = Date.now(); const elapsed = (now - this.last) / 1000; this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec); this.last = now; }
tryAcquire(): boolean { this.refill(); if (this.tokens >= 1) { this.tokens -= 1; return true; } return false; }}
const limiter = new TokenBucket(100, 50); // burst 100, sustain 50/sif (!limiter.tryAcquire()) { return new Response('Too Many Requests', { status: 429 });}import time
class TokenBucket: def __init__(self, capacity: int, refill_per_sec: float): self.capacity = capacity self.refill_per_sec = refill_per_sec self.tokens = float(capacity) self.last = time.monotonic()
def _refill(self) -> None: now = time.monotonic() elapsed = now - self.last self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_sec) self.last = now
def try_acquire(self) -> bool: self._refill() if self.tokens >= 1: self.tokens -= 1 return True return False
limiter = TokenBucket(capacity=100, refill_per_sec=50) # burst 100, sustain 50/sif not limiter.try_acquire(): return Response(status_code=429, content="Too Many Requests")type TokenBucket struct { mu sync.Mutex capacity float64 refillPerSec float64 tokens float64 last time.Time}
func NewTokenBucket(capacity, refillPerSec float64) *TokenBucket { return &TokenBucket{ capacity: capacity, refillPerSec: refillPerSec, tokens: capacity, last: time.Now(), }}
func (b *TokenBucket) TryAcquire() bool { b.mu.Lock() defer b.mu.Unlock() now := time.Now() elapsed := now.Sub(b.last).Seconds() b.tokens = math.Min(b.capacity, b.tokens+elapsed*b.refillPerSec) b.last = now if b.tokens >= 1 { b.tokens-- return true } return false}
// limiter := NewTokenBucket(100, 50) // burst 100, sustain 50/s// if !limiter.TryAcquire() { http.Error(w, "Too Many Requests", 429) }use std::time::Instant;
pub struct TokenBucket { capacity: f64, refill_per_sec: f64, tokens: f64, last: Instant,}
impl TokenBucket { pub fn new(capacity: f64, refill_per_sec: f64) -> Self { Self { capacity, refill_per_sec, tokens: capacity, last: Instant::now(), } }
pub fn try_acquire(&mut self) -> bool { let now = Instant::now(); let elapsed = now.duration_since(self.last).as_secs_f64(); self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity); self.last = now; if self.tokens >= 1.0 { self.tokens -= 1.0; true } else { false } }}
// let mut limiter = TokenBucket::new(100.0, 50.0); // burst 100, sustain 50/s// if !limiter.try_acquire() { return StatusCode::TOO_MANY_REQUESTS; }ผลลัพธ์ที่ตามมา
หัวข้อที่มีชื่อว่า “ผลลัพธ์ที่ตามมา”สิ่งที่คุณได้รับ:
- กำลังของระบบได้รับการปกป้อง service จะไม่รับงานเกินกว่าที่ให้บริการไหว เวลาเจอโหลดหนักจึงค่อย ๆ ทรุดด้วยการ ปฏิเสธบาง request แทนที่จะ ล้มทั้งหมด throughput จะนิ่งอยู่ที่เพดานแทนที่จะดิ่งลงเมื่อพ้นจุดพลิกผัน
- ความเป็นธรรมและการควบคุมการใช้งานในทางที่ผิด การจำกัดต่อ client (ต่อ API key, ต่อ IP) หยุดผู้เรียกที่เสียงดังหรือมุ่งร้ายตัวหนึ่งไม่ให้ทำให้คนอื่นทั้งหมดอดอยาก และให้คันโยกแก่คุณในการรับมือ retry loop ที่ควบคุมไม่ได้และ scraper
- ความทนทานต่อ burst token bucket ดูดซับการพุ่งสูงสั้น ๆ ที่ถูกต้องตามกฎหมายโดยใช้ capacity ที่เก็บไว้ และยังกำหนดขอบเขตอัตราที่ยั่งยืนไว้ได้ — คุณไม่ต้องเลือกระหว่าง “ราบเรียบเท่านั้น” กับ “ไม่มีขีดจำกัด”
สิ่งที่คุณต้องจ่าย:
- การเลือกขีดจำกัด ตั้งต่ำไปก็ปฏิเสธ traffic ที่ service รับไหวจริง ตั้งสูงไป limiter ก็ไม่ได้ปกป้องอะไร ตัวเลขต้องมาจาก load test กับกำลังจริง ไม่ใช่จากการเดา และต้องปรับใหม่ทุกครั้งที่ service เปลี่ยน
- การ return สัญญาณที่ถูกต้อง request ที่ถูกปฏิเสธควร return
429 Too Many Requestsในอุดมคติพร้อม headerRetry-Afterเพื่อให้ client ที่มีพฤติกรรมดีถอยกลับแทนที่จะกระหน่ำ error เปล่า ๆ เป็นการเชื้อเชิญให้ retry ทันทีซึ่งทำลายล้าง limiter - การประสานงานแบบกระจาย (distributed coordination) limiter ใน instance หนึ่งเห็นเฉพาะ traffic ของ instance นั้น การบังคับขีดจำกัดแบบ global ข้ามหลาย instance ต้องใช้สถานะที่แชร์กัน (central store เช่น Redis) ซึ่งเพิ่ม dependency และ latency บ้างใน hot path
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”- Circuit Breaker — ปกป้อง คุณ จาก dependency ที่ล้มเหลว ส่วน rate limiting ปกป้อง service จาก ผู้เรียกเข้ามา
- Bulkhead — จำกัดความ concurrency ส่วน rate limiting จำกัด throughput ทั้งสองประกอบกันได้ดี
- Retry and Timeout —
429พร้อมRetry-Afterบอกตรรกะการ retry ของ client อย่างชัดเจนว่าควรถอยกลับนานเท่าใด
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
| ป้องกัน overload จาก traffic spike หรือ abusive client | legitimate user อาจถูก limit โดยไม่ตั้งใจ |
| protect upstream service จาก cascade load | distributed rate limiting ซับซ้อน — ต้อง coordinate ข้าม instance |
| enforce fair usage ระหว่าง tenant หรือ API tier | rate limit ที่ strict เกินทำให้ user experience แย่ |
| ลด cost จาก excessive API usage | ต้องตั้ง limit ที่เหมาะสม — ยากถ้าไม่รู้ traffic pattern |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Global Rate Limit แทน Per-client — limit รวมทั้งหมด ไม่แบ่งตาม client อาการ:
- client หนึ่งส่ง request เยอะทำให้ client อื่นถูก limit ด้วย
- ใช้ per-API-key หรือ per-user-ID แทน global counter
Rate Limit ที่ไม่มี Retry-After Header — reject request โดยไม่บอก client ว่ารอเท่าไร อาการ:
- client retry ทันที ทำให้ rate limit แย่ลงกว่าเดิม
- ส่ง
Retry-Afterheader เพื่อให้ client รู้ว่ารอนานแค่ไหน
💡 ตัวอย่างจากของจริง
GitHub API:
- rate limit 5000 requests/hour สำหรับ authenticated user
- 60 requests/hour สำหรับ unauthenticated
- response header บอก remaining request และ reset time
Stripe:
- rate limit ตาม API key
- exponential backoff แนะนำให้ client ใช้เมื่อถูก limit
- test mode และ live mode มี limit แยกกัน