基於websocket單臺機器支援百萬連線分散式聊天(IM)系統

link1st發表於2019-08-27

基於websocket單臺機器支援百萬連線分散式聊天(IM)系統

本文將介紹如何實現一個基於websocket分散式聊天(IM)系統。

使用golang實現websocket通訊,單機可以支援百萬連線,使用gin框架、nginx負載、可以水平部署、程式內部相互通訊、使用grpc通訊協議。

本文內容比較長,如果直接想clone專案體驗直接進入專案體驗 goWebSocket專案下載 ,文字從介紹webSocket是什麼開始,然後開始介紹這個專案,以及在Nginx中配置域名做webSocket的轉發,然後介紹如何搭建一個分散式系統。

目錄

1、專案說明

1.1 goWebSocket

本文將介紹如何實現一個基於websocket聊天(IM)分散式系統。

使用golang實現websocket通訊,單機支援百萬連線,使用gin框架、nginx負載、可以水平部署、程式內部相互通訊、使用grpc通訊協議。

  • 一般專案中webSocket使用的架構圖 網站架構圖

1.2 專案體驗

2、介紹webSocket

2.1 webSocket 是什麼

WebSocket 協議在2008年誕生,2011年成為國際標準。所有瀏覽器都已經支援了。

它的最大特點就是,伺服器可以主動向客戶端推送資訊,客戶端也可以主動向伺服器傳送資訊,是真正的雙向平等對話,屬於伺服器推送技術的一種。

  • HTTP和WebSocket在通訊過程的比較 HTTP協議和WebSocket比較

  • HTTP和webSocket都支援配置證照,ws:// 無證照 wss:// 配置證照的協議標識 HTTP協議和WebSocket比較

2.2 webSocket的相容性

  • 瀏覽器的相容性,開始支援webSocket的版本

瀏覽器開始支援webSocket的版本

  • 服務端的支援

golang、java、php、node.js、python、nginx 都有不錯的支援

  • Android和IOS的支援

Android可以使用java-webSocket對webSocket支援

iOS 4.2及更高版本具有WebSockets支援

2.3 為什麼要用webSocket

    1. 從業務上出發,需要一個主動通達客戶端的能力

      目前大多數的請求都是使用HTTP,都是由客戶端發起一個請求,有服務端處理,然後返回結果,不可以服務端主動向某一個客戶端主動傳送資料

服務端處理一個請求

    1. 大多數場景我們需要主動通知使用者,如:聊天系統、使用者完成任務主動告訴使用者、一些運營活動需要通知到線上的使用者
    1. 可以獲取使用者線上狀態
    1. 在沒有長連結的時候通過客戶端主動輪詢獲取資料
    1. 可以通過一種方式實現,多種不同平臺(H5/Android/IOS)去使用

2.4 webSocket建立過程

    1. 客戶端先發起升級協議的請求

客戶端發起升級協議的請求,採用標準的HTTP報文格式,在報文中新增頭部資訊

Connection: Upgrade表明連線需要升級

Upgrade: websocket需要升級到 websocket協議

Sec-WebSocket-Version: 13 協議的版本為13

Sec-WebSocket-Key: I6qjdEaqYljv3+9x+GrhqA== 這個是base64 encode 的值,是瀏覽器隨機生成的,與伺服器響應的 Sec-WebSocket-Accept對應

# Request Headers
Connection: Upgrade
Host: im.91vh.com
Origin: http://im.91vh.com
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: I6qjdEaqYljv3+9x+GrhqA==
Sec-WebSocket-Version: 13
Upgrade: websocket

瀏覽器 Network

    1. 伺服器響應升級協議

服務端接收到升級協議的請求,如果服務端支援升級協議會做如下響應

返回:

Status Code: 101 Switching Protocols 表示支援切換協議

# Response Headers
Connection: upgrade
Date: Fri, 09 Aug 2019 07:36:59 GMT
Sec-WebSocket-Accept: mB5emvxi2jwTUhDdlRtADuBax9E=
Server: nginx/1.12.1
Upgrade: websocket
    1. 升級協議完成以後,客戶端和伺服器就可以相互傳送資料

websocket接收和傳送資料

3、如何實現基於webSocket的長連結系統

3.1 使用go實現webSocket服務端

3.1.1 啟動埠監聽

  • websocket需要監聽埠,所以需要在golang 成功的 main 函式中用協程的方式去啟動程式
  • main.go 實現啟動
go websocket.StartWebSocket()
  • init_acc.go 啟動程式
// 啟動程式
func StartWebSocket() {
    http.HandleFunc("/acc", wsPage)
    http.ListenAndServe(":8089", nil)
}

3.1.2 升級協議

  • 客戶端是通過http請求傳送到服務端,我們需要對http協議進行升級為websocket協議
  • 對http請求協議進行升級 golang 庫gorilla/websocket 已經做得很好了,我們直接使用就可以了
  • 在實際使用的時候,建議每個連線使用兩個協程處理客戶端請求資料和向客戶端傳送資料,雖然開啟協程會佔用一些記憶體,但是讀取分離,減少收發資料堵塞的可能
  • init_acc.go
func wsPage(w http.ResponseWriter, req *http.Request) {

    // 升級協議
    conn, err := (&websocket.Upgrader{CheckOrigin: func(r *http.Request) bool {
        fmt.Println("升級協議", "ua:", r.Header["User-Agent"], "referer:", r.Header["Referer"])

        return true
    }}).Upgrade(w, req, nil)
    if err != nil {
        http.NotFound(w, req)

        return
    }

    fmt.Println("webSocket 建立連線:", conn.RemoteAddr().String())

    currentTime := uint64(time.Now().Unix())
    client := NewClient(conn.RemoteAddr().String(), conn, currentTime)

    go client.read()
    go client.write()

    // 使用者連線事件
    clientManager.Register <- client
}

3.1.3 客戶端連線的管理

  • 當前程式有多少使用者連線,還需要對使用者廣播的需要,這裡我們就需要一個管理者(clientManager),處理這些事件:
  • 記錄全部的連線、登入使用者的可以通過 appId+uuid 查到使用者連線
  • 使用map儲存,就涉及到多協程併發讀寫的問題,所以需要加讀寫鎖
  • 定義四個channel ,分別處理客戶端建立連線、使用者登入、斷開連線、全員廣播事件
// 連線管理
type ClientManager struct {
    Clients     map[*Client]bool   // 全部的連線
    ClientsLock sync.RWMutex       // 讀寫鎖
    Users       map[string]*Client // 登入的使用者 // appId+uuid
    UserLock    sync.RWMutex       // 讀寫鎖
    Register    chan *Client       // 連線連線處理
    Login       chan *login        // 使用者登入處理
    Unregister  chan *Client       // 斷開連線處理程式
    Broadcast   chan []byte        // 廣播 向全部成員傳送資料
}

// 初始化
func NewClientManager() (clientManager *ClientManager) {
    clientManager = &ClientManager{
        Clients:    make(map[*Client]bool),
        Users:      make(map[string]*Client),
        Register:   make(chan *Client, 1000),
        Login:      make(chan *login, 1000),
        Unregister: make(chan *Client, 1000),
        Broadcast:  make(chan []byte, 1000),
    }

    return
}

3.1.4 註冊客戶端的socket的寫的非同步處理程式

  • 防止發生程式崩潰,所以需要捕獲異常
  • 為了顯示異常崩潰位置這裡使用string(debug.Stack())列印呼叫堆疊資訊
  • 如果寫入資料失敗了,可能連線有問題,就關閉連線
  • client.go
// 向客戶端寫資料
func (c *Client) write() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("write stop", string(debug.Stack()), r)

        }
    }()

    defer func() {
        clientManager.Unregister <- c
        c.Socket.Close()
        fmt.Println("Client傳送資料 defer", c)
    }()

    for {
        select {
        case message, ok := <-c.Send:
            if !ok {
                // 傳送資料錯誤 關閉連線
                fmt.Println("Client傳送資料 關閉連線", c.Addr, "ok", ok)

                return
            }

            c.Socket.WriteMessage(websocket.TextMessage, message)
        }
    }
}

3.1.5 註冊客戶端的socket的讀的非同步處理程式

  • 迴圈讀取客戶端傳送的資料並處理
  • 如果讀取資料失敗了,關閉channel
  • client.go
// 讀取客戶端資料
func (c *Client) read() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("write stop", string(debug.Stack()), r)
        }
    }()

    defer func() {
        fmt.Println("讀取客戶端資料 關閉send", c)
        close(c.Send)
    }()

    for {
        _, message, err := c.Socket.ReadMessage()
        if err != nil {
            fmt.Println("讀取客戶端資料 錯誤", c.Addr, err)

            return
        }

        // 處理程式
        fmt.Println("讀取客戶端資料 處理:", string(message))
        ProcessData(c, message)
    }
}

3.1.6 接收客戶端資料並處理

  • 約定傳送和接收請求資料格式,為了js處理方便,採用了json的資料格式傳送和接收資料(人類可以閱讀的格式在工作開發中使用是比較方便的)

  • 登入傳送資料示例:
    {"seq":"1565336219141-266129","cmd":"login","data":{"userId":"馬遠","appId":101}}
  • 登入響應資料示例:
    {"seq":"1565336219141-266129","cmd":"login","response":{"code":200,"codeMsg":"Success","data":null}}
  • websocket是雙向的資料通訊,可以連續傳送,如果傳送的資料需要服務端回覆,就需要一個seq來確定服務端的響應是回覆哪一次的請求資料
  • cmd 是用來確定動作,websocket沒有類似於http的url,所以規定 cmd 是什麼動作
  • 目前的動作有:login/heartbeat 用來傳送登入請求和連線保活(長時間沒有資料傳送的長連線容易被瀏覽器、移動中間商、nginx、服務端程式斷開)
  • 為什麼需要AppId,UserId是表示使用者的唯一欄位,設計的時候為了做成通用性,設計AppId用來表示使用者在哪個平臺登入的(web、app、ios等),方便後續擴充套件

  • request_model.go 約定的請求資料格式
/************************  請求資料  **************************/
// 通用請求資料格式
type Request struct {
    Seq  string      `json:"seq"`            // 訊息的唯一Id
    Cmd  string      `json:"cmd"`            // 請求命令字
    Data interface{} `json:"data,omitempty"` // 資料 json
}

// 登入請求資料
type Login struct {
    ServiceToken string `json:"serviceToken"` // 驗證使用者是否登入
    AppId        uint32 `json:"appId,omitempty"`
    UserId       string `json:"userId,omitempty"`
}

// 心跳請求資料
type HeartBeat struct {
    UserId string `json:"userId,omitempty"`
}
  • response_model.go
/************************  響應資料  **************************/
type Head struct {
    Seq      string    `json:"seq"`      // 訊息的Id
    Cmd      string    `json:"cmd"`      // 訊息的cmd 動作
    Response *Response `json:"response"` // 訊息體
}

type Response struct {
    Code    uint32      `json:"code"`
    CodeMsg string      `json:"codeMsg"`
    Data    interface{} `json:"data"` // 資料 json
}

3.1.7 使用路由的方式處理客戶端的請求資料

  • 使用路由的方式處理由客戶端傳送過來的請求資料
  • 以後新增請求型別以後就可以用類是用http相類似的方式(router-controller)去處理
  • acc_routers.go
// Websocket 路由
func WebsocketInit() {
    websocket.Register("login", websocket.LoginController)
    websocket.Register("heartbeat", websocket.HeartbeatController)
}

3.1.8 防止記憶體溢位和Goroutine不回收

    1. 定時任務清除超時連線 沒有登入的連線和登入的連線6分鐘沒有心跳則斷開連線

client_manager.go

// 定時清理超時連線
func ClearTimeoutConnections() {
    currentTime := uint64(time.Now().Unix())

    for client := range clientManager.Clients {
        if client.IsHeartbeatTimeout(currentTime) {
            fmt.Println("心跳時間超時 關閉連線", client.Addr, client.UserId, client.LoginTime, client.HeartbeatTime)

            client.Socket.Close()
        }
    }
}
    1. 讀寫的Goroutine有一個失敗,則相互關閉 write()Goroutine寫入資料失敗,關閉c.Socket.Close()連線,會關閉read()Goroutine read()Goroutine讀取資料失敗,關閉close(c.Send)連線,會關閉write()Goroutine
    1. 客戶端主動關閉 關閉讀寫的Goroutine 從ClientManager刪除連線
    1. 監控使用者連線、Goroutine數 十個記憶體溢位有九個和Goroutine有關 新增一個http的介面,可以檢視系統的狀態,防止Goroutine不回收 檢視系統狀態
    1. Nginx 配置不活躍的連線釋放時間,防止忘記關閉的連線
    1. 使用 pprof 分析效能、耗時

3.2 使用javaScript實現webSocket客戶端

3.2.1 啟動並註冊監聽程式

  • js 建立連線,並處理連線成功、收到資料、斷開連線的事件處理

```$js ws = new WebSocket("ws://127.0.0.1:8089/acc");

ws.onopen = function(evt) { console.log("Connection open ..."); };

ws.onmessage = function(evt) { console.log( "Received Message: " + evt.data); data_array = JSON.parse(evt.data); console.log( data_array); };

ws.onclose = function(evt) { console.log("Connection closed."); };


#### 3.2.2 傳送資料
- 需要注意:連線建立成功以後才可以傳送資料
- 建立連線以後由客戶端向伺服器傳送資料示例

登入: ws.send('{"seq":"2323","cmd":"login","data":{"userId":"11","appId":101}}');

心跳: ws.send('{"seq":"2324","cmd":"heartbeat","data":{}}');

關閉連線: ws.close();


## 4、goWebSocket 專案
### 4.1 專案說明
- 本專案是基於webSocket實現的分散式IM系統
- 客戶端隨機分配使用者名稱,所有人進入一個聊天室,實現群聊的功能
- 單臺機器(24核128G記憶體)支援百萬客戶端連線
- 支援水平部署,部署的機器之間可以相互通訊

- 專案架構圖
![網站架構圖](https://img.mukewang.com/5d4e5 ... 42.png)

### 4.2 專案依賴

- 本專案只需要使用 redis 和 golang 
- 本專案使用govendor管理依賴,克隆本專案就可以直接使用

主要使用到的包

github.com/gin-gonic/gin@v1.4.0 github.com/go-redis/redis github.com/gorilla/websocket github.com/spf13/viper google.golang.org/grpc github.com/golang/protobuf


### 4.3 專案啟動 
- 克隆專案

git clone git@github.com:link1st/gowebsocket.git

git clone https://github.com/link1st/gowebsocket.git

- 修改專案配置

cd gowebsocket cd config mv app.yaml.example app.yaml

修改專案監聽埠,redis連線等(預設127.0.0.1:3306)

vim app.yaml

返回專案目錄,為以後啟動做準備

cd ..

- 配置檔案說明

app: logFile: log/gin.log # 日誌檔案位置 httpPort: 8080 # http埠 webSocketPort: 8089 # webSocket埠 rpcPort: 9001 # 分散式部署程式內部通訊埠 httpUrl: 127.0.0.1:8080 webSocketUrl: 127.0.0.1:8089

redis: addr: "localhost:6379" password: "" DB: 0 poolSize: 30 minIdleConns: 30


- 啟動專案

go run main.go

- 進入IM聊天地址
[http://127.0.0.1:8080/home/index](http://127.0.0.1:8080/home/index)
- 到這裡,就可以體驗到基於webSocket的IM系統

## 5、webSocket專案Nginx配置
### 5.1 為什麼要配置Nginx
- 使用nginx實現內外網分離,對外只暴露Nginx的Ip(一般的網際網路企業會在nginx之前加一層LVS做負載均衡),減少入侵的可能
- 使用Nginx可以利用Nginx的負載功能,前端再使用的時候只需要連線固定的域名,通過Nginx將流量分發了到不同的機器
- 同時我們也可以使用Nginx的不同的負載策略(輪詢、weight、ip_hash)

### 5.2 nginx配置
- 使用域名 **im.91vh.com** 為示例,參考配置
- 一級目錄**im.91vh.com/acc** 是給webSocket使用,是用nginx stream轉發功能(nginx 1.3.31 開始支援,使用Tengine配置也是相同的),轉發到golang 8089 埠處理
- 其它目錄是給HTTP使用,轉發到golang 8080 埠處理

upstream go-im { server 127.0.0.1:8080 weight=1 max_fails=2 fail_timeout=10s; keepalive 16; }

upstream go-acc { server 127.0.0.1:8089 weight=1 max_fails=2 fail_timeout=10s; keepalive 16; }

server { listen 80 ; server_name im.91vh.com; index index.html index.htm ;

location /acc {
    proxy_set_header Host $host;
    proxy_pass http://go-acc;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_set_header Connection "";
    proxy_redirect off;
    proxy_intercept_errors on;
    client_max_body_size 10m;
}

location /
{
    proxy_set_header Host $host;
    proxy_pass http://go-im;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_redirect off;
    proxy_intercept_errors on;
    client_max_body_size 30m;
}

access_log  /link/log/nginx/access/im.log;
error_log   /link/log/nginx/access/im.error.log;

}


### 5.3 問題處理
- 執行nginx測試命令,檢視配置檔案是否正確

/link/server/tengine/sbin/nginx -t

- 如果出現錯誤

nginx: [emerg] unknown "connection_upgrade" variable configuration file /link/server/tengine/conf/nginx.conf test failed

- 處理方法
- 在**nginx.com**新增

http{ fastcgi_temp_file_write_size 128k; ..... # 需要新增的內容

#support websocket
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

..... gzip on;

}

- 原因:Nginx代理webSocket的時候就會遇到Nginx的設計問題 **End-to-end and Hop-by-hop Headers** 

## 6、壓測
### 6.1 Linux核心優化
- 設定檔案開啟控制程式碼數

ulimit -n 1000000

- 設定sockets連線引數
```bash
vim /etc/sysctl.conf
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 0

6.2 壓測準備

  • 待壓測,如果大家有壓測的結果歡迎補充

6.3 壓測資料

  • 專案在實際使用的時候,每個連線約佔 24Kb記憶體,一個Goroutine 約佔11kb
  • 支援百萬連線需要22G記憶體
線上使用者數 cup 記憶體 I/O net.out
1W
10W
100W

7、如何基於webSocket實現一個分散式Im

7.1 說明

  • 參考本專案原始碼
  • gowebsocket v1.0.0 單機版Im系統
  • gowebsocket v1.0.0 分散式Im系統

  • 為了方便演示,IM系統和webSocket(acc)系統合併在一個系統中
  • IM系統介面: 獲取全部線上的使用者,查詢單前服務的全部使用者+叢集中服務的全部使用者 傳送訊息,這裡採用的是http介面傳送(微信網頁版傳送訊息也是http介面),這裡考慮主要是兩點: 1.服務分離,讓acc系統儘量的簡單一點,不摻雜其它業務邏輯 2.傳送訊息是走http介面,不使用webSocket連線,才用收和傳送資料分離的方式,可以加快收發資料的效率

7.2 架構

  • 專案啟動註冊和使用者連線時序圖

使用者連線時序圖

  • 其它系統(IM、任務)向webSocket(acc)系統連線的使用者傳送訊息時序圖

分佈是系統隨機給使用者傳送訊息

8、回顧和反思

8.1 在其它系統應用

  • 本系統設計的初衷就是:和客戶端保持一個長連結、對外部系統兩個介面(查詢使用者是否線上、給線上的使用者推送訊息),實現業務的分離
  • 只有和業務分離可,才可以供多個業務使用,而不是每個業務都建立一個長連結

8.2 已經實現的功能

  • gin log日誌(請求日誌+debug日誌)
  • 讀取配置檔案 完成
  • 定時指令碼,清理過期未心跳連結 完成
  • http介面,獲取登入、連結數量 完成
  • http介面,傳送push、查詢有多少人線上 完成
  • grpc 程式內部通訊,傳送訊息 完成
  • appIds 一個使用者在多個平臺登入
  • 介面,把所有線上的人拉倒一個群裡面,傳送訊息 完成
  • 單聊、群聊 完成
  • 實現分散式,水平擴張 完成
  • 壓測指令碼
  • 文件整理
  • 文件目錄、百萬長連結的實現、為什麼要實現一個IM、怎麼實現一個Im
  • 架構圖以及擴充套件

IM實現細節:

  • 定義文字訊息結構 完成
  • html傳送文字訊息 完成
  • 介面接收文字訊息併傳送給全體 完成
  • html接收到訊息 顯示到介面 完成
  • 介面優化 需要持續優化
  • 有人加入以後廣播全體 完成
  • 定義加入聊天室的訊息結構 完成
  • 引入機器人 待定

8.2 需要完善、優化

  • 登入,使用微信登入 獲取暱稱、頭像等
  • 有賬號系統、資料系統
  • 介面優化、適配手機端
  • 訊息 文字訊息(支援表情)、圖片、語音、視訊訊息
  • 微服務註冊、發現、熔斷等
  • 新增配置項,單臺機器最大連線數量

8.3 總結

  • 雖然實現了一個分散式在聊天的IM,但是有很多細節沒有處理(登入沒有鑑權、介面還待優化等),但是可以通過這個示例可以瞭解到:通過WebSocket解決很多業務上需求
  • 本文雖然號稱單臺機器能有百萬長連結(記憶體上能滿足),但是實際在場景遠比這個複雜(cpu有些壓力),當然瞭如果你有這麼大的業務量可以購買更多的機器更好的去支撐你的業務,本程式只是演示如何在實際工作用使用webSocket.
  • 參考本文,你可以實現出來符合你需要的程式

9、參考文獻

維基百科 WebSocket

阮一峰 WebSocket教程

WebSocket協議:5分鐘從入門到精通

link1st gowebsocket

相關文章