弱网环境下实时数据推送,面对数据丢失问题如何处理

23 阅读6分钟

在**弱网(高延迟、丢包、频繁断连)**环境下做实时数据推送,核心目标不是“绝对不丢”,而是做到 可感知、可恢复、可追溯、可补偿。下面我按 问题 → 对策 → 工程实现 的方式,给出一套可落地的完整方案,同时也结合 WebSocket / 实时通信 / 离线队列 场景。

一、弱网下为什么会丢数据?

常见丢失场景有:

  1. 连接层
  • TCP连接断开但双方未及时感知
  • NAT(网络地址转换) / 移动网络切换导致“假连接”
  1. 传输层
  • 消息已发出,但ACK未返回
  • 客户端崩溃 / 刷新页面
  1. 应用层
  • 消息发送过快,客户端处理不过来
  • 服务端重启、进程崩溃
  1. 业务层
  • 客户端上线后不知道“从哪一条开始补”

所以:只靠WebSocket的“发一次”是必丢的。

二、核心设计思想(四板斧)

  1. 消息必须 可确认 (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
  1. 消息必须 可重发(重试+超时)

重发策略

  • 超过N秒未ACK ---> 重发
  • 最大重试次数(如3次)
  • 超过次数 ---> 标记失败,走补偿
setInterval(()=>{
    for(const [id,msg] of pending){
        if(Date.now() - msg.timestamp >3000){
            resend(msg)
        }
    }
},1000)
  1. 消息必须可 续传(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;
  1. 消息必须可 缓存(离线队列)

服务端缓存(推荐)

  • Redis Stream / List
  • Kafka / Pulsar
  • 内存 + WAL(写前日志)
消息 ---> 队列 ---> 推送 ---> ACK ---> 删除

客户端缓存(可选)

  • IndexedDB / LocalStorage
  • 适合指令类消息
  • 防止断网期间用户操作丢失

三、不同消息类型,不同策略(非常关键)

不是所有实时数据都值得“保证送达”。

消息类型示例策略
状态类实时位置、心跳允许丢,只保最新
事件类报警、订单状态必须送达
指令类控制指令必须ACK+重试
日志类行为日志批量上报

弱网环境下,强行“全不丢”会拖垮系统。

四、WebSocket实战推荐架构

服务端

WebSocket
  ↓
消息分发层
  ↓
Redis Stream(持久)
  ↓
ACK 管理 & 重试

客户端

Websocket
  ↓
本地seq记录
  ↓
ACK上报
  ↓
断线重连 + resume

五、关键工程细节(踩坑总结)

  1. 心跳 ≠ 保证连接可用
能ping通 ≠ 消息一定能送达

---> 心跳+ACK缺一不可

  1. 防止消息风暴
  • 客户端慢--->限流
  • 超过阈值--->丢弃旧状态信息
if(queue.length > MAX_QUEUE){
    dropOldest();
}
  1. 服务端重启不丢
  • ACK状态落Redis(消息是否已被客户端确认(ACK)这一状态,不止放在内存里,而是持久化存到Redis中。目的是:服务端进程重启/崩溃后,仍然知道哪些消息已经送达,哪些还需要补发。)
  • 未确认消息可恢复(消息本身可查 + ACK进度可恢复 + 能算出“缺哪几条”)

总结: 弱网下的实时推送,本质是: 用“应用层ACK + 序号 + 离线缓存 + 重连补发” 把“不可靠网络”变成“业务可接受的可靠性”。


ACK状态落Redis的详解:

ACK状态落Redis = “消息是否已被客户端确认(ACK)这一状态,不止放在内存里,而是持久化存到Redis中。” 目的是:服务端进程重启/崩溃后,仍然知道哪些消息已经送达,哪些还需要补发

一、如果ACK只放在内存里,会发生什么?

常见写法(有坑)

// 服务端内存
const pendingAcks = new Map<msgId,Message>();

流程:

  1. 服务端发送消息msgId=1001
  2. 放入pendingAcks
  3. 客户端ACK
  4. pendingAcks.delete(1001)

*** 问题来了

服务端 重启 / 崩溃

pendingAcks = new Map(); // 全没了

此时服务端:

  • 不知道哪些消息已经ACK
  • 不知道哪些消息没ACK
  • 无法补发
  • 只能“当没发过”或“当全发过”

数据丢失 or 重复,二选一

二、ACK状态落Redis,到底落的是什么?

不是把ACK消息本身存Redis 而是存“这条消息的确认状态

至少要存三类消息之一(任选 / 组合)

  1. 已确认的最大序号(最推荐)
ws:ack:{clientId} = 10234

含义:客户端已经确认收到seq≤10234的所有消息

  1. 未确认消息集合(精确型)
ws:pending:{clientId} = {1001,1002,1005}

用于:

  • 强一致业务
  • 精确控制重发

3.** 消息状态表(全量)**

msg:{msgId} = sent | acked

一般不推荐,量大、成本高

三、用【最大seq】方式,完整流程示意(重点)

  1. 服务端发送消息
const seq = nextSeq(clientId);

send({
    seq,
    payload
})

// 不需要存每一条
  1. 客户端收到并ACK
ws.send({
    type:'ack',
    seq:10234
})
  1. 服务端写入Redis(这一步就是“落Redis”)
SET ws:ack:client123 10234

可以用 SET / INCR / HSET

  1. 服务端重启后恢复状态
const lastAckSeq = redis.get('ws:ack:client123')

服务端立刻知道:这个客户端,10234之前的消息都不需要再发

  1. 客户端重连补发

客户端:

{
    "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 + 消息存储

  1. 服务端发送消息(发送即落库 / 队列)
const seq = nextSeq(clientId);

const msg = {
    clientId,
    seq,
    payload,
    ts:Date.now()
}
// ①消息先存
await db.insert('messages',msg)

// ②再推送
ws.send(msg);

顺序不能反,否则可能“发出但没存”

  1. 客户端ACK(只ACK seq)
{
    "type":"ack",
    "seq":10234
}

客户端语义:我已经完整收到 seq≤10234的所有消息。

  1. ACK状态落Redis(服务端)
// 原子更新
SET ws:ack:client123 10234

可以加TTL:

SET ws:ack:client123 10234 EX 86400
  1. 服务端突然重启(关键场景) 内存里这些都没了
  • pending map
  • websocket连接
  • 定时重试 但 Resis+DB 还在
  1. 客户端重连(服务端主动恢复) 客户端重连后第一件事:
{
    "type":"resume",
    "lastSeq":10234
}

这个lastSeq可以:

  • 来自客户端本地
  • 或服务端从Redis查
  1. 服务端“恢复未确认消息”(重点)

服务端逻辑:

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 的消息就是:“未确认消息”。

  1. 补发(恢复完成)
for(const msg of missedMessages){
    ws.send(msg);
}

客户端收到后继续ACK。

三、“未确认消息可恢复”的本质

不是“记住哪些没 ACK” 而是: 用 ACK 进度 + 顺序消息,反推出哪些没收到。