詳解使用Redis作為消息隊列服務場景應用案例
一、消息隊列場景簡介
“消息”是在兩臺計算機間傳送的數(shù)據(jù)單位。消息可以非常簡單,例如只包含文本字符串;也可以更復雜,可能包含嵌入對象。消息被發(fā)送到隊列中,“消息隊列”是在消息的傳輸過程中保存消息的容器。
在目前廣泛的Web應用中,都會出現(xiàn)一種場景:在某一個時刻,網(wǎng)站會迎來一個用戶請求的高峰期(比如:淘寶的雙十一購物狂歡節(jié),12306的春運搶票節(jié)等),一般的設計中,用戶的請求都會被直接寫入數(shù)據(jù)庫或文件中,在高并發(fā)的情形下會對數(shù)據(jù)庫服務器或文件服務器造成巨大的壓力,同時呢,也使響應延遲加劇。這也說明了,為什么我們當時那么地抱怨和吐槽這些網(wǎng)站的響應速度了。當時2011年的京東圖書促銷,曾一直出現(xiàn)在購物車中點擊“購買”按鈕后一直是“Service is too busy”,其實就是因為當時的并發(fā)訪問量過大,超過了系統(tǒng)的最大負載能力。當然,后邊,劉強東臨時購買了不少服務器進行擴展以求增強處理并發(fā)請求的能力,還請了信息部的人員“喝茶”,現(xiàn)在京東已經(jīng)是超大型的網(wǎng)上商城了,我也有同學在京東成都研究院工作了。
從京東當年的“Service is too busy”不難看出,高并發(fā)的用戶請求是網(wǎng)站成長過程中必不可少的過程,也是一個必須要解決的難題。在眾多的實踐當中,除了增加服務器數(shù)量配置服務器集群實現(xiàn)伸縮性架構設計之外,異步操作也被廣泛采用。而異步操作中最核心的就是使用消息隊列,通過消息隊列,將短時間高并發(fā)產生的事務消息存儲在消息隊列中,從而削平高峰期的并發(fā)事務,改善網(wǎng)站系統(tǒng)的性能。在京東之類的電子商務網(wǎng)站促銷活動中,合理地使用消息隊列,可以有效地抵御促銷活動剛開始就開始大量涌入的訂單對系統(tǒng)造成的沖擊。
記得我在實習期間,成都市XXXX局的一個價格信息采集發(fā)布系統(tǒng)項目中有一個采集任務發(fā)布的模塊,其中每個任務都是一個事務,這個事務中需要向數(shù)據(jù)庫中不斷地插入行,每個任務發(fā)布時都要往表中插入幾百行甚至幾千行的任務數(shù)據(jù)(比如價格采集日報,往往需要發(fā)布2-3年的任務數(shù)據(jù),每一天都是一個任務,所以大約有2,3千行任務期號數(shù)據(jù),還要發(fā)給很多個區(qū)縣的監(jiān)測中心,因此數(shù)據(jù)庫寫操作量很大,更別說同時發(fā)布的并發(fā)操作),由于業(yè)務邏輯的處理比較復雜和往數(shù)據(jù)庫的寫操作量交大,所以在沒有采用消息隊列時點擊“發(fā)布”按鈕后往往需要等待1分鐘左右的時間才提示“發(fā)布成功”,用戶體驗極不友好。
這時,我們就可以使用消息隊列的思想來重構這個發(fā)布模塊,在用戶點擊“發(fā)布”按鈕后,系統(tǒng)只需要把往數(shù)據(jù)庫插入的這個事務信息插入到指定的任務發(fā)布消息隊列里邊去(入隊操作,這里一般有一臺獨立的消息隊列服務器來單獨存儲和處理),然后系統(tǒng)就可以立即對用戶的這個發(fā)布請求進行響應(比如給出一個發(fā)布成功的操作提示,這里暫不考慮消息隊列服務操作失敗的情形,如果失敗了,可以考慮采用給用戶發(fā)送郵件、短信或站內消息,讓其重新進行發(fā)布操作)。
最后,消息隊列服務器中有一個進程單獨對消息隊列進行處理,首先判斷消息隊列中是否有待處理的消息,如果有,則將其取出(出隊操作,堅持“先進先出”的順序,保證事務的準確性)進行相應地處理(比如這里是進行保存數(shù)據(jù)的操作,將數(shù)據(jù)插入到數(shù)據(jù)庫服務器中的指定數(shù)據(jù)庫里邊,實質還是文件的IO操作)。就這樣,通過消息隊列將高并發(fā)用戶請求進行異步操作,然后一一對消息隊列進行出隊的同步操作,也避免了并發(fā)控制的難題。
說到這里,大家可能會想到這尼瑪不就是生產者消費者模式么?對的,么么嗒,消息隊列就是生產者消費者模式的典型場景。簡單地說,客戶端不同用戶發(fā)送的操作請求就是生產者,他們將要處理的事務存儲到消息隊列中,然后消息隊列服務器的某個進程不停地將要處理的單個事務從消息隊列中一個一個地取出來進行相應地處理,這就是消費者消費的過程。
下面我們將以異常日志為案例,介紹在.Net中如何采用消息隊列的思想解決并發(fā)問題。當然,消息隊列只是解決并發(fā)問題的其中一種方式,在實際中往往需要結合多種不同的技術方式來共同解決,比如負載均衡、反向代理、集群等方案。這里,雖然以異常日志為案例,但是“麻雀雖小五臟俱全”,日志寫入文件的高并發(fā)操作也同樣適用于數(shù)據(jù)庫的高并發(fā),所以,研究這個案例是具有實際意義的。
二、使用預置類型實現(xiàn)異常日志隊列
在日常的Web應用中,異常日志的記錄是一個十分重要的要點。因為,人無完人,系統(tǒng)也一樣,難免會在什么時候出一個測試階段未能完全測試到的異常。這時候,不能將異常信息直接顯示給客戶,那樣既不友好也不安全。所以,一般都采用將異常信息記錄到日志文件中(比如某個txt文件,數(shù)據(jù)庫中某個表等),然后技術支持人員通過查看異常日志,分析異常原因,改進BUG重新發(fā)布,保障系統(tǒng)正常運行。
在用戶的各種操作中,如果出現(xiàn)異常的時間一致,那么記錄異常日志的操作就會成為并發(fā)操作,而記錄異常日志又屬于文件的IO操作(其實數(shù)據(jù)庫的讀寫歸根結底也是對文件即對磁盤進行的IO操作),因此很有可能帶來并發(fā)控制的一系列問題。在以往的編碼實踐中,我們可以通過給不同的IO請求進行加鎖(C#中的lock),等第一個請求完成寫入后釋放鎖,第二個請求再獲得鎖,進行IO操作,然后釋放掉,一直到第N個請求釋放后結束。這種方式,雖然解決了并發(fā)操作帶來的問題,但是通過加鎖延遲了用戶響應請求的時間(比如第一個正在IO寫入操作時,后面的均處于等待狀態(tài)),并且加鎖也會給服務器帶來一定的性能負擔,造成服務器性能的下降。
基于以上原因,我們采用消息隊列的思想將異常日志的記錄操作改為隊列版,這里我們先不采用Redis,直接使用.Net為我們提供的預置類型-Queue。接下來,就讓我們動手開刀,寫起來。
(1)新建一個ASP.NET MVC 4項目,選擇“基本”類型,視圖引擎選擇“Razor”。
(2)既然是異常日志記錄,首先得有異常。這時,我們腦海中想到了那個經(jīng)典的異常:DividedByZeroException。于是,在Controllers文件夾中新建一個Controller,取名為Home(這里因為Global文件中的默認路由就指向了Home控制器中的Index這個Action),在HomeController中修改Index這個Action的代碼如下:
- public ActionResult Index()
- { int a = 10; int b = 0;
- int c = a / b;
- //會拋一個DividedByZero的異常
- return View();
- }
(3)在ASP.NET MVC項目中,我們需要在Global.asax中的Application_Start這個事件中修改全局過濾器(主要是App_Start中的FilterConfig類的RegisterGlobalFilters這個方法),讓系統(tǒng)支持對異常的全局處理操作(我們這里主要是對異常進行記錄到指定文件中)。PS:Application_Start是整個Web應用的起始事件,主要進行一些配置(如過濾器配置、日志器配置、路由配置等等)的初始化操作,當然這些配置也只會進行一次。
- public class FilterConfig
- { public static void RegisterGlobalFilters
- (GlobalFilterCollection filters)
- { // MyExceptionFilterAttribute繼承自HandleError,
- 主要作用是將異常信息寫入日志文件中 filters.Add
- (new MyExceptionFilterAttribute()); //
- 默認的異常記錄類 filters.Add(new
- HandleErrorAttribute
- ());
- }
- }
通過改寫過濾器配置,我們向全局過濾器中注冊了一個異常處理的過濾器配置,那么這個MyExceptionFilterAttribute類又是如何編寫的呢?
通過使該類繼承HandlerErrorAttribute并使其覆寫OnException這個事件,代表在異常發(fā)生時可以進行的操作。而我們在這兒主要通過一個異常隊列將獲取的異常寫入隊列,然后跳轉到自定義錯誤頁:~/Common/CommonError.html,這個錯誤頁很簡單,就是簡單的顯示“系統(tǒng)發(fā)生錯誤,5秒后自動跳轉到首頁”
(4)走到這里,生產者消費者模式中生產者的任務已經(jīng)完成了,接下來消費者就需要開始消費了。也就是說,消息隊列已經(jīng)建好了,我們什么時候從隊列中去任務,在哪里執(zhí)行?怎么樣執(zhí)行?通過上面的介紹,我們知道,在專門的消息隊列服務器中有一個進程在始終不停地監(jiān)視消息隊列,如果有需要待辦的任務信息,則會立即從隊列中取出來執(zhí)行相應的操作,直到隊列為空為止。于是,思路有了,我們馬上來實現(xiàn)以下。這個消息監(jiān)視的操作也是一個全局操作,在系統(tǒng)啟動時就會一直運行,于是它也應該寫在Application_Start這個全局起始事件里邊,于是按照標準的配置寫法,我們在Application_Start中添加了如下代碼:MessageQueueConfig.RegisterExceptionLogQueue();
- protected void Application_Start()
- {
- AreaRegistration.RegisterAllAreas();
- WebApiConfig.Register
- (GlobalConfiguration.Configuration);
- FilterConfig.RegisterGlobalFilters
- (GlobalFilters.Filters);
- RouteConfig.RegisterRoutes
- (RouteTable.Routes);
- BundleConfig.RegisterBundles
- (BundleTable.Bundles);
- //自定義事件注冊
- MessageQueueConfig.
- RegisterExceptionLogQueue
- ();
- }
那么,這個MessageQueueConfig.RegisterExceptionLogQueue()又是怎么寫的呢?
現(xiàn)在,讓我們來看看這段代碼:
①首先定義Log文件存放的文件夾目錄,這里我們一般放到App_Data里邊,因為放到這里邊外網(wǎng)是無法訪問到的,可以防止下載操作;
②其次通過線程池ThreadPool開啟一個線程,不停地監(jiān)聽消息隊列里邊的待辦事項個數(shù),如果個數(shù)>0,則進行出隊(FIFO,先入隊的先出隊)操作。這里主要是取出具體的異常實例對象,并將異常的具體堆棧信息追加寫入到指定命名格式的文件中。
PS:許多應用程序創(chuàng)建的線程都要在休眠狀態(tài)中消耗大量時間,以等待事件發(fā)生。其他線程可能進入休眠狀態(tài),只被定期喚醒以輪詢更改或更新狀態(tài)信息。線程池通過為應用程序提供一個由系統(tǒng)管理的輔助線程池使您可以更為有效地使用線程。
③如果該線程檢測到消息隊列中無待辦事項,則使用Thread.Sleep使線程“休息”一會,避免了CPU空轉(從理論上來說,CPU資源是很珍貴的,應該盡量提高CPU的利用率)。
(5)最后,我們來看看效果如何?
①首先,高大上的VS捕捉到了異常-DividedByZeroException:
②按照我們的全局異常處理過濾器,會將此異常記入隊列中,并返回HTTP 302重定向跳轉到自定義錯誤頁面:
③最后,打開App_Data文件夾,查看日志文件:
到這里時,我們已經(jīng)借助消息隊列的思想完成了一個自定義的異常日志隊列服務。但也許有朋友會說,這個跟Redis有關系么?異常日志不都是用Log4Net么?不要著急,后邊我們就會使用Redis+Log4Net來重構這個異常日志隊列服務
三、使用Redis重構異常日志隊列
(1)第一步,開啟Redis的服務,這里我們使用命令開啟Redis服務(之前已經(jīng)將Redis注冊到了Windows系統(tǒng)服務中了嘛,么么嗒):net start redis-instance,當然,也可以通過在Windows服務列表中開啟。
(2)第二步,在剛剛的版本1的Demo中新建一個文件夾,命名為Lib,將ServiceStack.Redis的dll和Log4Net的dll都拷貝進去。然后,在引用中添加對Lib文件夾中所有dll的引用。
(3)第三步,重寫MyExceptionFilterAttribute這個全局異常信息過濾器。這里使用到了Redis的客戶端連接池,每次連接時都是從池中取,不需要每次都創(chuàng)建,節(jié)省了時間和資源,提高了資源利用率。對于,多臺Redis服務器組成的集群而言,這里需要指定多個形如 IP地址:端口號 的字符串數(shù)組。
(4)第四步,首先在Web.config中加入Log4Net的詳細配置。
View Code
PS:Log4Net是用來記錄日志的一個常用組件(Log4J的移植版本),可以將程序運行過程中的信息輸出到一些地方(文件、數(shù)據(jù)庫、EventLog等)。由于Log4Net不是本篇博文介紹的重點,所以對Log4Net不熟悉的朋友,請在博客園首頁搜索:Log4Net,瀏覽其詳細的介紹。
其次,在App_Start文件夾中添加一個類,取名為LogConfig,定義一個靜態(tài)方法:RegisterLog4NetConfigure,具體代碼只有一行,實現(xiàn)了Log4Net配置的初始化操作。
- public class LogConfig
- { public static void RegisterLog4NetConfigure()
- { //獲取Log4Net配置信息(配置信息定義在Web.config文件中)
- log4net.Config.XmlConfigurator.Configure();
- }
- }
最后,在Global.asax中的Application_Start方法中添加一行代碼,注冊Log4Net的配置:
(5)第五步,改寫MessageQueueConfig中的RegisterExceptionLogQueue方法。這里就不再需要從預置類型Queue中取任務了,而是Redis中取出任務出隊進行相應處理。這里,我們使用了Log4Net進行異常日志的記錄工作。PS:注意在代碼頂部添加對log4net的引用:using log4net;
(6)最后一步,調試驗證是否能正常寫入App_Data文件的日志中,發(fā)現(xiàn)寫入的異常日志如下,格式好看,信息詳細,圓滿完成了我們的目的。
四、小結
使用消息隊列將調用異步化,可以改善網(wǎng)站系統(tǒng)的性能:消息隊列具有很好的削峰作用,即通過異步處理,將短時間高并發(fā)產生的事務消息存儲在消息隊列中,從而削平高峰期的并發(fā)事務。在電商網(wǎng)站的促銷活動中,合理使用消息隊列,可以有效地抵御促銷活動剛開始大量涌入的訂單對系統(tǒng)造成的沖擊。本文使用消息隊列的思想,借助Redis+Log4Net完成了一個超簡單的異常日志隊列的應用案例,可以有效地解決在多線程操作中對日志文件的并發(fā)操作帶來的一些問題。同樣地,借助消息隊列的思想,我們也可以完成對數(shù)據(jù)庫的高并發(fā)的消息隊列方案。