ลองนึกภาพว่าเว็บของเรามีหน้าเพจหนึ่งที่ประมวลผลหนักมาก ต้องยิง query ไปดึงข้อมูลจากฐานข้อมูลทุกครั้ง แล้ววันดีคืนดีมีผู้ใช้หลายร้อยคนกดเข้ามาขอหน้าเดิมพร้อมๆ กันภายในเสี้ยววินาที

ผล ก็คือ เซิร์ฟเวอร์ยิงคำขอที่เหมือนกันเป๊ะไปที่ฐานข้อมูลซ้ำๆ นับร้อยครั้ง ทั้งที่คำตอบที่ได้กลับมาก็เป็นก้อนเดียวกัน

นี่ คือ ปัญหาคลาสสิกที่ทำให้ระบบล่มตอน traffic พีคมากๆ และ เทคนิคที่ชื่อว่า Request Coalescing ก็เกิดมาเพื่อแก้เรื่องนี้โดยเฉพาะ

แนวคิดนี้ถูกพูดถึงอย่างน่าสนใจในบล็อกของ Discord ที่เล่าเบื้องหลังว่าพวกเขาเก็บข้อความระดับ "ล้านล้าน" ข้อความได้อย่างไร (How Discord Stores Trillions of Messages)

ทีมวิศวกรของ Discord เขียน data service ของตัวเองขึ้นมาคั่นกลางระหว่าง API หลักกับ cluster ฐานข้อมูล และหนึ่งในฟีเจอร์เด่นของ service นั้น ก็คือ Request Coalescing นี่เอง นอกจากนี้มันยังเป็นเทคนิคที่ CDN และ ระบบแคชหลายเจ้าใช้กันอยู่แล้วอย่างแพร่หลายด้วย


Request Coalescing คืออะไร

พูดง่ายๆ Request Coalescing คือ กลยุทธ์ที่ รวบคำขอที่เหมือนกันหลาย ๆ อันเข้าเป็นคำขอเดียว เพื่อลดภาระเซิร์ฟเวอร์และทำให้ตอบสนองได้เร็วขึ้น

ยกตัวอย่างเว็บเซิร์ฟเวอร์ที่ให้บริการหน้าเพจตาม URL ปกติเวลามีคนขอหน้าเพจเข้ามา เซิร์ฟเวอร์จะประมวลผลทีละคำขอ แล้วยิงไปที่ origin server แยกกันเป็นอันต่ออัน ซึ่งกินทรัพยากรและทำให้ช้าลง เพราะฐานข้อมูลเป็นทรัพยากรที่ทุกคนใช้ร่วมกัน

Request Coalescing เข้ามาแก้ตรงนี้ด้วยการ จับกลุ่มคำขอที่ขอทรัพยากรตัวเดียวกันภายในช่วงเวลาสั้นๆ เข้าด้วยกัน พอหมดช่วงเวลานั้น ระบบจะยิงคำขอไปที่ origin server แค่ครั้งเดียว แล้วเอาคำตอบที่ได้กลับมาแจกจ่ายให้ทุกคนที่ขอหน้าเดียวกัน

ผล คือ ลดทั้งภาระเซิร์ฟเวอร์และเวลาในการตอบสนอง

กลไกเบื้องหลัง ก็คือ การเก็บกลุ่มคำขอไว้ (มักใช้ map), ใช้ตัวจับเวลา (timer), สร้างคำตอบรวม แล้วกระจายกลับไปให้ทุกคำขออย่างมีประสิทธิภาพ

ข้อควรรู้: Request Coalescing ไม่ได้เหมาะกับทุก service หรือ ทุก route มันเหมาะกับ route ที่ประมวลผลหนักและมักคืนคำตอบเดิมๆ เท่านั้น

จริงๆ แล้วเทคนิคนี้ พบได้ทั่วไปใน cache server แบบ HTTP reverse proxy เช่น nginx, Squid หรือ Varnish อยู่แล้ว เพียงแต่แต่ละเจ้าเรียกชื่อต่างกันไป แม้หลักการทำงานจะคล้ายกัน — ใน nginx เรียกว่า proxy_cache_lock, ใน Squid เรียก Collapsed Forwarding


ประโยชน์ของ Request Coalescing

การรวบคำขอให้ผลดีในหลายมิติ ที่เด่นชัดเลย ได้แก่

  • ลดค่าใช้จ่ายด้าน Network: คำขอแต่ละอันมีต้นทุนแฝงของมันเอง ทั้งการ resolve DNS การเปิด/ปิด connection การรวบหลายคำขอเป็นก้อนเดียวช่วยตัดต้นทุนพวกนี้ลง
  • ลด Latency: ยิงคำขอที่ใหญ่ขึ้นแต่จำนวนน้อยลง ทำให้เวลาเดินทางของข้อมูลระหว่าง client กับ server ลดลงชัดเจน สำคัญมากในแอปพลิเคชันแบบ realtime ที่แพ้ทางเรื่องความหน่วง
  • ใช้ Resource ได้คุ้มขึ้น: เมื่อคำขอถูกรวมเป็นก้อน server ประมวลผลได้มีประสิทธิภาพกว่า เพราะใช้ประโยชน์จากการ reuse connection การทำ pipeline คำขอ หรือการ batch query ฐานข้อมูลได้
  • ประหยัด Bandwidth: ในกรณีที่มี bandwidth จำกัด หรือ มีต้นทุนสูง การส่ง payload ที่ใหญ่ แต่จำนวนน้อยมักคุ้มกว่าส่งของเล็กๆ จำนวนมาก
  • ช่วยเรื่อง Load Balancing และการ scale: สำหรับ load balancer และ reverse proxy การมีคอนเนกชันน้อยลงกระจายไปยัง backend ทำให้บริหารจัดการและกระจาย traffic ได้ง่ายขึ้น
  • ทำ Rate Limiting และ ป้องกัน Resource: การรวบคำขอช่วยให้ตั้ง rate limit ได้ละเอียดขึ้น และกันไม่ให้ resource ถูกถล่มจนหมด
  • ลด Overhead ฝั่ง Server: ลดภาระที่มาจากการต้องจัดการและประมวลผลทีละคำขอ ทำให้ต้นทุนการประมวลผลต่ำลง
  • เพิ่มความปลอดภัย: การรวบคำขอเป็นก้อนใหญ่ช่วยลดการเปิดเผย endpoint เฉพาะจุด ทำให้ผู้ไม่หวังดีเจาะช่องโหว่หรือโจมตีได้ยากขึ้น

ลงมือเขียนด้วย Go

ใน Go เราสามารถทำ Request Coalescing ได้โดยอาศัย goroutine, channel และกลไก synchronization อย่าง mutex เพื่อจับกลุ่มคำขอที่เหมือนกันแล้วยิงไปที่ server แค่ครั้งเดียวต่อกลุ่ม

ก่อนจะเขียนเอง มีไลบรารีที่ทำเรื่องนี้อยู่แล้วน่าลองดู — httpcoala เป็น middleware ที่รวบหลายคำขอสำหรับ URI เดียวกัน (และเมธอดที่กำหนด) ให้ประมวลผลเป็นคำขอเดียว และยังมีตัวอื่นอย่าง Stampede ที่ช่วยแก้ปัญหา cache miss, thundering herd และ cache coherence ได้ด้วย

แต่คราวนี้เราจะลองเขียนเวอร์ชันง่ายๆ ของ request coalescer ขึ้นมาเอง โดยใช้ httpbin.org เป็นปลายทางสำหรับยิง API ทดสอบ

package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"sync"
	"time"

	"github.com/gorilla/mux"
)

const (
	baseURL       = "https://httpbin.org"
	coalesceDelay = 5000 * time.Millisecond
)

var (
	coalesceMap  = make(map[string]*RequestData)
	coalesceLock sync.Mutex
)

type RequestData struct {
	URL     string
	Counter int
	Results chan *http.Response
}

func main() {
	r := mux.NewRouter()
	r.HandleFunc("/request", handleRequest)
	http.Handle("/", r)

	go processCoalesceQueue()

	fmt.Println("Server listening on :8080")
	http.ListenAndServe(":8080", nil)
}

func handleRequest(w http.ResponseWriter, r *http.Request) {
	URL := fmt.Sprintf("%s/get", baseURL)

	coalesceLock.Lock()
	data, exists := coalesceMap[URL]
	if !exists {
		data = &RequestData{
			URL:     URL,
			Counter: 0,
			Results: make(chan *http.Response),
		}
		coalesceMap[URL] = data
	} else {
		data.Counter++
	}
	coalesceLock.Unlock()

	go func() {
		data.Results <- sendRequest(URL)
	}()

	response := <-data.Results

	if response != nil {
		w.WriteHeader(response.StatusCode)
		w.Header().Set("Content-Type", "application/json")
		body, _ := ioutil.ReadAll(response.Body)
		w.Write([]byte(string(body)))
	}

	response.Body.Close()
}

func sendRequest(URL string) *http.Response {
	resp, err := http.Get(URL)
	if err != nil {
		fmt.Printf("Error sending request: %v\n", err)
		return nil
	}
	return resp
}

func processCoalesceQueue() {
	for {
		time.Sleep(coalesceDelay)

		fmt.Println("Processing coalesce queue")
		coalesceLock.Lock()
		for _, data := range coalesceMap {
			if data.Counter > 0 {
				go sendCoalescedRequest(data)
				data.Counter = 0
			}
		}
		coalesceLock.Unlock()
	}
}

func sendCoalescedRequest(data *RequestData) {
	if data.Counter == 0 {
		return
	}
	count := data.Counter
	fmt.Printf("Coalescing %d requests for URL: %s\n", count, data.URL)

	resp := sendRequest(data.URL)
	data.Results <- resp
}

มาดูโค้ดทีละส่วนกัน

Import: ดึงแพ็กเกจที่ใช้เข้ามา ทั้ง fmt สำหรับจัดรูปแบบข้อความ, io/ioutil สำหรับอ่าน response body, net/http สำหรับสร้าง HTTP server, sync สำหรับ synchronization และ github.com/gorilla/mux สำหรับ routing

ค่าคงที่และตัวแปร

  • baseURL — URL ตั้งต้นสำหรับยิงคำขอ
  • coalesceDelay — ระยะเวลาหน่วงสำหรับประมวลผลคำขอที่รวบไว้ (ตั้งไว้ที่ 5000 มิลลิวินาที หรือ 5 วินาที)
  • coalesceMap — map ที่เก็บข้อมูลคำขอที่รวบไว้ โดยใช้ URL เป็น key และค่าเป็น pointer ไปยัง RequestData
  • coalesceLock — mutex สำหรับล็อกการเข้าถึง coalesceMap

struct RequestData: เก็บข้อมูลของคำขอที่ถูกรวบ ประกอบด้วย URL, ตัวนับ (Counter) ไว้นับจำนวนคำขอ และ channel สำหรับเก็บ Object HTTP response

ฟังก์ชัน main: ตั้งค่า HTTP server ด้วย Gorilla Mux router, กำหนด route สำหรับรับคำขอ, แล้ว start goroutine เพื่อประมวลผลคิวที่รวบไว้ จากนั้นเปิด server ที่ port 8080

ฟังก์ชัน handleRequest: จัดการคำขอ HTTP ที่เข้ามา โดยเช็กว่ามีคำขอสำหรับ URL นั้นค้างอยู่ใน coalesceMap แล้วหรือยัง ถ้ายังไม่มีก็สร้าง RequestData ใหม่แล้วเพิ่มเข้า map แต่ถ้ามีอยู่แล้วก็แค่เพิ่มค่าตัวนับ จากนั้น start goroutine เพื่อส่งคำขอไปยัง channel รอรับคำตอบ ส่งกลับให้ client แล้วปิด response body

ฟังก์ชัน sendRequest: ยิง HTTP GET ไปยัง URL ที่กำหนดแล้วคืน response กลับมา ถ้าเกิด error ระหว่างทางจะพิมพ์ข้อความแจ้งแล้วคืน nil

ฟังก์ชัน processCoalesceQueue: goroutine ที่วน loop ทำงาน คอยเช็ก coalesceMap เป็นระยะ ถ้ามีคำขอที่รวบไว้รออยู่ (ดูจากตัวนับที่ไม่เป็นศูนย์) ก็จะรวบมันแล้วส่งไปยัง server ผ่าน sendCoalescedRequest

ฟังก์ชัน sendCoalescedRequest: รวบหลายคำขอที่ขอ URL เดียวกันให้เหลือคำขอเดียว ดึงคำตอบมาแล้วส่งต่อคำตอบเดียวกันนั้นให้ client ทุกคนที่รออยู่ผ่าน channel ของ RequestData

นี่เป็นการทำ Request Coalescing แบบพื้นฐานที่สุด เพื่อให้เห็นภาพว่า เราจะป้องกัน backend จาก traffic ที่พุ่งขึ้นมาได้อย่างไร (ในการใช้งานจริง ยังมีรายละเอียดเรื่อง race condition และการจัดการ channel ที่ต้องปรับแต่งเพิ่ม แต่แก่นแนวคิด คือ แบบนี้)


นำไปใช้จริงที่ไหนได้บ้าง

นอกจากเคสฐานข้อมูลที่ยกไปแล้ว Request Coalescing ยังถูกนำไปใช้ในสถานการณ์จริงอีกหลากหลาย

  • การเปิดเว็บ: ตอนโหลดหน้าเว็บ browser ต้องดึง resource หลายอย่าง เช่น รูป, style, script การรวบคำขอไปยังโดเมนเดียวกันช่วยลดจำนวนรอบเดินทางและทำให้หน้าโหลดเร็วขึ้น
  • CDN: คำขอเนื้อหาเดียวกันจากผู้ใช้หลายคนถูกรวบเพื่อเสิร์ฟจาก server ที่อยู่ใกล้ ลด latency และส่งเนื้อหาได้ไวขึ้น
  • Query ฐานข้อมูล: หลาย query ที่ขอข้อมูลชุดเดียวกันถูกรวบเป็น query เดียว มันช่วยลดภาระ DB และเพิ่มประสิทธิภาพ
  • การดาวน์โหลดไฟล์: คำขอดาวน์โหลดไฟล์เดียวกันหลายอัน (เช่น อัปเดตซอฟต์แวร์) ถูกรวบเพื่อลดภาระ server และประหยัด bandwidth
  • การรวมเนื้อหา (Content Aggregation): ในระบบที่รวมข่าวสาร คำขอบทความเดียวกันจากหลายแหล่งถูกรวบเป็นคำขอเดียว ลดการดึงซ้ำซ้อน
  • การ Prefetch resources: browser มักดึงทรัพยากรล่วงหน้า การรวบกันจะช่วยจัดกลุ่มและจัดลำดับความสำคัญของ prefetch ไม่ให้ดึงข้อมูลเกินจำเป็น
  • Cloud Computing: VM หรือ container หลายตัวที่ขออัปเดต หรือ ไฟล์ config เดียวกัน ถูกรวบเพื่อลดภาระของการ update server
  • การรวมข้อมูลเซนเซอร์ (IoT): อุปกรณ์ sensor ที่ส่งค่าอ่านคล้ายๆ กัน จะถูกรวบเพื่อลดจำนวนการส่งข้อมูลและประหยัดพลังงานแบตเตอรี่
  • API Requests: เมื่อ clients หลายรายยิงคำขอ API คล้ายๆ กัน การรวบช่วยลดภาระ server และเพิ่มความเร็วในการตอบสนอง
  • การจัดการ network: คำขออัปเดตสถานะ หรือ เปลี่ยน config ของอุปกรณ์เครือข่ายถูกรวบเพื่อลด overhead และเพิ่มประสิทธิภาพการ monitor

สรุป

Request Coalescing คือ เทคนิคที่เรียบง่ายแต่ทรงพลัง หัวใจของมันคือ "อย่าทำงานเดิมซ้ำหลายรอบโดยไม่จำเป็น" — เมื่อมีคำขอที่เหมือนกันหลั่งไหลเข้ามาพร้อมกัน แทนที่จะยิงไปที่ต้นทางทีละอัน ก็รวบเป็นคำขอเดียวแล้วแจกจ่ายคำตอบให้ทุกคน

จุดที่ต้องระวังคือมันไม่ใช่ยาครอบจักรวาล ควรเลือกใช้กับ route ที่ประมวลผลหนักและมักคืนคำตอบเดิม ๆ เท่านั้น สำหรับ Go ถ้าอยากได้โซลูชันพร้อมใช้ในโปรดักชัน แนะนำให้ดูไลบรารีอย่าง Stampede ที่จัดการเคสยาก ๆ เช่น thundering herd และ cache coherence ไว้ให้แล้ว แต่ถ้าอยากเข้าใจกลไกเบื้องหลัง การลองเขียนเวอร์ชันง่าย ๆ เองแบบในบทความนี้ก็เป็นจุดเริ่มต้นที่ดี


References: