
一个用 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 去重一次。多一层判断换来的是,任何一个环节写错都不会变成页面上看得见的重复。