一.簡介
消息隊列
消息隊列(Message Queue,簡稱MQ),本質是個隊列,FIFO先入先出,只不過隊列中存放的內容是message而已。其主要用途:不同進程Process/線程Thread之間通信。使用消息隊列大概有以下原因:
- 不同進程(process)之間傳遞消息時,兩個進程之間耦合程度過高,改動一個進程,引發(fā)必須修改另一個進程,為了隔離這兩個進程,在兩進程間抽離出一層(一個模塊),所有兩進程之間傳遞的消息,都必須通過消息隊列來傳遞,單獨修改某一個進程,不會影響另一個;
- 不同進程(process)之間傳遞消息時,為了實現標準化,將消息的格式規(guī)范化了,并且,某一個進程接受的消息太多,一下子無法處理完,并且也有先后順序,必須對收到的消息進行排隊,因此誕生了事實上的消息隊列;
不管到底是什么原因催生了消息隊列,總之,上面兩個猜測是其實際應用的典型場景。綜上:
- 消息隊列中的“消息”即指同一臺計算機的進程間,或不同計算機的進程間傳送的數據。
- 消息隊列是在消息的傳輸過程中保存消息的容器。
- 消息被發(fā)送到隊列中,消息隊列充當中間人,將消息從它的源中繼到它的目標。消息隊列可以保證在高并發(fā)狀態(tài)下數據入庫的順序性和準確性。
RabbitMq
RabbitMQ是實現AMQP(高級消息隊列協議)的消息中間件的一種。是應用比較廣泛和穩(wěn)定的成熟的消息隊列中間件。由于RabbitMQ是基于Erlang開發(fā)的,所以天生具有分布式優(yōu)點。
消息隊列應用場景
關于消息隊列的應用場景,可以先看這篇文章。這里我們主要模擬一個秒殺的場景。假定我們有100個商品的秒殺活動,現在我們需要保證前1000個發(fā)起請求的人能夠順利的記錄進入數據庫中。這個需求對于http方案是無法順利完成任務的,下面就需要消息隊列來完成。首先我們利用node.js和rabbitMQ搭建服務器,然后利用Siege模擬一個高并發(fā)的API請求??纯丛诟卟l(fā)請求下http方式和消息隊列方式的差異性和準確性。
二.應用
接下來我們就開始進行編碼環(huán)節(jié)。首先我們需要先安裝RabbitMQ,這里我們用之前說過的Docker來安裝。執(zhí)行如下命令。我的系統是Mac OS(懶得買服務器就在自己的機器上測試了),如果是linux的話會更好。
sudo docker pull rabbitmq
#如果rabbitmq鏡像下載失敗,可以嘗試下載rabbitmq:management版本
或者 sudo docker pull rabbitmq:management
#然后用docker啟動rabbitmq
sudo docker run -d -e RABBITMQ_NODENAME=my-rabbit --name some-rabbit -p 5672:5672 rabbitmq:management
rabbit服務默認會啟動在5672端口,我們把他映射到宿主主機的5672端口。
然后我們需要安裝amqplib來在node中連接rabbitmq。我們創(chuàng)建好node的工作目錄,然后創(chuàng)建server.js。我們先來看看最簡單的消息隊列,也就是客戶端通過隊列把消息傳到服務端。在你的工程目錄下運行如下命令。
npm install amqplib
打開server.js,我們完成服務端的代碼。在這里我們用es5的promise來完成對回調函數的處理。如果不了解promise的可以先去看<a >ES6詳解。</a>
/**
* Created by wsd on 17/2/23.
*/
var amqp = require('amqplib');
//首先我們需要通過amqp連接本地的rabbitmq服務,返回一個promise對象
amqp.connect('amqp://127.0.0.1').then(function(conn){
//進程檢測到終端輸入CTRL+C退出新號時,關閉RabbitMQ隊列。
process.once('SIGN',function(){
conn.close();
});
//連接成功后創(chuàng)建通道
return conn.createChannel().then(function(ch){
//通道創(chuàng)建成功后我們通過通道對象的assertQueue方法來監(jiān)聽hello隊列,并設置durable持久化為false。這里消息將會被保存在內存中。該方法會返回一個promise對象。
var ok = ch.assertQueue('hello',{durable:false}).then(function(_qok){
//監(jiān)聽創(chuàng)建成功后,我們使用ch.consume創(chuàng)建一個消費者。指定消費hello隊列和處理函數,在這里我們簡單打印一句話。設置noAck為true表示不對消費結果做出回應。
//ch.consume會返回一個promise,這里我們把這個promise賦給ok。
return ch.consume('hello',function(msg){
console.log("[x] Received '%s'",msg.content.toString());
},{noAck:true});
});
//消費者監(jiān)聽完成之后,打印一行成功信息
return ok.then(function(_consumeOk){
console.log('[*] Waiting for message. To exit press CRTL+C');
});
});
}).then(null,console.warn);//如果報錯打印報錯信息
以上就是服務端的相關代碼,下面我們來看客戶端。創(chuàng)建client.js。我們還需要安裝when來運行promise。運行npm install when。
/**
* Created by wsd on 17/2/23.
*/
var amqp = require('amqplib');
var when = require('when');
//連接本地消息隊列服務
amqp.connect('amqp://localhost').then(function(conn){
//創(chuàng)建通道,讓when立即執(zhí)行promise
return when(conn.createChannel().then(function(ch){
var q = 'hello';
var msg = 'Hello World';
//監(jiān)聽q隊列,設置持久化為false。
return ch.assertQueue(q,{durable: false}).then(function(_qok){
//監(jiān)聽成功后向隊列發(fā)送消息,這里我們就簡單發(fā)送一個字符串。發(fā)送完畢后關閉通道。
ch.sendToQueue(q,new Buffer(msg));
console.log(" [x] Sent '%s'",msg);
return ch.close()
});
})).ensure(function(){ //ensure是promise.finally的別名,不管promise的狀態(tài)如何都會執(zhí)行的函數
//這里我們把連接關閉
conn.close();
});
}).then(null,console.warn);
接下來我們啟動服務和客戶端。
node server.js
#[*] Waiting for message. To exit press CRTL+C
node client.js
#[x] Sent 'Hello World'
然后我們切換到服務端
#[*] Waiting for message. To exit press CRTL+C
#[*] Received 'Hello World'!
至此一個最簡單的消息隊列搭建完成。下面我們來模擬文章一開始所說的秒殺的場景。我們會基于Http和RabbitMQ兩種實現形式做對比。
秒殺活動場景 http模擬
首先我們編寫服務模擬前端向server發(fā)起請求,這里我們采用koa框架來實現。新建http_web_server.js。
/**
* Created by wsd on 17/2/23.
*/
var koa = require('koa');
//一個工具類
var util = require('util');
var route = require('koa-route');
var request = require('request');
//這個用于作為用戶id
var globalUserId = 1;
var app = koa()
//用于判斷服務是否啟動
app.use(route.get('/',function *(){
this.body = 'Hello world';
}))
//定義請求到后端的URL地址,這里為了方便我就在本機上測試,大家如果有遠程服務器的話可以在遠程服務器上測試
var uri = 'http://127.0.0.1:8000/buy?userid=%d';
var timeout = 30 * 1000;//超時30s
//設置路由
app.use(route.get('/buy',function *(){
//用戶id簡單地每次請求遞增1
var num = globalUserId ++;
//調用request發(fā)起請求
request({
method:'GET',
timeout:timeout,
uri:util.format(uri,num)
},function(error,req_res,body){
if(error){
this.status = 500
this.error = error
}else if(req_res.status != 200){
this.status = 500
}else{
this.body = body
}
})
}))
app.listen(5000,function(){
console.log('server listen on 5000');
})
首先我們安裝koa,util,koa-route,request四個模塊。然后我們模擬向最終入庫的server發(fā)送生成訂單請求。接下來我們完成入庫server的相關代碼。由于我們需要對數據庫操作,所以需要安裝mongodb和mongoose模塊。
#安裝mongodb
brew intall mongodb
#啟動mongodb,設置數據的存儲路徑
mongod --dbpath data/db --logappend
#安裝mongoose
npm install mongoose
然后我們首先創(chuàng)建數據庫Model文件orderModel.js。
/**
* Created by hwh on 17/2/23.
*/
var mongoose = require('mongoose');
//連接到本地開啟的mongodb,mongodb默認監(jiān)聽27017端口
var connstr = 'mongodb://127.0.0.1:27017/http_vs_rabbit';
//設置數據庫連接池大小
var poolsize = 50;
mongoose.connect(connstr,{server:{poolSize:poolsize}})
var Schema = mongoose.Schema;
var obj = {
userId:{type:Number, required:true},
writeTime:{type: Date,default: Date.now()}
}
var objSchema = new Schema(obj);
module.exports = mongoose.model('orders',objSchema);
然后我們創(chuàng)建數據庫操作文件orderLib.js。
/**
* Created by hwh on 17/2/24.
*/
var objModel = require('./orderModel.js');
//針對generator的存取操作
exports.countAll = function(obj){
//獲得訂單總數
return objModel.count()
}
exports.insertOneByObj = function(obj){
//創(chuàng)建訂單
return objModel.create(obj);
}
//針對非generator的存取操作
exports.countAllNormal = function(obj,cb){
return objModel.count(obj || {},cb)
}
exports.insertOneByObjNormal = function(obj,cb){
return objModel.create(obj || {},cb)
}
最后我們創(chuàng)建http_back.js來接收數據并入庫。
/**
* Created by hwh on 17/2/23.
*/
var koa = require('koa');
var route = require('koa-route');
var bodyparser = require('koa-bodyparser');
var app = koa();
var orderModel = require('./orderModellib.js');
var listenPort = 3000;
app.use(bodyparser())
app.use(route.get('/',function * (){
this.body = "hello world,listenPort:" + listenPort
}));
app.use(route.get('/buy',function * (){
//拿到參數
var userid = this.request.query.userid;
//獲取數據庫中訂單數量
var count = yield orderModel.countAll();
//做判斷,大于100就不再入庫
if (count > 100){
this.body = 'sold out!';
}else{
var model = yield orderModel.insertOneByObj({
userId:userid
});
if(model){
this.body = 'success';
}
}
}));
app.listen(listenPort,function(){
console.log('Server listening on:',3000);
})
這里由于我們需要對body進行解析,所以我們安裝koa-bodyparser模塊。代碼比較簡單。接下來我們安裝ngnix設置反向代理。
#安裝nginx
brew install nginx
#進入nginx目錄
cd /usr/local/etc/nginx
#修改配置文件
vi nginx.conf
我們主要設置反響代理相關配置。
#user wsd;
#開啟兩個nginx進程,等于cpu核心數或者cpu*2
worker_processes 2;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#pid logs/nginx.pid;
events {
#事件模型,由于是mac系統使用kqueue;linux使用epoll。簡單說明一下兩種事件模型使用場景。Kqueue和Epoll都屬于高效事件模型。
#Kqueue:使用于FreeBSD 4.1+, OpenBSD 2.9+, NetBSD 2.0 和 MacOS X. 使用雙處理器的MacOS X系統使用kqueue可能會造成內核崩潰。
#Epoll:使用于Linux內核2.6版本及以后的系統。
use kqueue;
#單個工作進程的最大連接數,和硬件配置有關系。
#盡量大但別超過CPU占用率的90%,這里我們?yōu)榱藴y試取值比較小。理論上每臺nginx服務器的最大連接數為worker_processes*worker_connections
worker_connections 2048;
}
#設定http服務器,利用它的反向代理功能提供負載均衡支持
http {
#設置請求數據格式,這里就使用mime支持的類型
include mime.types;
#http content_type
default_type application/octet-stream;
#暫不儲存日志(儲存日志需要先使用log_format指令設置日志格式)
access_log off;
#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';
#access_log logs/access.log main;
#指定 nginx 是否調用sendfile 函數(zero copy 方式)來輸出文件,對于普通應用,必須設為on。如果用來進行下載等應用磁盤IO重負載應用,可設置為off,以平衡磁盤與網絡IO處理速度,降低系統uptime
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
#設置超時時間
keepalive_timeout 65;
#定義負載均衡設備的Ip及設備狀態(tài)
upstream backend {
server 127.0.0.1:3000;
}
#gzip on;
#配置ngnix啟動的地址
server {
listen 8000;
server_name localhost;#這里nginx啟動在本機8000端口
#charset koi8-r;
#access_log logs/host.access.log main;
#匹配所有路徑
location / {
proxy_pass http://backend;#設置負載均衡的地址,這里設置為為backend里面的地址
proxy_redirect default;#設置返回客戶端請求頭的location的值,默認不設置
proxy_http_version 1.1;#代理的http協議版本
root html;
index index.html index.htm;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
include servers/*;
}
寫好配置文件以后我們保存然后啟動nginx。
#啟動nginx
nginx
我們訪問http://localhost:8000看看反向代理是否運行正常。如果正常的話會輸出server listen on 3000。
http測試
接下來我們就要對這個http服務進行壓力測試了。這里我們使用siege。
#安裝wget
brew install wget
#下載siege
wget http://download.joedog.org/siege/siege-latest.tar.gz
#解壓
tar -zxvf siege-latest.tar.gz
#進入siege腳本目錄
cd siege-4.0.2
#配置
./configure
#編譯并安裝
make && make install
可以輸入siege -help查看siege支持的命令。這里我們主要用到-c(指定并發(fā)數)和-r(指定測試次數)。現在我們分別模擬100、200、300個并發(fā),并循環(huán)發(fā)送10次。當然別忘記每次我們請求完畢后在下一次請求開始前要把數據庫清空。
siege -c 100 -r 10 -q http://192.168.1.150:5000/buy
siege -c 200 -r 10 -q http://192.168.1.150:5000/buy
siege -c 300 -r 10 -q http://192.168.1.150:5000/buy
每完成一次并發(fā)操作,我們使用mongo命令連接到本地的mongodb服務。并且查看數據庫里面訂單的數量。
#連接數據庫
mongo
#選擇數據庫
use http_vs_rabbit
#查看集合數量
db.orders.count()
下面是每一次并發(fā)操作后,數據庫中訂單的數量
//100次并發(fā)
114
//200次并發(fā)
125
//300次并發(fā)
119
可以看到,每一次并發(fā)訂單數量都會超出預訂值。下面是一些參數:
Date & Time Trans Elap Time Data Trans Resp Time Trans Rate Throughput Concurrent OKAY Failed
2017-03-08 15:09:47, 1000, 3.62, 0, 0.01, 276.24, 0.00, 1.49, 0, 0
2017-03-08 15:10:48, 2000, 3.96, 0, 0.01, 505.05, 0.00, 3.10, 0, 0
2017-03-08 15:11:27, 3000, 4.19, 0, 0.01, 715.99, 0.00, 8.64, 0, 0
看一下上述各參數的意思。
- Date & Time:請求時間
- Trans:請求總數
- Elap Time:測試用時
- Data Trans:測試傳輸數據量
- Resp Time:平均響應時間
- Trans Rate:每秒事務處理量
- Throughput:吞吐率
- Concurrent:并發(fā)用戶數
- OKAY:成功數
- Failed:失敗數
可以看到在并發(fā)測試中,http處理事務的速度雖然不錯,但并不能保證結果的準確和可靠。下面我們來看一下利用rabbitmq的測試結果。首先我們也是像http一樣,寫一個服務模擬前端請求。這里我們新建rabbit_web_server.js。
/**
* Created by wsd on 17/2/24.
*/
var koa = require('koa');
var router = require('koa-route');
var amqp = require('amqplib');
var uuid = require('node-uuid');
var app = koa();
var correlationId = uuid();
var q = 'fibq';//前端發(fā)送消息隊列
var q2 = 'ackq';//后臺回復隊列
//conn寫成全局變量,循環(huán)利用。否則每次訪問路由都會創(chuàng)建conn
var conn;
//依然id每次請求遞增1
var globalUserId = 1;
app.use(router.get('/',function * (){
this.body = 'hello world';
}));
app.use(router.get('/buy/',function*(){
var num = globalUserId ++;
//conn我們在外部創(chuàng)建,并且只創(chuàng)建一次(復用)
conn.createChannel().then(function(ch){
//監(jiān)聽q2隊列(訂單量如果到達100,服務端會通過q2隊列返回信息)
return ok = ch.assertQueue(q2,{durable:false}).then(function(){
//創(chuàng)建消費q2隊列,這里簡單把信息設置到res的body里
ch.consume(q2,function(msg){
console.log(msg.content.toString());
this.body = msg.content.toString();
ch.close();
},{noAck:true});
//發(fā)送消息到q隊列,這里把訂單id作為content。把q2隊列的name和uuid也傳過去,這里uuid用來做消息的關聯id
ch.sendToQueue(q,new Buffer(num.toString()),{replyTo:q2,correlationId:correlationId})
});
}).then(null,console.error);
}));
amqp.connect('amqp://127.0.0.1').then(function(_conn){
conn = _conn;
});
app.listen(5001,function(){
console.log('server listen on 5001');
});
下面我們在新建rabbit_mq_server.js文件,來寫入庫的操作。
/**
* Created by hwh on 17/2/24.
*/
var amqp = require('amqplib');
var co = require('co');
var orderModel = require('./orderModellib');
var q = 'fibq';
amqp.connect('amqp://127.0.0.1').then(function(conn){
process.once('SIGN',function(){
conn.close();
});
return conn.createChannel().then(function(ch){
//設置公平調度,這里是指rabbitmq不會向一個繁忙的隊列推送超過1條消息。
ch.prefetch(1);
//定義回傳消息函數
var ackSend = function(msg,content){
//要注意這里我們之前傳上來的隊列名和uuid會被保存在msg對象的properties中
//因為服務端并不知道回傳的隊列名字,所以我們需要把它帶過來
ch.sendToQueue(msg.properties.replyTo,new Buffer(content.toString()),
{correlationId:msg.properties.correlationId});
//ack表示消息確認機制。這里我們告訴rabbitmq消息接收成功。
ch.ack(msg);
}
//定義收到消息的處理函數
var reply = function (msg){
var userid = parseInt(msg.content.toString());
//這里由于consume的處理函數不支持generator語法,這里我們就用es5的方式訪問數據庫、
orderModel.countAllNormal({},function(err,count){
if(count >= 100){
return ackSend(msg,'sold out!');
}else{
orderModel.insertOneByObjNormal({
userId:userid
},function(err,model){
return ackSend(msg,"buy success,orderid:"+model._id.toString())
});
}
});
};
//監(jiān)聽隊列q并消費
var ok = ch.assertQueue(q,{durable:false}).then(function(){
ch.consume(q,reply,{noAck:false});
});
return ok.then(function(){
console.log(' [*] waiting for message')
})
})
}).then(null,console.error);
分別啟動rabbit_web_server.js和rabbit_mq_server.js。接下來我們還是像之前測試http服務一樣,用siege模擬100、200、300次并發(fā)。要注意這里我們的服務變成了5001端口。
可以看到不管多少并發(fā)數下,我們數據庫里的訂單都是100。這保證了我們數據的準確性。下面是siege記錄的參數:
Date & Time Trans Elap Time Data Trans Resp Time Trans Rate Throughput Concurrent OKAY Failed
2017-03-08 16:36:06, 1000, 3.81, 0, 0.01, 262.47, 0.00, 2.17, 0, 0
2017-03-08 16:40:50, 2000, 4.15, 0, 0.02, 481.93, 0.00, 9.16, 0, 0
2017-03-08 16:41:03, 3000, 4.47, 0, 0.08, 671.14, 0.00, 51.91, 0, 0
對比http,對于高并發(fā)的操作,確實隊列在耗時,每秒事務處理量和響應時間上會比http略遜一籌。由于node.js的異步I/O,所以http會存在插入超量的情況。因為很有可能你在異步往數據庫里面插入數據還沒有完成的時候,下一個請求已經過來了。但隊列保證了結果的準確性,這在秒殺場景以及一些特殊場景是硬性要求。這是一個很常見的場景,因此掌握消息隊列的操作是作為服務端開發(fā)來說必不可少的。
在這里,為了提升rabbitmq的性能,我們可以開啟多個rabbitmq進程。這個就交給大家下去測試吧。
rabbitmq還有以下幾種應用場景:
- 一個生產者多個消費者
-
輪詢
Paste_Image.png -
廣播(faout)
Paste_Image.png - 路由(direct)
-
- RPC遠程調用
- 跨平臺通信(比如node和python、java)
這些會在我后面的文章中講解,敬請期待。