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

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:

  • ใช้ sticky session สำหรับ RTM API — client ถูก pin ไว้กับ gateway instance
  • ลด handshake overhead เมื่อ reconnect บ่อย ๆ

Twitch Chat:

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