基本環(huán)境介紹
由于每個(gè)socket.io服務(wù)器上限就是保持5000個(gè)連接數(shù),考慮到大用戶量的情況,需要用到一定數(shù)量的socket.io服務(wù)器,專門用來保持和用戶的持久化連接,以便以后可以滿足消息的推送功能。服務(wù)器數(shù)量多了起來,由于socket.io的特殊性,需要考慮到在用戶和socket.io服務(wù)器建立TCP連接前的http請求,為此,在socket.io服務(wù)器和用戶中間使用了nginx做了代理轉(zhuǎn)發(fā)以及均衡負(fù)載作用。
socket.io具體機(jī)制
socket.io在建立連接之前,會經(jīng)過3次http請求,叫做3次握手,當(dāng)3次握手成功之后,才會建立TCP連接。socket.io底層是建立在engine.io之上,而非完全建立在websocket之上。engine.io使用了XMLHttpRequest(或JSON)和websocket封裝了自己的一套socket協(xié)議。在低版本瀏覽器里面使用長輪詢替代 WebSocket。一個(gè)完整的 EIO Socket 包括多個(gè) XHR 和 WebSocket 連接。
首先,前端,也可以理解為客戶端,EIO socket通過長輪詢,也就是一個(gè)XHR請求握手,告訴socket.io端,我要進(jìn)行XHR長輪詢了。后端返回的數(shù)據(jù),包括open(數(shù)字代表為0),以及一個(gè)sid(可以理解為session.id)和upgrades字段。由于一個(gè)EIO socket包含了多個(gè)請求,而后端又會同時(shí)連接多個(gè)EIO socket,此時(shí)sid就可以理解為session.id。
另一個(gè)字段upgrades,表示可以將連接方式從長輪訓(xùn)升級到websocket。
前端在發(fā)送第一個(gè)XHR時(shí)候就開始了長輪詢,所謂長輪詢,就是前端在發(fā)送一個(gè)請求的時(shí)候,服務(wù)端會等到有數(shù)據(jù)來到時(shí)候發(fā)送一個(gè)response,前端收到response之后再發(fā)送下一個(gè)request,實(shí)現(xiàn)雙向通信。
前端收到握手的 upgrades 后,EIO 會檢測瀏覽器是否支持 WebSocket,如果支持,就會啟動(dòng)一個(gè) WebSocket 連接,然后通過這個(gè) WebSocket 往服務(wù)器發(fā)一條內(nèi)容為 probe, 類型為 ping 的數(shù)據(jù)。如果這時(shí)服務(wù)器返回了內(nèi)容為 probe, 類型為 pong 的數(shù)據(jù),前端就會把前面建立的 HTTP 長輪詢停掉,后面只使用 WebSocket 通道進(jìn)行收發(fā)數(shù)據(jù)。
socket.io服務(wù)器集群基本思路
開始的思路,在集群方面,利用cluster的方式來生成socket.io服務(wù)器群,具體代碼如下
var cluster = require('cluster');
var os = require('os');
if (cluster.isMaster) {
var server = require('http').createServer();
for (var i = 0; i < os.cpus().length; i++) {
cluster.fork();
}
cluster.on('exit', function(worker, code, signal) {
console.log('worker ' + worker.process.pid + ' died');
});
}
if (cluster.isWorker) {
var express = require('express');
var app = express();
var http = require('http');
var server = http.createServer(app);
var io = require('socket.io').listen(server);
var redis = require('socket.io-redis');
io.adapter(redis({ host: 'localhost', port: 6379 }));
io.on('connection', function(socket) {
socket.emit('data', 'connected to worker: ' + cluster.worker.id);
});
app.listen(80);
}
cluster支持兩種分發(fā)模式,第一是由主進(jìn)程負(fù)責(zé)監(jiān)聽端口,接收新連接后再將連接循環(huán)分發(fā)給工作進(jìn)程。在分發(fā)中使用了一些內(nèi)置技巧防止工作進(jìn)程任務(wù)過載。子進(jìn)程無法去監(jiān)聽端口,不能最大化去設(shè)定每個(gè)子進(jìn)程的任務(wù)。
第二是主進(jìn)程創(chuàng)建監(jiān)聽socket后發(fā)送給感興趣的工作進(jìn)程,由工作進(jìn)程負(fù)責(zé)直接接收連接。
理論上第二種方法應(yīng)該是效率最佳的,但在實(shí)際情況下,由于操作系統(tǒng)調(diào)度機(jī)制的難以捉摸,會使分發(fā)變得不穩(wěn)定。我們遇到過這種情況:8個(gè)進(jìn)程中的2個(gè),分擔(dān)了70%的負(fù)載。
所以在下面例子中直接不采用cluster方式,而直接粗暴的創(chuàng)建socket.io服務(wù)器。
由于利用到多個(gè)socket.io服務(wù)器,為了能夠?qū)⒄麄€(gè)socket.io服務(wù)器群對于用戶而言等同于一個(gè)大的服務(wù)器,在socket.io和用戶之間用Nginx來做請求轉(zhuǎn)發(fā)?;舅悸穲D如下

首先用戶將請求發(fā)送到Nginx,Nginx在此基礎(chǔ)上做請求轉(zhuǎn)發(fā),將請求根據(jù)相關(guān)配置發(fā)送到4個(gè)服務(wù)器中的一個(gè)。假設(shè)socket.io服務(wù)器分別占用端口3001、3002、3003、3004的話,在Nginx中的相關(guān)配置nginx.conf如下:
在http模塊下有
http{
#在upstream配置socket.io服務(wù)器,可以理解為上游部分
upstream my_servers{
server 127.0.0.1:3001
server 127.0.0.1:3002
server 127.0.0.1:3003
server 127.0.0.1:3004
}
server{
listen 8080;
server_name localhost;
location / {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_pass http://my_servers;
}
}
}
這樣就可以將Client的請求轉(zhuǎn)發(fā)到這4個(gè)服務(wù)器中的任何一個(gè),此時(shí)socket.io代碼如下,僅僅將socket.io2作為實(shí)例,其余socket.io服務(wù)器代碼完全一致,除了各自編號
const express = require('express');
const http = require('http');
const app = express();
const server = http.Server(app)
const io = require('socket.io')(server);
io.on('connection',(socket)=>{
console.log('Server No.2 is connected');
socket.emit('message', {message: 'Here is the message coming from the server 2'});
})
server.listen(3002, ()=>{
console.log('hey there')
})
客戶端服務(wù)器代碼如下,主要是為了加載靜態(tài)頁,
const path = require('path');
const app = require('express')();
const server = require('http').createServer(app);
const io = require('socket.io')(server);
app.get('/',(req, res)=>{
res.sendFile(path.join(__dirname, './index.html'));
})
server.listen(3000)
index.html代碼如下
<script>
var btn = document.getElementById('btn1');
btn.addEventListener('click',function(){
var socket = io.connect('https://localhost:8080',{
reconnection: false
});
socket.on('connect', ()=>{
console.log('Client has connected')
socket.on('s:message',(d)=>{
console.log(d);
});
});
socket.on('error',function(err){
console.log(err);
})
});
</script>
此時(shí)將4個(gè)socket.io服務(wù)器同時(shí)運(yùn)行,模擬一個(gè)最小的集群樣例,開啟客戶端服務(wù)器,打開網(wǎng)頁localhost:3000,此時(shí)頁面上有一個(gè)連接的按鈕,點(diǎn)擊之后,打開瀏覽器控制臺可以看到

由于socket.io服務(wù)器要和用戶建立TCP的連接的前提在于三次握手成功,但是目前存在的問題是,比如,第一次用戶的請求分發(fā)給socket.io No.1,然后第二次請求分發(fā)給socket.io No.2,用戶所攜帶的sid無法被No.2所識別,所以導(dǎo)致失敗,也許前面3次都成功握手了,負(fù)責(zé)監(jiān)聽update事件的server也不是那個(gè)server也會導(dǎo)致失敗。
基本原理基本清楚了之后,重點(diǎn)是解決用戶的一次建立tcp之前的http請求,必須固定分發(fā)給一個(gè)服務(wù)器,在socket.io官方文檔給出的解決方案是利用sticky session,而Nginx本身也提供了這一策略,即ip_hash。
將ip_hash添加到http部分的upstream部分中,放置在首位。然后重啟nginx,運(yùn)行客戶端,點(diǎn)擊連接按鈕,連接成功。當(dāng)模擬一個(gè)socket.io掛掉的時(shí)候,用戶請求仍然可以轉(zhuǎn)發(fā)至其他正常運(yùn)行的服務(wù)器,針對sticky session策略目前是可行的。
模擬大量用戶并發(fā)訪問
單個(gè)用戶訪問建立TCP連接在上述是可行的,但是如果大量用戶并發(fā)訪問呢?目前用到apache ab工具來進(jìn)行高并發(fā)訪問實(shí)驗(yàn)。
命令如下
ab -n 10000 -c 100 -r 127.0.0.1:8080
模擬總量10000次,每次同時(shí)請求100。
此時(shí)socket.io服務(wù)器只有一個(gè)接受請求,其余3個(gè)都是處于空閑狀態(tài),而接受請求的那個(gè)負(fù)載很高,并且經(jīng)常以外拒絕請求,導(dǎo)致失敗。
原因分析應(yīng)該在于ip_hash策略,導(dǎo)致所有的請求都自動(dòng)綁定到某一個(gè)服務(wù)器上,而無法實(shí)現(xiàn)分?jǐn)偟狡渌?wù)器上。Nginx還有提供一個(gè)策略就是least_conn,即根據(jù)最小連接數(shù)來選擇相應(yīng)的服務(wù)器進(jìn)行連接,當(dāng)在upstream中加入least_conn策略的時(shí)候,運(yùn)行的時(shí)候提示least_conn把ip_hash覆蓋掉了,看來策略只能存在一個(gè)。但是least_conn可以將請求均攤到4個(gè)服務(wù)器上,看來還是在一定情況下滿足我們的需求。
在每個(gè)server 127.0.0.1:300x 后面加上權(quán)重的話,可以將服務(wù)器的訪問分流到我們想要的服務(wù)器上,但是缺點(diǎn)在于,目前只會手動(dòng)處理,不現(xiàn)實(shí)。因此這個(gè)方案也pass掉。
新解決方案1(代碼部分還未實(shí)現(xiàn))
由于least_conn方案可以將用戶的請求均攤到4個(gè)服務(wù)器上,如果用戶只通過nginx請求一次,分發(fā)到空閑的服務(wù)器上,然后再由服務(wù)器本身去與客戶直接連接tcp,將websocket連接繞開Nginx

白色箭頭表示普通的http的請求,也僅僅是單次請求,只請求一次,通俗而言就是客戶端通過Nginx,告訴4個(gè)socket.io服務(wù)器中的某一個(gè),我要開始進(jìn)行連接了,得到服務(wù)器端的通過之后,服務(wù)器端會繞開nginx,主動(dòng)與client建立websocket連接,目前方法是可行的,只是代碼部分還未實(shí)現(xiàn)。
新解決方案2
可以定制nginx的策略,來完成相關(guān)的均衡負(fù)載,但是不知道可行與否