golang寫的IM伺服器(文件更新)

Alber發表於2018-11-25

簡要介紹

goim 是一個即時通訊伺服器,程式碼全部使用 golang 完成,功能包含好友之間一對一聊天,群組聊天,支援單使用者多裝置同時線上,就像微信一樣,當你同時使用兩個裝置登入賬號時,兩個裝置可以都可以接收到訊息,當你用一個裝置傳送訊息時,另一個裝置也能收到你傳送的訊息。目前完成了第一版,第一版不想做的太複雜龐大,但是好多細節邏輯都做了反覆的推敲,其主要目的是先作出核心功能,不考略加快取和 MQ 提高效能,所以還不是很完善,以後會逐漸完善。

所用技術

golang+mysql 完成,web 框架使用了 gin(對 gin 進行了簡單的封裝),日誌框架使用了 zap,當然也自己寫了一些小元件,例如 TCP 拆包粘包,唯一訊息 id 生成器,資料庫統一事務管理等。

專案分層設計

專案主要氛圍兩層,connect 層和 logic 層,public 包下放置了一些 connect 層和 logic 層公用的程式碼和一些基礎庫。
connect 連線層,主要維護和客戶端的 tcp 連線,所以 connect 是有狀態的,connect 包含了 TCP 拆包粘包,訊息解碼,客戶端心跳等一些邏輯。
logic 邏輯層是無狀態的,主要維護訊息的轉發邏輯,以及對外提供 http 介面,提供一些聊天系統基本的業務功能,例如,登入,註冊,新增好友,刪除好友,建立群組,新增人到群組,群組踢人等功能

如何保證訊息不丟不重

首先我們要搞清楚什麼情況下訊息會丟會重
訊息重複:客戶端傳送訊息,由於網路原因或者其他原因,客戶端沒有收到伺服器的傳送訊息回執,這時客戶端會重複傳送一次,假設這種情況,客戶端傳送的訊息伺服器已經處理了,但是回執訊息丟失了,客戶端再次傳送,伺服器就會重複處理,這樣,接收方就會看到兩條重複的訊息,解決這個問題的方法是,客戶端對傳送的訊息增加一個遞增的序列號,伺服器儲存客戶端傳送的最大序列號,當客戶端傳送的訊息序列號大於序列號,伺服器正常處理這個訊息,否則,不處理。
訊息丟失:伺服器投遞訊息後,如果客戶端網路不好,或者已經斷開連線,或者是已經切換到其他網路,伺服器並不是立馬可以感知到的,伺服器只能感知到訊息已經投遞出去了,所以這個時候,就會造成訊息丟失。
怎樣解決:
1:訊息持久化:
2:投遞訊息增加序列號
3:增加訊息投遞的 ACK 機制,就是客戶端收到訊息之後,需要回執伺服器,自己收到了訊息。
這樣,伺服器就可以知道,客戶端那些訊息已經收到了,當客戶端檢測到 TCP 不可用(使用心跳機制),重新建立 TCP 連線,帶上自己收到的訊息的最大序列號,觸發一次訊息同步,伺服器根據這個序列號,將客戶端未收到再次投遞給客戶端,這樣就可以保證訊息不丟失了。

訊息轉發邏輯,是採用讀擴散還是寫擴散

首先解釋一下,什麼是讀擴散,什麼是寫擴散
讀擴散:當兩個客戶端 A,B 發訊息時,首先建立一個會話,A,B 發所發的每條訊息都會寫到這個會話裡,訊息同步的時候,只需要查詢這個會話即可。群發訊息也是一樣,群組內部傳送訊息時,也是先建立一個會話,都將這個訊息寫入這個會話中,觸發訊息同步時,只需要查詢這個會話就行,讀擴散的好處就是,每個訊息只需要寫入資料庫一次就行,但是,每個會話都要維持一個訊息序列,作訊息同步,客戶端要上傳 N 個(使用者會話個數)序列號,伺服器要為這 N 個序列號做訊息同步,想想一下,成百上千個序列號,是不是有點恐怖。
寫擴散:就是每個使用者維持一個訊息列表,當有其他使用者給這個使用者傳送訊息時,給這個使用者的訊息列表插入一條訊息即可,這樣做的好處就是,每個使用者只需要維護一個訊息列表,也只需要維護一組序列號,壞處就是,一個群組有多少人,就要插入多少條訊息,DB 的壓力會增大。
如何選型呢,我採用的時寫擴散,據說,微信 3000 人的群也是這樣乾的。當然也有一些 IM,對大群做了特殊處理,對超大群採用讀擴散的模式。

訊息拆包

首先說一下,一個訊息是怎樣定義的,使用的 TLV,解釋一下:
T:訊息型別
L:訊息長度
V:實際的資料
我是怎樣做的,訊息也是採用 TLV 的格式,T 採用了兩個位元組,L:也是兩個位元組,兩個位元組最大可以儲存 65536 的無符號整形,所以的單條資料的長度不能超過 65536,再大,就要考慮分包了。
拆包具體是怎樣實現的,首先,我自己實現了一個 buffer,這個 buffer 實質上就是一個 slice,首先,先從系統快取裡面讀入到這個 buffer 裡面,然後的拆包的所有處理,都在這個 buffer 裡面完成,拆出來的位元組陣列,也是複用 buffer 的記憶體,拆出來的陣列要是連續的,怎樣保證呢,我是這樣做的。
每次從系統快取讀取資料放到 buffer 後,然後進行拆包,當拆到不能再拆的時候(buffer 裡面不足一個訊息),把 buffer 裡面的剩餘位元組遷移到 slice 的頭部,下一次的從系統快取的讀到的位元組,追加到後面就行了,這樣就能保證所拆出來的訊息時連續的位元組陣列。
這裡感嘆一下,golang 的 slice 真心好用。
訊息協議使用 Google 的 Protocol Buffers,具體訊息協議定製在/public/proto 包下

心跳保活

這裡簡要說一下我理解的心跳保活的作用
1:客戶端檢測 TCP 連線是否有效,當無效時,斷開重聯
2:伺服器檢測 TCP 連線是否有效,當無效時,關閉連線,釋放伺服器資源
3:避免運營商 NAT 超時,斷開連線
如果想看詳細的,以及心跳的優化,我覺得這篇文章寫的不錯
https://juejin.im/entry/5aa6144e51882555731bc3e5

單使用者多裝置支援

首先說一下,單使用者單裝置的訊息轉發是怎樣的,使用寫擴散的方式,比如有兩個使用者 A 和 B 聊天,當 A 使用者給 B 使用者發訊息時,需要給 B 使用者的訊息列表插入一條訊息,然後,通過 TCP 長連結,將訊息投遞給 B 就行。
現在升級到但使用者多裝置,舉個例子,A 使用者下有 a1,a2 兩個裝置同時線上,B 使用者有 b1,b2 兩個裝置同時線上,A 使用者 a1 裝置給 B 使用者傳送訊息,訊息轉發流程就會變成這樣:
1:給 B 使用者的訊息列表插入一條訊息,然後將訊息投遞給裝置 b1,b2(這裡注意,一個使用者不管有多少裝置線上,指維護一個訊息列表)
2:給 A 使用者的訊息列表插入一條訊息,然後將訊息投遞給裝置 a1,a2(這裡注意,雖然 a1 是傳送訊息者,但是還會收到訊息投遞,當裝置發現傳送訊息的裝置 id 是自己的,就不對訊息做處理,只同步序列號就行)
這裡看到,單使用者單裝置模式下,一條訊息只需要儲存一份,而在單使用者多裝置模式下,一條訊息需要儲存兩份

訊息唯一 id

唯一訊息 id 的主要作用是用來標示一次訊息傳送的完整流程,訊息傳送->訊息投遞->訊息投遞迴執,用來線上排查線上問題。
每一個訊息有唯一的訊息的 id,由於訊息傳送頻率比較高 s,所以效能就很重要,當時沒有找到合適第三方庫,所以就自己實現了一個,原理就是,每次從資料庫中拿一個資料段,用完了再去資料庫拿,當用完之後去從資料庫拿的時候,會有一小會的阻塞,為了解決這個問題,就做成了非同步的,就是保證記憶體中有 n 個可用的 id,當 id 消耗掉小於 n 個時,就從資料庫獲取生成,當達到 n 個時,goroutine 阻塞等待 id 被消耗,如此往復。

主要邏輯

client: 客戶端
connect:連線層
logic:邏輯層
mysql:儲存層

登入

3496be2f9ee9d33e.jpg

單發

00d7e21cccc9050e.jpg

群發

7ee3ada2baf1dec0.jpg

日誌

使用了 zap 的日誌框架,為什麼選用 zap 呢?在我壓測下,效能表現不錯,而且功能也很完善,在專案中,日誌我做了細心的梳理,下圖展示了一次兩個裝置從登入,發一條訊息,再到下線的一次流程的完整日誌 9f644dcd04b20287.jpg

api 文件

https://documenter.getpostman.com/view/4164957/RzZ4q2hJ

github

https://github.com/alberliu/goim

更多原創文章乾貨分享,請關注公眾號
  • golang寫的IM伺服器(文件更新)
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章