28/07/2026 14:15น.

Golang The Series EP.164: Rate Limiting AI Requests ป้องกันระบบพังจากการเรียกใช้งาน API เกินโควตา
#Rate Limiting
#Token Bucket
#Golang Middleware
#ป้องกันระบบล่ม
#Go
#Golang
#AI API
สวัสดีครับชาว Gophers! ตอนที่แล้วเราวางระบบ Cache เพื่อลดภาระงานซ้ำและเซฟค่า Token ไปได้เยอะ แต่ในความเป็นจริงของระบบที่มีผู้ใช้งานจำนวนมาก ความเสี่ยงหลักที่เราต้องเจอเสมอคือเรื่องการใช้งานเกินขีดจำกัด (Abuse & Overuse) ครับ
ลองนึกภาพว่ามียูสเซอร์บางคนเขียนสคริปต์ลูปคำถามส่งมาหา AI รัวๆ หรือระบบภายในตัวอื่นเกิดบั๊กแล้วยิงถล่ม API เข้ามาหลักร้อยครั้งในหนึ่งวินาที สิ่งที่จะเกิดขึ้นคือเราจะโดนผู้ให้บริการภายนอกอย่าง OpenAI หรือค่ายอื่นๆ สั่งระงับการเข้าถึงชั่วคราวทันทีเพราะยิงเกินขีดจำกัด RPM (Requests Per Minute) ส่งผลให้ผู้ใช้อื่นในองค์กรใช้งานไม่ได้ไปด้วย หรือถ้าเราใช้โมเดล Open-source ที่รันภายในองค์กรเอง การสแปมแบบนี้จะทำให้หน่วยความจำการ์ดจอเต็ม (VRAM Out-of-Memory) และระบบพังลงมาทันที
เพื่อปกป้องเซิร์ฟเวอร์และกระจายทรัพยากรการประมวลผลอย่างเท่าเทียม เราจำเป็นต้องวางระบบจำกัดความเร็ว หรือ Rate Limiter Middleware มาคั่นไว้ที่หน้าประตูเซิร์ฟเวอร์ Go ของเราครับ
กลไกการทำงานของ Token Bucket Algorithm
อัลกอริทึมที่นิยมและมีประสิทธิภาพสูงในภาษา Go สำหรับทำ Rate Limiting คือ Token Bucket (ถังโทเคน) หลักการคือระบบจะมีถังเก็บโทเคนขนาดคงที่ (เช่น จุได้สูงสุด 3 ใบ) และมี Background Job คอยเติมโทเคนเข้าไปเรื่อยๆ ตามอัตราที่กำหนด (เช่น วินาทีละ 1 ใบ)
เมื่อมี Request เข้ามา ระบบจะเช็กว่ามีโทเคนเหลือในถังไหม ถ้ามี ก็จะหยิบออกไป 1 ใบแล้วปล่อยให้ผ่านไปหา AI Handler ได้ แต่ถ้าถังว่างเปล่า ระบบจะปฏิเสธและตีกลับ HTTP 429 Too Many Requests ทันที ข้อดีของวิธีนี้คือ สามารถรองรับการยิงกระชากแบบกะทันหัน (Bursting) ได้ตามขนาดความจุสูงสุดของถังที่เราตั้งไว้ครับ
Plaintext
[ เติม Token อัตราคงที่ ] -> (เช่น 1 ใบ / วินาที)
│
▼
┌──────────────────┐
│ Bucket (Max=3) │ <- ถังเก็บโทเคนสะสม
└──────────────────┘
│
[มี Request เข้ามา] --------┘
│
▼
(มี Token เหลือในถัง?)
├───> ใช่ ───> หยิบออก 1 ใบ ───> ทะลุเข้าสู่ AI Handler
└───> ไม่ ───> ปฏิเสธ Request ──> ตอบกลับ HTTP 429 Too Many Requests
การติดตั้ง Dependency
เราจะเลือกใช้แพ็กเกจย่อยอย่างเป็นทางการของทีมงาน Go นามว่า x/time/rate ซึ่งออกแบบและจัดการโครงสร้างให้ปลอดภัยต่อการทำงานแบบหลายเธรด (Thread-safe) มาให้เรียบร้อยแล้ว:
Bash
go get golang.org/x/time/rate
go get github.com/gin-gonic/gin
โครงสร้างข้อมูลและระบบจัดการ Rate Limiter ราย Client
เราจะแยกส่วนประกอบออกเป็นโมดูลเพื่อความสะอาดของโค้ด พร้อมเพิ่มกลไก sync.RWMutex เพื่ออุดรอยรั่วเรื่อง Data Race ระหว่างฟังก์ชันหลักกับโปรแกรมทำความสะอาดคีย์ขยะหลังบ้าน
โครงสร้างข้อมูลและการทำความสะอาดหน่วยความจำ (State & Background Cleanup)
Go
package main
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
// clientLimiter เก็บสถานะถังโทเคนของ Client แต่ละคนแยกกัน
type clientLimiter struct {
limiter *rate.Limiter
mu sync.RWMutex // เพิ่ม Mutex เพื่อป้องกัน Data Race ในการอ่าน/เขียน lastSeen
lastSeen time.Time
}
var (
// ใช้ sync.Map เพื่อรองรับการเข้าถึงข้อมูลพร้อมกันจากหลาย Goroutines
limiters sync.Map
)
// init สั่งทำงาน Background Worker เพื่อคอยสแกนล้างคีย์ที่เก่าเกินไป ป้องกัน Memory Leak
func init() {
go func() {
for {
time.Sleep(10 * time.Minute) // สแกนทุกๆ 10 นาที
limiters.Range(func(key, value any) bool {
client := value.(*clientLimiter)
client.mu.RLock()
lastSeen := client.lastSeen
client.mu.RUnlock()
// หากไม่มีการเข้าใช้งานนานเกิน 1 ชั่วโมง ให้ลบข้อมูลออกจาก Map
if time.Since(lastSeen) > 1*time.Hour {
limiters.Delete(key)
}
return true
})
}
}()
}
ฟังก์ชันตรวจสอบและสร้าง Limiter Middleware
Go
// getLimiter ค้นหาหรือสร้าง Rate Limiter สำหรับ Client แต่ละราย
func getLimiter(clientID string) *rate.Limiter {
actual, loaded := limiters.Load(clientID)
if !loaded {
// ตั้งค่า: เติมโทเคนอัตรา 1 ใบต่อวินาที และถังจุได้สูงสุด 3 ใบ (Burst)
limiterConfig := rate.NewLimiter(rate.Every(1*time.Second), 3)
actual, _ = limiters.LoadOrStore(clientID, &clientLimiter{
limiter: limiterConfig,
lastSeen: time.Now(),
})
}
c := actual.(*clientLimiter)
c.mu.Lock()
c.lastSeen = time.Now() // อัปเดต Timestamp ล่าสุดอย่างปลอดภัย
c.mu.Unlock()
return c.limiter
}
// RateLimitMiddleware ด่านตรวจจับความเร็วการส่ง Request
func RateLimitMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// ในระบบ Production แนะนำให้ดึงจาก User ID ที่แกะจาก JWT Token
// ตัวอย่างนี้ขอแยกแยะด้วย Client IP เพื่อความง่ายครับ
clientID := c.ClientIP()
limiter := getLimiter(clientID)
// ตรวจสอบสถานะว่ามีโทเคนเหลือให้หยิบไปทำงานไหม (Allow จะไม่บล็อก Thread)
if !limiter.Allow() {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too Many Requests",
"message": "คุณเรียกใช้งานระบบ AI บ่อยเกินไป กรุณารอเว้นช่วงสักครู่",
"retry_after": "1s",
})
c.Abort() // สั่งระงับสิทธิ์ ไม่ให้ Request ทะลุไปถึงฟังก์ชัน Handler ด้านใน
return
}
c.Next()
}
}
ส่วนควบคุมระบบหลักและการทดสอบระบบ: main.go
Go
func main() {
r := gin.Default()
// เปิดใช้งาน Middleware คลุมกลุ่ม Endpoint ที่ใช้ประมวลผล AI
aiGroup := r.Group("/api/v1/ai")
aiGroup.Use(RateLimitMiddleware())
aiGroup.POST("/chat", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "success",
"answer": "นี่คือผลลัพธ์จากการประมวลผลของโมเดล AI ประจำองค์กร...",
})
})
r.Run(":8080")
}
🎯 ท้าให้ลอง (Daily Mission)
ให้คุณเปิดรันเซิร์ฟเวอร์ตัวนี้ทิ้งไว้ จากนั้นเปิดหน้าต่าง Terminal ขึ้นมาใหม่ แล้วสั่งจำลองการยิงสแปม Request แบบรัวๆ ติดต่อกันด้วยคำสั่งลูป curl ชุดนี้ครับ:
Bash
for i in {1..6}; do curl -X POST http://localhost:8080/api/v1/ai/chat; echo ""; done
การบ้านชวนวิเคราะห์: ลองสังเกตผลลัพธ์บนหน้าจอ Terminal ดูครับว่า ในรอบการยิงที่เท่าไหร่ระบบเริ่มตัดบทและตีกลับข้อความมาเป็น HTTP Status Code 429 Too Many Requests?
และเพื่อทดสอบการดีไซน์ระดับแอดวานซ์: ถ้านโยบายบริษัทระบุว่า "พนักงานแผนก Engineering สามารถยิงใช้งาน AI ได้วินาทีละ 5 ครั้ง แต่พนักงานแผนก Marketing ยิงได้เพียงวินาทีละ 2 ครั้ง" คุณคิดว่าควรปรับปรุงโครงสร้างภายในฟังก์ชัน getLimiter อย่างไรเพื่อให้รองรับ Dynamic Configurations ตามกลุ่มผู้ใช้งานได้ดีที่สุด? ลองดีไซน์เล่นๆ ในหัวดูครับ!
FAQ: คำถามที่พบบ่อย
ทำไมต้องนำ sync.RWMutex เข้ามาครอบฟิลด์ lastSeen ทั้งๆ ที่ใช้ sync.Map อยู่แล้ว?
แม้ว่า sync.Map จะช่วยให้การอ่านและเขียนตัวแปร Map ปลอดภัยจากหลาย Goroutines แต่ตัวโครงสร้างข้อมูลย่อย (Struct) ที่อยู่ข้างในไม่ได้ถูกปกป้องด้วย หากเกิดเหตุการณ์ที่ Background Job กำลังอ่านค่า client.lastSeen เพื่อเช็กเวลาล้างข้อมูล ในขณะที่ตัว Middleware หลักกำลังเขียนอัปเดต c.lastSeen = time.Now() พร้อมๆ กัน จะเกิดปัญหา Data Race ทันที การใส่ Mutex แยกใน Struct จึงจำเป็นเพื่อความปลอดภัย 100% ครับ
ฟังก์ชัน Allow() กับ Wait() ในแพ็กเกจ x/time/rate มีความแตกต่างกันอย่างไร?
Allow() จะทำงานแบบ Non-blocking คือถ้ามีโทเคนเหลือก็หยิบแล้วผ่านไปเลย ถ้าไม่มีก็ตีกลับค่าเป็น False ทันที เหมาะสำหรับทำ API Middleware ส่วน Wait() จะเป็นแบบ Blocking คือถ้าโทเคนหมด มันจะหยุดรอ (Sleep) จนกว่าถังจะได้รับโทเคนเติมเข้ามาใหม่จนครบ จึงค่อยทำงานต่อ เหมาะสำหรับเขียนสคริปต์ยิงงานหลังบ้านที่เราไม่อยากให้เกิน Rate Limit ของปลายทางครับ
ถ้าเซิร์ฟเวอร์ถูกขยายเป็น 10 เครื่อง (Distributed System) ท่านี้ยังใช้ได้ไหม?
ท่านี้จะใช้งานไม่ได้ผลเต็มที่ครับ เพราะเมมโมรีและถังโทเคนจะแยกกันอยู่คนละเครื่อง ยูสเซอร์คนเดิมยิงเข้าเครื่อง 1, 2, 3 โควตาก็จะถูกนับแยกกัน หากเป็นระบบสเกลใหญ่ แนะนำให้ย้ายสถานะการทำ Rate Limiting ไปไว้ที่ตัวกลางอย่าง Redis โดยใช้กลไกประมวลผลผ่าน Lua Script แทนครับ
สรุปและตอนต่อไป (EP.165)
การทำ Rate Limiting ด้วยอัลกอริทึม Token Bucket เป็นวิธีคัดกรองโหลดที่ง่ายและได้ผลดีที่สุดในการป้องกันการโจมตีหรือบั๊กที่ยิงสแปมเข้ามาหาเซิร์ฟเวอร์ AI ของเรา ช่วยรักษาเสถียรภาพภาพรวมของแอปพลิเคชันไว้ได้
ในตอนต่อไป (EP.165): ถึงแม้เราจะมีด่านจำกัดความเร็วคอยช่วยป้องกันระบบล่มแล้ว แต่ถ้าเกิดความต้องการใช้งานของคนทั้งองค์กรเติบโตขึ้นอย่างถูกต้องพร้อมๆ กันล่ะ? การล็อกสิทธิ์ไว้จะทำให้พนักงานทำงานไม่ได้ และเซิร์ฟเวอร์ตัวเดียวรับน้ำหนักไม่ไหวแน่นอน ตอนหน้าเราจะก้าวเข้าสู่การขยายระบบระดับคลัสเตอร์ด้วยเรื่อง Load Balancing AI Servers — การกระจายโหลดงานเมื่อมีผู้ใช้งานระบบ AI พร้อมกันจำนวนมาก รอติดตามชมกันครับ Gophers!
ฝากกดติดตามพวกเราได้ที่ Superdev Academy ในทุกช่องทางนะครับ!
🔵 Facebook: Superdev Academy Thailand (อัปเดตข่าวสารและบทความใหม่)
🎬 YouTube: Superdev Academy Channel (ติวเข้มแบบวิดีโอ)
📸 Instagram: @superdevacademy (เกร็ดความรู้สั้นๆ และเบื้องหลังการทำงาน)
🎬 TikTok: @superdevacademy (Tips & Tricks ฉบับย่อยง่าย)
🌐 Website: superdevacademy.com (คลังบทความและคอร์สเรียนฉบับเต็ม)