为什么你发一条消息,对方瞬间就能收到?浏览器网页刷新一下要好几秒,为什么微信能做到"秒回"?
今天,用**"敲门"**的故事,来讲讲消息推送的技术原理。
原文地址
浏览器为什么"落后"于微信?
你给朋友发微信,消息瞬间送达,甚至能看到对方"正在输入"。
但打开网页版邮箱想知道有没有新邮件,只能手动刷新页面。
这个差异源于HTTP协议天生就是"单向"的。
回顾一下HTTP的工作方式:
浏览器 → 服务器:「有没有新消息?」
服务器 → 浏览器:「没有。」
(一秒钟后)
浏览器 → 服务器:「有没有新消息?」
服务器 → 浏览器:「没有。」
(又一秒)
浏览器 → 服务器:「有没有新消息?」
服务器 → 浏览器:「有了!你的验证码是123456。」
这就是问题所在:HTTP是"拉取"(Pull)模式,必须由客户端主动发起请求,服务器才能响应。
而微信采用的是 "推送"(Push)模式 ------有新消息时,服务器主动通知客户端。
方案一:短轮询------持续敲门确认对方在不在
最早的解决方案很简单:持续轮询。
每秒向服务器发送一次请求:「有没有新消息?」
服务器回复:「没有。」
继续问。
服务器回复:「没有。」
继续问。
服务器回复:「有了!有人给你点赞了!」
这就是短轮询(Short Polling)。
实现示例
javascript
setInterval(async () => {
const response = await fetch('/api/check-messages');
const data = await response.json();
if (data.hasNew) {
showNotification(data.message);
}
}, 1000);
短轮询的问题
| 问题 | 说明 |
|---|---|
| 资源浪费 | 无论是否有消息,每秒都在发送HTTP请求 |
| 带宽浪费 | 99%的情况下服务器回复都是{hasNew: false} |
| 延迟高 | 新消息到达时机恰好在轮询间隔之间时,需等待下一个周期 |
短轮询如同执着推销员,每隔一分钟就敲一次门问要不要买保险,邻居不堪其扰。
方案二:长轮询------餐厅等位,服务员主动叫号
能否改为"等待"而非"持续询问"?
**长轮询(Long Polling)**正是这个思路的产物。
以餐厅吃饭为例。短轮询如同每隔一分钟就跑到前台问一次"轮到我没有?",服务员每次都说"还没有"。
长轮询则是拿号等待,服务员叫号时主动来找你------"38号客户,请入座"------无需频繁询问。
长轮询的工作流程
yaml
1. 客户端 → 服务器:GET /api/messages(请求挂起)
2. 服务器 → 客户端:(等待中......有新消息才返回响应)
3. 服务器 → 客户端:{message: "你的验证码是123456"}
4. 客户端:(收到消息后,立即再次发起长轮询)
实现示例
javascript
async function longPoll() {
while (true) {
try {
const response = await fetch('/api/messages', {
signal: AbortSignal.timeout(30000) // 30秒超时
});
const data = await response.json();
showNotification(data.message);
} catch (e) {
// 超时或出错时,继续发起下一次长轮询
}
}
}
长轮询的改进
| 改进点 | 说明 |
|---|---|
| 减少请求次数 | 无新消息时服务器保持连接,不立即响应 |
| 降低延迟 | 新消息产生时,服务器立即推送 |
长轮询的问题
| 问题 | 说明 |
|---|---|
| 连接频繁建立 | 每次收到消息后需重新建立HTTP连接 |
| 服务器压力大 | 每个客户端需占用一个服务端连接 |
| 高并发场景不适用 | 10万人同时在线,服务器需维护10万个挂起的请求 |
长轮询如同餐厅等位------拿号等待,服务员主动叫号。
方案三:WebSocket------建立持久双向通道
真正的"双向通信"解决方案来了。
WebSocket如同在客户端与服务器之间建立了一条专线电话:
- 一次建立,长期保持
- 双方可随时发送消息
- 无需重复"握手确认"
WebSocket的工作原理
WebSocket的建立过程起始于HTTP,随后升级为不同的协议:
yaml
1. 客户端 → 服务器:GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
2. 服务器 → 客户端:HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYG3hQwbA==
3. (握手完成,连接从HTTP升级为WebSocket协议)
4. 客户端 ↔ 服务器:全双工消息传输,不再需要轮询
这就是著名的协议升级(Upgrade)机制 ------客户端发送Upgrade请求头,服务器同意后切换到WebSocket模式。
帧结构
WebSocket通信的基本单位是帧(Frame),而不是HTTP的请求/响应:
yaml
┌─────────────────────────────────────────────────────────────┐
│ 0 1 2 3 │
│ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 │
│ +-+---------------+-+---------------+-+---------------+-+-----+
│ |F|R|R|R| opcode |M| mask | payload len |
│ |I|S|S|S| |A| || |
│ |N|V|V|V| |S| ||
| 字段 | 说明 |
|---|---|
opcode |
帧类型(0x0=continuation, 0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong) |
MASK |
客户端发送给服务器时必须为1,帧内容使用masking key加密 |
payload len |
数据长度(最多125字节,超过时使用扩展) |
消息格式示例
javascript
// 客户端发送消息
socket.send(JSON.stringify({
type: 'message',
content: '你好,我想问一下订单的事'
}));
// 客户端接收消息
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('收到消息:', data.content);
};
WebSocket事件生命周期
yaml
┌─────────────────────────────────────────────────────────┐
│ WebSocket连接 │
│ │
│ onopen → onmessage → onclose │
│ (连接建立) (接收消息) (连接关闭) │
│ │
│ onerror │
│ (连接错误) │
└─────────────────────────────────────────────────────────┘
WebSocket支持ping/pong帧用于心跳检测,比自定义JSON消息更轻量。
WebSocket与HTTP对比
| 特性 | HTTP | WebSocket |
|---|---|---|
方向 |
单向(客户端发请求,服务器响应) | 双向(全双工) |
连接 |
每次请求新建 | 一次建立,长期保持 |
实时性 |
取决于轮询频率 | 真正的实时(毫秒级) |
资源消耗 |
高(频繁建连断连) | 低(单一连接) |
使用场景 |
查询、表单提交 | 聊天、实时协作、在线游戏 |
方案四:SSE------服务器单向推送
有时仅需服务器向客户端推送,无需双向通信。
典型场景:
股票行情:服务器持续推送价格变动新闻推送:服务器通知突发事件邮件提醒:收到新邮件时通知
**SSE(Server-Sent Events)**专为此类场景设计。
SSE的工作方式
SSE如同接收广播------打开收音机后,电台持续播报,只能听无法回应。
实现示例
javascript
// 客户端
const eventSource = new EventSource('/api/notifications');
eventSource.onmessage = (event) => {
console.log('收到通知:', event.data);
};
eventSource.addEventListener('stock', (event) => {
const stockData = JSON.parse(event.data);
updateStockPrice(stockData);
});
javascript
// 服务器(Node.js示例)
app.get('/api/notifications', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
const interval = setInterval(() => {
res.write(`data: ${JSON.stringify({time: Date.now()})}\n\n`);
}, 1000);
req.on('close', () => {
clearInterval(interval);
});
});
SSE事件格式
yaml
data: {"msg": "第一条消息"}
data: {"msg": "第二条消息"}
id: 10
data: {"msg": "带ID的消息"}
event: stock
data: {"price": 123.45}
| 字段 | 说明 |
|---|---|
data: |
消息内容,可多行 |
id: |
事件ID,浏览器自动记录,断了可自动续传 |
event: |
自定义事件类型 |
retry: |
断开后重连间隔(毫秒) |
SSE的特点
| 特性 | 说明 |
|---|---|
| 单向 | 仅服务器可推送,浏览器仅能接收 |
| 基于HTTP | 无需特殊协议,兼容性好 |
| 自动重连 | 浏览器自动维护,连接断开后自动恢复 |
| EventSource API | 原生浏览器支持,实现简单 |
深入了解实时通信技术 🔬
方案对比总览
| 技术 | 双向通信 | 连接类型 | 实时性 | 资源消耗 | 实现复杂度 |
|---|---|---|---|---|---|
| 短轮询 | ❌ | 短连接 | 差(秒级) | 高 | 低 |
| 长轮询 | ❌ | 长连接 | 中(亚秒级) | 中 | 中 |
| WebSocket | ✅ | 长连接 | 优(毫秒级) | 低 | 中 |
| SSE | ❌ | 长连接 | 优(毫秒级) | 低 | 低 |
WebSocket心跳机制
TCP 连接长时间无数据传输时,中间设备(如防火墙、负载均衡器)可能主动断开连接。 WebSocket 需定期发送心跳包保持连接活跃:
javascript
// 使用ping/pong帧(协议级)
const pingInterval = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.ping();
}
}, 30000);
socket.on('pong', () => {
console.log('收到pong响应,连接正常');
});
断线重连策略
网络波动时连接可能中断,需实现指数退避重连:
javascript
function connect() {
const socket = new WebSocket('wss://example.com/ws');
let retryDelay = 1000;
const maxDelay = 30000;
socket.onclose = () => {
console.log(`连接断开,${retryDelay}ms后重连...`);
setTimeout(() => {
connect();
retryDelay = Math.min(retryDelay * 2, maxDelay);
}, retryDelay);
};
socket.onerror = (error) => {
console.error('连接错误:', error);
};
}
WebSocket协议的握手细节
WebSocket握手基于HTTP,必须遵守同源策略。服务器响应中的Sec-WebSocket-Accept验证方式:
javascript
const crypto = require('crypto');
const key = 'dGhlIHNhbXBsZSBub25jZQ==';
const accept = crypto
.createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
// 返回值应为 's3pPLMBiTxaQ9kYG3hQwbA=='
真实的即时通讯系统是怎么实现的?
简化架构
yaml
┌─────────────────────────────────────────────────────────────┐
│ IM服务器集群 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 消息Router │ │ 消息存储 │ │ 推送服务 │ │
│ │ (一致性哈希) │ │ (历史消息) │ │ (APNs/FCM) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
└─────────┼──────────────────┼──────────────────┼─────────────┘
│ │ │
↓ ↓ ↓
┌─────────────────────────────────────────────────────────────┐
│ 客户端 │
│ │
│ WebSocket长连接 本地消息数据库 系统级推送通知 │
│ (在线时实时通信) (消息缓存) (离线时触达) │
└─────────────────────────────────────────────────────────────┘
消息发送完整流程
yaml
1. 客户端A → 服务器:POST /api/messages
{to: "用户B", content: "在吗?", clientMsgId: "uuid_xxx"}
2. 服务器 → 消息存储:写入消息
INSERT INTO messages (id, from, to, content, status)
VALUES ("msg_yyy", "A", "B", "在吗?", "pending")
3. 服务器 → 消息Router:查询用户B的在线状态
Redis GET user:B:online → "ws_session_123"
4. 用户B在线:
服务器 → 用户B:(WebSocket推送) {type: "message", content: "在吗?"}
服务器 → 用户A:(HTTP响应) {code: 0, msgId: "msg_yyy"}
服务器 → 消息存储:UPDATE messages SET status = "delivered"
5. 用户B离线:
服务器 → 推送服务:发送APNs/FCM推送
服务器 → 消息存储:UPDATE messages SET status = "pending_push"
消息幂等性
网络异常时客户端可能重复发送消息。服务器通过唯一消息ID实现幂等:
javascript
async function handleMessage(msg) {
// 检查是否已处理过
const exists = await redis.get(`msg:processed:${msg.clientMsgId}`);
if (exists) {
return { code: 0, duplicate: true };
}
// 写入数据库
await db.saveMessage(msg);
// 标记为已处理,设置过期时间
await redis.setex(`msg:processed:${msg.clientMsgId}`, 86400, '1');
// 推送给接收方
await pushToRecipient(msg);
return { code: 0, msgId: msg.id };
}
消息确认机制
客户端发送消息后需等待服务器确认(ack),未确认则重试:
yaml
发送消息 → 等待ack(超时3s)→ 未收到 → 重试(最多3次)→ 仍未确认 → 显示"发送失败"
WebSocket层的ping/pong与业务层的消息确认是两种独立机制:
javascript
// 业务层消息确认
socket.send(JSON.stringify({
type: 'message',
id: 'msg_123',
content: '在吗?',
requiresAck: true
}));
// 服务端收到后回复
socket.send(JSON.stringify({
type: 'ack',
id: 'msg_123',
timestamp: 1699999999
}));
离线消息与推送
用户离线时,消息暂存服务器,通过系统级推送服务触达:
| 平台 | 推送服务 |
|---|---|
iOS |
APNs(Apple Push Notification service) |
Android |
FCM(Firebase Cloud Messaging) |
推送 payload 示例:
json
{
"to": "用户设备Token",
"notification": {
"title": "微信消息",
"body": "张三:在吗?"
},
"data": {
"msgId": "msg_yyy",
"conversationId": "conv_zzz"
}
}
总结:技术选型指南
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 聊天应用、在线游戏 | WebSocket |
需真正的双向实时通信 |
| 股票行情、直播弹幕 | WebSocket / SSE |
需高速双向/单向推送 |
| 新闻推送、系统通知 | SSE |
仅需服务器单向推送 |
| 低频检查类需求 | 短轮询 / 长轮询 | 实现简单,无持久连接需求 |
| App离线推送 | APNs / FCCM | 应用关闭时仍需触达用户 |
写在最后
现在应该明白了:
- 短轮询 = 持续敲门确认对方在不在,资源消耗大
- 长轮询 = 通话等待,占用连接但减少无效请求
- WebSocket = 专线电话,一次建立永久使用
- SSE = 听广播,服务器单向推送
- 微信 = 技术组合:WebSocket实时通信 + 消息持久化 + APNs/FCM离线推送 + 确认重试机制
发一条微信消息,背后可能经历了一次HTTP请求、一次WebSocket推送、一次APNs/FCM离线推送,才能最终呈现在屏幕上。
技术不复杂,但组合起来,创造了"秒回"的用户体验。