การดู : 113

28/07/2026 14:15น.

กลไกอัลกอริทึม Token Bucket สำหรับระบบ Rate Limiting ด้วยภาษา Go

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 (คลังบทความและคอร์สเรียนฉบับเต็ม)