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

Sticky vs Stateless

คุณมี backplane ที่ fan-out message และ shared store ที่ติดตาม presence แล้ว เหลือคำถามเดียว และคนตอบคือ load balancer: เมื่อ client เชื่อมต่อ — หรือ reconnect — instance ไหนควรรับไปดูแล? มีสองปรัชญา และแต่ละแบบผลัก architecture ของคุณไปคนละทิศ

  • sticky session load balancer ปักหมุด client แต่ละตัวไว้กับ instance หนึ่งและ route กลับไปที่นั่นทุกครั้ง instance จึงเก็บ state ที่มีความหมายไว้ใน memory ได้ เพราะ client ตัวเดิมกลับมาที่เดิมเสมอ
  • stateless instance ใดก็ได้สามารถให้บริการ client ใดก็ได้ ไม่มี instance ใดถือ state ที่มีความสำคัญ — ทุกสิ่งที่สำคัญอยู่ใน shared store และวิ่งบน backplane — ดังนั้น balancer จึงอิสระที่จะ route connection ไปที่ไหนก็ได้
flowchart TB
  subgraph sticky["Sticky sessions"]
    ls["Load balancer (affinity)"]
    ls -- "client X -> always" --> sa["Instance A (holds X's state)"]
    ls -. "never" .-x sb["Instance B"]
  end
  subgraph stateless["Stateless"]
    lt["Load balancer (any instance)"]
    lt --> ta["Instance A"]
    lt --> tb["Instance B"]
    ta <--> store[["Shared store + backplane"]]
    tb <--> store
  end
sticky ปักหมุด client ไว้กับ instance เดียว ส่วน stateless route ที่ไหนก็ได้ผ่าน shared state

ไม่ว่าคุณจะเลือกโมเดลไหน balancer มีงานหนึ่งที่ต่อรองไม่ได้: WebSocket เริ่มต้นชีวิตเป็น HTTP request ที่พก header พิเศษมา และ upgrade นั้นต้องรอดผ่าน proxy ไปได้

WebSocket handshake คือ HTTP GET ที่มี:

  • Connection: Upgrade
  • Upgrade: websocket

balancer หรือ proxy ที่ตัดหรือเพิกเฉยต่อ header เหล่านี้จะเปลี่ยน handshake ให้กลายเป็น HTTP request ธรรมดา และ connection ล้มเหลวก่อนที่ข้อความใดจะถูกส่ง มีผลตามมาในทางปฏิบัติสองอย่าง:

  • proxy ต้องทำงานในโหมดที่ forward การ upgrade แทนที่จะปิด connection เหมือน HTTP ธรรมดา
  • WebSocket ไม่มี request หลัง handshake ดังนั้น idle/read timeout ต้องยาว (หรือปิดไว้) — มิฉะนั้น proxy จะปิด connection ที่สุขภาพดีอย่างสมบูรณ์แต่เงียบอยู่
# nginx: forward the upgrade and don't time out a quiet socket.
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # pass the Upgrade header
proxy_set_header Connection "upgrade"; # pass the Connection header
proxy_read_timeout 3600s; # tolerate long idle gaps
}

balancer ทำงานอยู่ตรงไหนของ stack เป็นตัวกำหนดว่าทำอะไรได้บ้าง

  • L4 (transport / TCP) balancer forward raw TCP และไม่เคยอ่าน HTTP เลย มองไม่เห็น header หรือ cookie จึงเร็วและปลอดภัยต่อ upgrade โดยธรรมชาติ — ไม่มี HTTP layer ให้ทำพัง affinity ทำโดย client IP หรือ connection ถูกและเรียบง่าย แต่ตาบอดต่อทุกอย่างที่อยู่เหนือ TCP
  • L7 (application / HTTP) balancer เข้าใจ HTTP จึง route ตาม path, ตั้ง cookie และอ่าน upgrade header ได้ พลังนั้นก็คือความเสี่ยงด้วย: L7 proxy ที่ตั้งค่าไม่รองรับ WebSocket คือตัวเดียวกับที่ตัด upgrade ทิ้ง ตั้งถูก L7 ให้ stickiness แบบ cookie และการควบคุมระดับ route ตั้งผิด handshake ก็พังเงียบ ๆ

รูปแบบที่พบบ่อยคือ L4 สำหรับ throughput ดิบและความเรียบง่าย หรือ L7 ที่ตั้งค่าถูกต้องเมื่อคุณต้องการ cookie affinity หรือ routing ตาม path

  • IP hash (L4 หรือ L7) route ตาม hash ของ client IP เรียบง่าย แต่ mobile client เปลี่ยน IP และผู้ใช้จำนวนมากอาจซ่อนอยู่หลัง NAT เดียว ทำให้ load เบี้ยว
  • cookie affinity (L7) balancer ตั้ง cookie ที่ระบุชื่อ instance แล้วอ่าน cookie นั้นตอน reconnect แม่นยำกว่า IP แต่ต้องใช้ L7 proxy ที่จัดการ upgrade ได้ถูกต้อง
sticky sessionstateless
in-memory state ต่อ clientอนุญาต — client กลับมาที่ instance เดิมหลีกเลี่ยง — อยู่ใน shared store
สูญหายเมื่อ instance ตายlocal state ของ client นั้นไม่มีอะไรที่เฉพาะกับ instance
การ rebalance loadยาก — client ถูกปักหมุดง่าย — route ที่ไหนก็ได้
deploy / autoscalingปั่นป่วนต่อ client ที่ถูกปักหมุดราบรื่น
ต้องการ backplane สำหรับ broadcastใช่ใช่

สรุปอย่างตรงไปตรงมา: stickiness ซื้อความสะดวกในการไว้ใจ in-memory state ให้คุณ โดยแลกกับความยืดหยุ่นและการ fail อย่างนุ่มนวล ส่วน stateless ทำให้คุณเสียมากกว่าตั้งแต่ต้น — ทุกข้อเท็จจริงที่มีความหมายต้องไปอยู่ใน shared store และ backplane — แต่ scale, deploy และ recover ได้ราบรื่นกว่ามาก

สังเกตว่า backplane จำเป็นในคอลัมน์ ทั้งสอง stickiness route client กลับ ไปยัง instance เดิม แต่ไม่ได้ช่วยให้ instance A ไปถึง client บน instance B เลย stickiness ว่าด้วยการ reconnect และ local state ไม่ใช่ตัวแทนของ cross-instance fan-out สำหรับระบบ real-time สมัยใหม่ส่วนใหญ่ ค่าเริ่มต้นที่แนะนำคือ stateless instance บวกกับ backplane และ shared store แล้วเก็บ stickiness ไว้ใช้เฉพาะกรณีที่การสร้าง state ต่อ client ขึ้นใหม่ทุกครั้งที่ reconnect แพงจริง ๆ

ApproachState ต่อ clientDeployต้องการ Backplane
Sticky Sessionอยู่ใน instance memoryปั่นป่วน — client ของ instance ที่ restart หลุดใช่ — สำหรับ cross-instance
Statelessอยู่ใน shared storeราบรื่น — instance ใดก็ได้ serve ได้ใช่ — เสมอ

ใช้ IP Hash Sticky Session กับ Mobile Client อาการ:

  • mobile เปลี่ยน IP เมื่อสลับ network — ไม่กลับ instance เดิม
  • in-memory state หาย — user ต้อง re-authenticate หรือ state reset
  • ใช้ cookie-based affinity (L7) หรือ stateless architecture แทน

ไม่ตั้ง proxy_read_timeout สำหรับ WebSocket อาการ:

  • nginx หรือ ALB timeout idle WebSocket connection ทุก 60 วินาที
  • user ที่ idle (ไม่พิมพ์) โดน disconnect โดยไม่รู้ตัว
  • ตั้ง proxy_read_timeout 3600s และ implement heartbeat ฝั่ง client

💡 ตัวอย่างจากของจริง

Slack (Legacy RTM API, deprecated):

  • เคยใช้ sticky session — client ถูก pin ไว้กับ gateway instance เดิม เพื่อลด handshake overhead เมื่อ reconnect บ่อย ๆ

หลักการทั่วไปของ chat platform ขนาดใหญ่ (แนวเดียวกับที่ live-chat service ใช้):

  • ใช้ stateless WebSocket server ร่วมกับ pub/sub backplane (เช่น Redis) แทน sticky session — instance ไหนก็รับ subscribe message จาก channel เดียวกันได้ ทำให้ autoscale ระหว่าง event ใหญ่ได้ง่ายกว่า
load balancer ต้องทำอะไรเพื่อให้ WebSocket handshake สำเร็จ?
ทำไม stateless instance จึงมักจะ deploy และ rebalance ได้ง่ายกว่า sticky?
ทำไมจึงยังต้องการ backplane แม้ว่าคุณจะใช้ sticky session?