Sticky vs Stateless
คุณมี backplane ที่ fan-out message และ shared store ที่ติดตาม presence แล้ว เหลือคำถามเดียว และคนตอบคือ load balancer: เมื่อ client เชื่อมต่อ — หรือ reconnect — instance ไหนควรรับไปดูแล? มีสองปรัชญา และแต่ละแบบผลัก architecture ของคุณไปคนละทิศ
สองคำตอบสำหรับ “instance ไหน?”
หัวข้อที่มีชื่อว่า “สองคำตอบสำหรับ “instance ไหน?””- 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 handshake ที่ balancer ต้องไม่ทำพัง
หัวข้อที่มีชื่อว่า “handshake ที่ balancer ต้องไม่ทำพัง”ไม่ว่าคุณจะเลือกโมเดลไหน balancer มีงานหนึ่งที่ต่อรองไม่ได้: WebSocket เริ่มต้นชีวิตเป็น HTTP request ที่พก header พิเศษมา และ upgrade นั้นต้องรอดผ่าน proxy ไปได้
WebSocket handshake คือ HTTP GET ที่มี:
Connection: UpgradeUpgrade: 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}L4 กับ L7
หัวข้อที่มีชื่อว่า “L4 กับ L7”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
stickiness ถูกปักหมุดอย่างไรจริง ๆ
หัวข้อที่มีชื่อว่า “stickiness ถูกปักหมุดอย่างไรจริง ๆ”- 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 ได้ถูกต้อง
trade-off
หัวข้อที่มีชื่อว่า “trade-off”| sticky session | stateless | |
|---|---|---|
| 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 แพงจริง ๆ
| Approach | State ต่อ client | Deploy | ต้องการ 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