獨(dú)立開發(fā)在線客服系統(tǒng) 5 年,終于穩(wěn)如老狗了:記錄我踩過的坑(一)
我在業(yè)余時間開發(fā)了一款自己的獨(dú)立產(chǎn)品:升訊威在線客服與營銷系統(tǒng)。陸陸續(xù)續(xù)開發(fā)了幾年,從一開始的偶有用戶嘗試,到如今線上環(huán)境和私有化部署均有了越來越多的穩(wěn)定用戶,在這個過程中,我也積累了不少如何開發(fā)運(yùn)營一款獨(dú)立產(chǎn)品的經(jīng)驗。
我有很多次在版本發(fā)布之后會感覺:這個版本絕對穩(wěn)如老狗了! ??
結(jié)果每次都是在一段時間之后:欸?還有這種奇葩問題? ??
從早幾年經(jīng)常性的 “欸?”,到今年偶爾 “欸?”。到今天,我相信這回真的穩(wěn)如老狗了,因為隨著這些天給幾個客戶的環(huán)境升級到最新版本之后,一切都安穩(wěn)了……
我自己的線上環(huán)境,也是從早先的經(jīng)常有用戶和我反饋在他們的環(huán)境或者場景中出現(xiàn)了什么問題,到現(xiàn)在,用的人更多了,在線訪客總量也越來越多,可是幾乎沒人找我反饋問題了…… ??
這是我官方環(huán)境的服務(wù)器同時連接數(shù),去年同時連接數(shù)超過 2K 時,我還發(fā)過朋友圈,今年翻倍到了 4K+,我已經(jīng) 淡然處之了(不發(fā)朋友圈了)。??

這個客服系統(tǒng),從我業(yè)余時間開始開發(fā)到今天,已經(jīng)過去了 5 年,我會在本文中,分享一些這 5 年間讓我感到:“欸?還有這種奇葩問題?” 的問題。
欸?還有這種奇葩問題?
有些表情能毀掉一段歷史:UTF-8 與數(shù)據(jù)庫編碼不一致
“為什么昨天聊的東西今天全沒了?!”
我連夜排查日志,發(fā)現(xiàn)數(shù)據(jù)庫居然正常返回0條記錄,不是超時、不是權(quán)限問題——就像這段聊天從未存在過。
再往前翻,發(fā)現(xiàn)客服當(dāng)時的最后一句記錄是:
“好的,請稍等一下??”
這個小小的“??”干掉了整條記錄。
問題癥狀:看似插入成功,其實(shí)SQL崩潰。
初始建表時,我用了“UTF-8”,但疏忽了MySQL里的utf8和utf8mb4不是一回事:
CREATE TABLE chat_message (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
visitor_id VARCHAR(50),
content TEXT CHARACTER SET utf8 COLLATE utf8_general_ci,
created_at DATETIME
);
當(dāng)訪客發(fā)的消息里包含四字節(jié)字符(如emoji、特殊符號)時,插入就會失敗:
INSERT INTO chat_message (visitor_id, content, created_at)
VALUES ('A123', '好的,請稍等一下??', NOW());
-- Error: Incorrect string value: '\xF0\x9F\xA5\xB2' for column 'content'
應(yīng)用層“假成功”:驅(qū)動層吞掉了異常
我用的 .NET MySQL Connector,默認(rèn)IgnorePrepare = true,加上代碼沒捕獲具體異常,結(jié)果就是“插入失敗但返回成功”:
try
{
await db.ExecuteAsync(
"INSERT INTO chat_message (visitor_id, content, created_at) VALUES (@v, @c, @t)",
new { v = visitorId, c = content, t = DateTime.UtcNow }
);
logger.Info("消息存儲成功:" + content);
}
catch (Exception ex)
{
// 沒有打出SQL錯誤碼,只記錄 ex.Message,最終被上層忽略
logger.Warn("消息存儲異常:" + ex.Message);
}
所以以為“成功存了”,客服第二天一查——空的。
深入原因:MySQL的“utf8”是三字節(jié)UTF-8。
MySQL的utf8只支持1-3字節(jié)字符(BMP平面),而emoji在U+1F600及以上,需要四字節(jié):
?? = U+1F972
UTF-8編碼 = F0 9F A5 B2
這類字符會直接導(dǎo)致插入失敗。
解決方案:全面切換到utf8mb4 + 兼容性改造
第一步,修改表結(jié)構(gòu)與庫:
ALTER DATABASE mychat CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
ALTER TABLE chat_message CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
第二步,連接字符串明確聲明:
var connStr = "Server=localhost;Database=mychat;Uid=root;Pwd=xxx;CharSet=utf8mb4;";
第三步,測試代碼驗證emoji可正常存取:
var testMessage = "歡迎使用升訊威在線客服系統(tǒng)????";
await db.ExecuteAsync(
"INSERT INTO chat_message (visitor_id, content, created_at) VALUES (@v, @c, @t)",
new { v = "T001", c = testMessage, t = DateTime.UtcNow }
);
var result = await db.QuerySingleAsync<string>(
"SELECT content FROM chat_message WHERE visitor_id = @v ORDER BY id DESC LIMIT 1",
new { v = "T001" }
);
Console.WriteLine(result); // 輸出: 歡迎使用升訊威在線客服系統(tǒng)????
更深一層的坑:索引長度與utf8mb4的沖突
切換utf8mb4后,我的復(fù)合索引突然建不起來了:
ALTER TABLE chat_message ADD INDEX idx_v_c(visitor_id, content);
-- Error: Specified key was too long; max key length is 767 bytes
因為utf8mb4每字符最多4字節(jié),VARCHAR(255)就可能超出InnoDB的索引限制。
解決辦法:只索引前綴或使用全文索引:
ALTER TABLE chat_message ADD INDEX idx_v_c(visitor_id, content(100));
總結(jié)與經(jīng)驗教訓(xùn)
- 不要以為utf8=UTF-8,MySQL的utf8是閹割版。
- 日志與異常必須精確記錄SQL錯誤碼,否則你根本不會發(fā)現(xiàn)消息丟失。
- 測試數(shù)據(jù)必須包含emoji,否則你永遠(yuǎn)不知道生產(chǎn)環(huán)境有多少小貓小狗在毀你的數(shù)據(jù)。
最終,我寫了一個測試用例:
it("should store emoji without error", async () => {
const message = "測試emoji ??????";
const res = await api.sendMessage({ visitorId: "U999", content: message });
const saved = await api.getLastMessage("U999");
expect(saved.content).toBe(message);
});
這個測試每次部署都跑,保證歷史不會再被一個??毀掉。
負(fù)載均衡 + Sticky Session 失效:消息亂序與丟失
在線客服系統(tǒng)為什么一定要“粘人”
在線客服系統(tǒng)的核心就是實(shí)時雙向通信。訪客的每條消息必須按順序送達(dá)客服端,否則就會出現(xiàn)這樣的慘劇:
訪客:“你好”
客服:“請問有什么可以幫您?”
訪客:“我想咨詢一下價格”
客服突然看到第一條:“你好” (延遲5秒)
客服:“???你已經(jīng)走了啊……”
問題是:服務(wù)器明明都有收到消息,為什么到客服端時順序亂了?
負(fù)載均衡讓消息“分身”
我的架構(gòu)是這樣的:
Visitor <-> Nginx(Load Balancer) <-> Node1(Node.js + WebSocket)
Node2(Node.js + WebSocket)
按理說,訪客連上Node1后,后續(xù)消息都應(yīng)該走Node1。可在高并發(fā)下,我看到日志:
[Node1] Received Message ID 1001 from Visitor A
[Node2] Received Message ID 1002 from Visitor A
訪客的兩條消息跑到了不同節(jié)點(diǎn),結(jié)果:
- Node1的消息發(fā)給客服A
- Node2的消息發(fā)給客服B
- 客服端UI收到的順序是 1002 -> 1001
更坑的是,消息確認(rèn)ACK回到訪客時,對不上號,導(dǎo)致重發(fā),最終客服端收到兩條重復(fù)的消息,順序還錯了。
根本原因:Sticky Session失效
我以為Nginx的默認(rèn)負(fù)載均衡會保持連接穩(wěn)定,其實(shí)不是。Nginx對HTTP有ip_hash、sticky模塊,但對WebSocket如果配置不當(dāng),會出現(xiàn)兩種問題:
問題1:沒有Session綁定
upstream websocket_backend {
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
server {
location /ws {
proxy_pass http://websocket_backend;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
}
這種配置下,連接建立時隨機(jī)選一個節(jié)點(diǎn),但重連時可能連到別的節(jié)點(diǎn)。
問題2:TCP層斷開重連未保持同一節(jié)點(diǎn)
即使初始連到Node1,一旦網(wǎng)絡(luò)波動或心跳失敗,客戶端重連后可能跑到Node2。
現(xiàn)場“事故”日志
真實(shí)日志片段:
2025-08-24 14:00:01 Node1 [Visitor: V001] Received message seq=15
2025-08-24 14:00:01 Node2 [Visitor: V001] Received message seq=16
2025-08-24 14:00:01 Node1 -> AgentA send seq=15
2025-08-24 14:00:01 Node2 -> AgentA send seq=16
2025-08-24 14:00:01 AgentA UI shows: (16) 我想咨詢價格
2025-08-24 14:00:02 AgentA UI shows: (15) 你好
客服直接懵逼:“怎么問候語在報價后面出現(xiàn)?”
解決方案
方案1:啟用真正的Sticky Session
在Nginx里使用 sticky 模塊,按連接ID/SessionID綁定節(jié)點(diǎn):
upstream websocket_backend {
sticky cookie srv_id expires=1h domain=.example.com path=/;
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
server {
location /ws {
proxy_pass http://websocket_backend;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
}
客戶端建立連接后,會拿到 srv_id,后續(xù)請求保持在同一節(jié)點(diǎn)。
方案2:無狀態(tài)化:消息中心化路由
徹底放棄Sticky,改為:
- 每個節(jié)點(diǎn)只負(fù)責(zé)接入,不存狀態(tài);
- 所有消息都通過一個中心化的消息路由(Redis Pub/Sub、Kafka、RabbitMQ)轉(zhuǎn)發(fā);
- 時序由消息中心保證。
// Node1收到消息 -> 發(fā)布到Redis頻道
redis.publish(`chat:${visitorId}`, JSON.stringify(msg));
// 訂閱消息并發(fā)送給客服
redis.subscribe(`chat:${visitorId}`, (msg) => {
sendToAgent(JSON.parse(msg));
});
這樣即使訪客在不同節(jié)點(diǎn)間跳轉(zhuǎn),也能保證消息順序統(tǒng)一。
方案3:應(yīng)用層順序控制
即使采用中心化,也建議用遞增序列號控制最終顯示順序:
// 客服端消息隊列
onMessageReceived(msg) {
if (msg.seq <= lastSeq) return; // 丟棄重復(fù)或過期消息
renderMessage(msg);
lastSeq = msg.seq;
}
總結(jié)與經(jīng)驗教訓(xùn)
- 實(shí)時通信+負(fù)載均衡=天然陷阱,沒配置好Sticky等于自找麻煩。
- 分布式必須要么粘人要么無狀態(tài)化,不能一半一半。
- 應(yīng)用層必須有時序保證,即使網(wǎng)絡(luò)重傳也不能亂。
最終我遷移到了Redis消息路由+應(yīng)用層序列號雙重保障,消息順序和丟失率歸零。
定時任務(wù)與時區(qū):凌晨4點(diǎn)沒人值班,消息隊列卡死
一覺醒來,消息爆炸
“為什么今天早上第一波客戶消息都是延遲5分鐘才到的?客服端全部卡死!”
我翻日志,發(fā)現(xiàn)凌晨4:00到4:10期間,消息隊列(RabbitMQ)消費(fèi)速率掉為0,積壓到10萬條,直到4:15自動恢復(fù)。
這意味著客戶夜間留言全部堆積,早班一上來直接崩潰。
懷疑是隊列或網(wǎng)絡(luò)問題,結(jié)果是“自殺式定時任務(wù)”
檢查監(jiān)控:
- CPU、內(nèi)存正常;
- RabbitMQ自身狀態(tài)正常;
- 消費(fèi)者日志顯示:凌晨4點(diǎn)消費(fèi)者全部停工。
最終在消費(fèi)者代碼中發(fā)現(xiàn)了“罪魁禍?zhǔn)住保?/p>
// 消費(fèi)者應(yīng)用啟動時,每天凌晨4點(diǎn)清理過期會話數(shù)據(jù)
cron.schedule('0 4 * * *', async () => {
await cleanupSessions(); // 清理數(shù)據(jù)庫中過期session
});
問題是,這個cleanupSessions()里有個長事務(wù),鎖了整張session表,消費(fèi)者處理消息時需要更新session的最后活躍時間,結(jié)果所有消費(fèi)者線程全部阻塞:
Deadlock waiting for table `session` lock...
凌晨4點(diǎn)“清理任務(wù)”把自己的兄弟“消息消費(fèi)”干死了。
更坑的點(diǎn):時區(qū)錯亂,任務(wù)比預(yù)期多跑了一次
我發(fā)現(xiàn)有些節(jié)點(diǎn)的定時任務(wù)在3:00也執(zhí)行過一次。為什么?
Docker鏡像默認(rèn)UTC時區(qū),而Kubernetes節(jié)點(diǎn)是Asia/Shanghai,Cron表達(dá)式用的是本地時區(qū),結(jié)果:
- 容器里4:00 UTC = 北京時間12:00,錯了一次;
- 容器外4:00 Asia/Shanghai = 正常凌晨4點(diǎn)。
同一個任務(wù)在兩套時區(qū)環(huán)境里跑了兩遍:
- 凌晨4點(diǎn)鎖表一次;
- 中午12點(diǎn)又鎖一次(正好是客服高峰期)。
事故日志現(xiàn)場
數(shù)據(jù)庫慢查詢?nèi)罩荆?/p>
2025-08-24T04:00:00Z LOCK table session (cleanupSessions)
2025-08-24T04:00:02Z UPDATE session SET last_active=... (BLOCKED)
2025-08-24T04:10:00Z UNLOCK table session
消費(fèi)者日志:
[04:00:01] Received message ID 9991
[04:00:01] ERROR: Deadlock - waiting for session lock
[04:05:02] Retrying message ID 9991
[04:10:00] Successfully processed message ID 9991
RabbitMQ監(jiān)控:
04:00:00 - Queue length: 0
04:05:00 - Queue length: 105,332
04:10:00 - Queue length: 2,105
正確解決方案
方案1:任務(wù)與業(yè)務(wù)徹底解耦
- 把清理任務(wù)移到單獨(dú)的Worker節(jié)點(diǎn),與消息消費(fèi)者分開;
- 使用消息隊列通知清理,而不是直接Cron掃全表。
// 消費(fèi)者只發(fā)事件,不清理
if (sessionExpired) redis.publish('cleanup', sessionId);
// 專門的Cleanup Worker訂閱事件
redis.subscribe('cleanup', async (id) => await deleteSession(id));
方案2:統(tǒng)一時區(qū) & 避免Cron表達(dá)式歧義
- 所有容器、數(shù)據(jù)庫、代碼統(tǒng)一使用UTC;
- Cron任務(wù)統(tǒng)一用UTC表達(dá)式,并在代碼中轉(zhuǎn)換為業(yè)務(wù)時區(qū)。
// node-cron配置,統(tǒng)一UTC
cron.schedule('0 20 * * *', cleanupSessions); // UTC 20:00 = 北京凌晨4:00
方案3:非鎖表清理 + 分批執(zhí)行
避免長事務(wù)鎖表,改成分頁清理:
DELETE FROM session WHERE expired=1 LIMIT 1000;
循環(huán)執(zhí)行,避免一次性鎖整個表。
方案4:監(jiān)控 + 自動報警
- 增加消息隊列積壓閾值報警;
- 定時任務(wù)執(zhí)行超時報警。
# Prometheus規(guī)則示例
- alert: QueueBacklog
expr: rabbitmq_queue_messages_ready{queue="chat"} > 1000
for: 5m
labels:
severity: critical
annotations:
summary: "Chat queue backlog"
這樣即使凌晨沒人值班,自動報警也會通知到值班機(jī)器人。
經(jīng)驗教訓(xùn)
- 凌晨任務(wù)不等于安全時段,分布式業(yè)務(wù)可能24/7在線;
- 時區(qū)不一致是生產(chǎn)殺手,統(tǒng)一UTC是第一原則;
- 任務(wù)與核心業(yè)務(wù)必須解耦,不要在主進(jìn)程里干副業(yè);
- 測試必須模擬生產(chǎn)時區(qū)+定時任務(wù)行為,不要只在本地跑一下。
我最后把這個坑寫進(jìn)了上線檢查清單:
“所有定時任務(wù)必須標(biāo)明時區(qū)、不可阻塞主業(yè)務(wù)進(jìn)程,且須有報警。”
獨(dú)立者的產(chǎn)品成果
可全天候 7 × 24 小時掛機(jī)運(yùn)行,網(wǎng)絡(luò)中斷,拔掉網(wǎng)線,手機(jī)飛行模式,不掉線不丟消息,歡迎實(shí)測。
訪客端:輕量直觀、秒級響應(yīng)的溝通入口
訪客端是客戶接觸企業(yè)的第一窗口,我精心打磨每一處交互細(xì)節(jié),確保用戶無需任何學(xué)習(xí)成本即可發(fā)起對話。無論是嵌入式聊天窗口、懸浮按鈕,還是移動端自適應(yīng)支持,都實(shí)現(xiàn)了真正的“即點(diǎn)即聊”。系統(tǒng)支持智能歡迎語、來源識別、設(shè)備類型判斷,可自動記錄訪客路徑并呈現(xiàn)于客服端,幫助企業(yè)更好地理解用戶意圖。在性能方面,訪客端采用異步加載與自動重連機(jī)制,即使網(wǎng)絡(luò)波動也能保障消息順暢送達(dá),真正做到——輕量不失穩(wěn)定,簡單不失智能。

客服端軟件:為高效率溝通而生
客服端是客服人員的作戰(zhàn)平臺,我構(gòu)建了一個專注、高效、響應(yīng)迅速的桌面級體驗。系統(tǒng)采用多標(biāo)簽會話設(shè)計,讓客服可同時處理多組對話;訪客軌跡、歷史會話、地理位置、設(shè)備信息、來源渠道等關(guān)鍵信息一目了然,協(xié)助客服快速做出判斷。內(nèi)置快捷回復(fù)、常用文件、表情支持和智能推薦功能,大幅降低重復(fù)勞動成本。同時,系統(tǒng)還支持智能分配、會話轉(zhuǎn)接、轉(zhuǎn)人工、自定義狀態(tài)等多種機(jī)制,保障團(tuán)隊協(xié)作流暢,讓客服不僅能應(yīng)對高峰,更能穩(wěn)定交付滿意度。

Web 管理后臺:
Web 管理后臺是企業(yè)對客服系統(tǒng)的“駕駛艙”,從接入配置、坐席管理,到數(shù)據(jù)統(tǒng)計、權(quán)限控制,一切盡在掌握。你可以靈活設(shè)置接待策略、工作時間、轉(zhuǎn)接規(guī)則,支持按部門/標(biāo)簽/渠道精細(xì)分配訪客,滿足復(fù)雜業(yè)務(wù)場景。系統(tǒng)還內(nèi)置訪問監(jiān)控、聊天記錄檢索、客服績效統(tǒng)計、錯失會話提醒等運(yùn)營級功能,助力管理者洞察服務(wù)瓶頸,持續(xù)優(yōu)化資源配置。支持私有化部署、分權(quán)限管理、日志記錄與數(shù)據(jù)導(dǎo)出,為追求安全性與高可控性的企業(yè),提供真正“掌握在自己手里的客服系統(tǒng)”。

希望能夠打造: 開放、開源、共享。努力打造一款優(yōu)秀的社區(qū)開源產(chǎn)品。
鐘意的話請給個贊支持一下吧,謝謝~

浙公網(wǎng)安備 33010602011771號