บทที่ 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 — ช้าและแปรผัน
ต้นทุน writeO(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 buckettoken เติมด้วยอัตราคงที่ แต่ละ request ใช้ 1 tokenอนุญาต burst ที่ควบคุมได้ ซึ่งมักเป็นสิ่งที่คุณต้องการจริงๆ · 2 พารามิเตอร์ (rate, burst) ที่ map เข้ากับวิธีคิดของคนได้ดี
Leaky bucket / queuerequest เข้าคิวแล้วระบายด้วยอัตราคงที่ทำ 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 แนวทาง

แนวทางทำงานอย่างไรใช้เมื่อ
Geohashencode lat/long เป็น string · prefix ที่ตรงกันหมายถึงอยู่ใกล้กันในทางกายภาพ · query = prefix match บน cell ข้างเคียงมี store อะไรก็ได้ที่ index string ได้ นำมาใช้ง่ายที่สุด · ข้อควรระวัง: cell ที่อยู่ใกล้ขอบของ prefix อยู่ใกล้กันทางกายภาพแต่ไม่มี prefix ร่วมกัน — ต้อง query cell ข้างเคียงทั้ง 8 ด้วย
H3 / S2 cellgrid แบบลำดับชั้นบนทรงกลม · H3 ใช้ hexagon: ระยะถึงเพื่อนบ้านสม่ำเสมอกว่า grid สี่เหลี่ยมการรวมสถิติตามพื้นที่ (surge pricing, heat map, ความครอบคลุม) และของที่เคลื่อนที่ · H3 ของ Uber
PostGIS / R-treespatial index จริงบน geometry type พร้อมระยะที่ถูกต้อง, polygon และ containmentdefault สำหรับอะไรที่มี 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