前言
最近在構建兩個系統的實時通訊部分,總結一下所學。
這是一個系列文章,暫時主要構思四個部分
- 深入淺出Websocket(一)Websocket協議
- 深入淺出Websocket(二)分散式Websocket叢集
- 深入淺出Websocket(三)分頻道的Websocket(分析socket.io原始碼以及ws-wrapper)
正文
這個是我在造的玩具的一個簡單架構圖。將實時通訊部分給抽離出來作為一個Websocket節點,形成了一個簡單的分散式系統,然後通過Redis的Pub/Sub做Websocket叢集之間的通訊以及Websocket節點與Restful API節點的通訊(比如使用者呼叫Restful API發表文章之後通知Websocket推送新訊息小紅點給前端)。
分散式系統的坑 左耳朵耗子:從亞馬遜的實踐,談分散式系統的難點
本文主要介紹下分散式Websocket叢集解決方案,最後會有個可執行的Demo。
Websocket分散式叢集解決方案
在這篇部落格裡,我們最終希望構建一個Websocket叢集來實現與客戶端的實時通訊,比如聊天室。我們當然可以通過簡單的demo構建一個Websocket伺服器並讓所有客戶端連線這臺機器,但當這個聊天室的互動量非常龐大呢?比如鬥魚的直播彈幕,我去鬥魚看了下請求,從命名也可以看到其建立了一個ws連線,叫做danmuproxy.douyu.com
,如下圖。
那麼問題來了,如果我只使用一臺伺服器,如何去支援可能有10萬人同時加入的這個聊天室呢?顯然我們需要一個解決方案,比如將流量負載均衡到不同的伺服器上並提供一種通訊機制讓各個伺服器能進行訊息同步(不然使用者A連上伺服器A,使用者B臉上伺服器B,它們發訊息的時候對方都沒法收到)。
其實從上圖的名字來看就知道鬥魚連線的這個danmuproxy.douyu.com
中的proxy
就大致能推斷出他們也是把流量做了一個分發。
Websocket叢集
由於和普通的HTTP伺服器的負載均衡不同,上一節也說到了這些Websocket伺服器需要共享資訊(當然,需要做Session共享的伺服器也一樣)。這意味著客戶端與Websocket伺服器的互動是有狀態(stateful)的,我們需要把每個客戶端的連線資料儲存在記憶體中。而當我們要實現分散式的時候,我們則需要在各個機器上共享這些資訊,所以我們需要一個Publish/Subscribe broker(其實broker以前上學講軟體設計體系結構的時候學過,但當時太萌新了沒理解)。接下來舉個例子。
假設我們現在使用Redis作為我們的解決方案,然後我們現在有三臺Websocket伺服器WS1
,WS2
和WS3
。然後每臺伺服器上連了三個使用者。WS1
機器上的其中一個使用者傳送了某個訊息到聊天室,在你的Websocket伺服器的邏輯中,你首先會把這個訊息存入資料庫做一個持久化(比如做歷史訊息),然後將這個訊息根據channelId之類的東西推送至這個聊天室的channel(Websocket的channel的實現會在下一篇中詳細講),我們假設這個channelId叫“The☆World”。
現在你把資料安全的存入了DB裡,並且你釋出了一個事件給你的Pub/Sub broker(Redis channel)來通知其他對此感興趣的部分(其他Websocket或者API伺服器等)。所以之前的另外兩個伺服器WS2
和WS3
因為對這部分感興趣所以他們也通過指令碼監聽了這一個Redis channel,它們就會得到通知,然後每個伺服器就會對DB請求query獲取更新然後emit訊息給Websocket上對應channel。
這就是你們可以看到的,使用Pub/Sub brooker來實現了一個橫向擴充套件的Websocket叢集。
從這裡也可以看到叢集具有的有點,高擴充套件性以及高可用性。
實現
這次實現使用了我的一臺高配阿里雲國內伺服器和一臺比較low的阿里雲9元學生伺服器以及高配伺服器上的redis。
Nginx負載均衡
首先配置Nginx做負載均衡,下圖是我的配置,只是個Demo沒做wss相關的。
伺服器端實現
程式碼都在github上。
Demo的程式碼也很短
const WebSocket = require('ws');
const publicIp = require('public-ip');
const uuidv1 = require('uuid/v1');
const redis = require("redis");
const config = require('./config');
const sub = redis.createClient(config.DB.REDIS_PORT, config.DB.REDIS_HOST);
const pub = redis.createClient(config.DB.REDIS_PORT, config.DB.REDIS_HOST);
if (config.DB.REDIS_PASSWORD) {
sub.auth(config.DB.REDIS_PASSWORD);
pub.auth(config.DB.REDIS_PASSWORD);
}
const wss = new WebSocket.Server({ port: 2333 });
const ip2name = {
'47.94.233.234': '樑王的高配據點',
'115.28.68.89': '樑王的9塊伺服器',
}
let sockets = {};
wss.on('connection', function connection(ws) {
const uuid = uuidv1();
ws.uuid = uuid;
sockets[uuid] = ws;
ws.on('message', function incoming(message) {
// publish訊息給其他伺服器
pub.publish('channel', `${ws.uuid}>${message}`);
console.log(`publish to channel: ${ws.uuid}>${message}`)
// 向本伺服器的socket廣播
wss.clients.forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(`來自${ws.from || '???'}的使用者${ws.uuid}傳送了: ${message}`);
}
});
});
publicIp.v4().then(ip => {
console.log(ip);
ws.from = ip2name[ip] ? ip2name[ip] : '未知';
ws.send(`你連線的伺服器為${ws.from}`);
});
});
// 監聽其他伺服器傳送的訊息
sub.on('message', function(channel, message) {
console.log(`channel ${channel}, ${message}`)
if (channel == 'channel')
{
var messageArr = message.split('>');
var uuid = messageArr[0]
var wsFrom = sockets[uuid];
var content = messageArr[1];
// 如果socket是非本伺服器的
if(!wsFrom) {
wss.clients.forEach(function each(client) {
client.send(`來自其他伺服器的使用者${uuid}傳送了: ${content}`);
});
}
}
});
sub.subscribe('channel');
複製程式碼
效果
可以用以下程式碼在控制檯中嘗試,伺服器後期可能會關。
var socket = new WebSocket('ws://websocket-demo.lwio.me');
// Listen for messages
socket.addEventListener('message', function (event) {
console.log('收到了', event.data);
});
// socket.send('keke')
複製程式碼
後記
4月1號更新,媽耶今天阿里雲一直報警,你們就看我redis直接暴露到公網就給我來了一波是吧。學習了學習了,向信安大佬低頭。
參考資料: