痛点分析:为什么“能跑”≠“好用”
做客服系统最怕的不是写不出 Demo,而是上线后“连环翻车”。我踩过的坑大概分三类:
- 跨端渲染差异:H5 里聊天气泡圆角 8 px,到 App 端被 WebView 吃成 4 px;iOS 安全区又顶起输入框,安卓键盘弹起直接盖住发送按钮。用户一句“界面错位”就能让评分掉 0.5。
- 消息丢失:短轮询 3 s 一次,结果弱网环境下 502 重试,用户看到“红色感叹号”直接卸载。实测 100 条消息里能丢 3~5 条,到达率 95 % 都不到。
- 高并发瓶颈:活动高峰期 2000 人同时进线,Node 单机 1000 QPS 就 CPU 90 %,再飙直接 502。老架构无队列、无缓存,MySQL 被刷到线程耗尽。
一句话:客服系统对“实时 + 可靠 + 好看”同时有要求,缺一个都翻车。
架构设计:把“实时”做成“可扩展”
通信方案对比
| 方案 | 延迟 | 流量 | 兼容 | 结论 |
|---|---|---|---|---|
| 短轮询 | ~3 s | 高 | 100 % | 仅适合 Fallback |
| SSE | ~200 ms | 中 | 部分小程序不支持 | H5 主站可用 |
| WebSocket | ~50 ms | 低 | App/H5/小程序皆 OK | 首选 |
实测同 1 k 消息:WebSocket 比轮询延迟降低 90 %,流量省 70 %,所以长连接是主菜,轮询做备胎。
状态管理选型
- Vuex:3.x 生态成熟,但 modules 写多了像“俄罗斯套娃”,类型推导靠“any 一把梭”。
- Pinia:原生 TS,getter 自动推断,代码量减 30 %;搭配 uni-app 官方插件 pinia-plugin-persist 可整库持久化。
结论:新工程直接 Pinia,老工程可渐进迁移。
消息持久化
- SQLite:App 端用原生插件,容量几乎无上限,查询灵活。
- IndexedDB:H5 端 50 MB 上限,易吃“配额不足”警告。
策略:先写 IndexedDB,超限时自动切 SQLite;两端接口封装成同一MessageStorage类,业务层无感。
核心代码:拿来就能跑
WebSocket 连接管理(TypeScript 版)
// types/ws.d.ts export interface WsMsg { id: string; from: 'user' | 'bot'; content: string; ts: number; } // utils/websocket.ts export class WsClient { private url: string; private ws: UniApp.SocketTask | null = null; private hbTimer: any = null; private reconnectCount = 0; private readonly maxReconnect = 5; constructor(url: string) { this.url = url; } connect(): Promise<void> { return new Promise((resolve, reject) => { this.ws = uni.connectSocket({ url: this.url, header: { 'x-client': 'uniapp' } }); this.ws.onOpen(() => { this.reconnectCount = 0; this.startHeartbeat(); resolve(); }); this.ws.onMessage((res) => { try { const msg: WsMsg = JSON.parse(res.data); useChatStore().addMessage(msg); } catch (e) { console.error('[ws] 解析失败', e); } }); this.ws.onClose(() => { this.stopHeartbeat(); if (this.reconnectCount < this.maxReconnect) { setTimeout(() => this.reconnect(), 1000 * ++this.reconnectCount); } }); this.ws.onError((err) => reject(err)); }); } send(data: any) { if (this.ws && this.ws.readyState === 1) { this.ws.send({ data: JSON.stringify(data) }); } } private startHeartbeat() { this.hbTimer = setInterval(() => this.send({ type: 'ping' }), 30000); } private stopHeartbeat() { clearInterval(this.hbTimer); } private reconnect() { console.warn('[ws] 第', this.reconnectCount, '次重连'); this.connect(); } close() { this.ws?.close(); } }Worker 消息队列(防止 UI 阻塞)
// workers/msgQueue.ts const queue: any[] = []; let flushing = false; self.onmessage = async (e) { queue.push(e.data); if (!flushing) flush(); }; async function flush() { flushing = true; while (queue.length) { const batch = queue.splice(0, 50); // 一次 50 条 // 调用后端批量写入 API await fetch('/api/batchSave', { method: 'POST', body: JSON.stringify(batch) }); } flushing = false; }在页面中:
const worker = uni.createWorker('workers/msgQueue.js'); worker.postMessage({ id: 'msg-xxx', content: '...' });跨端 UI 适配 SCSS 混入
/* styles/mixin.scss */ @mixin bubble-arrow($dir: 'left', $color: #fff) { position: relative; &::after { content: ''; position: $dir; width: 0; height: 0; border: 8rpx solid transparent; border-#{$dir}-color: $color; } } /* 页面引用 */ .msg-bubble-left { @include bubble-arrow('left', #f1f3f5); } .msg-bubble-right { @include bubble-arrow('right', #95ec69); }rpx + 条件编译/* #ifdef H5 */可保证 H5、小程序、App 三端圆角、箭头像素一致。
性能优化:把 2000 QPS 压到 10 % CPU
消息压缩
- JSON 文本 1 k → 约 600 B
- Protocol Buffers 同结构 1 k → 220 B,压缩率 63 %,解析耗时减 30 %
选型:PB 用于 App 端,H5 端用 pako.js gzip 做 Fallback,解析统一封装decodeMsg。
虚拟列表渲染
万级记录 DOM 会爆内存。用recycle-list组件(或自写)只渲染可视区域 10 条,滚动时复用节点,内存从 180 MB 降到 18 MB,低端机不再发烫。
压测方案
- JMeter 线程组 2000,Ramp-up 60 s
- 循环发送
{"type":"chat","content":"hello"},断言返回{"ok":true} - 后端用 PM2 集群 4 核机,CPU 稳定在 60 %,QPS 峰值 2300,99 RT 120 ms
脚本要点:
- 勾选“Use KeepAlive”,复用 TCP
- 添加 Header
Connection: Upgrade模拟 WebSocket(需装 WebSocket Sampler 插件)
避坑指南:上线前必读
iOS 后台运行限制
苹果不允许普通 App 后台常驻 WebSocket。方案:
- 退后台 30 s 内发送
disconnect通知服务器 - 服务器把后续消息推送到 APNs,回到前台再拉离线消息
实测:掉线率从 12 % 降到 1 %。
安卓 WebSocket 自动断开
国产 ROM 省电策略会冻结进程。用uni.setKeepScreenOn保持前台常亮不现实,折中:
- 心跳间隔改 20 s
- 进程加
plus.android.importClass('android.app.Notification');提为前台服务(仅 App 端)
敏感词过滤
客服消息必须合规。用 AC 自动机(Aho-Corasick)一次扫描:
class ACNode { children: Record<string, ACNode> = {}; fail: ACNode | null = null; end = false; } function buildTree(words: string[]) { /* 标准 AC 建树 */ } function filter(text: string): string { /* 替换命中关键词 */ }10 w 词库,200 字句子 2 ms 完成过滤,CPU 无感。
现场截图
小结与开放问题
整套模板上线两周,日均会话 8 w+,消息到达率 99.9 %,2000 QPS 时后端 CPU 仅 38 %,比旧方案省一半机器。代码已开源到 GitHub,可直接git clone跑通。
但还有一个分布式经典难题留给大家:如何设计消息已读未读的分布式同步?
- 多端同时在线,谁先谁后?
- 弱网离线,回到线上怎么合并?
- 已读偏移量存储在 Redis、MySQL 还是客户端?
期待看到你的思路,评论区一起头脑风暴!