分布式中使用Redis實現(xiàn)Session共享
Session實現(xiàn)原理
session和cookie是我們做web開發(fā)中常用到的兩個對象,它們之間會不會有聯(lián)系呢?
Cookie是什么? Cookie 是一小段文本信息,伴隨著用戶請求和頁面在 Web 服務器和瀏覽器之間傳遞。Cookie 包含每次用戶訪問站點時 Web 應用程序都可以讀取的信息。(Cookie 會隨每次HTTP請求一起被傳遞服務器端,排除js,css,image等靜態(tài)文件,這個過程可以從fiddler或者ie自帶的網絡監(jiān)控里面分析到,考慮性能的化可以從盡量減少cookie著手)
Cookie寫入瀏覽器的過程:我們可以使用如下代碼在Asp.net項目中寫一個Cookie 并發(fā)送到客戶端的瀏覽器(為了簡單我沒有設置其它屬性)。
- HttpCookie cookie = new HttpCookie("RedisSessionId", "string value");Response.Cookies.Add(cookie);

我們可以看到在服務器寫的cookie,會通過響應頭Set-Cookie的方式寫入到瀏覽器。
Session是什么? Session我們可以使用它來方便地在服務端保存一些與會話相關的信息。比如常見的登錄信息。
Session實現(xiàn)原理? HTTP協(xié)議是無狀態(tài)的,對于一個瀏覽器發(fā)出的多次請求,WEB服務器無法區(qū)分 是不是來源于同一個瀏覽器。所以服務器為了區(qū)分這個過程會通過一個sessionid來區(qū)分請求,而這個sessionid是怎么發(fā)送給服務端的呢?前面說了cookie會隨每次請求發(fā)送到服務端,并且cookie相對用戶是不可見的,用來保存這個sessionid是最好不過了,我們通過下面過程來驗證一下。
- Session["UserId"] = 123;

通過上圖再次驗證了session和cookie的關系,服務器產生了一次設置cookie的操作,這里的sessionid就是用來區(qū)分瀏覽器的。為了實驗是區(qū)分瀏覽器的,可以實驗在IE下進行登錄,然后在用chrome打開相同頁面,你會發(fā)現(xiàn)在chrome還是需要你登錄的,原因是chrome這時沒有sessionid。httpOnly是表示這個cookie是不會在瀏覽器端通過js進行操作的,防止人為串改sessionid。
asp.net默認的sessionid的鍵值是ASP.NET_SessionId,可以在web.config里面修改這個默認配置
- <sessionState mode="InProc" cookieName="MySessionId"></sessionState>
服務器端Session讀取? 服務器端是怎么讀取session的值呢 ,Session["鍵值"]。那么問題來了,為什么在Defaule.aspx.cs文件里可以獲取到這個Session對象,這個Session對象又是什么時候被初始化的呢。
為了弄清楚這個問題,我們可以通過轉到定義的方式來查看。
System.Web.UI.Page ->HttpSessionState(Session)
- 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又是什么時候被初始化的呢,我們接著往下看。
- 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應用創(chuàng)建一個應用程序域(AppDomain)。隨后,一個特殊的運行時IsapiRuntime被加載。IsapiRuntime定義在程序集System.Web中,對應的命名空間為System.Web.Hosting。IsapiRuntime會接管該HTTP請求。IsapiRuntime會首先創(chuàng)建一個IsapiWorkerRequest對象,用于封裝當前的HTTP請求,并將該IsapiWorkerRequest對象傳遞給ASP.NET運行時:HttpRuntime,從此時起,HTTP請求正式進入了ASP.NET管道。根據IsapiWorkerRequest對象,HttpRuntime會創(chuàng)建用于表示當前HTTP請求的上下文(Context)對象:HttpContext。
至此相信大家對Session初始化過程,session和cookie的關系已經很了解了吧,下面開始進行Session共享實現(xiàn)方案。
Session共享實現(xiàn)方案
一.StateServer方式
這種是asp.net提供的一種方式,還有一種是SQLServer方式(不一定程序使用的是SQLServer數據庫,所以通用性不高,這里就不介紹了)。也就是將會話數據存儲到單獨的內存緩沖區(qū)中,再由單獨一臺機器上運行的Windows服務來控制這個緩沖區(qū)。狀態(tài)服務全稱是“ASP.NET State Service ”(aspnet_state.exe)。它由Web.config文件中的stateConnectionString屬性來配置。該屬性指定了服務所在的服務器,以及要監(jiān)視的端口。
- <sessionState mode="StateServer" stateConnectionString="tcpip=127.0.0.1:42424" cookieless="false" timeout="20" />
在這個例子中,狀態(tài)服務在當前機器的42424端口(默認端口)運行。要在服務器上改變端口和開啟遠程服務器的該功能,可編輯HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\services\aspnet_state\Parameters注冊表項中的Port值和AllowRemoteConnection修改成1。 顯然,使用狀態(tài)服務的優(yōu)點在于進程隔離,并可在多站點中共享。 使用這種模式,會話狀態(tài)的存儲將不依賴于iis進程的失敗或者重啟,然而,一旦狀態(tài)服務中止,所有會話數據都會丟失(這個問題redis不會存在,重新了數據不會丟失)。
這里提供一段bat文件幫助修改注冊表,可以復制保存為.bat文件執(zhí)行
- 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實現(xiàn)session共享
下面我們將使用redis來實現(xiàn)共享,首先要弄清楚session的幾個關鍵點,過期時間,SessionId,一個SessionId里面會存在多組key/value數據?;谶@個特性我將采用Hash結構來存儲,看看代碼實現(xiàn)。用到了上一篇提供的RedisBase幫助類。
- 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";
- //
- // 摘要:
- // 獲取會話狀態(tài)集合中的項數。
- //
- // 返回結果:
- // 集合中的項數。
- public int Count
- {
- get
- {
- return RedisBase.Hash_GetCount(SessionID);
- }
- }
- //
- // 摘要:
- // 獲取一個值,該值指示會話是否為只讀。
- //
- // 返回結果:
- // 如果會話為只讀,則為 true;否則為 false。
- public bool IsReadOnly { get; set; }
- //
- // 摘要:
- // 獲取會話的唯一標識符。
- //
- // 返回結果:
- // 唯一會話標識符。
- public string SessionID
- {
- get
- {
- return GetSessionID();
- }
- }
- //
- // 摘要:
- // 獲取并設置在會話狀態(tài)提供程序終止會話之前各請求之間所允許的時間(以分鐘為單位)。
- //
- // 返回結果:
- // 超時期限(以分鐘為單位)。
- 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:
- // 會話值的鍵名。
- //
- // 返回結果:
- // 具有指定名稱的會話狀態(tài)值;如果該項不存在,則為 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);
- }
- //
- // 摘要:
- // 向會話狀態(tài)集合添加一個新項。
- //
- // 參數:
- // name:
- // 要添加到會話狀態(tài)集合的項的名稱。
- //
- // value:
- // 要添加到會話狀態(tài)集合的項的值。
- public void Add(string name, object value)
- {
- RedisBase.Hash_Set<object>(SessionID, name, value);
- }
- //
- // 摘要:
- // 從會話狀態(tài)集合中移除所有的鍵和值。
- public void Clear()
- {
- RedisBase.Hash_Remove(SessionID);
- }
- //
- // 摘要:
- // 刪除會話狀態(tài)集合中的項。
- //
- // 參數:
- // name:
- // 要從會話狀態(tài)集合中刪除的項的名稱。
- public void Remove(string name)
- {
- RedisBase.Hash_Remove(SessionID,name);
- }
- //
- // 摘要:
- // 從會話狀態(tài)集合中移除所有的鍵和值。
- public void RemoveAll()
- {
- Clear();
- }
- }
- }
下面是實現(xiàn)類似在cs文件中能直接使用Session["UserId"]的方式,我的MyPage類繼承Page實現(xiàn)了自己的邏輯主要做了兩件事 1:初始化RedisSession 2:實現(xiàn)統(tǒng)一登錄認證,OnPreInit方法里面判斷用戶是否登錄,如果沒有登錄了則跳轉到登陸界面
- using System;
- using System.Collections.Generic;
- using System.Linq;
- using System.Web;
- using System.Web.UI;
- namespace ResidSessionDemo.RedisDemo
- {
- /// <summary>
- /// 自定義Page 實現(xiàn)以下功能
- /// 1.初始化RedisSession
- /// 2.實現(xiàn)頁面登錄驗證,繼承此類,則可以實現(xiàn)所有頁面的登錄驗證
- /// </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的,至此我們實現(xiàn)了和Asp.netSession一模一樣的功能和使用方式。
- RedisSession.Remove("UserCode");
相比StateServer,RedisSession具有以下優(yōu)點
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實現(xiàn)負載均衡這篇文章。我們來看一下測試結果。
訪問127.0.0.1:8003 需要進行登錄 用戶名為admin 密碼為123

登錄成功以后,重點關注端口號信息

刷新頁面,重點關注端口號信息

可以嘗試直接訪問iis1地址127.0.0.1:8002 iis2地址127.0.0.1:9000 這兩個站點,你會發(fā)現(xiàn)都不需要登錄了。至此我們的redis實現(xiàn)session功能算是大功告成了。
回到頂部
問題拓展
使用redis實現(xiàn)session告一段落,下面留個問題討論一下方案。微信開發(fā)提供了很多接口,參考下面截圖,可以看到獲取access_token接口每日最多調用2000次,現(xiàn)在大公司提供的很多接口針對不對級別的用戶接口訪問次數限制都是不一樣的,至于做這個限制的原因應該是防止惡意攻擊和流量限制之類的。那么我的問題是怎么實現(xiàn)這個接口調用次數限制功能。大家可以發(fā)揮想象力參與討論哦,或許你也會碰到這個問題。

先說下我知道的兩種方案:
1.使用流量整形中的令牌桶算法,大小固定的令牌桶可自行以恒定的速率源源不斷地產生令牌。如果令牌不被消耗,或者被消耗的速度小于產生的速度,令牌就會不斷地增多,直到把桶填滿。后面再產生的令牌就會從桶中溢出。最后桶中可以保存的最大令牌數永遠不會超過桶的大小。
說淺顯點:比如上面的獲取access_token接口,一天2000次的頻率,即1次/分鐘。我們令牌桶容量為2000,可以使用redis 最簡單的key/value來存儲 ,key為用戶id,value為整形存儲還可使用次數,然后使用一個定時器1分鐘調用client.Incr(key) 實現(xiàn)次數自增;用戶每訪問一次該接口,相應的client.Decr(key)來減少使用次數。
但是這里存在一個性能問題,這僅僅是針對一個用戶來說,假設有10萬個用戶,怎么使用定時器來實現(xiàn)這個自增操作呢,難道是循環(huán)10萬次分別調用client.Incr(key)嗎?這一點沒有考慮清楚。
2.直接用戶訪問一次 先進行總次數判斷,符合條件再就進行一次自增
兩種方案優(yōu)缺點比較 | ||
優(yōu)點 | 缺點 | |
令牌桶算法 | 流量控制精確 | 實現(xiàn)復雜,并且由于控制精確反而在實際應用中有麻煩,很可能用戶在晚上到凌晨期間訪問接口次數不多,白天訪問次數多些。 |
簡單算法 | 實現(xiàn)簡單可行,效率高 | 流量控制不精確 |
總結
本篇從實際應用講解了redis,后面應該還會有幾篇繼續(xù)介紹redis實際應用,敬請期待!
本篇文章用到的資源打包下載地址:redis_demo
svn下載地址:http://code.taobao.org/svn/ResidSessionDemo/