侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 160 篇文章
  • 累计收到 7 条评论

重连之后列表多出三条重复消息,补数据的那一步写错了

2026-10-7 / 0 评论 / 8 阅读

重连之后列表多出三条重复消息,补数据的那一步写错了

一个用 SSE 推列表的页面,网络抖了一下,连接断开又自动接上,页面上多出三条重复记录。消息 id 从 1 开始,断开时手里最后一条是 3,重连回来收到的还是 1、2、3。这一轮里服务端送出的数据没错,错在它不知道客户端已经收到过哪几条。

现象:重连之后从头推了一遍

== 1. 首连,读到 3 条就断 ==
  服务端 mode=naive last-event-id=null -> 从头推,不看 Last-Event-ID,起始 id=1
  收到: 1 2 3

服务端按顺序推 1 到 12 号事件,客户端读到第三条就断开。再次连上来,服务端又是从 1 开始推。客户端如果只是把收到的数据往列表里追加,页面上就会出现三份重复。

影响面比重复显示更大

如果是纯展示的列表,重复只是难看。消息本身带着「加一条」「改一条」这类操作时,重复消费会把业务状态也带偏:计数翻倍、变更日志出现两条同样的记录、导出文件里同一行出现两次。

还有一种更安静的情况:客户端自己去做去重,判据写得不严,把正常的新事件也一起当成重复丢掉,现象就从「多出来」变成「少了几条」,更难查。

定位:先看服务端有没有读那个头

断开的那一刻,浏览器手里最后一条消息的 id 是知道的,重连时它会带在请求头里。四种写法的实测结果摆在一起,差别一眼能看出来:

== 2. 断线重连的四种写法,客户端手里只有 id=3 ==
  2a 什么都不带(手写客户端常见)
  服务端 mode=naive last-event-id=null -> 从头推,不看 Last-Event-ID,起始 id=1
  收到: 1 2 3
  2b 带 Last-Event-ID: 3,服务端不看这个头
  服务端 mode=naive last-event-id="3" -> 从头推,不看 Last-Event-ID,起始 id=1
  收到: 1 2 3
  2c 带 Last-Event-ID: 3,服务端按 last+1 续传
  服务端 mode=resume last-event-id="3" -> 从 last+1 开始,起始 id=4
  收到: 4 5 6
  2d 带 Last-Event-ID: 3,服务端差一位,从 last 开始
  服务端 mode=offby1 last-event-id="3" -> 从 last 开始(差一位),起始 id=3
  收到: 3 4 5

前两种写法收到的都是 1、2、3。2a 什么都没带,属于客户端没记住状态;2b 带了头,服务端那侧没读它,所以还是从头推。2c 按 last+1 续传,收到 4、5、6,正好接上。2d 是差一位的写法,从 last 本身开始推,第三条事件重复了一次。

把这四行和服务端要打印的 last-event-id 一起看,问题落在哪一侧就很清楚了:头的值有没有到位是一侧,服务端读没读是另一侧。

改法一:续传从 last 加一开始

服务端拿到 last-event-id 之后,从它的下一条开始推。差一位的写法(从 last 本身开始)会稳定地重复一条,且只在正好断在事件边界上时看得见,不查日志很难发现。

按 id 去重之后再看同一批数据,四种写法的重复条数是三种结果:

== 3. 客户端按 id 去重的话,四种写法各重复几条 ==
  服务端 mode=naive last-event-id=null -> 从头推,不看 Last-Event-ID,起始 id=1
  2a 无状态不带头: 共 3 条,重复 3 条
  服务端 mode=naive last-event-id="3" -> 从头推,不看 Last-Event-ID,起始 id=1
  2b 无状态带头: 共 3 条,重复 3 条
  服务端 mode=resume last-event-id="3" -> 从 last+1 开始,起始 id=4
  2c 续传: 共 3 条,重复 0 条
  服务端 mode=offby1 last-event-id="3" -> 从 last 开始(差一位),起始 id=3
  2d 差一位: 共 3 条,重复 1 条

无状态那两种各重复三条,续传零重复,差一位重复一条。这张表也解释了为什么「客户端做去重就够了」不是个好主意:去重只是把症状按住,重复的数据照样在网络上跑,该省的开销一点没省。

改法二:比较 id 的时候按数值

id 是字符串,接进来随手做比较很容易踩到。事件少的时候看不出来,超过九条就断档:

== 4. 服务端把 id 当字符串比,事件超过 9 条就断档 ==
  客户端手里是 id=9,服务端筛 String(id) > '9'
  服务端 mode=strcmp last-event-id="9" -> 按字符串比较,只推 String(id) > last 的那些,起始 id=1
  收到:
  同一条规则改成数字比较(服务端用 last+1)
  服务端 mode=resume last-event-id="9" -> 从 last+1 开始,起始 id=10
  收到: 10 11 12

客户端手里是 9,服务端筛「字符串大于 9」的事件,10、11、12 一条都没推出去,重连之后什么也收不到。换回数值比较,10、11、12 正常送达。原因是逐字符比较时,第一个字符 1 小于 9:

== 5. 数字比较与字符串比较的直接差别 ==
  "10" > "9" 字符串比较 = false,数值比较 = true
  "2" > "10" 字符串比较 = true,数值比较 = false

这个坑和条数有关,所以小数据量的联调阶段常常是好的,上线跑到十几条才开始丢。

防复发:两头都写清楚

服务端这一侧,续传点的计算要写成 last 加一,比较走数值。事件表如果有落库,加一个唯一键让重复写入直接失败,比事后去重可靠。

客户端这一侧,保留一份见过的 id,收到重复就丢掉。这条判据要和操作语义对齐:追加类的事件按 id 去重,覆盖类的事件按 id 覆盖,别用「内容一样就跳过」这种判据。

协议本身还有一处可以顺手用起来:事件流里带的 retry 字段决定浏览器重连的间隔,服务端可以在连接建立时先送一行。本机复现的是服务端拿到这个头之后的处理,浏览器自动带上它并且按 retry 重连这一段,是按规范写的,没有用真实的 EventSource 在本机跑过。

我的取舍是两头都做:服务端按 last 加一续传,客户端再按 id 去重一次。多一层判断换来的是,任何一个环节写错都不会变成页面上看得见的重复。

    🤞 分享