服务器一开口,页面爆仓了
双向线路刚接通,服务器激动得一口气塞来五千条消息。页面收件室瞬间爆仓,JavaScript 老大抓住 `bufferedAmount`:“刹车找到了!”WebSocket 却把表盘搬到另一扇门:“方向看反了。”
三秒一次,烦不烦?
本文承接上一篇:《服务器不在,我替它回》。
“有新订单吗?”
“没有。”
三秒后。
“有新订单吗?”
“没有!”
又三秒。
“现在呢?”
服务器额头上的青筋跳了三下。
URL 抱着空响应跑回页面,刚放下,又被定时器塞来一张相同的询问单。
“再跑一趟。”
URL 看了看服务器,又看了看页面:“要不你们直接打一架?比这样省腿。”
服务器终于拍桌子:“没有消息时,你们一遍遍来问;真有消息时,我还得等下一趟。为什么不能让我先开口?”
HTTP 理了理白大褂:“请求在前,响应在后。这是规矩。”
“规矩能改吗?”
“能。”
HTTP 回答得太快,服务器反而愣住了。
“今天这么好说话?”
“先别高兴。”TCP 老司机把一根双向线路放到桌上,“线路闲久了,谁去确认对面没睡着?半路断了,谁重新接?断线时漏掉的话,谁补?说得太快把页面淹了,谁踩刹车?”
TCP 老司机一口气拍下四张表:“想清楚,再签字。”
服务器抓过笔,在第一页右下角画了一个圈。
“签完了。接线。”
TCP 老司机低头看了一眼。
圈里写着:
已阅
“这不是签字。”
“但态度到了。”
JavaScript 老大已经在页面里拉下开关:
const socket = new WebSocket(
"wss://shop.example.com/live",
["orders.v1"]
);
线路两端同时亮起黄灯。
CONNECTING
服务器抓起第一条新订单就往线路里塞。
黄灯把消息原样弹了回来。
JavaScript 老大也试着发送订阅指令:
socket.send(JSON.stringify({ type: "subscribe" }));
执行大厅立刻响起警报:
InvalidStateError
两边一起拍桌:“线路不是接上了吗?”
TCP 老司机把表格翻到第二页:“线路已铺到门口,但不等于双方已经同意通话。”
先对暗号
HTTP 抱着一只文件夹走到线路中央。
“正式说话前,先握手。”
服务器差点把桌子掀了:“绕了一圈,还是得让页面先请求?”
“最后敲一次门。”HTTP 把文件夹按回桌面,“我先按 HTTP 的身份进去,替你们问一句:这条线路,愿不愿意换一套规矩?”
HTTP 把普通请求单翻到背面,添了几行平时没有的暗号:
GET /live HTTP/1.1
Host: shop.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: orders.v1
Origin: https://shop.example.com
HTTP 在文件夹侧面写下 HTTP/1.1 Upgrade:“这套握手就是先借我的身份敲门,再申请把连接换成 WebSocket 规矩。”
服务器翻了翻文件夹:“Upgrade: websocket,想换成 WebSocket 规矩。版本 13。还带了一个 Sec-WebSocket-Key。”
服务器把 Key 别到胸前:“有钥匙,身份通过。”
“摘下来。”服务器档案员从身份窗口后面伸出手,“那不是用户证件。”
“都叫 Key 了,还不能开门?”
“它配合 Sec-WebSocket-Accept 检查对面是否理解 WebSocket 握手,不负责证明用户是谁。”档案员抽出来源登记表,“该检查的 Origin、登录身份和业务权限,一项也不能少。”
HTTP 又捏了捏线路外皮:“页面穿着 HTTPS 的防护服,线路也得用带保护层的 wss。换成裸露的 ws,还没接到服务器,混合内容警报就先响了。”
服务器只好重新核对来源和身份,从文件夹里取出 Key,按 WebSocket 规则算出回执:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: orders.v1
HTTP 把 101 印章重重盖下。
黄灯跳成绿色。
OPEN
一个戴着双边耳机的新接线员滑进控制室,左耳连页面,右耳连服务器。
“WebSocket 到岗。不用等对方问了,谁有消息谁先说。”
谁都不用等
服务器抄起新订单:“订单 2049,红色运动鞋一双!”
页面也正好开口:“以后订单和库存一有变化,马上告诉我!”
两条消息从相反方向冲上线,擦肩而过,谁也没有停下来让路。
HTTP 盯着双向线路:“不用等上一句的响应?”
WebSocket 把耳机往上推了推:“看见两条轨道没有?左边说左边的,右边说右边的,谁也不用举手等对方讲完。这种双方都能随时发送的工作方式,叫全双工。”
TCP 老司机在底下扶住线路:“箱子交给我,保证按顺序送到。至于几只箱子算一句话、里面是文字还是图片,别找我拆。”
WebSocket 推来几只发光托盘。
第一只装着文字:
{ "type": "order.created", "id": 2049 }
第二只装着一块二进制图像。
第三只只露出半截消息,后面还跟着两只托盘。
URL 掀开第三只:“怎么切碎了?”
“长消息不一定一只托盘装得下,可以拆成多个帧。”WebSocket 把几只托盘重新扣好,“但送进页面执行大厅前,我会把同一条消息拼回来。JavaScript 通常拿到完整的 message 事件,不用趴在轨道边捡碎片。”
JavaScript 老大在执行大厅接住消息:
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
socket.send(JSON.stringify({
type: "subscribe",
channels: ["orders", "stock"]
}));
});
socket.addEventListener("message", (event) => {
if (typeof event.data === "string") {
handleMessage(JSON.parse(event.data));
return;
}
handleBinary(event.data);
});
服务器得意地往椅背上一靠:“以后有消息直接说,再也不用等人问。”
TCP 老司机没抬头:“长线接通第一天说这种话,一般活不过下一节。”
人呢?
凌晨两点,线路安静了下来。
服务器喊了一声:“页面?”
没人回答。
服务器摸了摸线:“还热着,应该在线。”
TCP 老司机拿来检测仪:“热的是这一头。另一头还在不在,得问过才知道。”
检测仪拉出事故回放:路由器半夜重启过一次,页面又从 Wi-Fi 换到了移动网络,中间的看门人还清理过太久没动静的线路。绿灯没来得及反应,路早就不是原来那条了。
服务器盯着绿灯,越看越不放心:“总不能每天派 URL 过去敲门吧?”
WebSocket 从控制柜里取出一只很小的控制帧:
Ping
“不用派人。”WebSocket 弹了弹小卡片,“我在线路规章里留了一个很轻的问候,叫 Ping。对面收到后,要尽快用 Pong 回一声。听见回声,才知道不只是灯还亮着。”
服务器把 Ping 推过去。
浏览器底层很快送回 Pong。
绿灯旁亮起一颗小点。
JavaScript 老大从执行大厅跑来:“给我也来一个 socket.ping()。”
WebSocket 摸遍控制台,递出一张空白说明书。
“控制柜归浏览器底层管。页面拿到的 WebSocket API,没有 ping() 这个按钮。”
“协议有,我却不能按?”
“浏览器底层会回协议里的 Pong。你要让自己的业务代码也听见心跳,就在普通消息里约定一套问候。”
JavaScript 老大只好另做两张小卡片:
{ "type": "ping", "time": 1724131200000 }
{ "type": "pong", "time": 1724131200000 }
HTTP 把两组卡片分开放好:“这边是线路自己用的控制帧,那边是你们业务约定的普通消息。虽然都爱叫 ping、pong,别混进同一个抽屉。”
服务器拿起定时器,准备每十毫秒发一次。
TCP 老司机一把夺走:“你不是在测心跳,是在制造心律失常。”
断了就再连?
上午九点,网络真的断了。
控制室里的绿灯熄灭,close 通知滑进执行大厅:
socket.addEventListener("close", (event) => {
console.log(event.code, event.reason, event.wasClean);
});
服务器把线路两头一对:“接回去不就行了?”
WebSocket 摊手:“我只负责这一条连接。断了以后,浏览器可不会替应用自动创建下一条。”
JavaScript 老大拿出重连按钮,按得飞快。
一千个断线页面也在同一秒按下按钮。
服务器门外瞬间挤满握手请求。
“谁让它们一起回来的!”
“你刚才说,断了就接。”
“但也没让它们撞门啊!”
JavaScript 老大把按钮改成逐渐变慢的闹钟,又往每只闹钟里撒了一点随机数:
let reconnectAttempt = 0;
let lastSeen = 2048;
let currentSocket = null;
let shouldReconnect = true;
function connect() {
const socket = new WebSocket(
"wss://shop.example.com/live",
["orders.v1"]
);
currentSocket = socket;
socket.addEventListener("open", () => {
reconnectAttempt = 0;
socket.send(JSON.stringify({
type: "resume",
after: lastSeen
}));
});
socket.addEventListener("close", () => {
if (!shouldReconnect) {
return;
}
const base = Math.min(
30_000,
1_000 * 2 ** reconnectAttempt++
);
const jitter = Math.random() * 1_000;
setTimeout(connect, base + jitter);
});
}
connect();
第一批页面一秒多后回来,下一批等得更久;随机抖动让它们不再踩着同一个点撞门。
服务器刚松口气,页面又问:“我断线期间漏了哪些消息?”
WebSocket 看向服务器。
服务器看向 TCP。
TCP 老司机立刻后退:“我只保证一条活着的路线里,字节可靠有序。断线期间的业务消息,可不归我。”
服务器只好给每条订单消息加上递增序号,并保存一段可重放记录。页面重连时带回 lastSeen,双方从缺口后面继续。
“还可能重复收到。”HTTP 提醒,“消息处理最好能认序号,别把同一张订单打印两遍。”
服务器在表格上补了一行:
重新连上,不等于自动接上刚才的活
一开口就说五千句
页面带着 lastSeen: 2048 重新上线。
服务器打开补发仓库,里面整整齐齐码着五千条消息。
“缺的都在这儿。”
“等等——”
服务器已经推下总闸。
五千只消息箱沿线路冲向页面。
WebSocket 接线员的双边耳机瞬间被震飞。事件大厅门口,message 任务从一楼排到楼顶。
JavaScript 老大刚处理完一条订单,后面又落下九十九条。
动画场记员从消息箱缝里伸出手:“先让我画一帧!”
“后面排队!”
“我已经在后面了!后面还有四千八百箱!”
页面滚动开始发抖,内存表盘一路变红。
服务器隔着线路喊:“不是嫌我不能主动说吗?现在说了又不高兴?”
“让你说话,没让你一口气念族谱!”
服务器指着全双工工作牌:“上面没写限速。”
事件大厅小弟抱住收件口,用脚抵着门:“这里都满了,能不能让那边知道,先停一停?”
WebSocket 的耳机慢慢滑到眼睛上。
“感觉不到。”
“你不是双向的吗?”小弟吼道,“这边都堵到门口了,就不能替我们喊一句‘慢点发’?”
“双向只说明你们也能往回说,可没答应替你们盯着收件室。”WebSocket 从箱子底下爬出来,“什么时候忙不过来、该让服务器慢多少,得你们自己定规矩。”
JavaScript 老大听明白了:“也就是说,消息收不过来时,这条线路不会自动让服务器减速?”
“对。要是接收的一方一忙,发送的一方就能跟着放慢,这种一路传回去的刹车才叫背压。经典 WebSocket API 没装这套自动刹车。”
消息仍在往里掉。浏览器只能继续排队,内存和 CPU 表盘一起往红区走。
Web Worker 戴着毛线帽,从隔壁计算分部探出头:“要不把重计算交给我?”
JavaScript 老大抓住帽子把它拽进来:“能分担解析和统计,但入口没有自动限流。服务器继续这么倒,换间屋也会堆满。”
服务器终于抬起总闸:“那你们想怎么听?”
JavaScript 老大从消息山下伸出一张纸:“先发一百箱。等我确认,再放下一批!”
服务器这回没顶嘴,拖来一道限流闸,只从补发仓库里放出一百箱。
页面处理完成,回一张应用确认单:
{ "type": "ack", "through": 2148 }
服务器收到确认,才把下一批箱子推过闸门。可很快又犯了难:“不管箱子里装的是什么,都得一只不漏地排队?”
三条库存消息立刻挤到门口。
“库存还剩 18!”
“我更新,剩 17!”
“你们都过时了,现在只剩 16!”
JavaScript 老大留下最后一条,把前两条推回去:“页面要看的是现在还剩多少。既然 16 已经到了,18 和 17 再送过去,只会白占队伍。”
几条协作编辑里的光标位置也凑了过来。第一条说光标在第八行,第二条说到了第九行,第三条已经跑到第十行。
“你们也只留最新的?”WebSocket 问。
“对。旧位置晚到一步就没用了,忙的时候少送几次也不要紧。”
订单消息一听,立刻抱紧货架:“别看我们!少一笔就要出事,而且谁先谁后也不能乱。”
JavaScript 老大点头:“你们得分批发送。每一批等我确认;中途断了,还要按照序号把缺的补回来。”
WebSocket 这才摘下遮住眼睛的耳机:“原来不能拿一条规矩管所有消息。库存只留最新的,光标位置过期就算了,订单却一条也不能少。我只负责把路接通,可不知道哪只箱子丢得起。”
服务器看着剩下四千九百只箱子:“早说。我刚才还以为全双工是两个方向一起不讲武德。”
这个数字管哪边?
补发速度终于降了下来,页面却还在不断回确认消息。
JavaScript 老大连续调用 send(),每次都立刻返回。
“看见没有?我这边一点都不卡。”
TCP 老司机指了指线路下面。没发出去的确认单已经堆成小山。
WebSocket 控制台上的数字不断上涨:
console.log(currentSocket.bufferedAmount);
JavaScript 老大眼睛一亮:“数字越变越大,是不是说明服务器送来的消息越堆越多?那我拿它当警报,超过红线就让服务器停手。”
“管反了。”
“哪里反了?”
WebSocket 把表盘从进货门搬到出货门:“这只表叫 bufferedAmount,装在页面的出货口。你每次调用 send(),消息只是先排进发送队伍,不一定已经上路。它数的是这些从页面出发、却还没发走的字节。现在数字上涨,是因为确认单堵在这里,不是因为服务器送来的消息堆在收件室。”
JavaScript 老大围着表盘转了一圈:“所以它不是接收队伍长度,也不是服务器的自动刹车?”
“终于看对方向了。”
JavaScript 老大只好在发送前先看警戒线:
const HIGH_WATER_MARK = 512 * 1024;
function trySend(socket, message) {
if (socket.readyState !== WebSocket.OPEN) {
return false;
}
if (socket.bufferedAmount > HIGH_WATER_MARK) {
return false;
}
socket.send(message);
return true;
}
HTTP 看完问:“这就叫背压?”
“这叫看见积压后,应用自己决定稍后重试、合并还是丢弃。”JavaScript 老大把没送出的确认单收回箱子。
“那等箱子降到警戒线以下,你叫我一声。”
WebSocket 指了指空荡荡的控制台:“经典 WebSocket 连这只标准提醒铃也没配。你得自己隔一会儿再看,或者在应用里另做发送队列。”
关门也要打招呼
晚上,主人准备离开页面。
JavaScript 老大抓住电闸就要往下拉。
WebSocket 抱住电闸:“接线要握手,关线也别直接砍。”
页面先送出 Close 控制帧:
function disconnect() {
shouldReconnect = false;
currentSocket?.close(1000, "page hidden");
}
服务器收到后,也回了一只 Close。双方都发过、也收过关门通知,TCP 老司机才开始收底下的连接。
“1000 表示正常关闭。”WebSocket 摘下双边耳机,“如果网络突然消失,双方未必能完成这套礼貌流程,页面就要从 close 事件判断情况,再决定是否重连。”
服务器把最后一条订单塞过来:“趁门还没关完,再说一句。”
Close 帧举起栏杆:“发出关闭帧后,不再接新的数据消息。”
服务器悻悻收手:“这条线规矩怎么比 HTTP 还多?”
HTTP 抿了口咖啡:“自由发言的会议,通常最需要主持人。”
到底选谁?
事故复盘会刚开始,轮询抱着一摞请求单冲进来。
轮询满头是汗,先把请求单往桌上一摔:“都笑我来回跑。可一个导出任务五分钟才变一次,我隔一会儿问一句,跑完就回家,至少不用养这么长一条线。”
角落里又走出一名拿单向扩音器的选手。
“新闻、进度、行情,如果服务器想持续往页面播报,可以找我。”扩音器清了清嗓子,“Server-Sent Events,简称 SSE。”
WebSocket 看了看扩音器:“页面要在同一条线上回消息呢?”
SSE 把扩音器翻过来,背面是实心的。
“这只喇叭只能从服务器朝页面喊。页面要回话,另派 HTTP 请求。”SSE 从口袋里掏出一张号码卡,“不过线路断了,我会尝试重新接;带着最后事件 ID,还能告诉服务器上次听到哪里。”
WebSocket 抱起双边耳机:“那聊天室里双方随时都要发消息呢?协作编辑里,页面刚改一个字,服务器马上要把别人的修改送回来呢?还让轮询跑断腿,或者让页面另开一条路回话?”
三名选手吵成一团,浏览器老大只好拿出三张工作单。
第一张写着“偶尔查询导出进度”。轮询一把抢走。
第二张写着“持续播报新闻标题”。SSE 把扩音器架好。
第三张写着“多人实时协作”。WebSocket 戴上双边耳机。
浏览器老大把“冠军”奖杯收回柜子:“没有谁包办所有工作。先看问题,再选线路。”
服务器举手:“那我这个订单系统——”
“选完还要负责。”三名选手一起说。
终于能好好说话了
第二天,双向线路重新开放。
服务器不再一次推下五千箱,而是先发一批,等页面确认,再继续下一批。JavaScript 老大盯着发送积压,忙不过来时合并确认。断线重连的页面带着最后序号回来,不再从头索要全部消息。
新订单抵达。
服务器先开口:“订单 7049。”
页面处理完成:“收到 7049。”
TCP 老司机看了看表,第一次没有翻页。
服务器小声问:“这回可以说全双工很好用了吧?”
WebSocket 扶正双边耳机:“可以。后半句也一起说。”
服务器叹了口气:“能同时说话,不等于可以同时乱说话。”
执行大厅里,JavaScript 老大正准备喝咖啡,消息处理器忽然展开。
订单、库存、聊天、通知、权限变更……几十种 type 挤在一个巨大的 switch 里,代码一直滚到大厅门外。
JavaScript 老大看了半天:“线路不堵了,处理消息的文件怎么堵了?”
门口传来整齐的脚步声。
一个小小的 import 领着一整支模块队伍走进来。
“听说这里要拆伙?”