บทที่ 19 · Part 5 — Decisions & Delivery
Scenarios: Scale Classics
Feed/timeline fan-out, chat & presence, distributed rate limiter และ nearby search แบบ geospatial
4 ระบบในบทนี้เป็นโจทย์คลาสสิกที่สอน trade-off ที่ต่างจากบทก่อน — read กับ write, connection ที่ stateful จริงๆ, ความแม่นยำกับต้นทุน, และการเลือก index ให้ตรงกับรูปร่างของคำถาม
จบบทนี้คุณจะ
- ออกแบบ feed แบบ hybrid fan-out ได้ และรู้ว่าทำไม timeline ต้องเก็บเป็น ID list
- จัดการ WebSocket ที่ stateful ด้วย connection registry + pub/sub ได้
- เลือก algorithm ของ rate limiter ได้ และรู้ว่า bug อันดับ 1 ของมันคืออะไร
- เลือก geospatial index ได้ตามชนิดของคำถาม
Scenario 5 · Feed / timeline (fan-out)
บทเรียน: trade-off คลาสสิกระหว่าง read กับ write และเหตุผลที่คำตอบคือ "ทั้ง 2 อย่าง"
S5 · Requirements
- ผู้ใช้เปิดแอปแล้วเห็นรายการเรียงจากใหม่ไปเก่าจาก account ที่ตัวเองติดตาม (หรือในแอป fintech: กิจกรรมจากบัญชีที่ผูกไว้, โปรโมชัน, event ของการโอนแบบกลุ่ม)
- p99 < 200 ms สำหรับหน้าแรก · read มากกว่า write อย่างมาก สมมติ 100:1 · ข้อมูลเก่าไปไม่กี่วินาทีรับได้ แต่รายการที่หายไปแล้วไม่โผล่มาอีกเลยรับไม่ได้
- Non-goal v1: การจัดอันดับด้วย ML, การแทรกโฆษณา, sync สถานะการอ่านข้ามอุปกรณ์
2 กลยุทธ์
| Fan-out on write (push) | Fan-out on read (pull) | |
|---|---|---|
| ทำอย่างไร | เมื่อ A โพสต์ ให้ insert item ID เข้า timeline ที่คำนวณไว้ล่วงหน้าของ follower ทุกคน | เมื่อ B เปิดแอป ให้ query item ล่าสุดของทุกคนที่ B ติดตาม แล้ว merge |
| ต้นทุน read | อ่าน list ที่พร้อมแล้วครั้งเดียวผ่าน index — เร็วมาก | N query + merge + sort — ช้าและแปรผัน |
| ต้นทุน write | O(followers) — คนที่มี follower 1 ล้าน = write 1 ล้านครั้งต่อโพสต์เดียว | O(1) |
| Storage | ซ้ำต่อ follower | เก็บครั้งเดียว |
| พังเมื่อ | มี celebrity account; follower list เปลี่ยนตลอด | ผู้ใช้ติดตามหลาย account; read volume สูง |
คำตอบบน production คือ hybrid
- ผู้ใช้ทั่วไป (< ~10k follower): FAN-OUT ON WRITE
ตอนโพสต์: push item ID เข้า timeline ของ follower แต่ละคน
(Redis sorted set ที่ cap ไว้ ~800 รายการ บวกสำเนาที่ durable)
- Celebrity account (เกิน threshold): ไม่ต้อง fan out
item ของเขาถูกดึงตอน read แล้ว merge เข้า timeline
- ผู้ใช้ที่ไม่ active: ข้าม fan-out ไปเลย แล้วสร้าง timeline ใหม่
ตอนเขากลับมา
--> ในกราฟโซเชียลขนาดใหญ่ ต้นทุน fan-out ส่วนใหญ่คือ
การเขียน timeline ที่ไม่มีใครอ่านRead path 5 ขั้น
1. อ่านหน้าของ timeline ที่คำนวณไว้จาก cache (เร็ว, O(1))
2. ดึง item ล่าสุดจาก celebrity ไม่กี่รายที่ติดตาม (N มีขอบเขต)
3. merge, dedupe, sort ตามเวลา, ใช้กฎ block/mute/visibility
4. HYDRATE: ดึงเนื้อหา item และข้อมูลผู้เขียนด้วย ID
ใน *การเรียกแบบ batch ครั้งเดียว*
--> ห้าม N+1 นี่คือขั้นที่ตัดสิน p99 ของคุณจริงๆ
5. cap ขนาดหน้า แล้วคืน opaque cursor สำหรับหน้าถัดไป
เก็บ timeline เป็น **ID list** ห้ามเก็บเนื้อหาที่ denormalize แล้ว
ถ้าโพสต์ถูกแก้หรือถูกลบ คุณต้องไม่มีสำเนาข้อความเก่า 1 ล้านชุด
และการ hydrate ตอน read ยังทำให้ กฎ visibility ถูกต้อง ณ เวลาที่อ่าน — ซึ่งเป็นสิ่งที่เรื่อง privacy และการ block ต้องการ
[!TIP] Real case — timeline ของ Twitter สถาปัตยกรรมที่ Twitter เผยแพร่ใช้ hybrid แบบนี้เป๊ะ: home timeline ที่คำนวณไว้ล่วงหน้าเก็บใน memory เพื่อการอ่านที่เร็ว, พร้อมการจัดการพิเศษสำหรับ account ที่มี follower สูงมากซึ่ง fan-out จะแพงเกินไป, และเส้นทางแยกสำหรับ search และ real-time stream
บทความของพวกเขายังย้ำว่า timeline เก็บเป็น tweet ID และ hydrate ตอน read
— The infrastructure behind Twitter: scale · Sharding & IDs at Instagram — ID ที่เรียงตามเวลาและ shard ได้มีอยู่ก็เพื่อให้ feed เรียงลำดับและ paginate ได้โดยไม่ต้องมี sequence กลาง
Failure mode และรายละเอียดที่สำคัญ
| เรื่อง | สิ่งที่ต้องทำ |
|---|---|
| Fan-out ต้อง async และ resume ได้ | publish event PostCreated แล้วให้ worker fan out เป็น batch · ถ้า worker ตายกลาง fan-out งานจะถูก retry → ทำให้ insert idempotent (sorted set ที่ key ด้วย item ID เป็น idempotent โดยธรรมชาติ) |
| Cap ความยาว timeline | เก็บ ~800 รายการล่าสุดใน store ที่เร็ว · หน้าที่เก่ากว่านั้น fall back ไป query store ที่ durable · list ต่อผู้ใช้ที่ไม่มีขอบเขตคือ memory leak ที่มีชื่อทางธุรกิจ |
| การลบเป็น eventual | เพราะ hydrate เกิดตอน read item ที่ถูกลบก็แค่หายไปเมื่อ hydrate ไม่สำเร็จ — แต่ต้องจัดการ "รู" ที่เกิดขึ้น ไม่ให้หน้าเล็กกว่าขนาดที่ขอ |
| Cache เย็นคือ outage | ถ้า timeline cache หาย การอ่านครั้งแรกของทุกคนกลายเป็น fan-out-on-read · rebuild แบบค่อยเป็นค่อยไปพร้อมจำกัด concurrency และ serve timeline ที่ degrade (สั้นกว่า หรือ "ล่าสุดเท่านั้น") ไปก่อน — วางแผนข้อนี้ก่อนที่จะต้องใช้ |
| Monotonic read สำคัญที่นี่ | ถ้า replica 2 ตัวไม่ตรงกัน ผู้ใช้ที่ pull-to-refresh จะเห็นรายการหายแล้วโผล่กลับมา · pin session ไว้กับ replica เดียว หรือ serve จาก snapshot ที่ consistent กับ cursor |
Scenario 6 · Chat และ presence
บทเรียน: การจัดการ connection ที่ stateful จริงๆ และความต่างระหว่าง การส่งถึง กับ สถานะการอ่าน
S6 · Requirements
- แชท 1:1 และกลุ่ม, ลำดับข้อความภายในบทสนทนา, delivery และ read receipt, presence (online/offline/last seen), ประวัติบนอุปกรณ์ใหม่, push notification เมื่อแอปปิด
- p95 < 200 ms สำหรับการส่งถึงในแอป · ห้ามข้อความหาย ไม่มีข้อยกเว้น — ข้อความที่หายในแชท support คือคำร้องเรียน ในการเจรจาเรื่องการจ่ายเงินคือข้อพิพาท
- สมมติ 200k concurrent connection ที่พีค
S6 · Architecture
Send path
1. client -> gateway node ของตัวเอง -> Chat Service
2. กำหนด sequence number ฝั่ง server ต่อ conversation
(นี่คือผู้มีอำนาจเรื่องลำดับ *ห้ามเชื่อ timestamp ของ client*)
3. persist ข้อความ <-- ACK กลับไปหาผู้ส่ง *หลัง* ขั้นนี้สำเร็จเท่านั้น
4. publish ไปที่ channel ของ conversation
5. ทุก gateway node ที่ถือ socket ของผู้รับส่งข้อความต่อ
6. ผู้รับที่ไม่มี socket ที่ยังมีชีวิต -> push notification
7. receipt (delivered/read) ไหลกลับเป็นข้อความเล็กๆ ของตัวเอง
ทำไมต้องมีทั้ง registry และ pub/sub
socket ของผู้ส่งกับ socket ของผู้รับแทบไม่เคยอยู่ node เดียวกัน
คุณเลือกได้ 2 ทาง: หาว่าผู้รับอยู่ที่ไหนแล้ว route ตรง (เร็ว แต่ registry อยู่บน critical path แล้ว) หรือ publish ไป channel ที่ทุก node ที่สนใจ subscribe อยู่ (ง่ายกว่า traffic มากกว่า)
ระบบส่วนใหญ่ทำ hybrid: pub/sub ต่อ conversation และใช้ registry สำหรับ presence และเพื่อตัดสินว่าจะส่ง push หรือไม่
S6 · Deep dive
Ordering — sequence number ที่ monotonic ต่อ conversation กำหนดโดย server client เรียงตามมัน, ตรวจจับช่องว่างได้ ("ผมมี 41 กับ 43 ขอ 42"), และ resume หลัง reconnect ได้ด้วย "ขอทุกอย่างหลัง 41" — ทนกว่าการเรียงตาม timestamp มาก และทำให้การ sync บนอุปกรณ์ใหม่ง่ายมาก
Idempotency — client สร้าง message ID (ULID) เอง
เพื่อให้การส่งซ้ำหลัง ACK หายไม่สร้างข้อความซ้ำ
unique constraint บน (conversation_id, client_msg_id) ฝั่ง server คือตัวกันจริง
Reconnect เป็นกรณีปกติ ไม่ใช่ข้อยกเว้น
mobile network หลุดตลอดเวลา ต้องออกแบบ 3 อย่าง:
- exponential backoff + jitter ตอน reconnect — ไม่อย่างนั้น การ restart gateway ทำให้ client 200k ตัวกลับมาพร้อมกัน และคุณกู้คืนไม่ได้
- resume-from-sequence
- ขนาดหน้าของการ catch-up ที่มีขอบเขต
Presence แพงและแทบไม่คุ้มกับความแม่นยำ — design ที่ตรงไปตรงมาจะ broadcast ทุกการเปลี่ยนสถานะไปหาทุก contact = O(contacts) write ต่อการเปลี่ยน ตลอดเวลา
วิธีที่ใช้ได้จริง:
- heartbeat ทุก 30 s เข้า key ที่มี TTL
- คำนวณ presence แบบ lazy ตอนที่มีคนดูจริงๆ
- debounce "last seen" ให้หยาบลง (ระดับนาที)
Storage — ข้อความคือ workload แบบ wide-column ตามตำรา:
append-only และอ่านเป็น "N รายการล่าสุดใน conversation นี้" เสมอ
partition ด้วย (conversation_id, time_bucket) เพื่อให้ partition มีขอบเขต
(คือ model ของ Discord ในบทที่ 8 เป๊ะ)
เพดาน fan-out ของกลุ่ม — กลุ่ม 5,000 คนเปลี่ยนข้อความ 1 ใบเป็นการส่ง 5,000 ครั้ง cap ขนาดกลุ่ม, batch การส่งต่อ node, และพิจารณาใช้ pull model สำหรับกลุ่มที่ใหญ่มาก (มันมีพฤติกรรมเหมือน feed ไม่ใช่แชท)
Real case — real-time messaging ของ Slack
Slack เขียนเล่าถึงการย้ายออกจาก model ที่ client แต่ละตัวถือ connection ที่ผูกกับ channel server ตัวเฉพาะ ไปเป็น routing tier (บน Envoy) บวก pub/sub layer เพราะการวางตำแหน่ง connection และ reconnect storm คือปัญหาที่ยากจริง
blog ของพวกเขายังครอบคลุมว่า global reconnect (thundering herd หลัง deploy หรือหลังเหตุการณ์ทาง network) คือ failure mode ที่ต้องออกแบบรับอย่างชัดเจน — Real-time Messaging at Slack
Scenario 7 · Distributed rate limiter
บทเรียน: component เล็กที่มี trade-off ลึกกว่าที่คิด และเป็นอันที่คุณจะสร้างหรือ config ในทุกระบบ
S7 · Requirements
จำกัดตาม API key, ตาม user, ตาม IP และตาม endpoint โดยมี limit ต่างกันตาม tier · ต้องทำงานข้าม API node จำนวน N ตัว · เพิ่ม p99 ไม่เกิน 5 ms · ต้อง fail open หรือ closed ตามนโยบายที่ระบุไว้ต่อ endpoint
Algorithm
| Algorithm | ทำอย่างไร | ข้อดี / ข้อเสีย |
|---|---|---|
| Fixed window | นับต่อ bucket นาที | ง่ายมาก · ปล่อยได้ 2 เท่าของ limit ที่ขอบ (request ทั้งหมดที่ 10:00:59 บวกทั้งหมดที่ 10:01:00) |
| Sliding window log | เก็บ timestamp ต่อ request แล้วนับที่อยู่ในหน้าต่าง | แม่นยำ · memory โตตามปริมาณ request — แพงเมื่อ limit สูง |
| Sliding window counter | ถ่วงน้ำหนักจำนวนของหน้าต่างก่อนหน้าตามสัดส่วนที่ยังอยู่ในมุมมอง | default เชิงปฏิบัติที่ดี · 2 counter ต่อ key, พฤติกรรมนุ่ม, memory จิ๋ว |
| Token bucket | token เติมด้วยอัตราคงที่ แต่ละ request ใช้ 1 token | อนุญาต burst ที่ควบคุมได้ ซึ่งมักเป็นสิ่งที่คุณต้องการจริงๆ · 2 พารามิเตอร์ (rate, burst) ที่ map เข้ากับวิธีคิดของคนได้ดี |
| Leaky bucket / queue | request เข้าคิวแล้วระบายด้วยอัตราคงที่ | ทำ output ให้เรียบ แต่เพิ่ม latency และ ต้องมีคิวที่มีขอบเขต · ดีสำหรับป้องกัน downstream ที่มีเพดานอัตราแข็ง |
Implementation notes
Bug อันดับ 1 ของการ implement rate limiter
การ check-and-increment ต้องเป็น atomic ไม่อย่างนั้นตอน concurrent คุณจะปล่อยผ่านมากกว่า limit ไปไกล
ใช้ Redis Lua script (หรือลำดับคำสั่ง atomic เดียว) เพื่อให้ read-modify-write เกิดขึ้นฝั่ง server ในขั้นเดียว
นี่คือ bug ที่พบบ่อยที่สุดในการ implement เรื่องนี้
LATENCY: Redis round trip 1 ครั้งต่อ request (~0.5 ms) มักรับได้
ถ้าไม่ได้ ให้ใช้ LOCAL token bucket ต่อ node ที่ถือส่วนแบ่งของงบ global
แล้ว resync ทุกวินาที
--> คุณเสียความแม่นยำและได้ความเร็ว เป็น trade-off ที่ชัดเจนและป้องกันได้
FAIL POLICY — ตัดสินต่อ endpoint และเขียนเป็นลายลักษณ์อักษร:
Redis ใช้ไม่ได้ที่ endpoint LOGIN -> fail CLOSED (ปฏิเสธ)
เพราะ endpoint login ที่ไม่มี limit คือของขวัญให้ credential stuffing
Redis ใช้ไม่ได้ที่ endpoint อ่านข้อมูล -> fail OPEN (อนุญาต)
เพราะการปฏิเสธการอ่านทั้งหมดแย่กว่าการเสีย rate limiting ชั่วคราว
rate limiter ที่ fail open อย่างเงียบๆ ทุกที่
คือ security control ที่ไม่มีอยู่จริง
--> เขียนนโยบายลงกระดาษ และ *ทดสอบมัน*
ส่ง header ทุกครั้ง:
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After
client ที่เห็นงบของตัวเองมีพฤติกรรมดีกว่า client ที่ค้นพบงบด้วยการถูกปฏิเสธ
MULTI-DIMENSION: เช็ค limit ที่ถูกและกว้างที่สุดก่อน (IP)
แล้วค่อยเช็คอันที่เฉพาะ (user + endpoint) short-circuit ที่การปฏิเสธครั้งแรก
FAIRNESS: limit ระดับ global ปล่อยให้ tenant รายใหญ่รายเดียว
ทำให้คนอื่นอดตาย -> ให้แต่ละ tenant มีพื้น (floor) แล้วแบ่งเฉพาะส่วนเกิน
อ้างอิง: How we built rate limiting capable of scaling to millions of domains — ตัวอย่างที่ดีของการแลกความแม่นยำกับต้นทุนที่ edge
Scenario 8 · Nearby search (geospatial)
บทเรียน: การเลือก index ให้ตรงกับรูปร่างของคำถาม
S8 · Requirements
- "หาตัวแทน / ATM / ร้าน 20 แห่งที่ใกล้ที่สุดในรัศมี 5 กม. เรียงตามระยะ"
- และ "ที่อยู่นี้อยู่ในเขตจัดส่งไหน" — คำถามคนละแบบ (point-in-polygon ไม่ใช่ nearest-neighbour)
- 50,000 location ส่วนใหญ่นิ่ง
ถ้าโจทย์เปลี่ยนเป็นของที่เคลื่อนที่ design เปลี่ยนหมด
ถ้าเป็นการติดตาม courier 50,000 คนที่อัปเดตตำแหน่งทุก 5 วินาที นั่นคือ 10,000 write/s และเป็น design ที่ต่างกันโดยสิ้นเชิง
3 แนวทาง
| แนวทาง | ทำงานอย่างไร | ใช้เมื่อ |
|---|---|---|
| Geohash | encode lat/long เป็น string · prefix ที่ตรงกันหมายถึงอยู่ใกล้กันในทางกายภาพ · query = prefix match บน cell ข้างเคียง | มี store อะไรก็ได้ที่ index string ได้ นำมาใช้ง่ายที่สุด · ข้อควรระวัง: cell ที่อยู่ใกล้ขอบของ prefix อยู่ใกล้กันทางกายภาพแต่ไม่มี prefix ร่วมกัน — ต้อง query cell ข้างเคียงทั้ง 8 ด้วย |
| H3 / S2 cell | grid แบบลำดับชั้นบนทรงกลม · H3 ใช้ hexagon: ระยะถึงเพื่อนบ้านสม่ำเสมอกว่า grid สี่เหลี่ยม | การรวมสถิติตามพื้นที่ (surge pricing, heat map, ความครอบคลุม) และของที่เคลื่อนที่ · H3 ของ Uber |
| PostGIS / R-tree | spatial index จริงบน geometry type พร้อมระยะที่ถูกต้อง, polygon และ containment | default สำหรับอะไรที่มี polygon (เขต, พื้นที่บริการ, จังหวัด) หรือที่ต้องการระยะที่แม่น · ST_DWithin บวก GiST index รับเคสนี้ได้สบาย |
คำตอบที่น่าเบื่อและถูกต้องสำหรับ 50k location ที่นิ่ง
CREATE INDEX idx_loc_geo ON locations USING GIST (geog);
SELECT location_id, name, ST_Distance(geog, :point) AS meters
FROM locations
WHERE ST_DWithin(geog, :point, 5000) -- ใช้ index
AND status = 'ACTIVE'
ORDER BY geog <-> :point -- KNN operator, index ช่วยได้
LIMIT 20;
สำหรับของที่เคลื่อนที่ด้วยอัตรา write สูง — ห้ามใส่ลง OLTP เลย
ตำแหน่งปัจจุบัน -> Redis GEO structure หรือ in-memory grid
(ephemeral, เขียนทับ, ไม่เก็บประวัติ)
ประวัติตำแหน่ง -> time-series หรือ append-only store แบบ batch
--> 2 อย่างนี้มีข้อกำหนดเรื่อง durability ต่างกันโดยสิ้นเชิง
และการรวมมันเข้าด้วยกันคือความผิดพลาดที่พบบ่อย
รายละเอียดที่กัด
Filter ก่อนหรือหลัง? — "ร้าน 20 แห่งที่ใกล้สุดที่เปิดอยู่และรับ QR" ถ้า filter หลัง spatial query คุณอาจได้น้อยกว่า 20 ทางแก้: ขยายรัศมีทีละขั้น หรือใช้วิธีผสม (spatial index บวก filter พร้อม retry ที่รัศมีใหญ่ขึ้น)
ระยะทางไม่ใช่เวลาเดินทาง
ที่ห่าง 500 เมตรคนละฝั่งแม่น้ำอยู่ห่างกัน 4 กม. ทางถนน
ถ้า product หมายถึงเวลาเดินทาง คุณต้องมี routing service และนั่นเป็น dependency ที่ต่างไปและแพงกว่ามาก
ให้ถามให้ชัดว่า requirement หมายถึงอันไหน — นี่เป็นคำถามเรื่อง requirement จริง ไม่ใช่รายละเอียดปลีกย่อย
[!CAUTION] พิกัดเป็นข้อมูลส่วนบุคคลเมื่อผูกกับตัวคน ประวัติตำแหน่งที่แม่นยำเป็นข้อมูลอ่อนไหว: ลด retention ให้น้อยที่สุด, รวมเป็นค่าสรุปเมื่อทำได้ (เก็บ H3 cell แทนพิกัดที่แม่นยำสำหรับงาน analytics), และควบคุมการเข้าถึง
ภายใต้ PDPA นี่เป็นหมวดที่ต้องจัดการอย่างระมัดระวัง — ยืนยันการจัดหมวดกับเจ้าของเรื่อง privacy ขององค์กร
สรุปบทนี้
Feed ที่ production ใช้ hybrid — fan-out on write สำหรับคนทั่วไป, อ่านตอน read สำหรับ celebrity, และข้ามคนที่ไม่ active · timeline เก็บเป็น ID list แล้ว hydrate ตอน read · chat ต้องมี server-side sequence number เป็นผู้มีอำนาจเรื่องลำดับ และ reconnect เป็นกรณีปกติที่ต้องมี jitter · rate limiter ต้อง check-and-increment แบบ atomic และเขียนนโยบาย fail open/closed ต่อ endpoint · และ geospatial ให้เลือก index ตามชนิดคำถาม — nearest-neighbour ต่างจาก point-in-polygon