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

相關文章