news

果唯云客服 Golang 后端实践:从 WebSocket 长连接到高并发消息链路

果唯云客服 Golang 后端实践:从 WebSocket 长连接到高并发消息链路

2026-08-25 18:20:33 12 行业文章

> 果唯云客服是一款面向中小企业的多渠道在线客服 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(监控)    │

└─────────────────────────────────────────────────────┘

```


几个关键设计决策,贯穿下文:


1. **接入网关和业务服务分离**。网关只干"管连接"这一件事,所有业务逻辑(消息落库、路由、机器人)通过 gRPC 下沉到服务层。这样网关可以做到完全无状态、随时水平扩缩容。

2. **多租户隔离放在数据层和应用层双层做**。每个请求都带 `tenant_id`,SQL 强制绑定租户条件,防止越权串数据。

3. **热数据和冷数据分库**。实时会话走 MySQL + Redis,访客轨迹这类写多读少、按时间聚合的分析型数据进 ClickHouse。


---


## 二、为什么关键链路用 Go 重写


我们早期是 PHP 栈,业务迭代很快,但两条硬伤越来越明显:


- **长连接资源占用高**。在线客服的核心是海量 WebSocket 长连接,PHP-FPM 的"每请求一进程"模型天然不适合长连接,靠 Swoole 强行改造,心智负担和维护成本都很高。

- **并发原语缺失**。消息推送、会话分配这类高并发逻辑,需要精细的并发控制,写起来很痛苦,还容易出并发 bug。


切到 Go 后,感受最直接的几点:


- **goroutine 模型**:一个连接一个 goroutine,单机扛数万长连接是常规操作,内存占用远低于线程/进程模型。

- **部署简单**:编译产物是单个二进制,Docker 镜像可以做到很小,对我们这种小团队来说,运维成本几乎为零。

- **标准库和生态成熟**:`net/http`、`encoding/json` 够用;`gorilla/websocket`、`grpc-go`、`go-redis` 等库非常成熟,踩坑少。


> 补充一点:我们并没有"一刀切"把 PHP 全干掉。管理后台、报表这类 CRUD 密集、并发要求不高的场景,PHP 依然在跑,Go 只接管对性能敏感、对并发要求高的核心链路。**技术栈迁移别搞运动式重构,按链路逐步替换才是稳的。**


---


## 三、WebSocket 长连接网关


### 3.1 连接建模


网关最核心的数据结构是一个"连接对象"。它至少要管三件事:底层 WS 连接、上行发送队列、所属用户/租户信息。


```go

// 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

}

```


这里有两个容易被新手忽略的点:


1. **不要多 goroutine 并发写同一个 `websocket.Conn`**。gorilla/websocket 要求同一时刻只能有一个 goroutine 写、一个 goroutine 读。我们统一用"发送队列 + 单个写泵"的模式:业务侧只往 `send` channel 塞消息,写泵 goroutine 串行消费并写出。

2. **发送队列必须带缓冲且有上限**。不带缓冲,写操作会阻塞业务;带缓冲但无限增长,下游慢时内存会爆。我们的做法是:队列满时直接断连,让客户端走断线重连 + 消息补偿,而不是无限堆积。


### 3.2 读写泵与心跳


经典的三段式:读泵、写泵、心跳。


```go

// 读泵:负责从连接上读消息

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 读超时兜底,任意一端发现超时就主动断开。


### 3.3 在线状态与路由


网关是无状态集群,客户端连到哪台机器是随机的。那"给某个用户推送一条消息",怎么知道该推给哪台机器?


答案是**注册表**:每个连接建立时,往 Redis 里写一条 `online:{tenantID}:{userID} -> {gatewayID}:{connID}`;断开时删掉。推送时先查注册表,命中就把消息发到对应网关的 gRPC 接口,由网关找到本地连接写出去。


```go

// 连接建立时注册在线状态,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,几秒内状态也会自动过期,不会出现"用户明明下线了还显示在线"的脏数据。


### 3.4 多节点消息推送


给单个用户推消息,走"查注册表 → 转发到对应网关"就够了。但客服系统里有个特殊场景:**坐席批量广播**(比如管理员发全员公告)、**会话内多端同步**。这时候一条消息可能要推给多个用户、多个网关。


我们的做法是引入一层轻量级的**推送分发**:把"目标用户集合"解析成"网关 → 用户列表"的映射,并行调各网关的 gRPC 批量推送接口。网关内部再按连接去重、逐个写出。


```go

// 推送分发:把用户集合按网关分组,并行推送

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),业务层做幂等去重。**


### 4.1 消息幂等:msg_id 去重


客户端生成消息时带上全局唯一的 `msg_id`(`UUID` 或 `雪花ID`),服务端对同一个 `msg_id` 只处理一次。


```go

// 消息入库前先做幂等去重

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,对客服场景足够。


### 4.2 消息时序:为什么不能只看时间戳


在线客服对消息顺序有硬要求:客户发一句、客服回一句,顺序乱了没法看。我们最开始直接按 `created_at` 排序,结果在断线重连场景下出现乱序——因为客户端重发的消息时间戳是本地时间,可能和服务器时间有偏差。


最终方案是**服务器单点分配序号**:


1. 客户端消息只带 `client_msg_id`,不带业务排序依据。

2. 服务端收到后,按会话维度用 Redis `INCR` 分配递增的 `seq`。

3. 所有消息按 `(session_id, seq)` 排序,客户端也按 seq 重排渲染。


```go

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)` 唯一索引,从根源上杜绝了同会话内消息并发落库导致的乱序。


### 4.3 离线消息与补偿


断线重连后,客户端要先跟服务端对账:本地最后一条消息的 `seq` 是 N,服务端把 `seq > N` 的消息补回来。


离线消息的存储,我们按"会话 + 时间窗口"做了两层:


- **近期热消息**(默认 7 天):存 Redis,`lpush offline:{userID}`,重连时批量拉取后删除。

- **历史冷消息**:落在 MySQL,客户端主动翻页拉取。


```go

// 断线重连:拉取 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 + gRPC:服务发现与连接池调优


服务多了以后,第一个问题是**服务间怎么互相找到**。我们选了 etcd:注册(服务启动时 `Lease.Grant` + `Put`)+ 发现(`Watch` 订阅变更)。这块相对标准,真正让我们掉头发的是 **gRPC 连接池**。


### 5.1 为什么需要连接池


gRPC 官方 client 本身是"连接复用"的(一个 ClientConn 内部有连接池),但有两个问题:


1. **服务地址变化时,旧的 ClientConn 不会自动切到新实例**。etcd 里实例挂了、扩容了,官方 balancer 的感知有延迟,高峰期会打到已下线的节点。

2. **默认配置下,连接建立、重连的参数不好精细控制**。在压测时我们踩过"重连风暴":服务端短暂抖动,所有客户端同时重建连接,把服务端打挂。


所以我们基于 etcd 做了一层自己的连接池,核心逻辑:


- etcd Watch 到实例列表变化 → 更新本地节点列表;

- 每个节点维护一个 `[]*grpc.ClientConn` 池,按"最小连接数"或"轮询"选择;

- 对失效连接做**退避式重连**,而不是立即重试风暴。


### 5.2 连接池核心实现


```go

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

}

```


### 5.3 踩坑实录:连接泄漏和重连风暴


这两个坑我们都是在线上压测时抓到的,值得单独说:


**坑一:连接泄漏。** 某次压测后 `ss -s` 看到大量 `TIME_WAIT` / `ESTABLISHED` 连接不释放。排查发现,我们曾在一个请求内直接 `grpc.Dial` 建连接、用完全没 `Close`。这是 gRPC 最典型的坑:`Dial` 默认是 lazy 的,连接建立后必须由你负责 `Close`,否则会一直挂着。教训是**连接必须收口到池里统一管理,谁创建谁负责关闭**,绝不在业务代码里裸建连接。


**坑二:重连风暴。** 有一回某下游服务发布,几十个网关实例同时发现旧连接失效,一起疯狂重连,把刚恢复的服务又打挂了。解决:


1. 重连必须**指数退避 + 抖动**(jitter),不能所有实例同步重试;

2. 健康检查(`health.Check`)通过才标记节点可用;

3. 客户端侧**快速失败**:短暂失败不重试,直接返回错误让业务层兜底,避免无效重试放大流量。


```go

// 指数退避 + 抖动:避免重连风暴

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

}

```


---


## 六、访客轨迹分析:ClickHouse 实践


客服系统有个杀手级功能是"访客轨迹":访客从哪个渠道来、点了哪些页面、停留多久、最终有没有发起咨询。这既是运营的抓手,也是产品差异化的点。但我们一开始用 MySQL 存事件,很快就顶不住了——**写多读少、按时间范围聚合、数据量大**,正是 MySQL 最不擅长的场景,后来整体迁到 ClickHouse。


### 6.1 事件表设计


访客轨迹本质是一张"事件流水表",按天分区:


```sql

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 批量能力**,单条 insert 性能差,必须攒批。


### 6.2 批量写入:攒批 + 背压


前端埋点会上报海量事件,如果逐条写 ClickHouse 会被写爆。我们用"内存攒批 + 定时/定量刷新"的经典模式:


```go

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"两个阈值取先到者,实测写入吞吐和延迟平衡得最好。


### 6.3 典型分析查询


访客画像里最常用的一个查询是"某访客的完整访问轨迹":


```sql

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;

```


以及"来源渠道转化漏斗":


```sql

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 的根本原因。


---


## 七、工单系统与满意度评价


### 7.1 工单状态机


工单的本质是一个**状态机**,我们刻意没有用"数据库字段随便改"的粗暴做法,而是显式建模状态迁移,防止状态错乱:


```go

// 工单状态迁移表

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` 流水表记录每次迁移的操作人、时间、备注,出问题可追溯,也给后续"工单报表"提供了数据基础。


### 7.2 SLA 提醒:超时自动升级


满意度、SLA 这类能力依赖"定时扫描 + 推送"。我们实现了一个轻量的 **SLA 扫描器**:定期扫出"超时未回复"的会话/工单,按优先级升级通知(站内推 + 桌面端弹窗)。


```go

// 定时扫描超时未回复的会话,触发提醒

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 扫一次对"响应超时提醒"足够,不用做到秒级,避免无谓的数据库压力。


---


## 八、可观测性:小团队也要有监控


小团队最容易犯的错是"出事了才去查日志"。我们在重构核心链路时同步补齐了三件套:


- **日志**:结构化 JSON 日志,统一注入 `trace_id` / `tenant_id` / `user_id`,方便按租户按用户串链路排查;

- **指标**:Prometheus 采集,重点盯 4 个指标——WebSocket 在线连接数、消息处理延迟 P99、消息推送失败率、各服务 QPS/错误率;

- **链路追踪**:gRPC 链路接入 OpenTelemetry,跨服务排问题能直接看全链路。


有一个指标我们踩过坑才意识到重要性:**WebSocket 在线连接数**。它是最能反映系统健康度的信号——连接数异常掉崖,多半是网关出问题;连接数异常暴涨,多半是客户端在重连风暴。我们在 Grafana 上专门做了这个指标的告警,比看任何业务报表都更早发现问题。


---


## 九、踩过的那些坑(避坑清单)


把实战中真正让我们痛苦的坑汇总一下,希望后来者少走弯路:


1. **goroutine 泄漏**:某个版本在连接关闭时忘了 `close(send)`,导致写泵 goroutine 永久阻塞,内存持续上涨直到 OOM。**教训:连接生命周期必须成对管理,用 `sync.WaitGroup` 或 `context` 保证读写泵必然退出。**


2. **fd 耗尽**:线上出现过 "too many open files",排查发现是客户端频繁断连、服务端没有及时 `Close` 底层连接。**教训:`websocket.Conn` 关闭必须成对,服务端主动断连时也要显式 Close,同时把 `ulimit` 调大并监控 fd 数。**


3. **消息乱序**:前面提到的按 `created_at` 排序导致断线重连乱序,改成服务端 `seq` 分配后解决。**教训:分布式场景别依赖客户端时间戳做排序,服务端统一分配单调序号。**


4. **重连风暴**:gRPC 连接失效时全员同步重连,把服务打挂。**教训:重连必须指数退避 + 抖动,健康检查通过再恢复流量。**


5. **ClickHouse 单条写入**:初期图省事逐条 insert,写入性能差到没法用。**教训:ClickHouse 必须攒批写入,千条级 batch 是基本操作。**


6. **全量订阅 Redis 变更**:早期在线状态用 Redis `PUB/SUB` 全量广播,节点多了消息量爆炸。**教训:在线状态改成"查询式"(写 Redis + 按需查),不要用广播式,节点多了根本扛不住。**


---


## 十、总结


回顾整个重构过程,最深的体会是:**在线客服系统的难点不在"写业务代码",而在长连接的生命周期管理、消息的可靠性、服务间的治理**。这几块做扎实了,业务功能反而是顺水推舟的事。


如果只提炼三条经验送给做类似系统的同学:


1. **网关要薄、要无状态**。连接管理收敛在网关,业务逻辑全部下沉,这样扩容、换协议、灰度都轻松。

2. **消息可靠性靠"幂等 + 序号"**。至少送达一次 + 业务幂等去重 + 服务端分配 seq,能解决 90% 的丢消息/重消息/乱序问题。

3. **分析型数据早用列存**。只要数据是"写多读少 + 按时间聚合",就别在 MySQL 里硬扛,早点上 ClickHouse 这类列式存储。


果唯云客服还在持续迭代,后端这块后续还有很多可以展开讲的:机器人服务与知识库检索、访客身份画像的实时计算、以及桌面端(Wails v3 + TDesign)与后端的长连接协同。等有空再单独写一篇,欢迎大家交流指正。


---


*如果你对果唯云客服感兴趣,欢迎访问官网 kf.guoway.net 体验,小企业用得起的在线客服,免费试用 30 天。*


分享到: