WebSocket提供持续的双向消息通道;SSE在HTTP响应中发送文本事件。两者都能让服务端主动发送数据,选择关键在于交互方向、消息类型、恢复语义及基础设施支持,而不是“谁能推送”。
例如订单页面主要接收状态变化,客户端修改订单仍用普通HTTP,SSE就能覆盖需求;协同编辑需要频繁双向操作和自定义消息协议,WebSocket更自然。无论选哪种,断线后恢复和慢消费者处理都需要应用设计。
| 问题 | 查阅重点 |
| WebSocket是否一定返回101 | HTTP/1.1 Upgrade与h2/h3扩展CONNECT |
| send一次是否等于一个网络包 | 消息、帧与传输分段 |
| mask能否保护秘密 | 掩码不是加密,保密依赖TLS等 |
| Pong是否证明应用任务健康 | 协议栈存活与业务处理分开 |
| SSE重连是否保证不丢事件 | ID、服务端日志和消费确认 |
| 页面越来越卡、内存增长 | 发送队列、背压与消息合并 |
WebSocket 建连:先确认协议,再传消息
HTTP/1.1握手使用GET、Upgrade与Connection字段,客户端提供随机16 B值的base64形式作为Sec-WebSocket-Key。服务端返回101与正确的Sec-WebSocket-Accept;子协议只能从客户端提供的候选中选择。
Accept计算是将Key字段的文本值与规定GUID拼接,再做SHA1和base64;不是先把Key解码成16 B再拼接。它证明服务端理解这次协议升级,不承担证书认证或保密职责。
import base64
import hashlib
key = 'dGhlIHNhbXBsZSBub25jZQ=='
guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
assert len(base64.b64decode(key)) == 16
accept = base64.b64encode(hashlib.sha1((key + guid).encode('ascii')).digest()).decode('ascii')
assert accept == 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
wrong = base64.b64encode(hashlib.sha1(base64.b64decode(key) + guid.encode('ascii')).digest()).decode('ascii')
assert accept != wrong
print('RFC 握手向量通过:', accept)
在HTTP/2、HTTP/3中,还可在双方支持的条件下使用扩展CONNECT建立WebSocket,成功响应为相应2xx而不是HTTP/1.1的101升级。承载的是单条HTTP流,不是把整条多路复用连接全部改成WebSocket。RFC 8441、RFC 9220
消息、帧和掩码
基础帧头包含FIN、扩展保留位、opcode、MASK和长度编码。长度0到125可直接表达,126表示后续16位长度,127表示后续64位长度且最高位需为0;存在掩码时再跟4 B键。
客户端发送给服务端的帧必须mask,服务端发出的帧不能mask,包括TLS内的WebSocket帧也遵守该规则。每帧需要新且不可预测的掩码键;下例固定键只用于复现标准教学向量。
payload = b'Hello'
mask = bytes.fromhex('37fa213d')
masked = bytes(v ^ mask[i % 4] for i, v in enumerate(payload))
wire = bytes([0x81, 0x80 | len(payload)]) + mask + masked
assert wire.hex() == '818537fa213d7f9f4d5158'
assert bytes(v ^ mask[i % 4] for i, v in enumerate(masked)) == payload
assert len(wire) == 11 assert 2 + len(payload) == 7 print('客户端 / 服务端短帧字节:', len(wire), 2 + len(payload))
XOR掩码不能阻止旁观者还原内容;它用于特定基础设施攻击防护,保密需要wss等传输保护。FIN结束的是消息分片序列,控制帧可插入其间;控制帧不可分片、payload不超过125 B。文本消息的UTF-8合法性在重组语义下检查,不能要求每个片段恰好在字符边界结束。RFC 6455:帧与分片
心跳、关闭与业务确认
Ping/Pong检查协议通道是否有回应。Pong按要求回应Ping数据,但WebSocket库可以在应用线程繁忙时独立处理它,所以Pong不保证数据库可用、房间订阅仍有效或业务消息已提交。
浏览器WebSocket API不提供直接发送协议Ping的JavaScript方法。若需要应用心跳,可定义带序号与时间的业务消息,再由应用处理并回包;不要把一个JSON字符串叫ping就误认为它是opcode9控制帧。WebSockets API
关闭握手使用Close帧;有状态码时先写2 B网络序,再写可选UTF-8原因。1000表示正常关闭,1006用于本地表示异常关闭,不能作为Close帧状态码在线发送。关闭原因也不等于某条业务消息的最终结果。
应用确认应使用独立消息ID、提交结果或已处理序号。例如“操作17已接收”和“操作17已持久提交”是不同确认点;连接断开后应凭操作ID查询或重放,而不是只看上一次send是否返回。
SSE 是事件格式,不依赖 chunked
SSE响应使用Content-Type: text/event-stream,内容是UTF-8文本,字段行和空行形成事件。在HTTP/1.1中可使用chunked承载,在HTTP/2/3中由相应DATA流承载;不能定义成“必须使用HTTP chunked”。
| 行 | 含义 |
data: ... | 事件数据,多行以换行拼接 |
event: alert | 事件类型;未指定时为message |
id: 42 | 更新事件ID,供恢复位置使用 |
retry: 3000 | 以毫秒表达重连时间设置 |
: keepalive | 注释,不派发业务事件 |
| 空行 | 触发完整事件的分派处理 |
下面生成两个data行的事件和一条注释心跳。事件格式的空行不能靠结束TCP连接代替;最后一条没有完成分隔的内容不会被当作完整事件自动交付。
wire = ('id: 42\nevent: alert\ndata: first line\ndata: second line\n\n'
': keepalive\n\n').encode('utf-8')
first, heartbeat, trailing = wire.decode('utf-8').split('\n\n')
fields = {}
values = []
for line in first.splitlines():
name, value = line.split(':', 1)
value = value[1:] if value.startswith(' ') else value
if name == 'data':
values.append(value)
else:
fields[name] = value
assert fields == {'id': '42', 'event': 'alert'}
assert '\n'.join(values) == 'first line\nsecond line'
assert heartbeat.startswith(':') and trailing == ''
print('事件类型 / ID / 多行数据:', fields['event'], fields['id'], repr('\n'.join(values)))
这段算例只验证本例LF格式和字段拼接,不是完整EventSource解析器。真实格式还定义CRLF/CR、空id、未知字段及NUL等处理规则。HTML:事件流解析
重连与恢复:ID 不会自动生成历史日志
EventSource在可恢复断开后尝试重连,带上已记录的Last-Event-ID;服务器需要自己实现按ID补发。若日志只保留一小时、客户端离线两小时,应提供重新获取快照及继续订阅的明确流程,而不是静默从最新事件开始。
浏览器分派事件并更新位置,不等于业务消费者已持久提交。页面崩溃、重复补发、处理失败都可能需要应用去重和检查点;仅依赖EventSource无法提供exactly-once业务交付。重新创建EventSource对象也不会自动保留旧对象的恢复状态。
SSE注释可提供链路活动,自动重连本身不是心跳。服务器还可用204结束重连意图,客户端也可调用close;应用需要区分暂时断网与权限失效、服务永久停止等条件。HTML:EventSource
有 Last-Event-ID,为什么还要重新取快照?
先把游标放在保留起点之前两格,金色事件已经无法从日志取回。把游标移到起点前一格时,下一条恰好仍在日志里,才具备完整补发条件。实际系统还要解决事件消费与快照订阅之间的一致性。
正在呈现知识画面
有 Last-Event-ID,为什么还要重新取快照?游标只是恢复位置,补发能力来自实际保留的日志。缺失的一段已淘汰时,静默从最旧可见事件继续会遗漏变化。
{"id":"sse-replay-retention","title":"有 Last-Event-ID,为什么还要重新取快照?","summary":"游标只是恢复位置,补发能力来自实际保留的日志。缺失的一段已淘汰时,静默从最旧可见事件继续会遗漏变化。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"cursor\" data-i18n=\"cursor\"></label><output for=\"cursor\" id=\"cursor-value\"></output><input id=\"cursor\" type=\"range\" min=\"0\" max=\"12\" value=\"4\" step=\"1\"></div><div class=\"control\"><label for=\"oldest\" data-i18n=\"oldest\"></label><output for=\"oldest\" id=\"oldest-value\"></output><input id=\"oldest\" type=\"range\" min=\"1\" max=\"12\" value=\"6\" step=\"1\"></div><div class=\"track-row\"><span data-i18n=\"log\"></span><div id=\"log\" class=\"track\"></div></div><div class=\"metrics\"><div><span data-i18n=\"result\"></span><strong id=\"result\"></strong></div></div><p id=\"ranges\" class=\"message\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n#log{display:grid;grid-template-columns:repeat(6,minmax(0,1fr))}.metrics strong{font-size:17px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}function draw(){const c=+$('#cursor').value,o=+$('#oldest').value,missing=c<o-1;set('cursor-value',c);set('oldest-value',o);cells('log',Array.from({length:12},(_,i)=>({text:i+1,cls:i+1<=c?'':i+1<o?'gold':'on'})));set('result',t(missing?'snapshot':'replay'));set('ranges','missing: '+(missing?'['+(c+1)+','+o+')':'∅')+'\\nreplay: '+(c<12?'['+Math.max(c+1,o)+',13)':'∅'));$('#result').dataset.gap=String(missing);}document.querySelectorAll('input').forEach(e=>e.oninput=draw);draw();","audio":false,"strings":{"cursor":"客户端 Last-Event-ID","heading":"有 Last-Event-ID,为什么还要重新取快照?","intro":"修改客户端游标和服务端保留起点,检查能否完整补齐连续事件。","log":"日志(蓝:需补发;金:缺失)","oldest":"服务端最旧保留 ID","replay":"日志可完整补发","reset":"重置","result":"恢复方式","scope":"事件ID为连续整数1…12,Last-Event-ID代表已分派位置,不等于业务提交。假设后续均应补发,无过滤/空ID/新EventSource对象状态。","scopeLabel":"模型范围与约定","snapshot":"需要快照或明确的补救"}}
浏览器认证与来源检查
浏览器WebSocket构造器和原生EventSource都不提供任意自定义请求头接口,不能在示例中假装能直接添加Authorization。EventSource可使用适用的cookie及withCredentials设置;跨源读取遵循CORS。若选择fetch自行读取事件流,则解析、恢复和重连也要由代码承担。
WebSocket握手不靠普通CORS允许头来认证来源。使用浏览器cookie认证时,服务端应验证Origin和业务授权,防止其他站点借用户凭据建立连接;非浏览器客户端可自报Origin,所以它也不能代替用户认证。
凭据过期后已有长连接怎样处理,需要协议约定:重新认证、断开重连或按消息校验权限。只在握手时检查一次而让连接无限存活,会使身份撤销与权限更新难以生效。
背压与代理:能推送不等于可以无限排队
经典WebSocket API的send会排入发送队列,bufferedAmount可帮助观察尚未发送的数据,但不会自动给业务生产者一个完整的可等待背压协议。接收侧也要限制消息大小和消费队列,防止慢处理造成内存增长。
例如仪表盘每秒更新100次但只需显示最新状态,可以按键合并中间更新;审计事件不能随意丢弃,则应设队列上限、持久日志与慢消费者恢复流程。两种需求不应使用相同的“队列满就丢”策略。
代理缓冲可能让SSE攒成一批才到,空闲超时可能关闭没有数据的长连接。心跳周期应考虑全链路最短空闲限制和网络抖动,而不是固定照抄15秒;部署排空还要定义最长等待和重连退避,避免同时重启形成重连风暴。
permessage-deflate可减少WebSocket文本体积,但增加CPU、状态和压缩信息泄漏的权衡;context takeover是否保留跨消息历史由协商决定,不应把“启用压缩”当无条件收益。RFC 7692
选型对照
| 需求 | WebSocket | SSE |
| 客户端与服务端频繁互发消息 | 直接双向通道 | 客户端操作通常另走HTTP |
| 原始二进制内容 | 支持二进制消息 | 文本格式,需要另行编码或下载 |
| 浏览器恢复支持 | 应用实现重连和恢复 | 原生重连和Last-Event-ID,补发仍由服务端实现 |
| 基础设施要求 | 支持实际采用的Upgrade/CONNECT承载 | 支持长响应和及时刷新 |
| 业务可靠性 | 自定义ACK、去重和重放 | 同样需要消费语义、去重与日志 |
小规模低频更新还可能用轮询;只有服务端推送需求并不自动决定WebSocket。先明确延迟、消息方向、丢失容忍和恢复成本,再选承载方式。
继续查阅