ข้ามไปยังเนื้อหา
กลับไปหน้าบทความ
การพัฒนาซอฟต์แวร์

WebSocket SSE และ Polling ต่างกันอย่างไร ควรเลือกแบบไหน

Polling ส่ง request ใหม่ทุกรอบ SSE เปิด response ค้างให้ server ส่ง event ทางเดียว WebSocket upgrade แล้วส่ง frame ได้สองทาง พร้อมเรื่อง reconnect และ proxy

ปานกลาง9 นาทีในการอ่าน

ในบทความนี้

ตอบสั้น ๆ ก่อนครับ ถ้าข้อมูลไหลจาก server มาที่ browser ทางเดียว เช่น สถานะงาน การแจ้งเตือน หรือค่าจาก sensor ให้เริ่มจาก SSE (Server-Sent Events) ถ้าทั้งสองฝั่งต้องส่งหากันบ่อย ๆ เช่น แชตหรือเอกสารที่แก้พร้อมกัน ให้ใช้ WebSocket ส่วน polling เหมาะเมื่อข้อมูลเปลี่ยนไม่บ่อยและยอมให้ช้าได้หลายวินาที หรือเมื่อโครงสร้างระหว่างทางไม่รองรับ connection ที่เปิดค้างนาน

สามแบบนี้ต่างกันที่หน้าตาของ connection บน wire และความต่างนั้นส่งผลถึง proxy, load balancer และ firewall ที่อยู่ระหว่างทางด้วย บทความนี้จะดูทีละแบบว่า request และ response จริงหน้าตาเป็นอย่างไร หลุดแล้วต่อใหม่อย่างไร แล้วจบด้วยสิ่งที่ต้องตั้งค่าที่ตัวกลางครับ

Polling ส่ง GET ใหม่ทุกไม่กี่วินาทีและได้ 204 เป็นส่วนใหญ่ SSE เปิด response ค้างไว้แล้ว server เขียน event ลงมา ส่วน WebSocket upgrade ครั้งเดียวแล้วส่ง frame ได้ทั้งสองทาง

Polling ทำงานอย่างไร

polling คือ client ส่ง HTTP request ไปถามเป็นระยะว่ามีอะไรใหม่ไหม RFC 6202 (เมษายน พ.ศ. 2554) แบ่งไว้สองแบบ

short polling client ส่ง request ทุกช่วงเวลาที่กำหนด ถ้าไม่มีข้อมูลใหม่ server ตอบกลับเป็น response ว่าง แล้ว client รอรอบถัดไป ปัญหาคือ resource ที่ใช้ทั้งฝั่ง server และ network ขึ้นกับว่ายอมให้ข้อมูลช้าได้แค่ไหน ถ้าต้องการให้ช้าไม่เกินไม่กี่วินาที ก็ต้องถี่ขึ้นจน server รับไม่ไหวได้

GET /api/updates?since=1041 HTTP/1.1
Host: app.example.com

HTTP/1.1 204 No Content

long polling server ไม่ตอบทันที แต่ถือ request ไว้จนกว่าจะมีข้อมูลใหม่หรือหมดเวลา เมื่อ client ได้ response ก็มักส่ง request ใหม่ทันที ผลคือ server มี request ค้างไว้รอตอบอยู่เกือบตลอด

RFC 6202 ระบุข้อเสียของ long polling ไว้หลายข้อ ข้อที่เจอบ่อยมีสามข้อ

ข้อดีของ polling คือมันเป็น HTTP ธรรมดาทุกประการ RFC 6202 บอกว่า proxy ส่วนใหญ่ทำงานกับ long polling ได้ดี เพราะ response ที่ส่งออกไปเป็น response ที่สมบูรณ์ทุกครั้ง

SSE ทำงานอย่างไร

SSE คือ client ส่ง GET ครั้งเดียว แล้ว server ตอบด้วย content type text/event-stream และไม่ปิด response แต่เขียน event ต่อท้ายไปเรื่อย ๆ ฝั่ง browser ใช้ interface EventSource ซึ่งกำหนดไว้ใน HTML Standard ของ WHATWG

const source = new EventSource("/api/events");
source.addEventListener("job", (e) => console.log(e.lastEventId, e.data));

response บน wire หน้าตาแบบนี้

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store

: keep-alive

id: 1042
event: job
data: {"job": 7, "state": "running"}

id: 1043
event: job
data: {"job": 7, "state": "done"}

กฎของรูปแบบนี้มีไม่กี่ข้อ (WHATWG, 9.2.6)

บรรทัด ความหมาย
data: ข้อมูลของ event ถ้ามีหลายบรรทัด จะต่อกันโดยคั่นด้วยขึ้นบรรทัดใหม่
event: ชื่อ event ถ้าไม่ใส่ จะเป็น event ชื่อ message
id: ตั้ง last event ID สำหรับใช้ตอน reconnect
retry: ตั้งเวลารอก่อน reconnect เป็นมิลลิวินาที
บรรทัดที่ขึ้นต้นด้วย : comment ระบบไม่สนใจ
บรรทัดว่าง ส่ง event ที่สะสมไว้ออกไป

stream ต้องเข้ารหัสเป็น UTF-8 เสมอ จึงส่งข้อมูล binary ตรง ๆ ไม่ได้ และ SSE เป็นconnection ทางเดียว ถ้า client ต้องส่งอะไรกลับ ต้องส่งเป็น HTTP request แยก เช่น POST ธรรมดาครับ

WebSocket ทำงานอย่างไร

WebSocket เริ่มจาก HTTP request ที่ขอเปลี่ยน protocol แล้วหลังจากนั้นไม่ใช่ HTTP อีกต่อไป RFC 6455 (ธันวาคม พ.ศ. 2554) ยกตัวอย่าง handshake ไว้ดังนี้ (RFC 6455):

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Version: 13

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

server คำนวณ Sec-WebSocket-Accept โดยเอาค่า key ต่อด้วย GUID คงที่ 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 แล้ว hash ด้วย SHA-1 และเข้ารหัส base64 เพื่อยืนยันว่า server เข้าใจ WebSocket จริง RFC เขียนไว้ชัดว่า WebSocket เป็นprotocol อิสระที่วิ่งบน TCP และเกี่ยวกับ HTTP แค่ตรงที่ handshake ถูกตีความเป็น Upgrade request

หลัง 101 ข้อมูลส่งเป็น frame แต่ละ frame มี opcode บอกชนิด เช่น 0x1 text, 0x2 binary, 0x8 close, 0x9 ping และ 0xA pong (RFC 6455, 5.2) และทั้งสองฝั่งส่งได้ตลอดเวลาบน TCP connection เดียว

มีรายละเอียดหนึ่งที่คนทำ network ควรรู้ client ต้อง mask ทุก frame ที่ส่งไป server ด้วยค่า 32 bit ที่สุ่มใหม่ทุก frame และ server ต้องไม่ mask frame ที่ส่งกลับ เหตุผลคือระหว่างที่ร่าง protocol มีการทดลองที่แสดงว่าทำ cache poisoning กับ proxy ที่ใช้งานจริงได้ ด้วยการส่งข้อมูลที่หน้าตาเป็น GET request ผ่าน connection ที่ upgrade แล้ว การ mask ทำให้ script ฝั่งผู้โจมตีกำหนดไม่ได้ว่าไบต์บน wire จะออกมาเป็นอะไร แต่ key อยู่ใน frame เอง ใครอ่าน frame ได้ก็ unmask ได้ การ mask จึงไม่ใช่การเข้ารหัส ความลับมาจาก TLS ของ wss:// ซึ่งใช้ port 443 เป็นค่าเริ่มต้น

ฝั่ง browser มีข้อจำกัดที่ควรรู้สองข้อ interface WebSocket ไม่รองรับ backpressure ถ้าข้อความมาเร็วกว่าที่แอปประมวลผลทัน ข้อความจะไปกองใน memory และหน้าที่มี WebSocket เปิดอยู่ browser อาจไม่เก็บลง bfcache จึงควรปิด connection เมื่อผู้ใช้ออกจากหน้า

หลุดแล้วต่อใหม่อย่างไร

SSE ต่อใหม่ให้เอง WebSocket ต้องเขียนเองครับ ข้อนี้เป็นความต่างที่ผมคิดว่าสำคัญที่สุดในงานจริง

SSE: ถ้า connection หลุด browser จะ reconnect เอง หลังรอตาม reconnection time ซึ่งค่าเริ่มต้นขึ้นกับ browser น่าจะอยู่ที่ไม่กี่วินาที และ server เปลี่ยนได้ด้วย retry: ตอนต่อใหม่ browser ส่ง header Last-Event-ID ที่มีค่า id: ล่าสุดที่ได้รับไปด้วย

GET /api/events HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 1043

server ที่เก็บ event ไว้สักช่วงจึงส่งต่อจาก 1044 ได้ โดยไม่ต้องส่งซ้ำทั้งหมด มีสองกรณีที่ browser หยุดต่อใหม่ คือ server ตอบ 204 No Content และกรณี response ไม่ใช่ 200 หรือ content type ไม่ใช่ text/event-stream ซึ่งถือว่า fail และจะไม่พยายามต่ออีก ข้อหลังนี้เจอได้เมื่อ proxy ตอบ 502 กลับมาครั้งเดียว stream ก็หยุดถาวร แอปควรดัก event error แล้วเปิด EventSource ใหม่เองเมื่อ readyState เป็น CLOSED

WebSocket: RFC 6455 ไม่ได้กำหนดกลไกแบบ Last-Event-ID ไว้ ถ้าต้องการส่งต่อจากข้อความล่าสุด ต้องออกแบบใน protocol ของแอปเอง เช่น ให้ client ส่งเลขข้อความล่าสุดเป็นข้อความแรกหลังต่อใหม่ เรื่องจังหวะการต่อใหม่ RFC กำหนดว่า client ควรใช้ backoff โดยครั้งแรกควรสุ่มรอ ซึ่งช่วงระหว่าง 0 ถึง 5 วินาทีเป็นค่าเริ่มต้นที่สมเหตุสมผล และครั้งต่อ ๆ ไปรอนานขึ้นเรื่อย ๆ เช่น truncated binary exponential backoff เหตุผลคือถ้า server ล่มแล้ว client ทุกตัวต่อใหม่ทันทีพร้อมกัน ผลจะไม่ต่างจาก denial-of-service

Polling: ไม่มี connection ให้หลุด request ถัดไปคือการต่อใหม่อยู่แล้ว แต่ควรส่ง cursor อย่าง since= ไปทุกครั้งเพื่อไม่ให้พลาดข้อมูลระหว่างรอบ

ข้อมูลไหลได้ทางไหนบ้าง

สรุปเทียบกันเป็นตารางครับ

Polling SSE WebSocket
ทิศทาง client ถาม server ตอบ server ไป client ทั้งสองทาง
connection request ใหม่ทุกรอบ response เดียวที่เปิดค้าง TCP connection เดียวหลัง upgrade
รูปแบบข้อมูล อะไรก็ได้ที่ HTTP ส่งได้ text UTF-8 text หรือ binary frame
reconnect ทุกรอบคือการต่อใหม่ browser ทำเองพร้อม Last-Event-ID แอปเขียนเอง
ตัวกลางมองเห็น HTTP ปกติ HTTP response ที่ยาวมาก HTTP แค่ตอน handshake

proxy, load balancer และ firewall มองแต่ละแบบอย่างไร

ตัวกลางมองสามแบบนี้ต่างกันมาก polling เป็น HTTP ปกติ SSE เป็น HTTP response ที่ไม่จบ ส่วน WebSocket เป็น HTTP แค่ตอนเริ่ม แล้วกลายเป็น protocol อื่นบน TCP connection เดิม ปัญหาที่เจอส่วนใหญ่มาจากเรื่องต่อไปนี้ครับ

idle timeout

ตัวกลางส่วนใหญ่ตัด connection ที่เงียบนานเกินไป nginx เป็นตัวอย่างที่ชัด เอกสารบอกว่าถ้า backend ไม่ส่งข้อมูลภายใน 60 วินาที connection จะถูกปิด โดยค่าเริ่มต้น แก้ได้ด้วย proxy_read_timeout หรือให้ backend ส่ง ping frame เป็นระยะ

ทางแก้ของแต่ละแบบ

buffering

SSE ใช้ได้ก็ต่อเมื่อแต่ละ event ไปถึง browser ทันที แต่ reverse proxy อาจ buffer response ไว้ก่อน nginx เปิด proxy_buffering เป็นค่าเริ่มต้น ผลคือ event ค้างอยู่ที่ proxy จนกว่า buffer จะเต็ม ปิดได้ทั้งใน config หรือให้ backend ส่ง header X-Accel-Buffering: no มากับ response ของ stream นั้น HTML Standard ก็เตือนเรื่องเดียวกันว่า chunking ที่ทำโดย layer ที่ไม่รู้เรื่องจังหวะเวลาอาจทำให้ SSE ไม่น่าเชื่อถือ

Upgrade header

Upgrade และ Connection เป็น hop-by-hop header ซึ่ง reverse proxy ไม่ส่งต่อให้ backend โดยอัตโนมัติ ถ้าไม่ตั้งค่า backend จะเห็นแค่ GET ธรรมดาและไม่ upgrade ใน nginx ต้องส่งต่อเอง:

location /ws/ {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 300s;
}

location /api/events {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_read_timeout 300s;
}

อีกเรื่องที่ตามมาคือ connection แบบ WebSocket หรือ SSE หนึ่งเส้นผูกกับ backend ตัวเดียวไปตลอดอายุ connection ถ้า load balancer ย้าย client ไปที่ backend อื่นได้แค่ตอนต่อใหม่ การ deploy หรือปิด backend หนึ่งตัวจึงทำให้ client ทั้งหมดของตัวนั้นต่อใหม่พร้อมกัน ซึ่งเป็นอีกเหตุผลที่ต้องมี backoff ครับ

จำนวน connection และ HTTP/2

ถ้าไม่ได้ใช้ HTTP/2 SSE เจอขีดจำกัดจำนวน connection ของ browser ที่ 6 connection ต่อ browser ต่อ domain รวมทุก tab ถ้าแต่ละ tab เปิด SSE หนึ่งเส้น tab ที่เจ็ดของเว็บเดียวกันจะต่อไม่ได้ บน HTTP/2 จำนวน stream พร้อมกันเป็นค่าที่สองฝั่งตกลงกัน และค่าเริ่มต้นคือ 100 HTML Standard ยังเสนอให้ใช้ EventSource ตัวเดียวร่วมกันผ่าน shared workerได้ด้วย

WebSocket แบบเดิมใช้ TCP connection ของตัวเอง ส่วน RFC 8441 (กันยายน พ.ศ. 2561) กำหนดวิธีรัน WebSocket บน stream เดียวของ HTTP/2 connection ผ่าน extended CONNECT ซึ่ง server ต้องประกาศ SETTINGS_ENABLE_CONNECT_PROTOCOL ก่อน

firewall และการตรวจ traffic

ฝั่ง security มีสองเรื่องที่ผมเช็กเสมอ

  • อุปกรณ์ที่ตรวจ HTTP เห็นอะไร polling และ SSE เป็น HTTP ตลอดทาง policy ที่อิง URL, method หรือ header ใช้ได้ตามปกติ ส่วน WebSocket หลัง 101 เป็น frame ของ protocol อื่น อุปกรณ์ที่อ่านแค่ HTTP จะเห็นแค่ handshake และถ้าเป็น wss:// ทั้งหมดอยู่ใน TLS ต้อง decrypt ก่อนถึงจะเห็นแม้แต่ handshake
  • ตรวจ Origin ที่ handshake script บนเว็บไหนก็ได้ที่ browser เปิดอยู่สามารถเปิด WebSocket ไปหา server ของเราได้ RFC จึงกำหนดว่า server ที่ไม่ได้ตั้งใจรับ input จากทุกเว็บควรตรวจว่า Origin เป็นค่าที่คาดไว้ และตอบ 403 Forbidden ถ้าไม่ใช่

ควรเลือกแบบไหน

ไล่คำถามตามลำดับนี้ครับ

  1. client ต้องส่งข้อมูลถี่ ๆ ระหว่างที่รับข้อมูลอยู่ด้วยหรือไม่ ถ้าใช่ (แชต, เกม, แก้เอกสารพร้อมกัน) ใช้ WebSocket และเตรียมเขียน reconnect, backoff และการส่งต่อจากข้อความล่าสุดเอง
  2. ข้อมูลไหลจาก server ทางเดียวและเป็น text หรือไม่ ถ้าใช่ ใช้ SSE ได้ reconnect และ Last-Event-ID มาฟรี และยังเป็น HTTP ที่ตัวกลางเข้าใจ อย่าลืมปิด buffering และส่ง comment keep-alive
  3. ข้อมูลเปลี่ยนนาน ๆ ครั้ง หรือ client หลายตัวอยู่หลัง proxy ที่คุมไม่ได้หรือไม่ ถ้าใช่ polling ง่ายที่สุด ทดสอบง่าย และ cache กับ log ได้แบบ HTTP ทั่วไป
  4. ระหว่างทางมี proxy, load balancer หรือ firewall อะไรบ้าง เช็ก idle timeout, buffering และการส่งต่อ Upgrade ของทุกตัวก่อนเลือก ทั้งสามแบบทำงานได้ที่ปลายทางทั้งสองฝั่ง แต่ตัวกลางตัวเดียวที่ตั้งค่าไม่ตรงก็ทำให้ connection หลุดหรือ event ค้างได้

ไม่ว่าเลือกแบบไหน ให้ข้อความทุกชิ้นมีเลขลำดับ แล้ว client ส่งเลขล่าสุดที่ได้ไปทุกครั้งที่ต่อใหม่หรือถามใหม่ สามแบบนี้หลุดได้ทั้งหมด และเลขลำดับคือสิ่งที่ทำให้ไม่พลาดข้อมูลครับ

แหล่งอ้างอิง

อ่านต่อ