前兩週做的一個 Web 應用系統項目中,遇到了一個由於跨頁面狀態傳遞機制設計不合理,造成內存泄露的小問題 。有這裏做以記錄,歡迎大家一同探討,同時在本文的後面探討了解決方案,並詳細探討了一個自定義 Session 實現並提供了完整代碼 。
閉話少絮,描述問題請先看圖。
上面的序列圖中描述的一個這樣特點的業務:
- 對於客戶端用戶來講,該業務由二個頁面組成。首先在用戶頁面1中填寫表單並提交到服務器,然後 Web 應用會根據頁面1提交的內容返回業頁面2供用戶填寫,在完成表單後由用戶提交,此時應用顯示成功頁完成該業務。
- 對於服務端的 Web 應用來講,頁面1後臺代碼有3步操作,頁面2後臺代碼涉及4步操作。對於頁面1來講,首先從 Oracle 加裁數據,然後由業務代碼完成處理,最後將處理後的“中間”結果(相對整個業務)保存在 Session 中。對於頁面2來講,在用戶提交後,首先從 Session 中取出上面步驟保存的臨時“中間”結果,然後由業務代碼完成相關處理,接下來會持久化一些數據到 Oracle,最後是清除 Session 中此業務所涉及的數據。
- 可以看到,該業務涉及到一次頁面狀態傳遞,並通過 Session 來保持。
- 上面圖中紅色所標記的調用是這裏的重點,首先狀態是由1.3方法存入 Session 的,然後在3.1方法中被使用,並最終由3.4方法將狀態數據從 Session 中清除,釋放內存(這裏所釋放的是 Heap 中的對象引用)。
相信明眼人已經看出來了,這個設計由於一些原因將一項業務分解到二個頁中完成,就必然涉及到一次狀態傳遞,因此一旦用戶在完成頁面1但又不提交頁面2(即二步操作被切斷,業務終止),則由頁面1保存的狀態數據就不會被釋放(即方法3.4不會“如期”執行)。由於這裏的方案是採用了 Session 作爲狀態數據容器,所以這些無用的對象最終會在 Session 過期後由後臺守護線程所清除。但是,這裏又有了另外的一個問題,也是我真正所要說的,Session 中對象過期是有時間的,一般都在幾十分鐘,往往默認都在20、30分鐘,有的可能更長。那麼結合到上述 Web 應用的結果的,只要在這幾十分鐘的 Session 有效期內、只訪問到該業務頁面1的併發用戶壓力足夠大、同時保存到 Session 中的狀態數據(由方法1.3存入)佔用的內存足夠大(往往都不小),就會使內存溢出。結果就是性能逐步下降,最終導致 core dump 的發生。
接下來討論一下可行的解決方案。實際上替代的方案真的不少,可以大致羅列一下:
- 通過頁面來傳遞狀態數據。包括最簡單的使用查詢字符串推送狀態數據,這種方式很常見。其次,可以像 ASP.NET 中常見的 ViewState 所使用的在頁面的表單中添加隱藏域的方式來推送狀態數據,這種方式相對安全得多,而且往往這些隱藏域中的狀態數據會經過加密。缺點是如果業務對安全性要求較高的話,一般不會使用從客戶端提交回的狀態數據,而會更加傾向於從服務端重新獲得。
- 使用獨立的專門用於存放狀態數據的緩存服務器,memcache 也許是首選。缺點是快速驗證的成本較大,如果想在已經上線的生產系統中快速修復這類問題,恐怕不太容易獲得資源。
- 第三種是我個人比較喜歡的方式,覺得它比較容易快速驗證、解決問題,成本也相對最小。實際上述問題本質上就是需要一種過期時間短同時生命週期也較短的容器對象,這類容器對象應該足夠輕量且構造方便。
下面的代碼所描述的就是這樣一個容器對象,Java 平臺的兄弟們看個意思吧。
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace com.lzy.javaeye { public interface ISessionId<T> { T Value { get; set; } } }
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace com.lzy.javaeye { public interface ISessionEntry<T> { ISessionId<T> SessionId { get; set; } DateTime LastAccessTime { get; set; } } }
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace com.lzy.javaeye { public interface ISession<T, U> where T : ISessionEntry<U> { IList<T> Entries(); void SetData(string key, object val, ISessionId<U> sessionId); object GetData(string key, ISessionId<U> sessionId); T this[ISessionId<U> sessionId] { get; } void Register(ref T newEntry); bool Unregister(ISessionId<U> sessionId); bool IsOnline(ISessionId<U> sessionId); void PrepareForDispose(); bool UpdateLastAccessTime(ISessionId<U> sessionId); SessionIdExpiresPolicy SessionIdExpiredPolicy { get; set; } event SessionEntryTimeoutDelegate<T, U> EntryTimeout; } public delegate void SessionEntryTimeoutDelegate<T, U>(T sessionEntry) where T : ISessionEntry<U>; }
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace com.lzy.javaeye { [Serializable] public class SessionLongId : ISessionId<long> { public SessionLongId() { this.Value = default(long); } #region ISessionId<long> Members public long Value { get; set; } #endregion public override bool Equals(object obj) { if (obj.GetType().Equals(this.GetType())) return this.Value == ((SessionLongId)obj).Value; else return obj.Equals(this); } public override int GetHashCode() { int i = this.Value.GetHashCode(); return i; } } }
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace com.lzy.javaeye { public class SessionEntry<T> : ISessionEntry<T> { public SessionEntry(ISessionId<T> sessionId) { this.SessionId = sessionId; } #region ISessionEntry<T> Members public ISessionId<T> SessionId { get; set; } public DateTime LastAccessTime { get; set; } #endregion } }
using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; namespace com.lzy.javaeye { public class Session<T> : ISession<T, long> where T : ISessionEntry<long> { // Timeout event. public event SessionEntryTimeoutDelegate<T, long> EntryTimeout = null; // Storage collections. private IList<T> activeEntries = new List<T>(); private IList<T> expiredEntries = new List<T>(); private Dictionary<ISessionId<long>, Hashtable> data = new Dictionary<ISessionId<long>, Hashtable>(); // 'FollowSessionEntry' policy is safe, but 'Never' is simple. SessionIdExpiresPolicy sessionIdExpiredPolicy = SessionIdExpiresPolicy.Never; // Threading private Thread cleaner = null; private ReaderWriterLockSlim slimLock = new ReaderWriterLockSlim(); private volatile bool running = true; private T GetEntryById(ISessionId<long> sessionId, out int posInActiveEntries) { T outputEntry = default(T); posInActiveEntries = -1; for (int idx = 0; idx < this.activeEntries.Count; idx++) { if (this.activeEntries[idx].SessionId.Equals(sessionId)) { outputEntry = this.activeEntries[idx]; posInActiveEntries = idx; break; } } return outputEntry; } public Session(int sessionTimeoutInMinutes) { this.cleaner = new Thread(new ParameterizedThreadStart(this.ClearExpired)); this.cleaner.IsBackground = true; this.cleaner.Start(sessionTimeoutInMinutes); } public T this[ISessionId<long> sessionId] { get { this.slimLock.EnterReadLock(); T outputEntry = default(T); int posInActiveEntries = -1; try { outputEntry = this.GetEntryById(sessionId, out posInActiveEntries); } finally { slimLock.ExitReadLock(); } if (posInActiveEntries == -1) throw new SessionIdExpiresException<ISessionId<long>, long>(sessionId); return outputEntry; } } public void Register(ref T newEntry) { this.slimLock.EnterWriteLock(); try { newEntry.LastAccessTime = DateTime.Now; // Support 'Never' session Id expires policy. if (newEntry.SessionId.Value == default(long)) newEntry.SessionId.Value = newEntry.LastAccessTime.ToBinary(); this.activeEntries.Add(newEntry); } finally { this.slimLock.ExitWriteLock(); } } public bool Unregister(ISessionId<long> sessionId) { this.slimLock.EnterWriteLock(); bool result = false; try { int posInActiveEntries = -1; this.GetEntryById(sessionId, out posInActiveEntries); if (posInActiveEntries != -1) { this.data.Remove(sessionId); this.activeEntries.RemoveAt(posInActiveEntries); result = true; } } finally { this.slimLock.ExitWriteLock(); } return result; } public bool UpdateLastAccessTime(ISessionId<long> sessionId) { this.slimLock.EnterWriteLock(); bool result = false; try { int posInActiveEntries = -1; T entry = this.GetEntryById(sessionId, out posInActiveEntries); if (posInActiveEntries != -1) { entry.LastAccessTime = DateTime.Now; result = true; } } finally { this.slimLock.ExitWriteLock(); } return result; } public bool IsOnline(ISessionId<long> sessionId) { this.slimLock.EnterReadLock(); bool result = false; try { int posInActiveEntries = -1; this.GetEntryById(sessionId, out posInActiveEntries); if (posInActiveEntries != -1) result = true; } finally { this.slimLock.ExitReadLock(); } return result; } public IList<T> Entries() { this.slimLock.EnterReadLock(); try { return this.activeEntries; } finally { this.slimLock.ExitReadLock(); } } public void SetData(string key, object val, ISessionId<long> sessionId) { if (!this.IsOnline(sessionId)) { if (sessionIdExpiredPolicy == SessionIdExpiresPolicy.FollowSessionEntry) { throw new SessionIdExpiresException<ISessionId<long>, long>(sessionId); } else if (sessionIdExpiredPolicy == SessionIdExpiresPolicy.Never) { T entry = (T)(ISessionEntry<long>)(new SessionEntry<long>(sessionId)); this.Register(ref entry); } else { throw new NotSupportedException(); } } this.slimLock.EnterWriteLock(); try { Hashtable ht = null; if (this.data.ContainsKey(sessionId)) { ht = data[sessionId]; // Overwrite value if key exists. Actions like the ASP.NET session. if (ht.ContainsKey(key)) ht[key] = val; else ht.Add(key, val); this.data[sessionId] = ht; } else { ht = new Hashtable(); ht.Add(key, val); this.data.Add(sessionId, ht); } } finally { this.slimLock.ExitWriteLock(); } } public object GetData(string key, ISessionId<long> sessionId) { this.slimLock.EnterReadLock(); object result = null; try { if (this.data.ContainsKey(sessionId)) result = data[sessionId][key]; } finally { this.slimLock.ExitReadLock(); } return result; } public SessionIdExpiresPolicy SessionIdExpiredPolicy { get; set; } public void PrepareForDispose() { // Setup flag. this.running = false; // Wake up cleaner. if (this.cleaner.ThreadState == ThreadState.WaitSleepJoin) this.cleaner.Interrupt(); // Wait for the thread to stop for (int i = 0; i < 100; i++) { if ((this.cleaner == null) || (this.cleaner.ThreadState == ThreadState.Stopped)) { System.Diagnostics.Debug.WriteLine( "Cleaner has stopped after " + i * 100 + " milliseconds"); break; } Thread.Sleep(100); } // Prepare objects for GC. this.activeEntries.Clear(); this.activeEntries = null; this.expiredEntries.Clear(); this.expiredEntries = null; this.data.Clear(); this.data = null; } void ClearExpired(object sessionTimeout) { while (this.running) { this.slimLock.EnterUpgradeableReadLock(); try { // Process all active entries. for (int i = 0; i < this.activeEntries.Count; i++) { TimeSpan span = DateTime.Now - this.activeEntries[i].LastAccessTime; if (span.TotalMinutes >= Convert.ToDouble(sessionTimeout)) this.expiredEntries.Add(this.activeEntries[i]); } // Remove timeout entries. if (this.expiredEntries.Count > 0) { this.slimLock.EnterWriteLock(); try { foreach (T entry in this.expiredEntries) { System.Diagnostics.Debug.WriteLine(string.Format("Session {0} expired.", entry.SessionId.Value)); // Will slow down the thread. if (this.EntryTimeout != null) this.EntryTimeout(entry); this.data.Remove(entry.SessionId); this.activeEntries.Remove(entry); } this.expiredEntries.Clear(); } finally { this.slimLock.ExitWriteLock(); } } } finally { this.slimLock.ExitUpgradeableReadLock(); } // Sleep for 1 minute. (larger values will speed up the session) Thread.Sleep((int)TimeSpan.FromMinutes(1).TotalMilliseconds); } } } }
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace com.lzy.javaeye { public enum SessionIdExpiresPolicy { FollowSessionEntry, Never // Unsafe option. } }
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Security.Permissions; using System.Runtime.Serialization; using System.Runtime.Remoting; namespace com.lzy.javaeye { [Serializable] public class SessionIdExpiresException<T, U> : RemotingException where T : ISessionId<U> { protected SessionIdExpiresException(SerializationInfo info, StreamingContext context) : base(info, context) { this.SessionId = (T) info.GetValue("_SessionId", typeof(T)); } public SessionIdExpiresException(T sessionId) : base(string.Format("Session {0} expired.", sessionId.Value)) { if (sessionId.Value.Equals(default(U))) throw new ArgumentException(string.Format("Session Id value '{0}' is invalid.", sessionId.Value)); this.SessionId = sessionId; } public T SessionId { get; private set; } [SecurityPermissionAttribute(SecurityAction.Demand, SerializationFormatter = true)] public override void GetObjectData(SerializationInfo info, StreamingContext context) { base.GetObjectData(info, context); info.AddValue("_SessionId", this.SessionId, typeof(T)); } } }
上面的代碼實現了以下5個方面,分別包括功能契約接口和實現:
- SessionLongId(ISessionId)表示 Session 中存放對象的標識。
- SessionEntry(ISessionEntry)表示 Session 中存放的對象。
- Session(ISession)表示 Session 對象。
- SessionIdExpiresException 表示了某個 Session Id 過期異常。
- SessionIdExpiresPolicy 表示了 Session 存放的對象過期時對其處理策略。
代碼本身已經很簡單了,相信仔細看看理解起來是沒問題的,需要注意的是需要把放入 Session 中的對象繼承自 SessionEntry (或自定義實現的 ISessionEntry 類),就這點來說有些侵入的稍深了些,就看怎麼看待了。
寫到這裏必須要感謝 stefanprodan,Session provider for .NET Remoting and WCF ,原型代碼和思路原自他。
先到這裏吧,準備休息迎接2009年了,也正好以此貼紀念不平凡的2008年(感覺實際還是平凡度過的,呵呵)。預祝大家元旦快樂,在2009年都能心想事成,一切順利。
// 2009.03.07 13:30 添加 ////
作者:lzy.je
出處:http://lzy.iteye.com
本文版權歸作者所有,只允許以摘要和完整全文兩種形式轉載,不允許對文字進行裁剪。未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接,否則保留追究法律責任的權利。