在**弱网(高延迟、丢包、频繁断连)**环境下做实时数据推送,核心目标不是“绝对不丢”,而是做到 可感知、可恢复、可追溯、可补偿。下面我按 问题 → 对策 → 工程实现 的方式,给出一套可落地的完整方案,同时也结合 WebSocket / 实时通信 / 离线队列 场景。
一、弱网下为什么会丢数据?
常见丢失场景有:
- 连接层
- TCP连接断开但双方未及时感知
- NAT(网络地址转换) / 移动网络切换导致“假连接”
- 传输层
- 消息已发出,但ACK未返回
- 客户端崩溃 / 刷新页面
- 应用层
- 消息发送过快,客户端处理不过来
- 服务端重启、进程崩溃
- 业务层
- 客户端上线后不知道“从哪一条开始补”
所以:只靠WebSocket的“发一次”是必丢的。
二、核心设计思想(四板斧)
- 消息必须 可确认 (ACK机制)
没有ACK,就当没送达。
机制
Server --- msg(id=1001) ---> client
Server <--- ack(1001) ------- client
服务端逻辑(示意)
pending.set(msgId,{
payload,
timestamp:Date.now()
});
客户端确认后:
pending.delete(msgId);
重点
- ACK是应用层ACK(业务级确认),不是TCP ACK
- ACK 必须带messageId
- 消息必须 可重发(重试+超时)
重发策略
- 超过N秒未ACK ---> 重发
- 最大重试次数(如3次)
- 超过次数 ---> 标记失败,走补偿
setInterval(()=>{
for(const [id,msg] of pending){
if(Date.now() - msg.timestamp >3000){
resend(msg)
}
}
},1000)
- 消息必须可 续传(offset / sequence)
断线重连不是“重新开始”,而是从上次收到的地方继续。
每条消息都有递增序号。
{
seq:10234,
type:'position',
data:{...}
}
客户端持久化:
lastSeq = 10234;
重连时:
{
"type":"resume",
"lastSeq":10234
}
服务端补发:
SELECT * FROM messages
WHERE seq > 10234
ORDER BY seq ASC
LIMIT 1000;
- 消息必须可 缓存(离线队列)
服务端缓存(推荐)
- Redis Stream / List
- Kafka / Pulsar
- 内存 + WAL(写前日志)
消息 ---> 队列 ---> 推送 ---> ACK ---> 删除
客户端缓存(可选)
- IndexedDB / LocalStorage
- 适合指令类消息
- 防止断网期间用户操作丢失
三、不同消息类型,不同策略(非常关键)
不是所有实时数据都值得“保证送达”。
| 消息类型 | 示例 | 策略 |
|---|---|---|
| 状态类 | 实时位置、心跳 | 允许丢,只保最新 |
| 事件类 | 报警、订单状态 | 必须送达 |
| 指令类 | 控制指令 | 必须ACK+重试 |
| 日志类 | 行为日志 | 批量上报 |
弱网环境下,强行“全不丢”会拖垮系统。
四、WebSocket实战推荐架构
服务端
WebSocket
↓
消息分发层
↓
Redis Stream(持久)
↓
ACK 管理 & 重试
客户端
Websocket
↓
本地seq记录
↓
ACK上报
↓
断线重连 + resume
五、关键工程细节(踩坑总结)
- 心跳 ≠ 保证连接可用
能ping通 ≠ 消息一定能送达
---> 心跳+ACK缺一不可
- 防止消息风暴
- 客户端慢--->限流
- 超过阈值--->丢弃旧状态信息
if(queue.length > MAX_QUEUE){
dropOldest();
}
- 服务端重启不丢
- ACK状态落Redis(消息是否已被客户端确认(ACK)这一状态,不止放在内存里,而是持久化存到Redis中。目的是:服务端进程重启/崩溃后,仍然知道哪些消息已经送达,哪些还需要补发。)
- 未确认消息可恢复(消息本身可查 + ACK进度可恢复 + 能算出“缺哪几条”)
总结: 弱网下的实时推送,本质是: 用“应用层ACK + 序号 + 离线缓存 + 重连补发” 把“不可靠网络”变成“业务可接受的可靠性”。
ACK状态落Redis的详解:
ACK状态落Redis = “消息是否已被客户端确认(ACK)这一状态,不止放在内存里,而是持久化存到Redis中。” 目的是:服务端进程重启/崩溃后,仍然知道哪些消息已经送达,哪些还需要补发。
一、如果ACK只放在内存里,会发生什么?
常见写法(有坑)
// 服务端内存
const pendingAcks = new Map<msgId,Message>();
流程:
- 服务端发送消息msgId=1001
- 放入pendingAcks
- 客户端ACK
- pendingAcks.delete(1001)
*** 问题来了
服务端 重启 / 崩溃:
pendingAcks = new Map(); // 全没了
此时服务端:
- 不知道哪些消息已经ACK
- 不知道哪些消息没ACK
- 无法补发
- 只能“当没发过”或“当全发过”
数据丢失 or 重复,二选一
二、ACK状态落Redis,到底落的是什么?
不是把ACK消息本身存Redis 而是存“这条消息的确认状态”
至少要存三类消息之一(任选 / 组合)
- 已确认的最大序号(最推荐)
ws:ack:{clientId} = 10234
含义:客户端已经确认收到seq≤10234的所有消息
- 未确认消息集合(精确型)
ws:pending:{clientId} = {1001,1002,1005}
用于:
- 强一致业务
- 精确控制重发
3.** 消息状态表(全量)**
msg:{msgId} = sent | acked
一般不推荐,量大、成本高
三、用【最大seq】方式,完整流程示意(重点)
- 服务端发送消息
const seq = nextSeq(clientId);
send({
seq,
payload
})
// 不需要存每一条
- 客户端收到并ACK
ws.send({
type:'ack',
seq:10234
})
- 服务端写入Redis(这一步就是“落Redis”)
SET ws:ack:client123 10234
可以用 SET / INCR / HSET
- 服务端重启后恢复状态
const lastAckSeq = redis.get('ws:ack:client123')
服务端立刻知道:这个客户端,10234之前的消息都不需要再发。
- 客户端重连补发
客户端:
{
"type":"resume",
"lastSeq":10234
}
服务端:
SELECT * FROM messages
WHERE seq > 10234
ORDER BY seq ASC;
零丢失,可恢复。
未确认消息可恢复的详解
未确认消息可恢复 = 消息本身可查 + ACK 进度可恢复 + 能算出“缺哪几条”
所以一定是三件事同时成立,而不是只存ACK。
一、完整恢复模型
核心数据分3类
| 数据 | 放哪 | 作用 |
|---|---|---|
| 消息内容 | DB / Redis Stream Kafka | 能重新拿到消息 |
| ACK进度 | Redis | 知道客户收到哪了 |
| 序号(seq) | 单调递增 | 计算缺口 |
二、可落地方案:seq + Redis ACK + 消息存储
- 服务端发送消息(发送即落库 / 队列)
const seq = nextSeq(clientId);
const msg = {
clientId,
seq,
payload,
ts:Date.now()
}
// ①消息先存
await db.insert('messages',msg)
// ②再推送
ws.send(msg);
顺序不能反,否则可能“发出但没存”
- 客户端ACK(只ACK seq)
{
"type":"ack",
"seq":10234
}
客户端语义:我已经完整收到 seq≤10234的所有消息。
- ACK状态落Redis(服务端)
// 原子更新
SET ws:ack:client123 10234
可以加TTL:
SET ws:ack:client123 10234 EX 86400
- 服务端突然重启(关键场景) 内存里这些都没了
- pending map
- websocket连接
- 定时重试 但 Resis+DB 还在
- 客户端重连(服务端主动恢复) 客户端重连后第一件事:
{
"type":"resume",
"lastSeq":10234
}
这个lastSeq可以:
- 来自客户端本地
- 或服务端从Redis查
- 服务端“恢复未确认消息”(重点)
服务端逻辑:
const lastSeq = await redis.get('ws:ack:client123');
const missedMessages = await db.query(`
SELECT * FROM messages
WHERE clinet_id = ?
AND seq > ?
ORDER BY seq ASC
LIMIT 1000
`,[clientId,lastAckSeq])
这些 seq > lastAckSeq 的消息就是:“未确认消息”。
- 补发(恢复完成)
for(const msg of missedMessages){
ws.send(msg);
}
客户端收到后继续ACK。
三、“未确认消息可恢复”的本质
不是“记住哪些没 ACK” 而是: 用 ACK 进度 + 顺序消息,反推出哪些没收到。