上週我才意外發現:古老的 Session 不只會害 ASP.NET WebForm 大排長龍,就連 ASP.NET MVC Controller 也難逃魔掌,對 AJAX 網站效能的殺傷力直逼 BOSS 等級!

Session 是 ASP 時代就存在的活化石,允許每個工作階段有自己專屬的資料存放空間,不必費心規劃參數傳遞方式,在任一 ASPX 塞入資料,中間不管使用者歷經多少網頁做過多少事,只要有需要,在任何網頁呼叫 Session["…"],資料就回來了。由於它無腦直覺又好寫,深受開發新手喜愛,成為許多 ASP/ASP.NET 開發人員鍾愛並廣泛使用的資料傳遞管道,因此也就不難理解,十多年來歷經 ASP、ASP.NET 1.1/2.0/3.5/4.0/4.5 一路演進到 ASP.NET MVC,它一直都在。

雖然到處可見,從程式架構的角度,Session 卻不是好東西,至少存在以下缺點:(我的觀察啦,歡迎 Session 系同學補充)

  • Session 具備全域變數性質,生命周期與使用範圍難以管理,常常使用完畢仍繼續佔用記憶體,另外也不利單元測試
  • Session["…"] 非強型別,無法靠 Visual Studio 快速追蹤讀取及寫入來源,追查問題不易,更名重構的難度也較高
  • In-Process Session 被保存於特定主機的記憶體,即便 WebFarm 有多台主機,限定該工作階段後續 Request 要由同一主機處理,不利於負載平衡最佳化。(負載平衡的最高境界要做到由 Web Server A 取得網頁 UI,按鈕送出時改由 Web Server B 處理也 OK)
  • 最後且最致命的一點,就是先前文章點出的 Session 預設互斥鎖定行為,導致所有用到 Session 的 ASPX 或 MVC Action 必須排成一列逐一執行,在 AJAX 模式中嚴重傷害效能

那麼,如果不用 Session,有什麼替代方案能避開上述缺點而達到類似效果?

  1. 要避免全域變數難以管理、易浪費記憶體,最簡單的做法是將狀態資訊透過呼叫函式、方法參數傳遞。如此,變數及物件的生命周期與範圍明確,傳送軌跡清晰,易於偵錯,單元測試也好寫許多。
  2. 在一些流程動線複雜的情境裡,要貫徹只用參數傳遞資訊往往需要堅定信仰與強大心理素質,並不容易。例如,A呼叫B、B呼叫C、C呼叫D、D再呼叫E,A要將資訊交給D,就得在 B、C、D、E 呼叫介面都加上該狀態參數並層層傳遞,程式碼光想就覺得噁心。因此很多時候,適度依賴「具有全域性質的狀態保存機制」可讓程式架構簡化,在 Web 開發領域,Cookie 是首選!
    但 Cookie 只適合儲存單純字串,由於會每個 Request Header 都會夾帶,長度愈短愈好。實務上,常見做法是為工作階段產生唯一的識別字串,真正的狀態資訊則保存在伺服器端(MemoryCache、資料庫… 等),當需要更新或讀取狀態,以識別字串為憑取出資料物件(存入資料庫的話還需序列化及反序列化), ASP.NET Session 就是用同樣原理實作而成。
  3. 要實作「以Cookie 為憑存取伺服器端物件」,MemoryCache 是最簡便的選擇,MemoryCache 可以指定到期時間或多久沒存取自動清除,能大幅減少耗用不必要的記憶體佔用,其本質跟 Session 一樣,可以用來保存各式狀態資訊或物件。(稍後的實作範例有更多細節)
  4. 將狀態資訊轉為類別的屬性值保存於 MemoryCache,有助於改善 Session["…"] 非強型別難以追蹤的缺點,例如以下程式示意:
    排版顯示純文字
        public class SessionInfo
        {
            public static UserProfile Profile
            {
                get
                {
                    return 資料保存機制.Read<UserProfile>();
                }
                set
                {
                    資料保存機制.Save<UserProfile>(value);
                }
            }
        }
  5. MemoryCache 固然簡便,但保存在記憶體將侷限 WebFarm 主機的調度彈性,遇到當機重開將導致工作階段資料遺失,要克服這點,得改用資料庫或獨立伺服器保存資料。Session 的強大之處也在於它已考慮到這一層,提供將 Session 資料保存在 SQL Server 或是 StateServer 的選項。依此要領,要自幹類似機制並非不可能,但複雜度不低,且需留意效能,超出本文討論範圍甚多,就此打住。
  6. 據我了解,有不少開發者使用 Session 從頭到尾只用於保存使用者身分,為此忍受 Session 獨佔鎖定的副作用有點不值得。如為此種情境,可考慮改用前述的 Cookie + MemoryCache 概念、ASP.NET Membership 機制,甚至最新的 ASP.NET Identity。為了 Session 改換認證底層工程是浩大了點,但導入新機制可獲得額外整合彈性與安全強化,應一併納入投資報酬率評估。

說了這麼多,相信不少開發者心中不免犯滴咕:「這堆有的沒的我懂,但我只想避免 Session 獨佔鎖定讓 ASP.NET 變蝸牛,完全不想為此大興土木啊啊啊啊」

如果線上網站已運轉十多年,雖然把 Session 當全域變數用架構很醜,但它頭好壯壯日進斗金。Session 鎖定是問題,但為此異動架構帶來風險,未必是明智之舉。

有沒有不用開腸破肚,只鎖定 Session 切除的微創手術?

這也是我在工作專案中遇到的實際挑戰-如何用最小幅度修改避免 Session 帶來的效能衝擊?

下是我的解法-UnobstrusiveSession(低調風 Session),用法與 Session 幾乎完全一樣,差別在於它的鎖定僅限於資料讀寫的短暫期間,不影響 ASP.NET 程式的並行性。

UnobstrusiveSession 核心程式如下,不到 80 行,其原理如先前所提,用 Cookie 保存工作階段識別碼(用 GUID 保證不重複),以 Cookie 為憑存取實際儲存於 MemoryCache 的資料,Cache 保存政策則比照 Session 設為 20 分鐘不存取自動清除:

排版顯示純文字
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Caching;
using System.Web;
 
public static class UnobtrusiveSession
{
    static HttpContext CurrContext
    {
        get
        {
            if (HttpContext.Current == null)
                throw new ApplicationException("HttpContext.Current is null");
            return HttpContext.Current;
        }
    }
    const string COOKIE_KEY = "UnobtrusiveSessionId";
    public static string SessionId
    {
        get
        {
            var cookie = CurrContext.Request.Cookies[COOKIE_KEY];
            if (cookie != null) return cookie.Value;
            //set session id cookie
            var sessId = Guid.NewGuid().ToString();
            CurrContext.Response.SetCookie(new HttpCookie(COOKIE_KEY, sessId));
            return sessId;
        }
    }
    public static SessionObject Session
    {
        get
        {
            var cache = MemoryCache.Default;
            var sessId = SessionId;
            if (!cache.Contains(sessId))
            {
                cache.Add(sessId, new SessionObject(sessId), new CacheItemPolicy()
                {
                    SlidingExpiration = TimeSpan.FromMinutes(20)
                });
            }
            return (SessionObject)cache[sessId];
        }
    }
 
    public class SessionObject
    {
        public string SessionId;
        Dictionary<string, object> items =
            new Dictionary<string, object>();
        public SessionObject(string sessId)
        {
            SessionId = sessId;
        }
        public object this[string key]
        {
            get
            {
                lock (items)
                {
                    if (items.ContainsKey(key)) return items[key];
                    return null;
                }
            }
            set
            {
                lock (items)
                {
                    items[key] = value;
                }
            }
        }
 
    }
}

使用時,只需將 Session["…"] 改寫成 UnobstrusiveSession.Session["…"] 即可,其餘都不用修改。我寫了一支測試網頁:

排版顯示純文字
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="WebForm.aspx.cs" Inherits="WebNoSession.WebForm" %>
 
<!DOCTYPE html>
 
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Session Lab</title>
    <style>
        div {
            font-size: 9pt;
            margin: 6px;
        }
    </style>
</head>
<body>
    <form id="form1" runat="server">
        <div>
            SessionId=<%= UnobtrusiveSession.Session.SessionId %>
        </div>
        <div>
            Session["Data"]=<%=UnobtrusiveSession.Session["Data"] %>
            <br />
        </div>
        <div>
            Session["Data"]: <asp:TextBox runat="server" ID="txtData" Width="80px"></asp:TextBox>
            <asp:Button runat="server" ID="btnSet" Text="Save" OnClick="btnSet_Click" />
            <asp:Button runat="server" ID="btnRefresh" Text="Refresh" />
        </div>
    </form>
</body>
</html>

Server 端:

排版顯示純文字
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
 
namespace WebNoSession
{
    public partial class WebForm : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            if (!IsPostBack)
            {
                txtData.Text = (string)UnobtrusiveSession.Session["Data"];
            }
        }
 
        protected void btnSet_Click(object sender, EventArgs e)
        {
            UnobtrusiveSession.Session["Data"] = txtData.Text;
        }
    }
}

如下圖所示,我開了 Chrome、Chrome 無痕視窗、IE,形成三個獨立的工作階段,各自擁有自己的 Session["Data"]。

補充幾點:

UnobstrusiveSession 使用 MemoryCache 保存資料,特性與 In-Process Session 相同(重啟或切換 Web Server 會遺失工作階段資料),Cache 部分採取 20 分鐘沒存取任何 Session 內資料就將所有 Session 資料清空的策略,與 ASP.NET Session 只要存取 ASPX 不一定要讀寫 Session 都會保留的策略不同。如果沒有每支 ASPX 都讀取使用 Session,20 分鐘後資料就會遺失,如要改善,可設定 MasterPage 或 Application_BeginRequest 持續讀寫 Session 避免資料被移除。

另外要強調一點,除了不會因鎖定機制重創 AJAX ASPX 效能,Session 的其他缺點 UnobtrusiveSession 一個都不少(全域變數、非強型別、不利負載平衡最佳化),其目的在力求以最低成本換掉 Session 解決鎖定問題,如果環境允許,建議調整系統架構避免使用 Session 這類機制才是上策。

2017-06-13 補充

感謝 ChrisTorng 補充,網路上可以找到一些沒有獨佔鎖定行為的非官方版本 SessionStateModule,透過 web.config 抽換掉 ASP.NET 內建 SessionStateModule ,程式完全不用修改就能避開 Session 鎖定效應,比起用 UnobstrusiveSession 更優雅。這篇 Stackoverflow 討論有整理一些可用選項,值得參考。

排版顯示純文字
 <system.webServer>
    <modules>
      <remove name="Session" />
      <add name="Session" type="自訂 SessionStateModule 之組件型別字串 (Assembly Qualified Name)" />      
    </modules>
  </system.webServer>

Comments

# by y

Server key 相同的話另外一台主機應該也可以解 session 出來吧?

# by Jeffrey

to y, 不是很懂 Server Key 與另一台解 Session 的意義,可否提供更詳細一點的說明?

# by Richie

黑大, 有一個問題想請教您, 我目前在做 WebAPI 的時候, 會在進入每一道Controller時, Set Login User ID 在 Session 中, 以利後面的Service叫用, 而且會想採用這方式就像您說的“A呼叫B、B呼叫C、C呼叫D、D再呼叫E,A要將資訊交給D,就得在 B、C、D、E 呼叫介面都加上該狀態參數並層層傳遞,程式碼光想就覺得噁心。”, 但我所儲存的Session無需帶回前端(前端為angular架構), 請教在這種情況下, 是否建議用您這個方法? 還是有什麼建議?謝謝!

# by Richie

to y, 您說的應該是指後面有2台主機(A/B), 前面可能有個L4 switch, 然後在A主機儲存一個Session["Data"], B主機能否解出Session["Data"]的值, 是嗎?就我的了解, 以MemoryCache的方式應該是沒辦法的, Session之所以可以達到此效果, 應該是利用黑大在文章中所提到的“Session 資料保存在 SQL Server 或是 StateServer 的選項”才能達到A/B主機吃到同一組Session

# by Jeffrey

to Richie, 將使用者身分存入伺服器端,後續任一WebAPI呼叫由由伺服器端資料識別使用者是很簡潔且常見的設計方式,前端的識別用Cookie幾為共識,伺服器端則有多種選擇,Session為系統內建,用來很無腦,但獨佔鎖定造成效能問題是重大缺陷,個人認為MemoryCache實作簡單,是我優先考量改用它的理由。

# by Tom

Y 應該是把session跟viewstate之間搞混了吧 Viewstate才有跨server反解的問題

# by 詹姆士爸爸

ASP. NET對於Request在存取Session使用佇列來確保存取的順序,我覺得要看使用者故事怎麼使用它,以購物車為例好了,購物車內容在結帳前先暫存在Session裡,假設使用者的動作為加入商品,顯示購物車內容,清空購物車內容,而執行速度為清空快於加入快於顯示,那以佇列來看,預期的結果為購物車是空的,但如果不用佇列來確保執行順序,最後的結果可能為空的購物車,也有可能為有一個商品的購物車,端看哪個動作先鎖定了購物車的容器 所以要從使用者故事來看,或許比較適合判斷Session所附帶的佇列效應是好或不好,如果可以預期當下Request進去的page/action不會修改到session的內容,可以試著把session state設定成read-only模式,這樣就能讓該Request跳脫佇列的限制,進而達到效能最大化,至於實作的部份,Google上已經有很多大神都提出了做法,我就不在這獻醜了,哈哈

# by wellxion

To Jeffrey Y說的應該是用machine key設定共享Session機制吧

# by Tom

wellxion 沒有這種東西吧

# by Jeffrey

to ChrisTorng, 倒沒想過自訂Session State Module,過去寫過不少 Cookie Token 對應 Server 端資料的機制,遇到這個問題沒爬太多文,憑直覺寫幾行程式把問題解了。 抽換 Session State Module 的做法完全不改程式,明顯更優雅,已補充於本文,感謝。

# by y

回完忘記回來看這篇... @wellxion 對我就是說這個

# by ChrisTorng

我的需求,並不需要以最少修改達成目標,我願意多寫一些程式,換取更佳效率。 仔細考慮過後,我認為抽換 Session State Module 還是有太多包袱,還是以您的類別簡單明瞭又快速,但我認為效能上還可以再提升。 我為何要採用 Dictionary? 何不改用 Strong Type? 因此我改寫成泛型版本 (另加縮短 GUID 字串編碼,以及建立物件時不必再查 cache (return sessionObject)): using System; using System.Runtime.Caching; using System.Web; namespace SessionTest.Models { public static class FastSession<T> where T : class, new() { private const string COOKIE_KEY = "FastSessionId"; private const int SessionTimeoutMinutes = 20; private static HttpContext CurrentContext { get { if (HttpContext.Current == null) { throw new ApplicationException("HttpContext.Current is null."); } return HttpContext.Current; } } private static string NewShortGuid() { return Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-').Substring(0, 22); } private static string SessionId { get { HttpCookie cookie = CurrentContext.Request.Cookies[COOKIE_KEY]; if (cookie != null) { return cookie.Value; } string sessionId = NewShortGuid(); CurrentContext.Response.SetCookie(new HttpCookie(COOKIE_KEY, sessionId)); return sessionId; } } public static T Session { get { MemoryCache cache = MemoryCache.Default; string sessionId = SessionId; if (!cache.Contains(sessionId)) { T sessionObject = new T(); cache.Add(sessionId, sessionObject, new CacheItemPolicy() { SlidingExpiration = TimeSpan.FromMinutes(SessionTimeoutMinutes) }); return sessionObject; } return cache[sessionId] as T; } } } public class TypedSessionObject { public string StringValue; public int IntValue; } } 如下使用: using FastSession = SessionTest.Models.FastSession<SessionTest.Models.TypedSessionObject>; string s = FastSession.Session.StringValue; int i = FastSession.Session.IntValue; 至此,我發現新問題: 為何我的版本用不上 lock??? 如果真的不用 lock,那效能又提升了。 另外,我的版本我覺得還有一個缺失,就是我希望建立 T() 時能有預設值 (也就是自訂建構式參數),而不是先建立全空物件再一個個屬性填值。但還沒想到好方法。這也犧牲了 TypedSessionObject 中取得 SessionId 的能力 (除非要求實作 interface,我覺得麻煩沒必要)。 如果一個個屬性填值的話,代表填值步驟中若另有讀取要求,可能會讀到半成品...因此是否代表 lock 其實是要整段寫入包成一整個 lock,而不是一個個值各別 lock...回頭來看原始 Session 的 lock 機制的確是在容易使用 (不必自己寫 lock) 的前提下保證了資料的完整性...

# by roger

請問怎麼判斷是session lock造成網站效能緩慢的問題呢?謝謝

# by Jeffrey

to roger,文章開頭的兩篇文章連結有實例,Session Lock 要導致問題有一些前題,最常見的案例是同一使用者同時發出多個 AJAX Request,第一個執行完才跑第二個、第二個跑完才跑第三個... 多個 AJAX Request 無法同時執行。網站上多個使用者間不會互卡(Session 不同),純用 Postback 一次一個 Request 也不會受影響。

Post a comment