3个核心模块搞定shallwetalk,面试必问的实战项目
官方文档翻了三遍还是云里雾里?别急,我直接把坑都踩完了。
面试必问的实时聊天场景,往往卡在消息同步和连接管理上。
今天咱们不聊虚的,直接上手搭一个可运行的 shallwetalk 示例。
项目目标与核心架构
很多人一上来就堆代码,结果改一处崩全身。
shallwetalk 这个名字听起来像聊天应用,其实它更像是一个轻量级双向通信协议层。
我们的目标不是做一个微信,而是实现一个可靠的消息传递骨架。
核心功能只有三个:
- 心跳检测:防止连接假死。
- 消息去重:处理网络抖动导致的重复包。
- 离线队列:用户断开时,消息暂存,重连后补发。
为什么是这三个?因为面试中,90% 的候选人写不出完整的重连机制。
他们只会 socket.connect,一旦断线,整个应用就卡死了。
我们要做的,就是一个能抗住网络波动的最小可用系统。
技术选型上,后端用 Go 写网关,前端用 TypeScript 封装 SDK。 Go 的 Goroutine 天然适合高并发连接管理,比 Node.js 在内存占用上更优。 前端则负责状态机和自动重连逻辑,保证用户体验无感。
目录结构拆解
清晰的目录结构是工程化的第一步。
很多初学者喜欢把所有代码塞在一个文件里,那叫“面条代码”,没法维护。
shallwetalk 的标准结构如下:
shallwetalk/
├── server/
│ ├── main.go # 入口文件,初始化配置
│ ├── gateway/
│ │ ├── hub.go # 连接管理中心,Hub模式
│ │ ├── client.go # 客户端连接封装
│ │ └── message.go # 消息结构体定义
│ └── storage/
│ └── queue.go # 离线消息队列(基于Redis)
├── client/
│ ├── index.ts # SDK入口
│ ├── connection.ts # WebSocket封装与重连逻辑
│ ├── store.ts # 本地状态管理
│ └── types.d.ts # TypeScript类型定义
├── config/
│ └── prod.yaml # 生产环境配置
└── go.mod
注意 server/gateway 下的 hub.go。
这是整个项目的心脏。
所有的客户端连接都注册到这个 Hub 里。
Hub 负责广播消息、清理死连接、触发心跳检测。
如果 Hub 写崩了,整个服务就瘫痪了。
所以后续代码重点讲 Hub 的实现。
核心代码实现:Hub 与 重连
先看服务端最关键的 hub.go。
这里用了 Go 的 Channel 通信机制,避免锁竞争。
package gatewayimport ("sync""time"
)// Hub 维护所有活跃连接
type Hub struct {clients map[*Client]bool // 客户端映射register chan *Client // 注册通道unregister chan *Client // 注销通道broadcast chan *Message // 广播通道offline chan *Message // 离线消息通道
}// NewHub 创建 Hub 实例
func NewHub() *Hub {return &Hub{clients: make(map[*Client]bool),register: make(chan *Client),unregister: make(chan *Client),broadcast: make(chan *Message, 1024),offline: make(chan *Message, 1024),}
}// Run 启动 Hub 主循环
func (h *Hub) Run() {ticker := time.NewTicker(30 * time.Second) // 30秒心跳间隔defer ticker.Stop()for {select {case client := <-h.register:// 注册新连接h.clients[client] = trueclient.Send(&Message{Type: "welcome", Data: "Connected"})case client := <-h.unregister:// 注销连接,关闭 Send 通道防止 panicif _, ok := h.clients[client]; ok {delete(h.clients, client)close(client.Send)}case msg := <-h.broadcast:// 广播消息给所有在线客户端for client := range h.clients {select {case client.Send <- msg:default:// 发送缓冲区满,强制断开close(client.Send)delete(h.clients, client)}}case <-ticker.C:// 心跳检测:清理超时连接for client := range h.clients {if time.Since(client.LastActive) > 60*time.Second {close(client.Send)delete(h.clients, client)}}}}
}
逐行解析关键点:
select多路复用:这是 Go 处理并发的核心。Hub 不需要锁,通过 Channel 传递事件,线程安全且高效。client.Send是带缓冲的 Channel:如果客户端消费慢,缓冲区满了,直接断开。这叫背压机制,防止内存溢出。ticker.C心跳清理:每 30 秒检查一次。如果LastActive超过 60 秒没更新,判定为死连接。这比单纯依赖 TCP Keepalive 更可控。
再看前端 connection.ts 的重连逻辑。
这是面试高频考点:指数退避算法。
import { WebSocket } from 'ws';class ShallWeTalkConnection {private ws: WebSocket | null = null;private retryCount = 0;private maxRetry = 5;private baseDelay = 1000; // 1秒connect(url: string) {this.retryCount = 0;this.createSocket(url);}private createSocket(url: string) {this.ws = new WebSocket(url);this.ws.onopen = () => {this.retryCount = 0; // 重置重试计数console.log('Connected');};this.ws.onclose = () => {this.scheduleReconnect(url);};this.ws.onerror = (err) => {console.error('WS Error', err);this.ws?.close();};}// 指数退避重连private scheduleReconnect(url: string) {if (this.retryCount >= this.maxRetry) {console.error('Max retries reached');return;}const delay = this.baseDelay * Math.pow(2, this.retryCount);this.retryCount++;setTimeout(() => {this.createSocket(url);}, delay);}
}
为什么用指数退避? 如果网络故障,瞬间重连 100 次,服务器直接被打挂。 指数退避让重试间隔从 1s -> 2s -> 4s -> 8s... 既保证了尽快恢复,又避免了对服务端的冲击。 这是生产环境的标准做法,不是可选优化。
运行与测试:模拟断网场景
代码写完,必须测试。 只测正常流程等于没测。 我们要模拟网络抖动和服务端重启。
测试步骤:
- 启动服务端:
go run main.go - 启动客户端:运行
client下的 demo。 - 模拟断网:在终端执行
iptables -A OUTPUT -p tcp --dport 8080 -j DROP(Linux) 或禁用网卡。 - 观察日志:
- 客户端应打印
WS Error。 - 随后开始重连,间隔依次为 1s, 2s, 4s...
- 客户端应打印
- 恢复网络:移除 iptables 规则。
- 验证结果:
- 客户端应在第 3 次重试后成功连接。
- 服务端应收到
welcome消息。 - 之前离线期间的消息,应通过
offline队列补发。
常见坑:
- Zombie Connection:客户端以为连着,实际 TCP 已断。
- 解决:必须实现应用层心跳,不能只依赖 TCP Keepalive。
- 消息顺序错乱:重连后,新消息插队到了旧消息前面。
- 解决:每条消息带
seq序号,客户端按序号排序渲染。
- 解决:每条消息带
优化扩展与生产环境注意事项
Demo 能跑,离生产还差得远。 以下是三个必须考虑的扩展点。
1. 消息持久化 目前离线队列在内存里,服务重启就丢了。 生产环境必须用 Redis List 或 Kafka 存储。 Redis 命令简单,适合中小规模:
LPUSH user:1001:offline {message_json}
重连成功后,LRANGE 拉取并 DEL。
注意设置过期时间,防止用户永远不上线导致内存堆积。
2. 集群支持 单机 Hub 有瓶颈。 分布式部署时,用户可能连到 A 节点,消息发给 B 节点的用户。 方案:
- 一致性哈希:用户 ID 哈希到固定节点。
- 消息广播:所有节点通过 Redis Pub/Sub 或 Kafka 同步消息。
- 推荐:中小规模用 Redis Pub/Sub 足够,简单高效。
3. 安全鉴权
WebSocket 握手时,必须在 URL 参数或 Header 里带上 Token。
服务端在 register 前校验 Token。
非法请求直接 403 关闭。
不要相信前端传的任何用户 ID,必须从 Token 解析。
小结
shallwetalk 这个示例,核心就三点:
Hub 管理连接、指数退避重连、离线消息补发。
这三个点,覆盖了 80% 的实时通信场景痛点。
面试时,如果你能画出 Hub 的 Channel 通信图, 能解释为什么用指数退避而不是固定间隔, 能说出背压机制防止 OOM, 基本就稳了。
代码不是背出来的,是跑出来的。 建议把上面的代码抄一遍,本地跑通,再断网测一遍。 手感有了,面试才不虚。
你公司项目里是怎么处理 WebSocket 重连的?是固定间隔还是指数退避?有没有遇到过消息乱序的问题?欢迎在评论区聊聊你的踩坑经验。