一、本文產生原由:
之前文章《總結訊息佇列RabbitMQ的基本用法》已對RabbitMQ的安裝、用法都做了詳細說明,而本文主要是針對在高併發且單次從RabbitMQ中消費訊息時,出現了連線數不足、連線響應較慢、RabbitMQ伺服器崩潰等各種效能問題的解方案,之所以會出現我列舉的這些問題,究基根源,其實是TCP連線建立與斷開太過頻繁所致,這與我們使用ADO.NET來訪問常規的關係型DB(如:SQL SERVER、MYSQL)有所不同,在訪問DB時,我們一般都建議大家使用using包裹,目的是每次建立完DB連線,使用完成後自動釋放連線,避免不必要的連線數及資源佔用。可能有人會問,為何訪問DB,可以每次建立再斷開連線,都沒有問題,而同樣訪問MQ(本文所指的MQ均是RabbitMQ),每次建立再斷開連線,如果在高併發且建立與斷開頻率高的時候,會出現效能問題呢?其實如果瞭解了DB的連線建立與斷開以及MQ的連線建立與斷開原理就知道其中的區別了。這裡我簡要說明一下,DB連線與MQ連線 其實底層都是基於TCP連線,建立TCP連線肯定是有資源消耗的,是非常昂貴的,原則上儘可能少的去建立與斷開TCP連線,DB建立連線、MQ建立連線可以說是一樣的,但在斷開銷燬連線上就有很大的不同,DB建立連線再斷開時,預設情況下是把該連線回收到連線池中,下次如果再有DB連線建立請求,則先判斷DB連線池中是否有空閒的連線,若有則直接複用,若沒有才建立連線,這樣就達到了TCP連線的複用,而MQ建立連線都是新建立的TCP連線,斷開時則直接斷開TCP連線,簡單粗暴,看似資源清理更徹底,但若在高併發高頻率每次都重新建立與斷開MQ連線,則效能只會越來越差(上面說過TCP連線是非常昂貴的),我在公司專案中就出現了該問題,後面在技術總監的指導下,對MQ的連線建立與斷開作了優化,實現了類似DB連線池的概念。
連線池,故名思義,連線的池子,所有的連線作為一種資源集中存放在池中,需要使用時就可以到池中獲取空閒連線資源,用完後再放回池中,以此達到連線資源的有效重用,同時也控制了資源的過度消耗與浪費(資源多少取決於池子的容量)
二、原始碼奉獻(可直接複製應用到大家的專案中)
下面就先貼出實現MQHelper(含連線池)的原始碼:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using RabbitMQ.Util; using RabbitMQ.Client; using RabbitMQ.Client.Events; using System.Web.Caching; using System.Web; using System.Configuration; using System.IO; using System.Collections.Concurrent; using System.Threading; using System.Runtime.CompilerServices; namespace Zuowj.Core { public class MQHelper { private const string CacheKey_MQConnectionSetting = "MQConnectionSetting"; private const string CacheKey_MQMaxConnectionCount = "MQMaxConnectionCount"; private readonly static ConcurrentQueue<IConnection> FreeConnectionQueue;//空閒連線物件佇列 private readonly static ConcurrentDictionary<IConnection, bool> BusyConnectionDic;//使用中(忙)連線物件集合 private readonly static ConcurrentDictionary<IConnection, int> MQConnectionPoolUsingDicNew;//連線池使用率 private readonly static Semaphore MQConnectionPoolSemaphore; private readonly static object freeConnLock = new object(), addConnLock = new object(); private static int connCount = 0; public const int DefaultMaxConnectionCount = 30;//預設最大保持可用連線數 public const int DefaultMaxConnectionUsingCount = 10000;//預設最大連線可訪問次數 private static int MaxConnectionCount { get { if (HttpRuntime.Cache[CacheKey_MQMaxConnectionCount] != null) { return Convert.ToInt32(HttpRuntime.Cache[CacheKey_MQMaxConnectionCount]); } else { int mqMaxConnectionCount = 0; string mqMaxConnectionCountStr = ConfigurationManager.AppSettings[CacheKey_MQMaxConnectionCount]; if (!int.TryParse(mqMaxConnectionCountStr, out mqMaxConnectionCount) || mqMaxConnectionCount <= 0) { mqMaxConnectionCount = DefaultMaxConnectionCount; } string appConfigPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "App.config"); HttpRuntime.Cache.Insert(CacheKey_MQMaxConnectionCount, mqMaxConnectionCount, new CacheDependency(appConfigPath)); return mqMaxConnectionCount; } } } /// <summary> /// 建立連線 /// </summary> /// <param name="hostName">伺服器地址</param> /// <param name="userName">登入賬號</param> /// <param name="passWord">登入密碼</param> /// <returns></returns> private static ConnectionFactory CrateFactory() { var mqConnectionSetting = GetMQConnectionSetting(); var connectionfactory = new ConnectionFactory(); connectionfactory.HostName = mqConnectionSetting[0]; connectionfactory.UserName = mqConnectionSetting[1]; connectionfactory.Password = mqConnectionSetting[2]; if (mqConnectionSetting.Length > 3) //增加埠號 { connectionfactory.Port = Convert.ToInt32(mqConnectionSetting[3]); } return connectionfactory; } private static string[] GetMQConnectionSetting() { string[] mqConnectionSetting = null; if (HttpRuntime.Cache[CacheKey_MQConnectionSetting] == null) { //MQConnectionSetting=Host IP|;userid;|;password string mqConnSettingStr = ConfigurationManager.AppSettings[CacheKey_MQConnectionSetting]; if (!string.IsNullOrWhiteSpace(mqConnSettingStr)) { mqConnSettingStr = EncryptUtility.Decrypt(mqConnSettingStr);//解密MQ連線字串,若專案中無此需求可移除,EncryptUtility是一個AES的加解密工具類,大家網上可自行查詢 if (mqConnSettingStr.Contains(";|;")) { mqConnectionSetting = mqConnSettingStr.Split(new[] { ";|;" }, StringSplitOptions.RemoveEmptyEntries); } } if (mqConnectionSetting == null || mqConnectionSetting.Length < 3) { throw new Exception("MQConnectionSetting未配置或配置不正確"); } string appConfigPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "App.config"); HttpRuntime.Cache.Insert(CacheKey_MQConnectionSetting, mqConnectionSetting, new CacheDependency(appConfigPath)); } else { mqConnectionSetting = HttpRuntime.Cache[CacheKey_MQConnectionSetting] as string[]; } return mqConnectionSetting; } public static IConnection CreateMQConnection() { var factory = CrateFactory(); factory.AutomaticRecoveryEnabled = true;//自動重連 var connection = factory.CreateConnection(); connection.AutoClose = false; return connection; } static MQHelper() { FreeConnectionQueue = new ConcurrentQueue<IConnection>(); BusyConnectionDic = new ConcurrentDictionary<IConnection, bool>(); MQConnectionPoolUsingDicNew = new ConcurrentDictionary<IConnection, int>();//連線池使用率 MQConnectionPoolSemaphore = new Semaphore(MaxConnectionCount, MaxConnectionCount, "MQConnectionPoolSemaphore");//訊號量,控制同時併發可用執行緒數 } public static IConnection CreateMQConnectionInPoolNew() { SelectMQConnectionLine: MQConnectionPoolSemaphore.WaitOne();//當<MaxConnectionCount時,會直接進入,否則會等待直到空閒連線出現 IConnection mqConnection = null; if (FreeConnectionQueue.Count + BusyConnectionDic.Count < MaxConnectionCount)//如果已有連線數小於最大可用連線數,則直接建立新連線 { lock (addConnLock) { if (FreeConnectionQueue.Count + BusyConnectionDic.Count < MaxConnectionCount) { mqConnection = CreateMQConnection(); BusyConnectionDic[mqConnection] = true;//加入到忙連線集合中 MQConnectionPoolUsingDicNew[mqConnection] = 1; // BaseUtil.Logger.DebugFormat("Create a MQConnection:{0},FreeConnectionCount:{1}, BusyConnectionCount:{2}", mqConnection.GetHashCode().ToString(), FreeConnectionQueue.Count, BusyConnectionDic.Count); return mqConnection; } } } if (!FreeConnectionQueue.TryDequeue(out mqConnection)) //如果沒有可用空閒連線,則重新進入等待排隊 { // BaseUtil.Logger.DebugFormat("no FreeConnection,FreeConnectionCount:{0}, BusyConnectionCount:{1}", FreeConnectionQueue.Count, BusyConnectionDic.Count); goto SelectMQConnectionLine; } else if (MQConnectionPoolUsingDicNew[mqConnection] + 1 > DefaultMaxConnectionUsingCount || !mqConnection.IsOpen) //如果取到空閒連線,判斷是否使用次數是否超過最大限制,超過則釋放連線並重新建立 { mqConnection.Close(); mqConnection.Dispose(); // BaseUtil.Logger.DebugFormat("close > DefaultMaxConnectionUsingCount mqConnection,FreeConnectionCount:{0}, BusyConnectionCount:{1}", FreeConnectionQueue.Count, BusyConnectionDic.Count); mqConnection = CreateMQConnection(); MQConnectionPoolUsingDicNew[mqConnection] = 0; // BaseUtil.Logger.DebugFormat("create new mqConnection,FreeConnectionCount:{0}, BusyConnectionCount:{1}", FreeConnectionQueue.Count, BusyConnectionDic.Count); } BusyConnectionDic[mqConnection] = true;//加入到忙連線集合中 MQConnectionPoolUsingDicNew[mqConnection] = MQConnectionPoolUsingDicNew[mqConnection] + 1;//使用次數加1 // BaseUtil.Logger.DebugFormat("set BusyConnectionDic:{0},FreeConnectionCount:{1}, BusyConnectionCount:{2}", mqConnection.GetHashCode().ToString(), FreeConnectionQueue.Count, BusyConnectionDic.Count); return mqConnection; } private static void ResetMQConnectionToFree(IConnection connection) { lock (freeConnLock) { bool result = false; if (BusyConnectionDic.TryRemove(connection, out result)) //從忙佇列中取出 { // BaseUtil.Logger.DebugFormat("set FreeConnectionQueue:{0},FreeConnectionCount:{1}, BusyConnectionCount:{2}", connection.GetHashCode().ToString(), FreeConnectionQueue.Count, BusyConnectionDic.Count); } else { // BaseUtil.Logger.DebugFormat("failed TryRemove BusyConnectionDic:{0},FreeConnectionCount:{1}, BusyConnectionCount:{2}", connection.GetHashCode().ToString(), FreeConnectionQueue.Count, BusyConnectionDic.Count); } if (FreeConnectionQueue.Count + BusyConnectionDic.Count > MaxConnectionCount)//如果因為高併發出現極少概率的>MaxConnectionCount,則直接釋放該連線 { connection.Close(); connection.Dispose(); } else { FreeConnectionQueue.Enqueue(connection);//加入到空閒佇列,以便持續提供連線服務 } MQConnectionPoolSemaphore.Release();//釋放一個空閒連線訊號 //Interlocked.Decrement(ref connCount); //BaseUtil.Logger.DebugFormat("Enqueue FreeConnectionQueue:{0},FreeConnectionCount:{1}, BusyConnectionCount:{2},thread count:{3}", connection.GetHashCode().ToString(), FreeConnectionQueue.Count, BusyConnectionDic.Count,connCount); } } /// <summary> /// 傳送訊息 /// </summary> /// <param name="connection">訊息佇列連線物件</param> /// <typeparam name="T">訊息型別</typeparam> /// <param name="queueName">佇列名稱</param> /// <param name="durable">是否持久化</param> /// <param name="msg">訊息</param> /// <returns></returns> public static string SendMsg(IConnection connection, string queueName, string msg, bool durable = true) { try { using (var channel = connection.CreateModel())//建立通訊通道 { // 引數從前面開始分別意思為:佇列名稱,是否持久化,獨佔的佇列,不使用時是否自動刪除,其他引數 channel.QueueDeclare(queueName, durable, false, false, null); var properties = channel.CreateBasicProperties(); properties.DeliveryMode = 2;//1表示不持久,2.表示持久化 if (!durable) properties = null; var body = Encoding.UTF8.GetBytes(msg); channel.BasicPublish("", queueName, properties, body); } return string.Empty; } catch (Exception ex) { return ex.ToString(); } finally { ResetMQConnectionToFree(connection); } } /// <summary> /// 消費訊息 /// </summary> /// <param name="connection">訊息佇列連線物件</param> /// <param name="queueName">佇列名稱</param> /// <param name="durable">是否持久化</param> /// <param name="dealMessage">訊息處理函式</param> /// <param name="saveLog">儲存日誌方法,可選</param> public static void ConsumeMsg(IConnection connection, string queueName, bool durable, Func<string, ConsumeAction> dealMessage, Action<string, Exception> saveLog = null) { try { using (var channel = connection.CreateModel()) { channel.QueueDeclare(queueName, durable, false, false, null); //獲取佇列 channel.BasicQos(0, 1, false); //分發機制為觸發式 var consumer = new QueueingBasicConsumer(channel); //建立消費者 // 從左到右引數意思分別是:佇列名稱、是否讀取訊息後直接刪除訊息,消費者 channel.BasicConsume(queueName, false, consumer); while (true) //如果佇列中有訊息 { ConsumeAction consumeResult = ConsumeAction.RETRY; var ea = (BasicDeliverEventArgs)consumer.Queue.Dequeue(); //獲取訊息 string message = null; try { var body = ea.Body; message = Encoding.UTF8.GetString(body); consumeResult = dealMessage(message); } catch (Exception ex) { if (saveLog != null) { saveLog(message, ex); } } if (consumeResult == ConsumeAction.ACCEPT) { channel.BasicAck(ea.DeliveryTag, false); //訊息從佇列中刪除 } else if (consumeResult == ConsumeAction.RETRY) { channel.BasicNack(ea.DeliveryTag, false, true); //訊息重回佇列 } else { channel.BasicNack(ea.DeliveryTag, false, false); //訊息直接丟棄 } } } } catch (Exception ex) { if (saveLog != null) { saveLog("QueueName:" + queueName, ex); } throw ex; } finally { ResetMQConnectionToFree(connection); } } /// <summary> /// 依次獲取單個訊息 /// </summary> /// <param name="connection">訊息佇列連線物件</param> /// <param name="QueueName">佇列名稱</param> /// <param name="durable">持久化</param> /// <param name="dealMessage">處理訊息委託</param> public static void ConsumeMsgSingle(IConnection connection, string QueueName, bool durable, Func<string, ConsumeAction> dealMessage) { try { using (var channel = connection.CreateModel()) { channel.QueueDeclare(QueueName, durable, false, false, null); //獲取佇列 channel.BasicQos(0, 1, false); //分發機制為觸發式 uint msgCount = channel.MessageCount(QueueName); if (msgCount > 0) { var consumer = new QueueingBasicConsumer(channel); //建立消費者 // 從左到右引數意思分別是:佇列名稱、是否讀取訊息後直接刪除訊息,消費者 channel.BasicConsume(QueueName, false, consumer); ConsumeAction consumeResult = ConsumeAction.RETRY; var ea = (BasicDeliverEventArgs)consumer.Queue.Dequeue(); //獲取訊息 try { var body = ea.Body; var message = Encoding.UTF8.GetString(body); consumeResult = dealMessage(message); } catch (Exception ex) { throw ex; } finally { if (consumeResult == ConsumeAction.ACCEPT) { channel.BasicAck(ea.DeliveryTag, false); //訊息從佇列中刪除 } else if (consumeResult == ConsumeAction.RETRY) { channel.BasicNack(ea.DeliveryTag, false, true); //訊息重回佇列 } else { channel.BasicNack(ea.DeliveryTag, false, false); //訊息直接丟棄 } } } else { dealMessage(string.Empty); } } } catch (Exception ex) { throw ex; } finally { ResetMQConnectionToFree(connection); } } /// <summary> /// 獲取佇列訊息數 /// </summary> /// <param name="connection"></param> /// <param name="QueueName"></param> /// <returns></returns> public static int GetMessageCount(IConnection connection, string QueueName) { int msgCount = 0; try { using (var channel = connection.CreateModel()) { channel.QueueDeclare(QueueName, true, false, false, null); //獲取佇列 msgCount = (int)channel.MessageCount(QueueName); } } catch (Exception ex) { throw ex; } finally { ResetMQConnectionToFree(connection); } return msgCount; } } public enum ConsumeAction { ACCEPT, // 消費成功 RETRY, // 消費失敗,可以放回佇列重新消費 REJECT, // 消費失敗,直接丟棄 } }
現在對上述程式碼的核心點作一個簡要的說明:
先說一下靜態建構函式:
FreeConnectionQueue 用於存放空閒連線物件佇列,為何使用Queue,因為當我從中取出1個空閒連線後,空閒連線數就應該少1個,這個Queue很好滿足這個需求,而且這個Queue是併發安全的Queue哦(ConcurrentQueue)
BusyConnectionDic 忙(使用中)連線物件集合,為何這裡使用字典物件呢,因為當我用完後,需要能夠快速的找出使用中的連線物件,並能快速移出,同時重新放入到空閒佇列FreeConnectionQueue ,達到連線複用
MQConnectionPoolUsingDicNew 連線使用次數記錄集合,這個只是輔助記錄連線使用次數,以便可以計算一個連線的已使用次數,當達到最大使用次數時,則應斷開重新建立
MQConnectionPoolSemaphore 這個是訊號量,這是控制併發連線的重要手段,連線池的容量等同於這個訊號量的最大可並行數,保證同時使用的連線數不超過連線池的容量,若超過則會等待;
具體步驟說明:
1.MaxConnectionCount:最大保持可用連線數(可以理解為連線池的容量),可以通過CONFIG配置,預設為30;
2.DefaultMaxConnectionUsingCount:預設最大連線可訪問次數,我這裡沒有使用配置,而是直接使用常量固定為1000,大家若有需要可以改成從CONFIG配置,參考MaxConnectionCount的屬性設定(採取了依賴快取)
3.CreateMQConnectionInPoolNew:從連線池中建立MQ連線物件,這個是核心方法,是實現連線池的地方,程式碼中已註釋了重要的步驟邏輯,這裡說一下實現思路:
3.1 通過MQConnectionPoolSemaphore.WaitOne() 利用訊號量的並行等待方法,如果當前併發超過訊號量的最大並行度(也就是作為連線池的最大容量),則需要等待空閒連線池,防止連線數超過池的容量,如果併發沒有超過池的容量,則可以進入獲取連線的邏輯;
3.2FreeConnectionQueue.Count + BusyConnectionDic.Count < MaxConnectionCount,如果空閒連線佇列+忙連線集合的總數小於連線池的容量,則可以直接建立新的MQ連線,否則FreeConnectionQueue.TryDequeue(out mqConnection) 嘗試從空閒連線佇列中獲取一個可用的空閒連線使用,若空閒連線都沒有,則需要返回到方法首行,重新等待空閒連線;
3.3MQConnectionPoolUsingDicNew[mqConnection] + 1 > DefaultMaxConnectionUsingCount || !mqConnection.IsOpen 如果取到空閒連線,則先判斷使用次數是否超過最大限制,超過則釋放連線或空閒連線已斷開連線也需要重新建立,否則該連線可用;
3.4BusyConnectionDic[mqConnection] = true;加入到忙連線集合中,MQConnectionPoolUsingDicNew[mqConnection] = MQConnectionPoolUsingDicNew[mqConnection] + 1; 使用次數加1,確保每使用一次連線,連線次數能記錄
4.ResetMQConnectionToFree:重置釋放連線物件,這個是保證MQ連線用完後能夠回收到空閒連線佇列中(即:回到連線池中),而不是直接斷開連線,這個方法很簡單就不作作過多說明。
好了,都說明了如何實現含連線池的MQHelper,現在再來舉幾個例子來說明如何用:
三、實際應用(簡單易上手)
獲取並消費一個訊息:
public string GetMessage(string queueName) { string message = null; try { var connection = MQHelper.CreateMQConnectionInPoolNew(); MQHelper.ConsumeMsgSingle(connection, queueName, true, (msg) => { message = msg; return ConsumeAction.ACCEPT; }); } catch (Exception ex) { BaseUtil.Logger.Error(string.Format("MQHelper.ConsumeMsgSingle Error:{0}", ex.Message), ex); message = "ERROR:" + ex.Message; } //BaseUtil.Logger.InfoFormat("第{0}次請求,從訊息佇列(佇列名稱:{1})中獲取訊息值為:{2}", Interlocked.Increment(ref requestCount), queueName, message); return message; }
傳送一個訊息:
public string SendMessage(string queueName, string msg) { string result = null; try { var connection = MQHelper.CreateMQConnectionInPoolNew(); result = MQHelper.SendMsg(connection, queueName, msg); } catch (Exception ex) { BaseUtil.Logger.Error(string.Format("MQHelper.SendMessage Error:{0}", ex.Message), ex); result = ex.Message; } return result; }
獲取訊息佇列訊息數:
public int GetMessageCount(string queueName) { int result = -1; try { var connection = MQHelper.CreateMQConnectionInPoolNew(); result = MQHelper.GetMessageCount(connection, queueName); } catch (Exception ex) { BaseUtil.Logger.Error(string.Format("MQHelper.GetMessageCount Error:{0}", ex.Message), ex); result = -1; } return result; }
這裡說一下:BaseUtil.Logger 是Log4Net的例項物件,另外上面沒有針對持續訂閱消費訊息(ConsumeMsg)作說明,因為這個其實可以不用連線池也不會有問題,因為它是一個持久訂閱並持久消費的過程,不會出現頻繁建立連線物件的情況。
最後要說的是,雖說程式碼貼出來,大家一看就覺得很簡單,好像沒有什麼技術含量,但如果沒有完整的思路也還是需要花費一些時間和精力的,程式碼中核心是如何簡單高效的解決併發及連線複用的的問題,該MQHelper有經過壓力測試並順利在我司專案中使用,完美解決了之前的問題,由於這個方案是我在公司通宵實現的,可能有一些方面的不足,大家可以相互交流或完善後入到自己的專案中。