背景痛点:传统客服系统的性能瓶颈
传统客服系统大多诞生于 Java/.NET 时代,线程模型重、内存占用高,面对“双 11”或直播带货的瞬时流量,常出现以下症状:
- 每条 WebSocket 长连接占用 1 线程或 1 用户态协程,4 C8 G 虚机只能撑 5 k 连接,CPU 空转在上下文切换。
- 对话状态放在本地 HashMap,多节点无同步,用户刷新页面后坐席看不到历史记录,重复提问、体验崩溃。
- 峰值时网关 502,重启后所有长连接断链,客户端疯狂重连又带来惊群效应,雪上加霜。
Go 的 goroutine 调度器把“M:N”做到极致,一个线程可驱动 10 w 级 goroutine,天然适合“海量长连接 + 高并发小任务”场景,于是有了本文的开源实践。
技术选型:为什么选 Go,又为什么 WebSocket 与 gRPC 混用
- 协程调度:Go1.21 引入的
GOMAXPROCS=auto让容器内 CPU 感知更精准,相比 Java 线程池省去池大小调参。 - 内存管理:TCmalloc 衍生的小对象分配器 + 低于 1 ms 的 GC Stop-The-World,让 P99 延迟稳定在 200 ms 内。
- 通信协议:
- 客户端↔网关:WebSocket,支持浏览器,心跳帧小,epoll 事件驱动。
- 网关↔NLP 微服务:gRPC,利用 HTTP/2 多路复用,一条连接打满 2 Gbps 内网带宽,IDL 保证版本兼容。
- 网关↚坐席端:双向 gRPC Stream,坐席可批量订阅多用户事件,一条连接服务 500 终端。
核心实现:三板斧搞定并发与一致性
1. sync.Map 做会话存储
type Session struct { UID string Conn *websocket.Conn EnterTime time.Time mu sync.Mutex // 保护写操作 } var sessionPool sync.Mapap // 开箱即用,CAS 乐观锁 // 注册 func register(uid string, conn *websocket.Conn) { s := &Session{UID: uid, Conn: conn, EnterTime: time.Now()} sessionPool.Store(uid, s) }sync.Map 的LoadOrStore在 1.21 内部用unsafe.Pointer做无锁读,百万并发下 CPU 节省 18%。
2. Gin 中间件实现请求限流
func RateLimit(cap int64) gin.HandlerFunc { bucket := make(chan struct{}, cap) // 预填充令牌 for i := int64(0); i < cap; i++ { bucket <- struct{}{} } return func(c *gin.Context) { select成全双工通信 case <-bucket: c.Next() bucket <- struct{}{} // 归还令牌 default: c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"msg": "rate limit"}) } } }把令牌桶做成中间件,网关单实例 2 万 RPS 压测无掉牌。
3. Redis 分布式锁保证对话状态一致
func LockDialogue(ctx context.Context, redis *redis.Client, dialogueID string) error { key := "lock:dialogue:" + dialogueID ok, err := redis.SetNX(ctx, key, "1", 5s).Result() if err != nil || !ok { return fmt.Errorf("lock failed: %w", err) } return nil }锁过期 5 s,业务 3 s 内必释放,防止坐席与机器人同时回复。
性能优化:把 CPU 用在刀刃上
pprof 找 goroutine 泄漏
压测 30 min 后go tool pprof http://:6060/debug/pprof/goroutine,发现 1.2 w 条阻塞在time.After:
原代码每 30 s 心跳用select {case <-time.After(30s):}却未Stop(),改成t := time.NewTimer(30s)并在循环内Reset,goroutine 降回 80 条。连接池参数
gRPC 默认MaxConcurrentStreams=100,坐席端批量订阅时被打满,调MaxConcurrentStreams=1000+InitialWindowSize=8 MB,单链路吞吐从 1.2 k msg/s 提到 9 k msg/s。
避坑指南:那些踩过的坑
上下文传递丢失元数据
在 Gin 里把trace-id塞进context.Context,后续 goroutine 内再用context.Background()重新创建,导致链路断档。统一用c.Request.WithContext(ctx)向下透传,日志里 100% 能索引。第三方 NLP 重试策略
早期简单for i<3; i++重试,把瞬时 502 放大成 3 倍流量。改为“指数退避 + 断路器”:- 失败 1 次 200 ms、2 次 400 ms、3 次 800 ms,超过 50% 错误率熔断 30 s,下游保护立竿见影。
生产建议:可观测与灰度
OpenTelemetry 全链路
在网关、NLP、坐席三处植入otelgrpc与otelhttp,统一把traceparent打在 gRPC metadata,Jaeger UI 里一条 Trace 横跨 9 个服务,定位 500 ms 延迟出现在 Redis 慢查询,优化后 P99 降到 120 ms。灰度发布
基于 Kubernetes Deployment + Argo Rollout:- 新版本 10% 流量,观察 Prometheus 指标:
goroutine_num、msg_queue_delay、redis_lock_ratio。 - 5 min 内无异常自动全量;若
redis_lock_ratio>5%立即回滚,保证客服业务零中断。
- 新版本 10% 流量,观察 Prometheus 指标:
写在最后
把这套代码推到生产已平稳运行两个季度,峰值 120 万并发在线,内存占用稳定在 4 G 以内。Go 的调度器让“写阻塞”不再可怕,而 WebSocket+gRPC 的混合协议既照顾了浏览器,也释放了内网带宽。若你正准备重写客服系统,希望这份笔记能帮你少走一点弯路——调优无止境,监控永相随,祝你发布不踩雷。