分佈式中使用 Redis 實現 Session 共享(中)

http://blog.jobbole.com/91874/

上一篇介紹了一些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

svn下載地址:http://code.taobao.org/svn/ResidSessionDemo/

 1 贊  3 收藏  評論


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章