Presence ข้าม Instance
“ตอนนี้ใครออนไลน์อยู่บ้าง?” ฟังดูเป็นคำถามที่ตอบง่าย ๆ บน instance เดียวก็เกือบจะง่ายจริง — แค่อ่าน connections map ของคุณ แต่พอข้ามหลาย instance การออนไลน์กลับกระจัดกระจาย: แต่ละเครื่องรู้แค่ client ของตัวเอง และคุณเห็นมาแล้วจากบทเรียน broadcast ว่าไม่มี instance ไหนอ่าน memory ของอีกตัวได้ presence จึงเป็นปัญหา cross-instance เดิมที่สวมหมวกใบใหม่ แถมพ่วงจุดยุ่งยากที่น่ารำคาญมาด้วย: ผู้คนหายตัวไปโดยไม่บอกกล่าว
การออนไลน์ไม่ใช่ข้อเท็จจริงระดับ local อีกต่อไป
หัวข้อที่มีชื่อว่า “การออนไลน์ไม่ใช่ข้อเท็จจริงระดับ local อีกต่อไป”คำตอบแบบไร้เดียงสา — “นับ socket ที่เชื่อมต่อกับฉัน” — ให้มุมมองที่บางส่วนและผิดแก่แต่ละ instance รายชื่อผู้ใช้บน instance A ขาดทุกคนบน instance B ดังนั้น presence จึงต้องอยู่ในที่ที่ทุก instance อ่านและเขียนได้: shared store ซึ่งโดยทั่วไปคือ Redis
รูปแบบคือ key หนึ่งตัวต่อผู้ใช้หนึ่งคน (หรือ set ของ online user id) ที่ทุก instance อัปเดตเมื่อ client เข้ามาและจากไป
// A shared store every instance can read and write (e.g. Redis).// `store` stands in for a Redis client here.
async function markOnline(userId: string) { // Record presence with a short expiry — see heartbeats below. await store.set('presence:' + userId, '1', { expireSeconds: 30 });}
async function isOnline(userId: string): Promise<boolean> { return (await store.get('presence:' + userId)) !== null;}คุณไว้ใจให้ client บอกลาไม่ได้
หัวข้อที่มีชื่อว่า “คุณไว้ใจให้ client บอกลาไม่ได้”close frame ที่สะอาดตอน logout คือเส้นทางในอุดมคติ แต่โลกความเป็นจริงนั้นยุ่งเหยิงกว่านั้น: แล็ปท็อปเข้าโหมด sleep, โทรศัพท์สัญญาณหลุด, tunnel ล่ม ในทุกกรณีเหล่านั้น socket ตายเงียบ ๆ — server ของคุณอาจเก็บ object ของ connection ที่ตายแล้วไว้นาน และ shared store ก็จะรายงานผีว่า “ออนไลน์” อย่างยินดี
ทางแก้คืออย่าปฏิบัติต่อ presence ว่าเป็นสิ่งถาวร มีกลไกสองอย่างที่ทำงานร่วมกัน:
- Heartbeat client (หรือ server) ส่ง ping เป็นระยะ แต่ละ heartbeat จะ รีเฟรช presence key โดยเลื่อนเวลาหมดอายุออกไปข้างหน้า
- การหมดอายุ presence key มี TTL สั้น ๆ — สมมติ 30 วินาที ตราบใดที่ heartbeat ยังเข้ามาเรื่อย ๆ key ก็ไม่มีวันหมดอายุ แต่ถ้า client หายไป heartbeat หยุด key ก็หมดอายุเอง แล้วผู้ใช้ตกออฟไลน์อัตโนมัติโดยไม่ต้องบอกลา
นี่คือแนวคิดหลัก: presence หมดอายุโดยค่าเริ่มต้นและถูกทำให้มีชีวิตอยู่ด้วยกิจกรรม แทนที่จะถูกตั้งค่าอย่างชัดเจนและถูกล้างอย่างชัดเจน การ crash เพียงแค่หยุดการรีเฟรช
sequenceDiagram
participant C as Client
participant I as Instance
participant S as Shared store (TTL 30s)
C->>I: connect
I->>S: SET presence:u1, TTL 30s
loop every 10s while alive
C->>I: heartbeat (ping)
I->>S: refresh TTL -> 30s
end
Note over C,I: laptop sleeps — socket dies silently
Note over S: no refresh arrives
S-->>S: key expires after 30s -> u1 offline event การ join และ leave
หัวข้อที่มีชื่อว่า “event การ join และ leave”presence ไม่ใช่แค่รายการที่คุณ query เท่านั้น ผู้คนอยากเห็นการเปลี่ยนแปลง — “Sam เข้าร่วม”, “Dana ออกไป” event เหล่านั้นวิ่งบน backplane เดียวกันจากบทเรียนก่อนหน้า
เมื่อ shared store เปลี่ยนสถานะผู้ใช้ระหว่างออนไลน์และออฟไลน์ instance จะ publish presence event ไปยัง channel แล้วทุก instance ก็ fan-out ต่อไปยัง client ภายในของตัวเอง — เหมือน message แชทเป๊ะ ๆ
// On a real connect (the store had no key before):async function onConnect(userId: string) { const wasOffline = !(await isOnline(userId)); await markOnline(userId); if (wasOffline) { bus.publish('presence:events', JSON.stringify({ type: 'join', userId })); }}
// Leave events come from expiry or a clean close, published the same way:// bus.publish('presence:events', JSON.stringify({ type: 'leave', userId }))การเช็ค wasOffline มีความสำคัญ: ผู้ใช้ที่มีสองแท็บควรยิง join ครั้งเดียว ไม่ใช่สองครั้ง การนับจำนวน connection ต่อผู้ใช้ (counter เล็ก ๆ ใน store) ช่วยให้ join/leave ซื่อสัตย์เมื่อคนคนเดียวกันถือ socket หลายตัว
reconnect stampede (thundering herd)
หัวข้อที่มีชื่อว่า “reconnect stampede (thundering herd)”ตอนนี้มาถึงจุดยุ่งยากที่ทำให้ทีมตั้งตัวไม่ทัน เมื่อ instance restart — ตอน deploy, crash, หรือ autoscaler scale in — client ทุกตัว ที่ instance นั้นถือไว้จะหลุดพร้อมกันในวินาทีเดียว แล้ว reconnect ภายในหนึ่งถึงสองวินาที การ reconnect พร้อมกันหลายพันครั้งหมายถึง:
- handshake และ auth check ใหม่พุ่งขึ้นบน instance ที่รอดมาได้
- presence write และ join event พุ่งเข้าชน shared store พร้อมกันทั้งหมด
- อาจเกิด cascade: instance ที่รอดมาทรุดลงใต้แรงพุ่ง แล้วตัด client ของตัวเอง ทิ้ง client ชุดนั้นก็ reconnect ต่อ วนไปเรื่อย ๆ
นี่คือ thundering herd การป้องกันคือการกระจายฝูงออกไปตามเวลา:
- jittered reconnect backoff client รอ delay แบบสุ่มก่อน reconnect (เช่น 0–5 วินาที จากนั้นโตแบบ exponential) เพื่อไม่ให้ทุกตัวกลับมาพร้อมกันใน tick เดียว
- connection rate limiting ที่ server หรือ balancer เพื่อจำกัดความเร็วในการรับ socket ใหม่
- coalesced presence writes จับกลุ่มหรือ debounce กระแสน้ำของ presence update แทนที่จะ round-trip หนึ่งครั้งต่อ reconnect
flowchart TB
R["Instance restarts"] --> D["All its clients drop at once"]
D --> Q{"Reconnect strategy?"}
Q -- "immediate (no jitter)" --> H["Synchronized stampede -> store + survivors overload"]
Q -- "random jittered backoff" --> S["Reconnects spread over time -> smooth recovery"] | Presence Approach | ความแม่นยำ | Scale |
|---|---|---|
| Count local connections | ต่ำ — เห็นเฉพาะ instance ตัวเอง | ไม่ scale ข้าม instance |
| Shared store (Redis) + TTL | สูง — ทุก instance เห็นตรงกัน | scale ข้าม instance |
| Heartbeat + TTL (no explicit offline) | สูง — crash ก็ clean ขึ้นเอง | ดีที่สุดสำหรับ production |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Mark Presence แต่ไม่ใช้ TTL อาการ:
- user crash → presence key ค้างใน Redis ตลอดกาล
- รายชื่อ “Online” แสดง user ที่จริง offline ไปนานแล้ว
- ใช้ TTL เสมอ (30-60 วินาที) + heartbeat refresh — ให้ silence ทำ cleanup ให้
ไม่ Coalesce Presence Write เมื่อ Stampede อาการ:
- instance restart → client 10,000 ตัว reconnect พร้อมกัน → Redis โดนกระหน่ำ write พร้อมกัน
- Redis latency พุ่ง, shared store ล่ม
- debounce หรือ batch presence write ในช่วง reconnect surge
💡 ตัวอย่างจากของจริง
Slack:
- presence เก็บใน distributed store + TTL
- “Active” status รีเฟรชทุก 30 วินาที — ไม่มี explicit “offline” event
Figma:
- แสดง cursor และ user ที่ active ใน document แบบ real-time
- presence key expire หลัง idle — cursor หายเองเมื่อ user ปิด tab โดยไม่แจ้ง