如何给 Go 语言的 TCP 聊天服务加上 ACK 可靠送达机制

0 阅读5分钟

如何给 Go 语言的 TCP 聊天服务加上 ACK 可靠送达机制

在我们学习 Go 语言网络编程时,实现一个简单的 TCP 聊天室往往是入门的必经之路。原项目8h-GoIM通过建立 TCP 连接并将接收到的文本广播给所有在线用户,非常直观地展示了 Go 语言在并发和通道设计上的优雅。

然而,如果要把这个“玩具”推向生产环境的“可用消息通道”,单纯的文本广播就显得捉襟见肘了。最近,我为这个项目引入了消息 ACK(确认)机制。本文将以技术分享的形式,复盘这次重构的设计思考、实现细节以及踩过的坑。

为什么纯文本广播不够用?

在最初的设计中,客户端发来一行文本,服务器原封不动地转发给其他 socket。虽然简单,但在真实的弱网环境下存在以下致命缺陷:

  • 重复推送不可控(去重):客户端因为网络超时触发重试,服务端会把同一句话当作两条新消息广播;
  • 消息时序混乱(有序):多客户端高并发发言时,没有一个基准的序号,各个接收端看到的消息顺序可能不一致;
  • 无状态导致的丢失(可靠):网络瞬断或对端掉线期间的消息,一旦错过就永远消失了,服务器没有任何送达记录。

因此,我们的首要目标并不是一上来就搞定高可用的分布式架构,而是立足单机,先确保在线场景下消息“不重、不乱序、可确认”,为以后的离线消息漫游打下结构化的基石。

协议重塑:从字符串到状态机

要追踪一条消息的生命周期,首先必须给它发“身份证”。

为了兼顾现有代码里 bufio.Reader 按行读取的简便性,我选用了单行 JSON 作为传输协议。我们定义了一个包含类型和各种元数据的 Message 结构:

{"type":"send","client_msg_id":"c-12345","chat_id":"room1","from":"alice","body":"hello world!"}

这里面藏了几个关键字段的设计考量:

  • type:我们把它分成了 send(发送)、send_ack(发送确认)、deliver(投递)、deliver_ack(投递确认) 和 sync(拉取同步)。
  • client_msg_id这是去重的核心。客户端在本地生成一个唯一 ID(如 UUID 或纳秒时间戳),服务端据此判断是否为重复请求。
  • server_msg_id:服务端接收落盘后生成的全局唯一 ID,作为系统内流转的凭证。
  • seq:服务端针对某个 chat_id(会话空间)分配的严格单调递增序号,用来保证业务侧的严格有序。

当服务器收到上述的 send 请求并处理成功后,会即刻响应:

{"type":"send_ack","client_msg_id":"c-12345","server_msg_id":"s-98765","seq":10}

紧接着,服务器打包 deliver 消息推给目标接收者,接收者收到后必须回复 deliver_ack

核心架构拆解

在服务端,要支撑这套状态扭转,我引入了三个关键组件隔离了原先混在单一逻辑里的业务:

Store (内存状态管理器)

目前的实现叫 InMemoryStore,它承担了三个职责:

  1. 防重:内部用 Map 维护 (from, client_msg_id),如果遇到历史出现过的键,直接返回原有的 send_ack(极简实现幂等);
  2. 分配序列号:通过对 chat_id 的原子操作自增,生成连续的 seq
  3. 维护状态:为每一条投递记录打上 Pending(待确认)标签。

DeliverQueue + DeliverWorker (异步投递队列)

客户端发完消息直接获得 send_ack 代表服务器“已揽收”,随后这个 server_msg_id 会被扔进异步的 DeliverQueue 中。

后台的 DeliverWorker 监听这个队列,取出消息后:

  • 从 Store 拉取目标接收人(无论是私聊的一个人还是广播的所有人);
  • 如果对方在线,发送 deliver JSON;
  • 对方不仅收到了数据,也知道了 server_msg_id,借此回传 deliver_ack
  • 如果对方不在线,这条记录依然在 Store 中保持 Pending。

业务入口聚合处理

改造了原来的字符串处理流程 HandleClientSend 伪代码如下:

func HandleClientSend(req *Message) {
    // 1. 幂等拦截
    if store.Exist(from, client_msg_id) {
       return reply(send_ack)
    }
    
    // 2. 生成凭证
    seq := store.NextSeq(chat_id)
    serverMsgID := newID()
    
    // 3. 落库与投递列表
    store.SaveMessage(serverMsgID, req)
    store.SaveDelivery(serverMsgID, recipients)
    
    // 4. 回写成功响应
    reply(send_ack_with_seq)
    
    // 5. 送入后台投递
    enqueue(DeliverQueue, serverMsgID)
}

实践中的反思与权衡

由于是迭代性质的升级,过程中做了一些务实的取舍:

  • 为什么把 seq 生成放在服务端? 如果依赖客户端维护序列号,在多端并发发送同一个群组通道时必然产生冲突。服务端作为唯一权威的发号器,保证了同一 chat_id 下的单调性,接收端只需要依据 seq 就可以轻松识别出乱序或者缺失。
  • 关于拥塞控制 原版项目一旦对端读的慢,写入就会被卡死阻塞。现在因为投递操作变成了独立的异步任务并交给客户端专属的 Goroutine Select 处理,配合定期的心跳或写超时机制,拥塞引起的系统假死已被规避,大不了断开那个死木头连接,反正我们的 Store 仍会把发送状态定义为 Pending。

测试验证

要验证这个新机制非常直观。开两个终端连接服务器:

客户端 A 发送: {"type":"send","client_msg_id":"apple123","body":"Hello!"}

客户端 A 收到服务器回应: {"type":"send_ack","client_msg_id":"apple123","server_msg_id":"s-001","seq":1}

客户端 B 收到被推过来的消息: {"type":"deliver","server_msg_id":"s-001","from":"客户端A","seq":1,"body":"Hello!"}

客户端 B 按照规矩给服务器回传: {"type":"deliver_ack","server_msg_id":"s-001"}

整个闭环极其清晰。如果 B 故意不回发 deliver_ack,该记录将一直“挂起”,这是我们后续补偿机制的基础。