前两周做的一个 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
本文版权归作者所有,只允许以摘要和完整全文两种形式转载,不允许对文字进行裁剪。未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。