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

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

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

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

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

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

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

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

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

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

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

json 复制代码
{"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 请求并处理成功后,会即刻响应:

json 复制代码
{"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 伪代码如下:

go 复制代码
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,该记录将一直"挂起",这是我们后续补偿机制的基础。

相关推荐
小羊没烦恼!2 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
伞伞悦读3 天前
【第38期】Python 模块与包详解:import、from、模块搜索路径、包结构和 __init__
开发语言·python
ttwuai3 天前
Go开源后台管理系统推荐:怎么按技术栈和边界比较4个官方仓库?
golang·gin
C语言小火车3 天前
C/C++ 为什么需要编译器?
开发语言·c++
codeejun3 天前
每日一Go·MySQL-5、锁机制全解析
云原生·golang
霍霍的袁3 天前
【C++】map 和 set 的使用 | 从用法到底层
开发语言·c++·学习·visual studio
孙启超3 天前
【AI开发之Rust】第 11 课:智能指针与内部可变性
开发语言·后端·rust
此生决int3 天前
深入理解C++系列(20)——C++11(下)
开发语言·c++
Achou.Wang3 天前
k8s中nginx worker process自动设置
后端·golang
CoderYanger3 天前
A.每日一题:835. 图像重叠
java·开发语言·程序人生·leetcode·面试·职场和发展·学习方法