果唯云客服是一款面向中小企业的多渠道在线客服 SaaS,把网站、公众号、小程序、App 的客户咨询统一接入一个工作台,主打"小企业用得起的在线客服"。 团队很小,后端从 PHP 起步,但长连接、消息推送、访客轨迹这几条关键链路最终全部用 Go 重写。这篇文章不聊产品,只聊工程:我们把在线客服里最难啃的几块——WebSocket 长连接网关、消息可靠投递、etcd+gRPC 服务治理、ClickHouse 访客轨迹分析——是怎么一步步做出来的,以及踩过的那些坑。
先说结论:在线客服系统的本质,是一个低延迟、高可靠、多租户的即时通讯系统,再叠加会话分配、工单、机器人、数据报表等业务能力。
我们最终落地的架构分四层:
┌─────────────────────────────────────────────────────┐
│ 接入层(多渠道) │
│ Web / H5 一行JS | 小程序 SDK | App SDK | 公众号 │
└──────────────────────┬──────────────────────────────┘
│ WSS
┌──────────────────────▼──────────────────────────────┐
│ 接入网关层 WebSocket Gateway(无状态,水平扩展) │
│ 连接管理 | 心跳保活 | 协议编解码 | 鉴权 | 限流 │
└──────────────────────┬──────────────────────────────┘
│ gRPC
┌──────────────────────▼──────────────────────────────┐
│ 核心服务层(Go 微服务) │
│ 会话服务 | 消息服务 | 路由分配 | 机器人 | 工单服务 │
│ 访客服务 | 满意度评价 | 报表服务 │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ 基础设施层 │
│ etcd(服务发现/配置) | Redis(在线状态/离线消息/缓存) │
│ MySQL(业务主库) | ClickHouse(访客轨迹/行为分析) │
│ Kafka(消息削峰/解耦) | Prometheus+Grafana(监控) │
└─────────────────────────────────────────────────────┘
几个关键设计决策,贯穿下文:
tenant_id,SQL 强制绑定租户条件,防止越权串数据。我们早期是 PHP 栈,业务迭代很快,但两条硬伤越来越明显:
切到 Go 后,感受最直接的几点:
net/http、encoding/json 够用;gorilla/websocket、grpc-go、go-redis 等库非常成熟,踩坑少。补充一点:我们并没有"一刀切"把 PHP 全干掉。管理后台、报表这类 CRUD 密集、并发要求不高的场景,PHP 依然在跑,Go 只接管对性能敏感、对并发要求高的核心链路。技术栈迁移别搞运动式重构,按链路逐步替换才是稳的。
网关最核心的数据结构是一个"连接对象"。它至少要管三件事:底层 WS 连接、上行发送队列、所属用户/租户信息。
// Conn 封装一个 WebSocket 连接
type Conn struct {
ws *websocket.Conn
send chan []byte // 发送队列,缓冲一定量
tenantID string // 租户 ID
userID string // 当前登录用户(访客 or 坐席)
userType UserType // visitor / agent
deviceID string // 设备标识,用于多端
mu sync.Mutex // 保护并发写
closed atomic.Bool
}
这里有两个容易被新手忽略的点:
不要多 goroutine 并发写同一个 websocket.Conn。gorilla/websocket 要求同一时刻只能有一个 goroutine 写、一个 goroutine 读。我们统一用"发送队列 + 单个写泵"的模式:业务侧只往 send channel 塞消息,写泵 goroutine 串行消费并写出。经典的三段式:读泵、写泵、心跳。
// 读泵:负责从连接上读消息
func (c *Conn) readPump() {
defer c.close()
c.ws.SetReadLimit(maxMessageSize)
_ = c.ws.SetReadDeadline(time.Now().Add(pongWait))
c.ws.SetPongHandler(func(string) error {
return c.ws.SetReadDeadline(time.Now().Add(pongWait))
})
for {
_, data, err := c.ws.ReadMessage()
if err != nil {
return
}
// 解析并派发到消息服务
c.handle(data)
}
}
// 写泵:串行消费发送队列
func (c *Conn) writePump() {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case msg, ok := <-c.send:
if !ok {
_ = c.ws.WriteMessage(websocket.CloseMessage, []byte{})
return
}
_ = c.ws.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.ws.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
// 服务端主动 ping,探测半开连接
_ = c.ws.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.ws.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
心跳是长连接系统最不能省的东西。 没有心跳,客户端拔网线、路由器断网这类"半开连接"永远不会被发现,连接会被白白占着,直到系统资源耗尽。我们的节奏是:客户端 30s 发一次应用层 ping,服务端 30s 发一次 WS ping + 60s 读超时兜底,任意一端发现超时就主动断开。
网关是无状态集群,客户端连到哪台机器是随机的。那"给某个用户推送一条消息",怎么知道该推给哪台机器?
答案是注册表:每个连接建立时,往 Redis 里写一条 online:{tenantID}:{userID} -> {gatewayID}:{connID};断开时删掉。推送时先查注册表,命中就把消息发到对应网关的 gRPC 接口,由网关找到本地连接写出去。
// 连接建立时注册在线状态,TTL 由心跳续期兜底
func registerOnline(ctx context.Context, u *Conn) error {
key := fmt.Sprintf("online:%s:%s", u.tenantID, u.userID)
val := fmt.Sprintf("%s:%s", u.gatewayID, u.connID)
// 10s TTL,靠心跳续期,防止异常退出导致僵尸在线状态
return rdb.Set(ctx, key, val, 10*time.Second).Err()
}
这里有个细节:在线状态不能只在断开时删,因为可能异常崩溃根本没机会删。所以我们给在线状态设置了短 TTL(比如 10s),每次收到客户端心跳就续期。这样即使进程被 kill,几秒内状态也会自动过期,不会出现"用户明明下线了还显示在线"的脏数据。
给单个用户推消息,走"查注册表 → 转发到对应网关"就够了。但客服系统里有个特殊场景:坐席批量广播(比如管理员发全员公告)、会话内多端同步。这时候一条消息可能要推给多个用户、多个网关。
我们的做法是引入一层轻量级的推送分发:把"目标用户集合"解析成"网关 → 用户列表"的映射,并行调各网关的 gRPC 批量推送接口。网关内部再按连接去重、逐个写出。
// 推送分发:把用户集合按网关分组,并行推送
func PushToUsers(ctx context.Context, tenantID string, userIDs []string, msg []byte) error {
// 1. 批量查在线注册表,得到 gateway -> []connID 的映射
groups, err := resolveOnline(ctx, tenantID, userIDs)
if err != nil {
return err
}
// 2. 并行推送到各网关
eg := errgroup.Group{}
for gwID, conns := range groups {
gwID, conns := gwID, conns
eg.Go(func() error {
return gwClient(gwID).Push(ctx, &PushReq{TenantID: tenantID, ConnIDs: conns, Data: msg})
})
}
return eg.Wait()
}
为什么不让网关之间直接互联,而是统一走服务层分发? 因为网关要尽量"傻",职责单一,业务规则(谁能推给谁、要不要做敏感词过滤、要不要落库)全部收敛在服务层,网关只是执行者。这样后续加网关、换协议都容易。
长连接系统最大的坑,是"消息到底送没送到"。网络抖动、断线重连、进程崩溃,任何一个环节都可能丢消息或重复消息。我们的原则是:消息至少送达一次(at-least-once),业务层做幂等去重。
客户端生成消息时带上全局唯一的 msg_id(UUID 或 雪花ID),服务端对同一个 msg_id 只处理一次。
// 消息入库前先做幂等去重
func SaveMessage(ctx context.Context, m *Message) (bool, error) {
key := fmt.Sprintf("msg:d:%s", m.MsgID)
// SETNX 成功说明这条消息第一次见到
ok, err := rdb.SetNX(ctx, key, "1", 24*time.Hour).Err()
if err != nil {
return false, err
}
if !ok {
return false, nil // 重复消息,直接丢弃
}
// 真正落库
if err := msgRepo.Insert(ctx, m); err != nil {
return false, err
}
return true, nil
}
这里有个权衡:去重 key 的 TTL 设多久?太短,晚到的重试会被放进来;太长,Redis 内存浪费。我们按"网络重试窗口"设了 24h,对客服场景足够。
在线客服对消息顺序有硬要求:客户发一句、客服回一句,顺序乱了没法看。我们最开始直接按 created_at 排序,结果在断线重连场景下出现乱序——因为客户端重发的消息时间戳是本地时间,可能和服务器时间有偏差。
最终方案是服务器单点分配序号:
client_msg_id,不带业务排序依据。INCR 分配递增的 seq。(session_id, seq) 排序,客户端也按 seq 重排渲染。func AllocSeq(ctx context.Context, sessionID string) (int64, error) {
key := fmt.Sprintf("seq:%s", sessionID)
return rdb.Incr(ctx, key).Result()
}
会话维度的 INCR 天然带"原子 + 单调递增",配合 (session_id, seq) 唯一索引,从根源上杜绝了同会话内消息并发落库导致的乱序。
断线重连后,客户端要先跟服务端对账:本地最后一条消息的 seq 是 N,服务端把 seq > N 的消息补回来。
离线消息的存储,我们按"会话 + 时间窗口"做了两层:
lpush offline:{userID},重连时批量拉取后删除。// 断线重连:拉取 seq 之后的消息
func PullAfter(ctx context.Context, tenantID, sessionID string, afterSeq int64) ([]*Message, error) {
// 先看 Redis 热数据,不够再落 MySQL
msgs, _ := cachePull(ctx, sessionID, afterSeq)
if len(msgs) < cacheLimit {
more, err := dbPull(ctx, tenantID, sessionID, afterSeq)
if err != nil {
return nil, err
}
msgs = append(msgs, more...)
}
return msgs, nil
}
服务多了以后,第一个问题是服务间怎么互相找到。我们选了 etcd:注册(服务启动时 Lease.Grant + Put)+ 发现(Watch 订阅变更)。这块相对标准,真正让我们掉头发的是 gRPC 连接池。
gRPC 官方 client 本身是"连接复用"的(一个 ClientConn 内部有连接池),但有两个问题:
所以我们基于 etcd 做了一层自己的连接池,核心逻辑:
[]*grpc.ClientConn 池,按"最小连接数"或"轮询"选择;type Pool struct {
mu sync.RWMutex
nodes map[string]*nodePool // addr -> 连接池
// 退避参数
baseDelay time.Duration
maxDelay time.Duration
}
type nodePool struct {
conns []*grpc.ClientConn
next uint32
}
// 获取一个可用连接
func (p *Pool) Get(ctx context.Context) (*grpc.ClientConn, error) {
p.mu.RLock()
defer p.mu.RUnlock()
if len(p.nodes) == 0 {
return nil, ErrNoAvailableNode
}
// 轮询选节点,避免热点
addrs := make([]string, 0, len(p.nodes))
for addr := range p.nodes {
addrs = append(addrs, addr)
}
sort.Strings(addrs)
addr := addrs[atomic.AddUint32(&p.next, 1)%uint32(len(addrs))]
np := p.nodes[addr]
conn := np.conns[atomic.AddUint32(&np.next, 1)%uint32(len(np.conns))]
return conn, nil
}
这两个坑我们都是在线上压测时抓到的,值得单独说:
坑一:连接泄漏。 某次压测后 ss -s 看到大量 TIME_WAIT / ESTABLISHED 连接不释放。排查发现,我们曾在一个请求内直接 grpc.Dial 建连接、用完全没 Close。这是 gRPC 最典型的坑:Dial 默认是 lazy 的,连接建立后必须由你负责 Close,否则会一直挂着。教训是连接必须收口到池里统一管理,谁创建谁负责关闭,绝不在业务代码里裸建连接。
坑二:重连风暴。 有一回某下游服务发布,几十个网关实例同时发现旧连接失效,一起疯狂重连,把刚恢复的服务又打挂了。解决:
health.Check)通过才标记节点可用;// 指数退避 + 抖动:避免重连风暴
func backoff(attempt int) time.Duration {
d := time.Duration(math.Pow(2, float64(attempt))) * 100 * time.Millisecond
if d > 5*time.Second {
d = 5 * time.Second
}
// 加 ±20% 抖动,避免多个实例同步
j := time.Duration(rand.Float64()*0.4+0.8) * float64(d)
return j
}
客服系统有个杀手级功能是"访客轨迹":访客从哪个渠道来、点了哪些页面、停留多久、最终有没有发起咨询。这既是运营的抓手,也是产品差异化的点。但我们一开始用 MySQL 存事件,很快就顶不住了——写多读少、按时间范围聚合、数据量大,正是 MySQL 最不擅长的场景,后来整体迁到 ClickHouse。
访客轨迹本质是一张"事件流水表",按天分区:
CREATE TABLE visitor_event
(
tenant_id UInt64,
visitor_id String,
session_id String,
event_type String, -- page_view / click / input / consult / ...
url String,
referrer String,
channel String, -- 渠道来源
keywords String, -- 搜索关键词
duration UInt32, -- 停留时长(ms)
event_time DateTime64(3),
-- 预聚合字段,避免高频 group by 大表
day Date MATERIALIZED toDate(event_time)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, day, visitor_id, event_time)
几个设计要点:
DROP PARTITION,比 DELETE 高效几个量级;排序键 (tenant_id, day, visitor_id, event_time) 让"查某个租户某天某个访客的轨迹"走稀疏索引,避免全表扫描;前端埋点会上报海量事件,如果逐条写 ClickHouse 会被写爆。我们用"内存攒批 + 定时/定量刷新"的经典模式:
type BatchWriter struct {
mu sync.Mutex
events []*VisitorEvent
batchSize int
flushInterval time.Duration
}
func (w *BatchWriter) Add(e *VisitorEvent) {
w.mu.Lock()
w.events = append(w.events, e)
shouldFlush := len(w.events) >= w.batchSize
w.mu.Unlock()
if shouldFlush {
w.Flush()
}
}
func (w *BatchWriter) Flush() {
w.mu.Lock()
events := w.events
w.events = nil
w.mu.Unlock()
if len(events) == 0 {
return
}
// 批量 INSERT ... VALUES (...), (...)
if err := clickhouseInsert(events); err != nil {
// 失败先回写内存重试,避免丢数据
w.retry(events)
}
}
要点:攒批要防两件事——攒太小(写太频繁)和攒太大(内存压力、延迟高)。我们按"每 1000 条 或 500ms"两个阈值取先到者,实测写入吞吐和延迟平衡得最好。
访客画像里最常用的一个查询是"某访客的完整访问轨迹":
SELECT event_type, url, referrer, channel, duration, event_time
FROM visitor_event
WHERE tenant_id = ? AND visitor_id = ?
AND event_time >= now() - INTERVAL 30 DAY
ORDER BY event_time;
以及"来源渠道转化漏斗":
SELECT
channel,
countIf(event_type = 'page_view') AS visits,
countIf(event_type = 'consult') AS consult_cnt,
round(consult_cnt / visits * 100, 2) AS consult_rate
FROM visitor_event
WHERE tenant_id = ? AND day >= today() - 7
GROUP BY channel
ORDER BY consult_cnt DESC;
这类"多条件 + 时间范围 + 聚合"的查询,在 ClickHouse 上基本毫秒级返回,放 MySQL 上随便一个都几百毫秒起步。这也是我们把分析型数据彻底迁出 MySQL 的根本原因。
工单的本质是一个状态机,我们刻意没有用"数据库字段随便改"的粗暴做法,而是显式建模状态迁移,防止状态错乱:
// 工单状态迁移表
var ticketTransitions = map[TicketStatus][]TicketStatus{
StatusPending: {StatusProcessing, StatusClosed},
StatusProcessing:{StatusPending, StatusResolved, StatusClosed},
StatusResolved: {StatusClosed},
StatusClosed: {}, // 终态
}
func Transition(t *Ticket, target TicketStatus) error {
allowed, ok := ticketTransitions[t.Status]
if !ok || !slices.Contains(allowed, target) {
return fmt.Errorf("invalid transition: %s -> %s", t.Status, target)
}
t.Status = target
return nil
}
配合一个 ticket_event 流水表记录每次迁移的操作人、时间、备注,出问题可追溯,也给后续"工单报表"提供了数据基础。
满意度、SLA 这类能力依赖"定时扫描 + 推送"。我们实现了一个轻量的 SLA 扫描器:定期扫出"超时未回复"的会话/工单,按优先级升级通知(站内推 + 桌面端弹窗)。
// 定时扫描超时未回复的会话,触发提醒
func slaLoop(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
overdues := findOverdueSessions(ctx, time.Now())
for _, s := range overdues {
pushEscalation(ctx, s) // 推送给坐席/管理员
}
}
}
}
这里的心得是:定时扫描任务的频率要和业务容忍度匹配,30s 扫一次对"响应超时提醒"足够,不用做到秒级,避免无谓的数据库压力。
小团队最容易犯的错是"出事了才去查日志"。我们在重构核心链路时同步补齐了三件套:
trace_id / tenant_id / user_id,方便按租户按用户串链路排查;有一个指标我们踩过坑才意识到重要性:WebSocket 在线连接数。它是最能反映系统健康度的信号——连接数异常掉崖,多半是网关出问题;连接数异常暴涨,多半是客户端在重连风暴。我们在 Grafana 上专门做了这个指标的告警,比看任何业务报表都更早发现问题。
把实战中真正让我们痛苦的坑汇总一下,希望后来者少走弯路:
回顾整个重构过程,最深的体会是:在线客服系统的难点不在"写业务代码",而在长连接的生命周期管理、消息的可靠性、服务间的治理。这几块做扎实了,业务功能反而是顺水推舟的事。
如果只提炼三条经验送给做类似系统的同学:
果唯云客服还在持续迭代,后端这块后续还有很多可以展开讲的:机器人服务与知识库检索、访客身份画像的实时计算、以及桌面端(Wails v3 + TDesign)与后端的长连接协同。等有空再单独写一篇,欢迎大家交流指正。
如果你对果唯云客服感兴趣,欢迎访问官网 kf.guoway.net 体验,小企业用得起的在线客服,免费试用 30 天。
联系人: 张女士
QQ: 739800600
电话: 15010402094
电话: 13240702278
E-mail: support@guoway.net