如何给 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,它承担了三个职责:
- 防重:内部用 Map 维护
(from, client_msg_id),如果遇到历史出现过的键,直接返回原有的send_ack(极简实现幂等); - 分配序列号:通过对
chat_id的原子操作自增,生成连续的seq; - 维护状态:为每一条投递记录打上
Pending(待确认)标签。
DeliverQueue + DeliverWorker (异步投递队列)
客户端发完消息直接获得 send_ack 代表服务器“已揽收”,随后这个 server_msg_id 会被扔进异步的 DeliverQueue 中。
后台的 DeliverWorker 监听这个队列,取出消息后:
- 从 Store 拉取目标接收人(无论是私聊的一个人还是广播的所有人);
- 如果对方在线,发送
deliverJSON; - 对方不仅收到了数据,也知道了
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,该记录将一直“挂起”,这是我们后续补偿机制的基础。