Web端即時通訊技術因受限于浏覽器的設計限制,一直以來實作起來并不容易,主流的Web端即時通訊方案大緻有4種:傳統Ajax短輪詢、Comet技術、WebSocket技術、SSE(Server-sent Events)。
方案一、Ajax輪詢拉取
輪詢拉取,是最容易想到的實作方式:
- 發送方發送了消息,先入隊列
- 網頁端起一個timer,每個一段時間(例如10秒),發起一個輪詢請求,拉取隊列裡的消息
- 如果隊列裡有消息,就傳回消息
- 如果隊列裡無消息,就10秒後再次輪詢
這種方式的優勢是:實作簡單,直覺,容易了解,網際網路興起時,人數不多的聊天室就是這麼玩的。
缺點也很明顯:
- 實時性差:最壞的情況下,1條消息進入隊列後,10s之後才會收到
- 效率低下:發消息是一個低頻動作,如果10次輪詢才收到1條消息,請求有效性隻有10%,浪費了大量伺服器資源
更要命的是,在這種方案下,實時性與效率是一對不可調和的沖突:如果将輪詢周期設為1/10,将時延縮短到1秒,意味着100次輪詢才會收到1條消息,請求有效性則降為了1%。
方案二、HTTP長輪詢
在Ajax輪詢基礎上做的一些改進,它的特點是:
- 這是一條browser發往web-server的HTTP連接配接
- 這條連接配接隻用來收取推送通知
- 不像普通的“請求-響應”式HTTP請求,這個HTTP會被服務端hold住,直到有推送通知到達,或者超過約定的時間
對于HTTP請求,為了提高效率,一般來說browser和web-server都會有一些設定,如果一條HTTP請求長時間沒有資料(例如,120秒),會被斷開。“通知連接配接”為了不被browser和web-server粗暴斷開,一般會設定一個約定門檻值(例如,小于120秒),由系統傳回一個空消息,以便“優雅傳回”。
通常把這種實作也叫做comet。
普通Ajax輪詢與基于Ajax的長輪詢原理對比:

更具體的,對于不同情況下長輪詢接收消息是如何實作的?
場景1,發起通知連接配接時,消息隊列裡正好有消息,則:
- 發起通知連接配接,正好隊列裡有消息
- 實時把隊列裡的消息帶回
- 立馬再發起通知連接配接
場景二,發起通知連接配接時,隊列裡無消息,則:
- 發起通知連接配接時,隊列裡無消息
- 一直等待,直到觸發“時間門檻值”,傳回無消息
- 立馬再發起通知連接配接
場景三,新消息來時,正好有通知連接配接在,則:
- 新消息來時,正好有通知連接配接在
- 通知連接配接實時将消息帶回
- 立馬再發起通知連接配接
上面三個場景的最終狀态,都是“一定,永遠,會有一條通知連接配接,連接配接在浏覽器與伺服器之間”,這樣就能夠保證消息的實時性。當然,有人會說,HTTP的傳回與再次發起會有一個時間差,如果這個時間差,恰巧有新消息過來呢?
場景四,新消息來時,沒有通知連接配接,則:
- 新消息來時,沒有通知連接配接
- 把新消息放入隊列
最後這個場景,發生的機率非常小,但也確定了在“HTTP的傳回與再次發起會有一個時間差”内,消息不會丢失,在通知連接配接發起後,消息能夠實時傳回。
方案三、iframe流(streaming)
iframe流方式是在頁面中插入一個隐藏的iframe,利用其src屬性在伺服器和用戶端之間建立一條長連接配接,伺服器向iframe傳輸資料,來實時更新頁面。
- 優點:消息能夠實時到達;浏覽器相容好
- 缺點:伺服器維護一個長連接配接會增加開銷;IE、chrome、Firefox會顯示加載沒有完成,圖示會不停旋轉。(Google 使用了稱為“htmlfile”的 ActiveX 解決了在 IE 中的加載顯示問題,并将這種方法應用到了 gmail+gtalk 産品中。)
以上方案均是請求/響應模式,并沒有從根本上實作雙向通信。
方案四、WebSocket
Websocket是一個獨立的基于TCP的協定,與http協定相容、卻不會融入http協定。與http協定不同的請求/響應模式不同,Websocket在建立連接配接後,雙方即可雙向通信。
HTTP1.1與WebSocket的異同
1.相同點
- 都是基于TCP的應用層協定。
- 都使用Request/Response模型進行連接配接的建立。
- 都可以在網絡中傳輸資料。
- 在連接配接的建立過程中對錯誤的處理方式相同,在這個階段WebSocket可能傳回和HTTP相同的傳回碼。
2.不同點
- http協定基于請求應答,隻能做單向傳輸,是半雙工協定,而WebSocket是全雙工協定,類似于socket通信,雙方都可以在任何時刻向另一方發送資料。
- WebSocket的連接配接不能通過中間人來轉發,它必須是一個直接連接配接。
- websocket傳輸的資料是二進制流,是以幀為機關的,http傳輸的是明文傳輸,是字元串傳輸,WebSocket的資料幀有序。
- websocket的請求的頭部和http請求頭部不同。
方案五、SSE
SSE(Server-Sent Event,服務端推送事件)允許服務端向用戶端推送新資料,是 HTML 5 規範中的一個組成部分。
與WebSocket的比較
- 簡單不說,SSE适用于更新頻繁、低延遲并且資料都是從服務端到用戶端。
- 它和WebSocket的差別:
- 便利,不需要添加任何新元件,用任何習慣的後端語言和架構就能繼續使用,不用為建立虛拟機弄一個新的IP或新的端口号而勞神。
- 伺服器端的簡潔。因為SSE能在現有的HTTP/HTTPS協定上運作,是以它能夠直接運作于現有的代理伺服器和認證技術。
- WebSocket相較SSE最大的優勢在于它是雙向交流的,這意味着伺服器發送資料就像從伺服器接受資料一樣簡單,而SSE一般通過一個獨立的Ajax請求從用戶端向服務端傳送資料,是以相對于WebSocket使用Ajax會增加開銷。是以,如果需要以每秒一次或者更快的頻率向服務端傳輸資料,就應該用WebSocket。
常用實作的對比
網頁端收消息,采取哪種方式實作需結合具體實際情況決定。對于即時通信使用者發送的“聊天消息”,對實時性要求很強,我們一般采用WebSocket方式實作雙向通信;對于系統發給使用者的“系統通知”,對實時性要求沒那麼高,我們可以采用輪詢等“拉”的方式來實作服務端對用戶端的資料“推送”。
魯迅說,任何脫離業務場景的架構設計都是耍流氓。是以我們接下來針對不同場景進行分析,服務端與用戶端的資料傳遞使用推送or 拉取?
Part 1:系統通知
一、系統對1的通知
典型業務,計數類通知:
- 有10個美女添加了你為好友
- 有8個好友私信了你
很多業務經常有這類計數通知,通知結果隻針對你,這類通知是推送,還是拉取的呢?常見的有這樣一些實踐:
如果業務需求對計數需求需要實時展現,例如微網誌的加好友計數,假如希望實作不重新整理網頁,計數就實時變化:
- 登入微網誌時,會有一個計數的拉取,對網頁端的計數進行初始化
- 在浏覽微網誌的過程中,一旦有人加你為好友,服務端對網頁端進行實時推送,告之增加了1個(或者N個)好友
這裡的思路是,一開始得到初始值,後續推送增量值,由網頁端計算最終計數并呈現最終結果。需要注意,針對不同業務,計數變化的內插補點可增可減。
上述方案的壞處是,一旦有消息丢失,網頁端的計數會一直不一緻,直至再次登入重新初始化計數。這個計算計數可以優化為在伺服器直接計算并通知網頁端最終的結果,網頁端隻負責呈現即可,這樣網頁端的邏輯會變輕。
如果業務對此類通知的展現不需要這麼實時,完全可以通過拉取:
- 隻有在連結跳轉,或者重新整理網頁時,才重新拉取最新的通知
這樣系統的實作會最簡單。需要注意,通知拉取要異步,不要影響首頁面的快速傳回。
系統對1的推送,例如針對1個使用者的業務計數推送,計數的變化頻率其實非常低,使用cache來存儲這些計數能夠極大提升系統性能。
二、系統對多的通知
系統對多的通知消息,會比系統對1的通知消息複雜一些,以兩個場景為例:
- QQ登入彈窗新聞
- QQ右下角彈窗廣告
IM登入彈窗新聞
這個通知的需求是:
- 同一天,使用者登入彈出的新聞是相同的(很多業務符合這樣的場景),不同天新聞則不一樣(但所有使用者都一樣)
- 每天第一次登入彈出新聞,當天的後續登入不出新聞
不妨設有一個表存放彈窗新聞:
t_msg(msg_id, date, msg_content)
有一個表來存放使用者資訊:
t_user(user_id, user_info, …)
有一個表來存放使用者收到的新聞彈窗:
t_user_msg(user_id, msg_id, date)
這裡的實作明顯不能采用推送的方式:
- 将t_user_msg裡對于所有user_id推送插入一個msg_id,表示未讀
- 在user每天第一次登入的時候,将當天的msg_id拉取出來,并删除,表示已讀
- 在user每天非第一次登入的時候,就拉取不到msg_id于是不會再次彈窗
這個笨拙的方式,會導緻t_user_msg裡有大量的髒資料,畢竟大部分使用者并不會登入。
如果改為拉取的方式會好很多:
- 在user每天第一次登陸時,将當天的msg_id拉取出來,并插入t_user_msg,表示已讀
- 在user每天非第一次登陸時,則會插入t_user_msg失敗,則說明已讀,不再進行二次彈窗展現
這個方式雖然有所優化,但t_user_msg的資料量依然很大。
還有一種巧妙的方式,去除t_user_msg表,改為在t_user表加一列,表示使用者最近拉取的彈窗時間:
t_user(user_id, user_info, last_msg_date, …)
這樣業務流程會更新為:
- 在user每天第一次登入時,将當天的msg_id拉取出來,并将last_msg_date修改為今天
- 在user每天非第一次登入時,發現last_msg_date為今天,則說明今天已讀
這種方式不再存儲消息與使用者的笛卡爾關系,資料量會大大減少,是不是有點意思?
IM右下角彈窗廣告
這個通知的需求是:
- 每天會對一批線上使用者推送相同的彈窗TIPS廣告,例如球鞋廣告,手機廣告等
最直覺的感受,這是一個for循環批量推送的過程。如果是推送,必須要考慮的問題是,推送限速控制,避免短時間内對系統造成沖擊,引發雪崩。
能不能用拉取呢?
完全可以,這是一個對實時性要求不太高的場景,使用者早1分鐘晚1分鐘收到這個廣告影響不大,其實可以借助IM原本已有的keepalive請求,在請求傳回時,告之“有消息拉取”,然後采用拉取的方式拉取廣告消息。
這個方案的好處是,由于5KW線上使用者的keepalive請求是均勻的,是以可以很均勻的将廣告拉取的請求同樣均勻的分散到一段時間内,避免5KW集中推送對系統造成沖擊。
三、總結
廣義系統通知,究竟是推送還是拉取呢?不同業務,不同需求,實作方式不同。
系統對1的通知:
- 實時性要求高,進行推送
- 實時性要求低,可以拉取
系統對N的通知:
- 登入彈窗新聞,拉取更佳,可以用一個last_msg_date來避免大量資料的存儲
- 批量彈窗廣告,常見的方法是推送,需要注意限速,也可以拉取,以實作請求的均勻分散
Part 2:狀态同步場景
狀态同步,有好友狀态的同步,有群友狀态的同步,有的需要實時同步,有的能夠容忍延時。結合具體場景來看下,狀态同步,究竟是推還是拉。
不同的産品,會有不同的用戶端狀态,例如隐身、離線、忙碌、勿擾等,這些狀态大多是産品功能需求。後文為了友善描述,假設用戶端狀态也隻有線上和離線兩種狀态,後文統一稱為“使用者狀态”。
如何擷取好友的狀态?
uid-A登入時,先去資料庫拉取自己的好友清單,再去緩存擷取所有好友的狀态。
使用者uid-A的好友uid-B狀态改變時(由登入、登出等動作觸發),uid-A如何同步這一事件?
這裡就有推拉的設計折衷了。
- 如果對于狀态變更實時性要求不高,可以采用拉取
uid-A向伺服器輪詢拉取uid-B(其實是自己的全部好友)的狀态,例如每1分鐘一次,其缺點是:
(1)如果uid-B的狀态改變,uid-A擷取不實時,可能有1分鐘時延
(2)如果uid-B的狀态不改變,uid-A會有大量無效的輪詢請求,非常低效
- 如果對于狀态變更實時性要求較高,則必須推送
uid-B狀态改變時(由登入、登出等動作觸發),服務端不僅要在緩存中修改uid-B的狀态,還要将這個狀體改變的通知推送給uid-B的線上好友。
推送的優勢是:實時
缺點是:當線上好友量很大時,任何一個使用者狀态的改變,會擴散成N個實時通知,這個N叫做“消息風暴擴散系數”。假設一個IM系統平均每個使用者有200個好友,平均有20%的好友線上,那麼消息風暴擴散系數N=40,這意味着,任何一個狀态的變化會變成40個推送請求。
群友狀态的一緻性,和好友狀态的一緻性相比,複雜在哪裡?可不可以采用實時推送?
群這個業務場景大夥也非常之熟悉,你能夠加入若幹群(例如20個),假設平均每個群有200人,即你會有4000個群友。
理論上群友狀态也可以通過實時推送的方式實作,以保證明時性。進一步讨論之前,先一起估算下這個業務場景下的“消息風暴擴散系數”。
假設平均每個使用者加了20個群,平均每個群有200個使用者,依然假設20%的使用者線上,那麼為了保證群友狀态的實時性,每個使用者登入,就要将自己的狀态改變通知發送給20*200*20%=800個群友,N=800,意味着,任何一個狀态的變化會變成800個推送請求。如果說好友狀态實時推送,消息風暴擴散系數N=40尚可以接受,那麼群友狀态實時推送,N=800則是災難性的。此類業務往往采用輪詢拉取的方式,獲得群友的狀态。
輪詢拉取群友狀态也會給伺服器帶來過大的壓力,還有什麼優化方式?
群友的資料量太大,雖然每個使用者平均加入了20個群,但實際上并不會每次登入都進入每一個群。不采用輪詢拉取,而采用按需拉取,延時拉取的方式,在真正進入一個群時才實時拉取群友的線上狀态,是既能滿足使用者需求(使用者感覺是狀态是實時、一緻的,但其實是進入群才拉取的),又能降低伺服器壓力。這是一種常見方法。
總結
狀态的實時性與一緻性是一個較難解決的技術問題,不同的業務實作方式不同,一般來說:
- 好友狀态同步,是采用推送的方式同步
- 群友狀态同步,由于消息風暴擴散系數過大,一般采用拉取的方式同步
- 群友狀态同步,還能采用按需拉取的優化方式,進一步降低服務端壓力
- “消息風暴擴散系數”是指一個消息發出時,變成N個消息的擴散系數,這個系數一定程度上決定了技術采用推送還是拉取
Part 3:群消息已讀回執
釘釘用于商務交流,其“強制已讀回執”功能,讓職場人無法再“假裝不線上”,“假裝沒收到”。
有甚者,釘釘的群有“強制已讀回執”功能,你在群裡發出的消息,能夠知道誰讀了消息,誰沒有讀消息。
群消息的流程如何,接收方如何確定收到群消息,發送方如何收已讀回執,究竟是拉取,還是推送,是今天要讨論的問題。
一、群消息投遞流程,以及可達性保證
大家一起跟着樓主的節奏,一步一步來看群消息怎麼設計。
核心問題1:群消息,隻存一份?還是,每個成員存一份?
答:存一份,為每個成員設定一個群消息隊列,會有大量資料備援,并不合适。
核心問題2:如果群消息隻存一份,怎麼知道每個成員讀了哪些消息?
答:可以利用群消息的偏序關系,記錄每個成員的last_ack_msgid(last_ack_time),這條消息之前的消息已讀,這條消息之後的消息未讀。該方案意味着,對于群内的每一個使用者,隻需要記錄一個值即可。
解答上述兩個核心問題後,很容易得到群消息的核心資料結構。
群消息表:記錄群消息。
group_msgs(msgid, gid, sender_uid, time, content);
各字段的含義為:消息ID,群ID,發送方UID,發送時間,發送内容。
群成員表:記錄群裡的成員,以及每個成員收到的最後一條群消息。
group_users(gid, uid, last_ack_msgid);
各字段的含義為:群ID,群成員UID,群成員最後收到的一條群消息ID。
在核心資料結構設計完之後,一起來看看群消息發送的流程。
業務場景:
(1)一個群中有A, uid1, uid2, uid3四名成員
(2)A, uid1, uid2線上,期望實時收到線上消息
(3)uid3離線,期望未來拉取到離線消息
其整個消息發送的流程1-4如上圖:
(1)A發出群消息
(2)server收到消息後,一來要将群消息落地,二來要查詢群裡有哪些群成員,以便實施推送
(3)對于群成員,查詢線上狀态
(4)對于線上的群成員,實施推送
這個流程裡,隻要第二步消息落地完成,就能保證群消息不會丢失。
核心問題3:如何保證接收方一定收到群消息?
答:各個收到消息後,要修改各群成員的last_ack_msgid,以告訴系統,這一條消息确認收到了。
線上消息,離線消息的last_ack_msgid的修改,又各有不同。
對于線上的群友,收到群消息後,第一時間會ack,修改last_ack_msgid。
對于離線的群友,會在下一次登入時,拉取未讀的所有群離線消息,并将last_ack_msgid修改為最新的一條消息。
核心問題4:如果ack丢失,群友會不會拉取重複的群消息?
答:會,可以根據msgid在用戶端本地做去重,即使系統層面收到了重複的消息,仍然可以保證良好的使用者體驗。
上述流程,隻能確定接收方收到消息,發送方仍然不知道哪些人線上閱讀了消息,哪些人離線未閱讀消息,并沒有實作已讀回執,那已讀回執會對系統設計産生什麼樣的影響呢?
二、已讀回執流程
對于發送方發送的任何一條群消息,都需要知道,這條消息有多少人已讀多少人未讀,就需要一個基礎表來記錄這個關系。
消息回執表:用來記錄消息的已讀回執。
msg_acks(sender_uid, msgid, recv_uid, gid,if_ack);
各字段的含義為:發送方UID,消息ID,回執方UID,群ID,回執标記。
增加了已讀回執邏輯後,群消息的流程會有細微的改變。
步驟二,server收到消息後,除了要:
- 将群消息落地
- 查詢群裡有哪些群成員,以便實施推送
之外,還需要:
- 插入每條消息的初始回執狀态
接收方修改last_ack_msgid的流程,會變為:
(1)發送ack請求
(2)修改last_ack_msgid,并且,修改已讀回執if_ack狀态
(3)查詢發送方線上狀态
(4)向發送方實時推送已讀回執(如果發送方線上)
如果發送方不線上,ta會在下次登入的時候:
(5)從關聯表裡拉取每條消息的已讀回執
這裡的初步結論是:
- 如果發送方線上,會實時被推送已讀回執
- 如果發送方不線上,會在下次線上時拉取已讀回執
三、流程優化方案
再次詳細的分析下,群消息已讀回執的“消息風暴擴散系數”,假設每個群有200個使用者,其中20%的使用者線上,即40各使用者線上。群使用者每發送一條群消息,會有:
- 40個消息,通知給群友
- 40個ack修改last_ack_msgid,發給服務端
- 40個已讀回執,通知給發送方
可見,其消息風暴擴散系數非常之大。
同時:
- 需要存儲40條ack記錄
群數量,群友數量,群消息數量越來越多之後,存儲也會成為問題。
是否有優化方案呢?
群消息的推送,能否改為接收方輪詢拉取?
答:不能,消息接收,實時性是核心名額。
對于last_ack_msgid的修改,真的需要每個群消息都進行ack麼?
答:其實不需要,可以批量ack,累計收到N條群消息(例如10條),再向伺服器發送一次last_ack_msgid的修改請求,同時修改這個請求之前所有請求的已讀回執,這樣就能将40個發送給服務端的ack請求量,降為原來的1/10。
以上會帶來什麼副作用?
答:last_ack_msgid的作用是,記錄接收方最近新取的一條群消息,如果不實時更新,可能導緻,異常退出時,有一些群消息沒來得及更新last_ack_msgid,使得下次登陸時,拉取到重複的群消息。但這不是問題,用戶端可以根據msgid去重,使用者體驗不會受影響。
發送方線上時,對于已讀回執的發送,真的需要實時推送麼?
答:其實不需要,發送方每發一條消息,會收到40個已讀回執,采用輪詢拉取(例如1分鐘一次,一個小時也就60個請求),可以大大降低請求量。
畫外音:或者直接放到應用層keepalive請求裡,做到0額外請求增加。
以上會帶來什麼副作用?
答:已讀回執更新不實時,最壞的情況下,1分鐘才更新回執。當然,可以根據性能與産品體驗來折衷配置這個輪詢時間。
如何降低資料量?
答:回執資料不是核心資料
- 已讀的消息,可以進行實體删除,而不是标記删除
- 超過N長時間的回執,歸檔或者删除掉
四、總結
對于群消息已讀回執,一般來說:
- 如果發送方線上,會實時被推送已讀回執
- 如果發送方不線上,會在下次線上時拉取已讀回執
如果要對進行優化,可以:
- 接收方累計收到N條群消息再批量ack
- 發送方輪詢拉取已讀回執
- 實體删除已讀回執資料,定時删除或歸檔非核心曆史資料