上一篇介紹了一些redis的安裝及使用步驟,本篇開始將介紹redis的實際應用場景,先從最常見的session開始,剛好也重新學習一遍session的實現原理。在閱讀之前假設你已經會使用nginx+iis實現負載均衡搭建負載均衡站點了,這裡我們會搭建兩個站點來驗證redis實現的session是否能共享。
Session實現原理
session和cookie是我們做web開發中常用到的兩個物件,它們之間會不會有聯絡呢?
Cookie是什麼? Cookie 是一小段文字資訊,伴隨著使用者請求和頁面在 Web 伺服器和瀏覽器之間傳遞。Cookie 包含每次使用者訪問站點時 Web 應用程式都可以讀取的資訊。(Cookie 會隨每次HTTP請求一起被傳遞伺服器端,排除js,css,image等靜態檔案,這個過程可以從fiddler或者ie自帶的網路監控裡面分析到,考慮效能的化可以從儘量減少cookie著手)
Cookie寫入瀏覽器的過程:我們可以使用如下程式碼在Asp.net專案中寫一個Cookie 併傳送到客戶端的瀏覽器(為了簡單我沒有設定其它屬性)。
1 |
HttpCookie cookie = new HttpCookie("RedisSessionId", "string value");Response.Cookies.Add(cookie); |
我們可以看到在伺服器寫的cookie,會通過響應頭Set-Cookie的方式寫入到瀏覽器。
Session是什麼? Session我們可以使用它來方便地在服務端儲存一些與會話相關的資訊。比如常見的登入資訊。
Session實現原理? HTTP協議是無狀態的,對於一個瀏覽器發出的多次請求,WEB伺服器無法區分 是不是來源於同一個瀏覽器。所以伺服器為了區分這個過程會通過一個sessionid來區分請求,而這個sessionid是怎麼傳送給服務端的呢?前面說了cookie會隨每次請求傳送到服務端,並且cookie相對使用者是不可見的,用來儲存這個sessionid是最好不過了,我們通過下面過程來驗證一下。
1 |
Session["UserId"] = 123; |
通過上圖再次驗證了session和cookie的關係,伺服器產生了一次設定cookie的操作,這裡的sessionid就是用來區分瀏覽器的。為了實驗是區分瀏覽器的,可以實驗在IE下進行登入,然後在用chrome開啟相同頁面,你會發現在chrome還是需要你登入的,原因是chrome這時沒有sessionid。httpOnly是表示這個cookie是不會在瀏覽器端通過js進行操作的,防止人為串改sessionid。
asp.net預設的sessionid的鍵值是ASP.NET_SessionId,可以在web.config裡面修改這個預設配置
1 |
<sessionState mode="InProc" cookieName="MySessionId"></sessionState> |
伺服器端Session讀取? 伺服器端是怎麼讀取session的值呢 ,Session[“鍵值”]。那麼問題來了,為什麼在Defaule.aspx.cs檔案裡可以獲取到這個Session物件,這個Session物件又是什麼時候被初始化的呢。
為了弄清楚這個問題,我們可以通過轉到定義的方式來檢視。
System.Web.UI.Page ->HttpSessionState(Session)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
protected internal override HttpContext Context { [System.Runtime.TargetedPatchingOptOut("Performance critical to inline across NGen image boundaries")] get { if (_context == null) { _context = HttpContext.Current; } return _context; } } public virtual HttpSessionState Session { get { if (!_sessionRetrieved) { /* try just once to retrieve it */ _sessionRetrieved = true; try { _session = Context.Session; } catch { // Just ignore exceptions, return null. } } if (_session == null) { throw new HttpException(SR.GetString(SR.Session_not_enabled)); } return _session; } } |
上面這一段是Page物件初始化Session物件的,可以看到Session的值來源於HttpContext.Current,而HttpContext.Current又是什麼時候被初始化的呢,我們接著往下看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
public sealed class HttpContext : IServiceProvider, IPrincipalContainer { internal static readonly Assembly SystemWebAssembly = typeof(HttpContext).Assembly; private static volatile bool s_eurlSet; private static string s_eurl; private IHttpAsyncHandler _asyncAppHandler; // application as handler (not always HttpApplication) private AsyncPreloadModeFlags _asyncPreloadModeFlags; private bool _asyncPreloadModeFlagsSet; private HttpApplication _appInstance; private IHttpHandler _handler; [DoNotReset] private HttpRequest _request; private HttpResponse _response; private HttpServerUtility _server; private Stack _traceContextStack; private TraceContext _topTraceContext; [DoNotReset] private Hashtable _items; private ArrayList _errors; private Exception _tempError; private bool _errorCleared; [DoNotReset] private IPrincipalContainer _principalContainer; [DoNotReset] internal ProfileBase _Profile; [DoNotReset] private DateTime _utcTimestamp; [DoNotReset] private HttpWorkerRequest _wr; private VirtualPath _configurationPath; internal bool _skipAuthorization; [DoNotReset] private CultureInfo _dynamicCulture; [DoNotReset] private CultureInfo _dynamicUICulture; private int _serverExecuteDepth; private Stack _handlerStack; private bool _preventPostback; private bool _runtimeErrorReported; private PageInstrumentationService _pageInstrumentationService = null; private ReadOnlyCollection<string> _webSocketRequestedProtocols; } |
我這裡只貼出了一部分原始碼,HttpContext包含了我們常用的Request,Response等物件。HttpContext得從ASP.NET管道說起,以IIS 6.0為例,在工作程式w3wp.exe中,利用Aspnet_ispai.dll載入.NET執行時(如果.NET執行時尚未載入)。IIS 6.0引入了應用程式池的概念,一個工作程式對應著一個應用程式池。一個應用程式池可以承載一個或多個Web應用,每個Web應用對映到一個IIS虛擬目錄。與IIS 5.x一樣,每一個Web應用執行在各自的應用程式域中。如果HTTP.SYS接收到的HTTP請求是對該Web應用的第一次訪問,在成功載入了執行時後,會通過AppDomainFactory為該Web應用建立一個應用程式域(AppDomain)。隨後,一個特殊的執行時IsapiRuntime被載入。IsapiRuntime定義在程式集System.Web中,對應的名稱空間為System.Web.Hosting。IsapiRuntime會接管該HTTP請求。IsapiRuntime會首先建立一個IsapiWorkerRequest物件,用於封裝當前的HTTP請求,並將該IsapiWorkerRequest物件傳遞給ASP.NET執行時:HttpRuntime,從此時起,HTTP請求正式進入了ASP.NET管道。根據IsapiWorkerRequest物件,HttpRuntime會建立用於表示當前HTTP請求的上下文(Context)物件:HttpContext。
至此相信大家對Session初始化過程,session和cookie的關係已經很瞭解了吧,下面開始進行Session共享實現方案。
Session共享實現方案
一.StateServer方式
這種是asp.net提供的一種方式,還有一種是SQLServer方式(不一定程式使用的是SQLServer資料庫,所以通用性不高,這裡就不介紹了)。也就是將會話資料儲存到單獨的記憶體緩衝區中,再由單獨一臺機器上執行的Windows服務來控制這個緩衝區。狀態服務全稱是“ASP.NET State Service ”(aspnet_state.exe)。它由Web.config檔案中的stateConnectionString屬性來配置。該屬性指定了服務所在的伺服器,以及要監視的埠。
1 |
<sessionState mode="StateServer" stateConnectionString="tcpip=127.0.0.1:42424" cookieless="false" timeout="20" /> |
在這個例子中,狀態服務在當前機器的42424埠(預設埠)執行。要在伺服器上改變埠和開啟遠端伺服器的該功能,可編輯HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\services\aspnet_state\Parameters登錄檔項中的Port值和AllowRemoteConnection修改成1。 顯然,使用狀態服務的優點在於程式隔離,並可在多站點中共享。 使用這種模式,會話狀態的儲存將不依賴於iis程式的失敗或者重啟,然而,一旦狀態服務中止,所有會話資料都會丟失(這個問題redis不會存在,重新了資料不會丟失)。
這裡提供一段bat檔案幫助修改登錄檔,可以複製儲存為.bat檔案執行
1 2 3 4 5 6 7 8 |
reg add "HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\services\aspnet_state\Parameters" /v "AllowRemoteConnection" /t REG_DWORD /d 1 /f reg add "HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\services\aspnet_state\Parameters" /v "Port" /t REG_DWORD /d 42424 /f net stop aspnet_state net start aspnet_state pause |
二.redis實現session共享
下面我們將使用redis來實現共享,首先要弄清楚session的幾個關鍵點,過期時間,SessionId,一個SessionId裡面會存在多組key/value資料。基於這個特性我將採用Hash結構來儲存,看看程式碼實現。用到了上一篇提供的RedisBase幫助類。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.SessionState; using ServiceStack.Redis; using Com.Redis; namespace ResidSessionDemo.RedisDemo { public class RedisSession { private HttpContext context; public RedisSession(HttpContext context, bool IsReadOnly, int Timeout) { this.context = context; this.IsReadOnly = IsReadOnly; this.Timeout = Timeout; //更新快取過期時間 RedisBase.Hash_SetExpire(SessionID, DateTime.Now.AddMinutes(Timeout)); } /// <summary> /// SessionId識別符號 /// </summary> public static string SessionName = "Redis_SessionId"; // // 摘要: // 獲取會話狀態集合中的項數。 // // 返回結果: // 集合中的項數。 public int Count { get { return RedisBase.Hash_GetCount(SessionID); } } // // 摘要: // 獲取一個值,該值指示會話是否為只讀。 // // 返回結果: // 如果會話為只讀,則為 true;否則為 false。 public bool IsReadOnly { get; set; } // // 摘要: // 獲取會話的唯一識別符號。 // // 返回結果: // 唯一會話識別符號。 public string SessionID { get { return GetSessionID(); } } // // 摘要: // 獲取並設定在會話狀態提供程式終止會話之前各請求之間所允許的時間(以分鐘為單位)。 // // 返回結果: // 超時期限(以分鐘為單位)。 public int Timeout { get; set; } /// <summary> /// 獲取SessionID /// </summary> /// <param name="key">SessionId識別符號</param> /// <returns>HttpCookie值</returns> private string GetSessionID() { HttpCookie cookie = context.Request.Cookies.Get(SessionName); if (cookie == null || string.IsNullOrEmpty(cookie.Value)) { string newSessionID = Guid.NewGuid().ToString(); HttpCookie newCookie = new HttpCookie(SessionName, newSessionID); newCookie.HttpOnly = IsReadOnly; newCookie.Expires = DateTime.Now.AddMinutes(Timeout); context.Response.Cookies.Add(newCookie); return "Session_"+newSessionID; } else { return "Session_"+cookie.Value; } } // // 摘要: // 按名稱獲取或設定會話值。 // // 引數: // name: // 會話值的鍵名。 // // 返回結果: // 具有指定名稱的會話狀態值;如果該項不存在,則為 null。 public object this[string name] { get { return RedisBase.Hash_Get<object>(SessionID, name); } set { RedisBase.Hash_Set<object>(SessionID, name, value); } } // 摘要: // 判斷會話中是否存在指定key // // 引數: // name: // 鍵值 // public bool IsExistKey(string name) { return RedisBase.Hash_Exist<object>(SessionID, name); } // // 摘要: // 向會話狀態集合新增一個新項。 // // 引數: // name: // 要新增到會話狀態集合的項的名稱。 // // value: // 要新增到會話狀態集合的項的值。 public void Add(string name, object value) { RedisBase.Hash_Set<object>(SessionID, name, value); } // // 摘要: // 從會話狀態集合中移除所有的鍵和值。 public void Clear() { RedisBase.Hash_Remove(SessionID); } // // 摘要: // 刪除會話狀態集合中的項。 // // 引數: // name: // 要從會話狀態集合中刪除的項的名稱。 public void Remove(string name) { RedisBase.Hash_Remove(SessionID,name); } // // 摘要: // 從會話狀態集合中移除所有的鍵和值。 public void RemoveAll() { Clear(); } } } |
下面是實現類似在cs檔案中能直接使用Session[“UserId”]的方式,我的MyPage類繼承Page實現了自己的邏輯主要做了兩件事 1:初始化RedisSession 2:實現統一登入認證,OnPreInit方法裡面判斷使用者是否登入,如果沒有登入了則跳轉到登陸介面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.UI; namespace ResidSessionDemo.RedisDemo { /// <summary> /// 自定義Page 實現以下功能 /// 1.初始化RedisSession /// 2.實現頁面登入驗證,繼承此類,則可以實現所有頁面的登入驗證 /// </summary> public class MyPage:Page { private RedisSession redisSession; /// <summary> /// RedisSession /// </summary> public RedisSession RedisSession { get { if (redisSession == null) { redisSession = new RedisSession(Context, true, 20); } return redisSession; } } protected override void OnPreInit(EventArgs e) { base.OnPreInit(e); //判斷使用者是否已經登入,如果未登入,則跳轉到登入介面 if (!RedisSession.IsExistKey("UserCode")) { Response.Redirect("Login.aspx"); } } } } |
我們來看看Default.aspx.cs是如何使用RedisSession的,至此我們實現了和Asp.netSession一模一樣的功能和使用方式。
1 |
RedisSession.Remove("UserCode"); |
相比StateServer,RedisSession具有以下優點
1.redis伺服器重啟不會丟失資料 2.可以使用redis的讀寫分離個叢集功能更加高效讀寫資料
測試效果,使用nginx和iis部署兩個站點做負載均衡,iis1地址127.0.0.1:8002 iis2地址127.0.0.1:9000 nginx代理服務地址127.0.0.1:8003,不懂如何配置的可以去閱讀我的nginx+iis實現負載均衡這篇文章。我們來看一下測試結果。
訪問127.0.0.1:8003 需要進行登入 使用者名稱為admin 密碼為123
登入成功以後,重點關注埠號資訊
重新整理頁面,重點關注埠號資訊
可以嘗試直接訪問iis1地址127.0.0.1:8002 iis2地址127.0.0.1:9000 這兩個站點,你會發現都不需要登入了。至此我們的redis實現session功能算是大功告成了。
問題擴充
使用redis實現session告一段落,下面留個問題討論一下方案。微信開發提供了很多介面,參考下面截圖,可以看到獲取access_token介面每日最多呼叫2000次,現在大公司提供的很多介面針對不對級別的使用者介面訪問次數限制都是不一樣的,至於做這個限制的原因應該是防止惡意攻擊和流量限制之類的。那麼我的問題是怎麼實現這個介面呼叫次數限制功能。大家可以發揮想象力參與討論哦,或許你也會碰到這個問題。
先說下我知道的兩種方案:
1.使用流量整形中的令牌桶演算法,大小固定的令牌桶可自行以恆定的速率源源不斷地產生令牌。如果令牌不被消耗,或者被消耗的速度小於產生的速度,令牌就會不斷地增多,直到把桶填滿。後面再產生的令牌就會從桶中溢位。最後桶中可以儲存的最大令牌數永遠不會超過桶的大小。
說淺顯點:比如上面的獲取access_token介面,一天2000次的頻率,即1次/分鐘。我們令牌桶容量為2000,可以使用redis 最簡單的key/value來儲存 ,key為使用者id,value為整形儲存還可使用次數,然後使用一個定時器1分鐘呼叫client.Incr(key) 實現次數自增;使用者每訪問一次該介面,相應的client.Decr(key)來減少使用次數。
但是這裡存在一個效能問題,這僅僅是針對一個使用者來說,假設有10萬個使用者,怎麼使用定時器來實現這個自增操作呢,難道是迴圈10萬次分別呼叫client.Incr(key)嗎?這一點沒有考慮清楚。
2.直接使用者訪問一次 先進行總次數判斷,符合條件再就進行一次自增
兩種方案優缺點比較 | ||
優點 | 缺點 | |
令牌桶演算法 | 流量控制精確 | 實現複雜,並且由於控制精確反而在實際應用中有麻煩,很可能使用者在晚上到凌晨期間訪問介面次數不多,白天訪問次數多些。 |
簡單演算法 | 實現簡單可行,效率高 | 流量控制不精確 |
總結
本篇從實際應用講解了redis,後面應該還會有幾篇繼續介紹redis實際應用,敬請期待!
本篇文章用到的資源打包下載地址:redis_demo