ข้ามไปยังเนื้อหา

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;
}

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
heartbeat รีเฟรช presence key ที่มีอายุสั้น ความเงียบปล่อยให้ key หมดอายุ

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 หลายตัว

ตอนนี้มาถึงจุดยุ่งยากที่ทำให้ทีมตั้งตัวไม่ทัน เมื่อ 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"]
การ restart กระตุ้นให้เกิด reconnect ที่ synchronized กัน jitter ช่วยกระจายออกไป
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 โดยไม่แจ้ง
ทำไม presence key จึงใช้ TTL สั้น ๆ ที่รีเฟรชด้วย heartbeat แทนที่จะตั้งค่าครั้งเดียวแล้วล้างตอน logout?
ทำไมจึงต้องเช็คว่าผู้ใช้เคยออฟไลน์มาก่อนหรือไม่ ก่อนที่จะยิง join event?
อะไรคือการป้องกัน reconnect thundering herd ที่มีประสิทธิภาพที่สุด?