<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>IM on 止语Lab</title>
        <link>https://www.wujiachen.com.cn/tags/im/</link>
        <description>Recent content in IM on 止语Lab</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh-cn</language>
        <lastBuildDate>Sun, 12 Jul 2026 16:59:54 +0800</lastBuildDate><atom:link href="https://www.wujiachen.com.cn/tags/im/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>at-least-once 不是 bug：IM 消息可靠投递的三道防线</title>
            <link>https://www.wujiachen.com.cn/posts/im-reliable-delivery/</link>
            <pubDate>Sun, 12 Jul 2026 16:59:52 +0800</pubDate>
            <guid>https://www.wujiachen.com.cn/posts/im-reliable-delivery/</guid>
            <description>&lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/cover.png&#34; alt=&#34;Featured image of post at-least-once 不是 bug：IM 消息可靠投递的三道防线&#34; /&gt;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/cover.png&#34; alt=&#34;封面&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;消息队列里 at-least-once 常被当成 bug。到了 IM，它却是唯一靠谱的默认，靠三道防线，把&amp;quot;可能收到多次&amp;quot;变成用户无感。&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;&lt;h2 id=&#34;目录&#34;&gt;&lt;a href=&#34;#%e7%9b%ae%e5%bd%95&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;目录&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;一、at-least-once 被低估了&lt;/li&gt;&#xA;&lt;li&gt;二、第一道防线：发送端用 msgID 兜住弱网&lt;/li&gt;&#xA;&lt;li&gt;三、第二道防线：服务端把离线消息先存着&lt;/li&gt;&#xA;&lt;li&gt;四、第三道防线：接收端按 msgID 多端去重&lt;/li&gt;&#xA;&lt;li&gt;五、三层怎么接力：一条时间线看全&lt;/li&gt;&#xA;&lt;li&gt;六、为什么别追 exactly-once&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;一at-least-once-被低估了&#34;&gt;&lt;a href=&#34;#%e4%b8%80at-least-once-%e8%a2%ab%e4%bd%8e%e4%bc%b0%e4%ba%86&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;一、at-least-once 被低估了&#xA;&lt;/h2&gt;&lt;p&gt;at-least-once（至少一次投递）在消息队列语境里常被当成需要额外处理的麻烦：&amp;ldquo;会重复，得自己写消费端幂等&amp;rdquo;。但在 IM 里，它不是 bug，是唯一正确的默认。&lt;/p&gt;&#xA;&lt;p&gt;你可能遇到过这种事：发一条消息，先是&amp;quot;发送中&amp;quot;，过一会儿变成&amp;quot;已送达&amp;quot;，对方也只收到一条。你没操心&amp;quot;会不会发重了&amp;quot;，因为客户端替你兜住了。这背后就是 at-least-once 的思维，允许重发，但保证你最终只看到一条。&lt;/p&gt;&#xA;&lt;p&gt;很多人第一次接触&amp;quot;消费端幂等&amp;quot;，是在消息队列里：MQ 引入了&amp;quot;消费者幂等&amp;quot;这个没人愿意写的代价，它的语境是&amp;quot;单个消费者从队列里取消息&amp;quot;。本文不是那个问题。我们谈的是 IM：一个用户同时开着手机、电脑、平板，关了又开，地铁里没信号，电梯里掉线。失败模式从&amp;quot;消费者挂了&amp;quot;变成了&amp;quot;多端 + 离线 + 弱网&amp;quot;。把 MQ 那套照搬过来，会漏掉 IM 真正难的地方。&lt;/p&gt;&#xA;&lt;p&gt;IM 的可靠投递靠三道防线兜着：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;发送端用唯一 msgID 做超时补传；&lt;/li&gt;&#xA;&lt;li&gt;服务端做离线暂存（store-and-forward）；&lt;/li&gt;&#xA;&lt;li&gt;接收端按 msgID 多端幂等去重。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;为什么是三道，不是两道？因为单独两道都留洞。&lt;/p&gt;&#xA;&lt;p&gt;只有发送端重试，对方离线时消息直接丢；加了服务端暂存，对方上线能收到，但 at-least-once 意味着可能收到多次，用户看到重复。再加接收端去重，才把&amp;quot;多端 + 离线 + 弱网&amp;quot;三种失败模式全盖住。任何一层都拦不住所有情况，但三层叠起来，&amp;ldquo;消息最终到达、且用户只看到一条&amp;quot;就成立了。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/ch1-concept.png&#34; alt=&#34;exactly-once 与 at-least-once 语义对比：单消费者单分区 vs 多端&amp;#43;离线&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;下面每道防线和一段可运行的 Go 代码一起看，代码都在 &lt;code&gt;evidence/code/&lt;/code&gt; 下，能直接 &lt;code&gt;go run&lt;/code&gt;。&lt;/p&gt;&#xA;&lt;h2 id=&#34;二第一道防线发送端用-msgid-兜住弱网&#34;&gt;&lt;a href=&#34;#%e4%ba%8c%e7%ac%ac%e4%b8%80%e9%81%93%e9%98%b2%e7%ba%bf%e5%8f%91%e9%80%81%e7%ab%af%e7%94%a8-msgid-%e5%85%9c%e4%bd%8f%e5%bc%b1%e7%bd%91&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;二、第一道防线：发送端用 msgID 兜住弱网&#xA;&lt;/h2&gt;&lt;p&gt;先说发送端。你发一条&amp;quot;在吗&amp;rdquo;，网络一抖，服务端可能根本没收到，或者收到了但回你的 ACK 在路上丢了。这时候怎么办？最朴素的办法是：等 ACK，超时没收到就重发。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;at-least-once（至少一次投递）&lt;/strong&gt;。发送端保证消息&amp;quot;最终至少送达一次&amp;quot;，代价是可能发多次。对应的机制是：每条消息带一个全局唯一 ID（msgID），发完等 ACK，超时没收到就重发，但重发时 msgID 不变。&lt;/p&gt;&#xA;&lt;p&gt;这里有个细节值得停一下：msgID 必须在重发前就持有，所以常见做法是客户端在发送前就生成，&lt;code&gt;uid + 时间戳 + 客户端自增序号&lt;/code&gt;，或者直接上 UUIDv7 这种带时间有序的 ID。服务端统一分配 msgID 也是一条可行路线，区别主要在 ID 的有序性和冲突处理上。为什么客户端生成更顺手？因为重发发生在发送端本地，网络抖了一下，客户端得立刻用同一个 ID 重发，它不能先去问服务端&amp;quot;我这条的 ID 是多少&amp;quot;。ID 在第一次发送前就定下来了，重发只是把同一封信再寄一次。&lt;/p&gt;&#xA;&lt;p&gt;我写了个最小可跑的发送端（Go 1.26.4，仅标准库，代码在 &lt;code&gt;evidence/code/sender-retry&lt;/code&gt;）。片段省略了 &lt;code&gt;ackCh&lt;/code&gt; 的定义，它模拟&amp;quot;第 1 次 ACK 丢失&amp;quot;的场景，看发送端怎么反应：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;kd&#34;&gt;const&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msgID&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&amp;#34;m-0f3a9c&amp;#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 发送端生成的全局唯一 ID，重试不变&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nx&#34;&gt;timeout&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;50&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;*&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;time&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Millisecond&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;for&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;attempt&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;attempt&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;lt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;3&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;attempt&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;++&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msg&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Message&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;MsgID&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msgID&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Body&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;body&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Try&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;attempt&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;fmt&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Printf&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&amp;#34;[发送端] 第 %d 次发送 msgID=%s body=%q\n&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;attempt&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msg&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;MsgID&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msg&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Body&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;select&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;case&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;lt;-&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;ackCh&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;fmt&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Printf&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&amp;#34;[发送端] 收到 ACK，投递成功（共尝试 %d 次，msgID 始终=%s）\n&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;attempt&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msgID&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;return&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;case&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;lt;-&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;time&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;After&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;timeout&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;):&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;fmt&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Printf&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&amp;#34;[发送端] 超时未收到 ACK，准备用同一 msgID 重发\n&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;实测输出（Go 1.26.4 darwin/arm64）：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[发送端] 第 1 次发送 msgID=m-0f3a9c body=&amp;#34;在吗？周末爬山&amp;#34;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[网络] 第 1 次 ACK 丢失&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[发送端] 超时未收到 ACK，准备用同一 msgID 重发&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[发送端] 第 2 次发送 msgID=m-0f3a9c body=&amp;#34;在吗？周末爬山&amp;#34;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[发送端] 收到 ACK，投递成功（共尝试 2 次，msgID 始终=m-0f3a9c）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;注意第 1 次和第 2 次的 msgID 都是 &lt;code&gt;m-0f3a9c&lt;/code&gt;。重试不是发了一条新消息，是同一封信的重寄。这一点很关键：接收端拿到重发的消息，靠 msgID 就能认出&amp;quot;这是刚才那封&amp;quot;，而不是当成两条。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/ch2-sender-retry.png&#34; alt=&#34;第一道防线：发送端超时重发时序（弱网丢 ACK → 同 msgID 重发）&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;顺便澄清一个常见误区：有人觉得&amp;quot;TCP 不是保证可靠传输吗，为什么还要应用层 ACK？&amp;ldquo;TCP 的可靠只到对端内核收到字节，不保证应用真的处理了这条消息，也不管你有没有回已读。IM 的 ACK 是应用层的&amp;quot;我收到了、我处理了&amp;rdquo;，和 TCP 那层不是一回事。弱网下 TCP 连接本身可能重连，重连后上层的消息状态得靠 msgID 自己理清。&lt;/p&gt;&#xA;&lt;p&gt;这个 msgID 还顺手解决了&amp;quot;消息状态&amp;quot;的展示问题。你发消息时看到的灰圈、单勾、双勾，本质就是状态机在走：灰色&amp;quot;发送中&amp;quot;= 本地还没拿到 ACK；单勾=服务端已收；双勾=已送达对方设备（已读另靠回执）。前两个状态靠的就是这一层的 ACK，msgID 是把这些状态钉在同一条消息上的关键。&lt;/p&gt;&#xA;&lt;p&gt;生产里重试不会用固定超时硬等。固定 50 毫秒在真实移动网络下要么太短（地铁里 RTT 可能几百毫秒）要么太长（WiFi 下白白等）。&lt;/p&gt;&#xA;&lt;p&gt;常见做法是超时随重试次数指数退避，并设上限和总次数：退避防弱网里雪崩式重发，上限防一条消息永远卡在重试里。超过上限还没 ACK，这条消息就该交给第二道防线：服务端暂存，等对方上线再投。&lt;/p&gt;&#xA;&lt;p&gt;这层防的是&amp;quot;弱网丢包&amp;quot;，ACK 丢了，消息不至于石沉大海。但它有个前提：接收方迟早会回 ACK。可 IM 的真实情况是，对方手机锁屏、切了后台、进了电梯，他根本不在线。消息发到服务端，不能再干等。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/ch6-three-lines.png&#34; alt=&#34;三道防线总览：为什么是三道不是两道——少一层就露一个破绽&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;三第二道防线服务端把离线消息先存着&#34;&gt;&lt;a href=&#34;#%e4%b8%89%e7%ac%ac%e4%ba%8c%e9%81%93%e9%98%b2%e7%ba%bf%e6%9c%8d%e5%8a%a1%e7%ab%af%e6%8a%8a%e7%a6%bb%e7%ba%bf%e6%b6%88%e6%81%af%e5%85%88%e5%ad%98%e7%9d%80&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;三、第二道防线：服务端把离线消息先存着&#xA;&lt;/h2&gt;&lt;p&gt;第一道防线假设接收方在线。真实 IM 里对方常常不在：手机锁屏、电脑合盖、人在地下车库。这时候消息到服务端，得先存起来，等他上线再给。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;store-and-forward（存储转发）&lt;/strong&gt;。服务端收到消息先落库，在线设备立刻推，离线设备暂存，等它上线再从库里拉历史消息。&lt;/p&gt;&#xA;&lt;p&gt;落库存什么？至少消息体、发送者、发送时间、还有一条单调递增的序号 seq。seq 是后面&amp;quot;增量拉取&amp;quot;的锚点。存储上通常是两张表：一张消息表（按 seq 排序，全量历史），一张设备投递状态表（记录每个 &lt;code&gt;uid+device_id&lt;/code&gt; 拉到了哪个 seq）。demo 在 &lt;code&gt;evidence/code/server-store-forward&lt;/code&gt;，一台 phone 在线、pc 和 tablet 离线，服务端收到一条&amp;quot;晚上八点组局火锅&amp;quot;：&lt;/p&gt;&#xA;&lt;p&gt;（注意：真实落库还要按 &lt;code&gt;(uid, msgID)&lt;/code&gt; 做幂等写入——防线 1 的重发会用同一 msgID 再来一次，不幂等就会写进两条，把后面靠 &lt;code&gt;last_seq&lt;/code&gt; 的增量拉取也带乱。）&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;kd&#34;&gt;func&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;*&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Server&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Receive&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msg&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kt&#34;&gt;string&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 落库前按 (uid, msgID) 唯一约束 / Redis SETNX 做幂等写入，防止防线1 重发污染消息表&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;store&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;append&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;store&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msg&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 1. 落库（真实场景带 seq/发送者/时间）&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;for&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;_&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;range&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;devices&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Online&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;            &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Inbox&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;append&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Inbox&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msg&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 在线：立即推&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;else&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;            &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 离线：暂存，等上线拉取&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;kd&#34;&gt;func&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;*&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Server&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;OnConnect&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;devID&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kt&#34;&gt;string&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;:=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;devices&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;devID&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Online&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kc&#34;&gt;true&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Inbox&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;append&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;Inbox&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;store&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;...&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;// 上线拉历史&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;实测输出（Go 1.26.4 darwin/arm64）：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[服务端] 消息落库 seq=1: &amp;#34;晚上八点组局火锅&amp;#34;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[服务端] 推送给在线设备 phone&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[服务端] 设备 pc 离线，暂存，等待上线拉取&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[服务端] 设备 tablet 离线，暂存，等待上线拉取&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;--- PC 上线 ---&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[服务端] 设备 pc 上线，拉取历史消息 1 条&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;--- tablet 上线 ---&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[服务端] 设备 tablet 上线，拉取历史消息 1 条&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;各设备收件箱：&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  phone: [晚上八点组局火锅]&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  pc: [晚上八点组局火锅]&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;  tablet: [晚上八点组局火锅]&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/ch3-store-forward.png&#34; alt=&#34;第二道防线：服务端 store-and-forward（落库→推在线→离线暂存→上线拉取）&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;phone 当场收到，pc 和 tablet 上线后从库里拿到同一条。消息没因为设备离线而丢。&lt;/p&gt;&#xA;&lt;p&gt;在线设备为什么能&amp;quot;当场收到&amp;quot;？因为它走的是长连接推送通道（WebSocket 或自研长连），服务端主动推。但长连也会断，地铁进隧道，连接掉了，这段时间来的消息服务端不知道你在不在线，只能先落库。所以&amp;quot;在线推送&amp;quot;和&amp;quot;离线暂存&amp;quot;不是二选一，是一条消息先落库、能推就推、推不了就等上线拉，两条路都从落库这一步分出去。落库是这条防线的地基，没有它，长连一断消息就没了着落。&lt;/p&gt;&#xA;&lt;p&gt;但这里有个简化得说清楚：demo 让离线设备上线时拉了全量历史，真实系统不会这么干。真实做法是每台设备记一个&amp;quot;已读游标&amp;quot; &lt;code&gt;last_seq&lt;/code&gt;，上线只拉 &lt;code&gt;seq &amp;gt; last_seq&lt;/code&gt; 的消息，已经看过的不再推。游标是 IM 多端同步的核心：你在手机上看到了 seq=100，电脑上线时从 101 开始拉，不会把 100 再推一遍。这和防线 3 的去重是两套机制，一个管&amp;quot;拉哪些&amp;quot;，一个管&amp;quot;收到重复的怎么处理&amp;quot;，互补但不替代。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/ch7-seq-cursor.png&#34; alt=&#34;seq / last_seq 增量游标：已读的不再推&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;还有个 ACK 的小陷阱：设备收到推送会回 ACK，服务端据此标记&amp;quot;这台设备已投递&amp;quot;。但 at-least-once 下这个 ACK 也可能丢，服务端以为没投到，又推一次。于是设备可能收到两条一样的。这就接出了第三道防线。&lt;/p&gt;&#xA;&lt;h2 id=&#34;四第三道防线接收端按-msgid-多端去重&#34;&gt;&lt;a href=&#34;#%e5%9b%9b%e7%ac%ac%e4%b8%89%e9%81%93%e9%98%b2%e7%ba%bf%e6%8e%a5%e6%94%b6%e7%ab%af%e6%8c%89-msgid-%e5%a4%9a%e7%ab%af%e5%8e%bb%e9%87%8d&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;四、第三道防线：接收端按 msgID 多端去重&#xA;&lt;/h2&gt;&lt;p&gt;前两层保证&amp;quot;到&amp;quot;和&amp;quot;不丢&amp;quot;。第三层保证&amp;quot;不重&amp;quot;，而且是在&amp;quot;多端&amp;quot;这个 IM 特有的麻烦上。&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;幂等（Idempotent）&lt;/strong&gt;。同一条消息处理多次和处理一次效果相同。&lt;strong&gt;去重&lt;/strong&gt;。靠 msgID 认出重复并丢弃。在 IM 里这俩要绑在&amp;quot;多端&amp;quot;上理解：一条消息要出现在我手机、电脑、平板上，但每台设备上只能出现一次；设备重连后服务端重推的历史消息，也不能在我某台设备上冒出第二条。&lt;/p&gt;&#xA;&lt;p&gt;这和 MQ 的消费端幂等不一样。MQ 里常见用 Redis SetNX 给&amp;quot;单个消费者&amp;quot;去重，一条消息从队列出来，别被同一个消费者重复处理。IM 不是这样：你有三台设备，每台都是独立的消费者，各自有消费状态。难点不是&amp;quot;一个人别重复处理同一条&amp;quot;，是&amp;quot;一个人的多台设备各自别重复，且重连时的历史消息也别重复&amp;quot;。去重的维度从 &lt;code&gt;(consumer_group, msgID)&lt;/code&gt; 变成了 &lt;code&gt;(uid, device_id, msgID)&lt;/code&gt;。多出来的 &lt;code&gt;device_id&lt;/code&gt; 就是 IM 和 MQ 的本质差别。&lt;/p&gt;&#xA;&lt;p&gt;demo 在 &lt;code&gt;evidence/code/receiver-dedup&lt;/code&gt;，每个设备维护自己的 &lt;code&gt;seen&lt;/code&gt; 集合（生产环境用 Redis SETNX 或 DB 唯一约束 + 已读游标），先看同一消息推三端，再看服务端重推：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-go&#34; data-lang=&#34;go&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;kd&#34;&gt;func&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;*&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;DeviceInbox&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Deliver&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msgID&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;body&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kt&#34;&gt;string&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;seen&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msgID&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;fmt&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nf&#34;&gt;Printf&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&amp;#34;[%s] msgID=%s 已消费过，丢弃（去重）\n&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;ID&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msgID&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;return&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;seen&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;msgID&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;kc&#34;&gt;true&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;d&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;nx&#34;&gt;consumed&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;++&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;实测输出（Go 1.26.4 darwin/arm64）：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-fallback&#34; data-lang=&#34;fallback&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;=== 场景1：同一消息推给一个用户的三台设备 ===&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[phone] 消费 msgID=m-7b2d11 body=&amp;#34;晚上八点组局火锅&amp;#34;（本设备第 1 条）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[pc] 消费 msgID=m-7b2d11 body=&amp;#34;晚上八点组局火锅&amp;#34;（本设备第 1 条）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[tablet] 消费 msgID=m-7b2d11 body=&amp;#34;晚上八点组局火锅&amp;#34;（本设备第 1 条）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;=== 场景2：at-least-once 下服务端重推同一条历史消息 ===&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[phone] msgID=m-7b2d11 已消费过，丢弃（去重）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;[pc] msgID=m-7b2d11 已消费过，丢弃（去重）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;各设备最终消费条数：phone=1 pc=1 tablet=1（均为 1，无重复）&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/ch4-multidevice-dedup.png&#34; alt=&#34;第三道防线：接收端多端幂等去重（每设备独立 seen，重推丢弃）&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;三台设备各出现一次，消息要上我所有设备；重推的旧消息被各自丢弃，不能在我某台设备上冒出第二条。这就是 IM 幂等去重和 MQ 单消费者去重不一样的地方：它防的不是&amp;quot;一个消费者重复处理&amp;quot;，而是&amp;quot;一个用户的多端重复看到&amp;quot;。&lt;/p&gt;&#xA;&lt;p&gt;把 &lt;code&gt;device_id&lt;/code&gt; 这层展开看会更清楚：手机上的 &lt;code&gt;seen&lt;/code&gt; 有 &lt;code&gt;m-7b2d11&lt;/code&gt;，不代表电脑的 &lt;code&gt;seen&lt;/code&gt; 也有，它们是各自独立的集合。所以电脑第一次收到这条，要显示、并把自己的 &lt;code&gt;seen&lt;/code&gt; 写进去；手机重推时，手机的 &lt;code&gt;seen&lt;/code&gt; 已经有过，直接丢弃。正是这种&amp;quot;每设备消费状态独立&amp;quot;，才让&amp;quot;消息上我所有设备、每台只一次&amp;quot;同时成立。MQ 的单消费者去重没有 &lt;code&gt;device_id&lt;/code&gt; 这一维，因为它根本不存在&amp;quot;一个用户多台设备&amp;quot;的语义。&lt;/p&gt;&#xA;&lt;p&gt;生产里 &lt;code&gt;seen&lt;/code&gt; 集合不会只放内存，进程一重启就没了。常用两招：Redis SETNX，天然&amp;quot;存在则设失败&amp;quot;，正好表达&amp;quot;这条我已处理过&amp;quot;；或者 DB 上给 &lt;code&gt;(uid, device_id, msgID)&lt;/code&gt; 建唯一约束，插入冲突即判重复。图片、文件这类大消息，去重按 msgID 走，资源本身靠 msgID 引用同一份，不会重复下载。&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;seen&lt;/code&gt; 也不能无限涨。at-least-once 的重推通常集中在短时间内（弱网抖动、刚上线那几秒），所以去重窗口一般只保留近期，按会话或按时间清理，或只记最近 N 条 seq 的位图。真要长期防重，最终还是落到 DB 唯一约束那层，内存窗口只是挡掉高频重推的提速手段。&lt;/p&gt;&#xA;&lt;p&gt;顺带一提：去重管的是&amp;quot;同一条消息别显示两次&amp;quot;，它不解决&amp;quot;已读状态多端同步&amp;quot;，你在手机上读了，电脑上得显示已读，那是另一个靠 &lt;code&gt;last_seq&lt;/code&gt; 和回执广播解决的问题，不在本文范围，但它是 IM 多端复杂度里另一块难啃的骨头。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/ch8-exactly-once-myth.png&#34; alt=&#34;exactly-once 误区：把 MQ 的&amp;#34;单消费者单分区&amp;#34;语义套进 IM 堵不住&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;五三层怎么接力一条时间线看全&#34;&gt;&lt;a href=&#34;#%e4%ba%94%e4%b8%89%e5%b1%82%e6%80%8e%e4%b9%88%e6%8e%a5%e5%8a%9b%e4%b8%80%e6%9d%a1%e6%97%b6%e9%97%b4%e7%ba%bf%e7%9c%8b%e5%85%a8&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;五、三层怎么接力：一条时间线看全&#xA;&lt;/h2&gt;&lt;p&gt;一个场景把三层串起来：你发&amp;quot;晚上八点组局火锅&amp;quot;，我的手机在充电、电脑锁屏。&lt;/p&gt;&#xA;&lt;p&gt;弱网触发防线 1：地铁里第一条 ACK 丢了，客户端用同一 msgID 重发，服务端最终收到。离线触发防线 2：服务端落库暂存，两小时后我上线靠 &lt;code&gt;last_seq&lt;/code&gt; 拉到。多端重推触发防线 3：电脑醒来，服务端又推一次，但 &lt;code&gt;seen&lt;/code&gt; 已认出重复并丢弃。&lt;/p&gt;&#xA;&lt;p&gt;任何一层单拿出来都有可感破绽：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;缺防线 1，地铁里发出的消息可能永远卡在&amp;quot;已发送&amp;quot;，对方收不到；&lt;/li&gt;&#xA;&lt;li&gt;缺防线 2，我离线时消息直接丢，上线也救不回来；&lt;/li&gt;&#xA;&lt;li&gt;缺防线 3，我醒来看到三条&amp;quot;晚上八点组局火锅&amp;quot;，得自己判断哪条是真的。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;三层各管一种失败模式：弱网、离线、多端。少一层，就露一个破绽；三层叠起来，用户只感知到一句：消息到了，且只有一条。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/ch5-timeline.png&#34; alt=&#34;三层接力时间线：弱网&amp;#43;离线&amp;#43;多端重推，三层如何兜底&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;六为什么别追-exactly-once&#34;&gt;&lt;a href=&#34;#%e5%85%ad%e4%b8%ba%e4%bb%80%e4%b9%88%e5%88%ab%e8%bf%bd-exactly-once&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;六、为什么别追 exactly-once&#xA;&lt;/h2&gt;&lt;p&gt;说到这，有人会问：那直接用 exactly-once（Kafka 事务、SQS FIFO）不就好了？&lt;/p&gt;&#xA;&lt;p&gt;这是 IM 里最常见的误解，得说清楚。exactly-once 在消息中间件里指的是&amp;quot;单消费者对单分区的消息精确处理一次&amp;quot;（这是为便于对比的简化模型，真实是靠幂等+事务实现的 effectively-once），它解决的是&amp;quot;一个消费者别重复处理同一条&amp;quot;。但 IM 要的不是这个，是一条消息推给一个用户的 N 台设备，且设备可能离线。即使你用 Kafka 事务保证了&amp;quot;服务端处理一次&amp;quot;，到了&amp;quot;推给手机、电脑、平板&amp;quot;这一步，仍然是 at-least-once：网络丢一个 ACK，某台设备就得多收一次，然后靠那台设备的去重兜住。&lt;/p&gt;&#xA;&lt;p&gt;（上面这段是逻辑推演，不是我跑出来的实测。但推理链只有两步，前提明确：exactly-once 的语义边界是&amp;quot;单消费者单分区&amp;quot;，而 IM 的失败模式是&amp;quot;多端 + 离线&amp;quot;。把前者套到后者上，语义根本没覆盖到真正的失败点。）&lt;/p&gt;&#xA;&lt;p&gt;实践上 exactly-once 也贵。我的判断是：Kafka 事务、SQS FIFO 这类&amp;quot;精确一次&amp;quot;方案在吞吐和复杂度上的代价，远高于 at-least-once，因为&amp;quot;精确一次&amp;quot;要么靠事务把多步操作绑成原子（牺牲吞吐），要么靠去重表把重复挡在存储层（增加复杂度）。我的看法是：在 IM 这种本质就是 at-least-once 的场景里，花大代价追 exactly-once 是打错靶，把三层保障做对，比换语义实在得多。你纠结语义的名字，不如纠结 msgID 有没有贯穿三层、去重的维度有没有带上 device_id。&lt;/p&gt;&#xA;&lt;p&gt;有人会说&amp;quot;Kafka 也能多个消费者啊&amp;quot;。但那是不同的消费者组各消费一遍，不是同一用户的多台设备；而且消费者组之间不解决&amp;quot;设备离线&amp;quot;，离线那段还是得自己落库。把 MQ 的 exactly-once 搬进 IM，等于用一套不覆盖&amp;quot;多端 + 离线&amp;quot;的语义去堵 IM 真正的洞，堵不住的。&lt;/p&gt;&#xA;&lt;p&gt;所以别再听见 at-least-once 就皱眉。在 IM 里，它不是 bug，是唯一正确的默认。真正的 bug 是把&amp;quot;可能收到多次&amp;quot;留给用户去忍；而这三道防线，让重复停在工程层：msgID 贯穿三层、去重带上 device_id，用户几乎察觉不到。&lt;/p&gt;&#xA;&lt;p&gt;&#xA;    &lt;img src=&#34;https://img.wujiachen.com.cn/im-reliable-delivery/ch9-pullquote.png&#34; alt=&#34;金句：at-least-once 不是 bug，是唯一正确的默认&#34; loading=&#34;lazy&#34;&gt;&lt;/p&gt;&#xA;&lt;p&gt;你们线上 IM 的去重维度是怎么设计的？按 (uid, device_id, msgID)，还是只按 msgID？欢迎在评论区聊聊你踩过的坑。&lt;/p&gt;&#xA;&lt;h2 id=&#34;附录实验代码和原始数据&#34;&gt;&lt;a href=&#34;#%e9%99%84%e5%bd%95%e5%ae%9e%e9%aa%8c%e4%bb%a3%e7%a0%81%e5%92%8c%e5%8e%9f%e5%a7%8b%e6%95%b0%e6%8d%ae&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;附录：实验代码和原始数据&#xA;&lt;/h2&gt;&lt;p&gt;本文 3 组实验的代码和实测输出已开源：&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;GitHub：&lt;a class=&#34;link&#34; href=&#34;https://github.com/wujiachen0727/zhiyulab-evidence/tree/main/im-reliable-delivery&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;zhiyulab-evidence/im-reliable-delivery&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;code&gt;sender-retry/&lt;/code&gt;：发送端带唯一 msgID 的超时补传（E1，对应第一道防线）&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;server-store-forward/&lt;/code&gt;：服务端 store-and-forward 离线暂存（E2，对应第二道防线）&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;receiver-dedup/&lt;/code&gt;：接收端按 (uid, device_id, msgID) 多端幂等去重（E3，对应第三道防线）&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;每个子目录都有独立 README，说明如何 &lt;code&gt;go run&lt;/code&gt; 复现。二进制编译产物不入库，跑实验前自己 &lt;code&gt;go build&lt;/code&gt;。&lt;/p&gt;&#xA;&#xA;    &lt;blockquote&gt;&#xA;        &lt;p&gt;原文发布于 &lt;a class=&#34;link&#34; href=&#34;https://www.wujiachen.com.cn/posts/im-reliable-delivery&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;止语Lab&lt;/a&gt;&lt;/p&gt;&#xA;&#xA;    &lt;/blockquote&gt;&#xA;</description>
        </item></channel>
</rss>
