上週我才意外發現:古老的 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,有什麼替代方案能避開上述缺點而達到類似效果?
- 要避免全域變數難以管理、易浪費記憶體,最簡單的做法是將狀態資訊透過呼叫函式、方法參數傳遞。如此,變數及物件的生命周期與範圍明確,傳送軌跡清晰,易於偵錯,單元測試也好寫許多。
- 在一些流程動線複雜的情境裡,要貫徹只用參數傳遞資訊往往需要堅定信仰與強大心理素質,並不容易。例如,A呼叫B、B呼叫C、C呼叫D、D再呼叫E,A要將資訊交給D,就得在 B、C、D、E 呼叫介面都加上該狀態參數並層層傳遞,程式碼光想就覺得噁心。因此很多時候,適度依賴「具有全域性質的狀態保存機制」可讓程式架構簡化,在 Web 開發領域,Cookie 是首選!
但 Cookie 只適合儲存單純字串,由於會每個 Request Header 都會夾帶,長度愈短愈好。實務上,常見做法是為工作階段產生唯一的識別字串,真正的狀態資訊則保存在伺服器端(MemoryCache、資料庫… 等),當需要更新或讀取狀態,以識別字串為憑取出資料物件(存入資料庫的話還需序列化及反序列化), ASP.NET Session 就是用同樣原理實作而成。 - 要實作「以Cookie 為憑存取伺服器端物件」,MemoryCache 是最簡便的選擇,MemoryCache 可以指定到期時間或多久沒存取自動清除,能大幅減少耗用不必要的記憶體佔用,其本質跟 Session 一樣,可以用來保存各式狀態資訊或物件。(稍後的實作範例有更多細節)
- 將狀態資訊轉為類別的屬性值保存於 MemoryCache,有助於改善 Session["…"] 非強型別難以追蹤的缺點,例如以下程式示意:
publicclass SessionInfo
{
publicstatic UserProfile Profile
{
get
{
return資料保存機制.Read<UserProfile>();
}
set
{
資料保存機制.Save<UserProfile>(value);
}
}
}
- MemoryCache 固然簡便,但保存在記憶體將侷限 WebFarm 主機的調度彈性,遇到當機重開將導致工作階段資料遺失,要克服這點,得改用資料庫或獨立伺服器保存資料。Session 的強大之處也在於它已考慮到這一層,提供將 Session 資料保存在 SQL Server或是 StateServer的選項。依此要領,要自幹類似機制並非不可能,但複雜度不低,且需留意效能,超出本文討論範圍甚多,就此打住。
- 據我了解,有不少開發者使用 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;
publicstaticclass UnobtrusiveSession
{
static HttpContext CurrContext
{
get
{
if (HttpContext.Current == null)
thrownew ApplicationException("HttpContext.Current is null");
return HttpContext.Current;
}
}
conststring COOKIE_KEY = "UnobtrusiveSessionId";
publicstaticstring 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;
}
}
publicstatic 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];
}
}
publicclass SessionObject
{
publicstring SessionId;
Dictionary<string, object> items =
new Dictionary<string, object>();
public SessionObject(string sessId)
{
SessionId = sessId;
}
publicobjectthis[string key]
{
get
{
lock (items)
{
if (items.ContainsKey(key)) return items[key];
returnnull;
}
}
set
{
lock (items)
{
items[key] = value;
}
}
}
}
}
使用時,只需將 Session["…"] 改寫成 UnobstrusiveSession.Session["…"] 即可,其餘都不用修改。我寫了一支測試網頁:
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="WebForm.aspx.cs" Inherits="WebNoSession.WebForm" %>
<!DOCTYPEhtml>
<htmlxmlns="http://www.w3.org/1999/xhtml">
<headrunat="server">
<title>Session Lab</title>
<style>
div {
font-size: 9pt;
margin: 6px;
}
</style>
</head>
<body>
<formid="form1"runat="server">
<div>
SessionId=<%= UnobtrusiveSession.Session.SessionId %>
</div>
<div>
Session["Data"]=<%=UnobtrusiveSession.Session["Data"] %>
<br/>
</div>
<div>
Session["Data"]: <asp:TextBoxrunat="server"ID="txtData"Width="80px"></asp:TextBox>
<asp:Buttonrunat="server"ID="btnSet"Text="Save"OnClick="btnSet_Click"/>
<asp:Buttonrunat="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
{
publicpartialclass WebForm : System.Web.UI.Page
{
protectedvoid Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
txtData.Text = (string)UnobtrusiveSession.Session["Data"];
}
}
protectedvoid 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 這類機制才是上策。