大多数具有一定重要性的 Web 应用程序都要求维护某种会话状态,如用户购物车的内容。如何在群集服务器应用程序中治理和复制状态对应用程序的可伸缩性有显著影响。许多 J2SE 和 J2EE 应用程序将状态存储在由 Servlet API 提供的 HttpSession 中。本文作者分析了状态复制的一些选项以及如何最有效地使用 HttpSession 以提供好的伸缩性和性能。
不管正在构建的是 J2EE 还是 J2SE 服务器应用程序,都有可能以某种方式使用 Java Servlet —— 可能是直接地通过像 jsp 技术、Velocity 或者 WebMacro 这样的表示层,也可能通过一个基于 servlet 的 Web 服务实现,如 Axis 或者 Glue。Servlet API 提供的一个最重要的功能是会话治理 —— 通过 HttpSession 接口进行用户状态的认证、失效和维护。
会话状态
几乎每一个 Web 应用程序都有一些会话状态,这些状态有可能像记住您是否已登录这么简单,也可能是您的会话的更具体的历史,如购物车的内容、以前查询结果的缓存或者 20 页动态问卷表的完整响应历史。因为 HTTP 协议本身是无状态的,所以需要将会话状态存储在某处并与浏览会话以某种方式相关联,使得下次请求同一 Web 应用程序的页面时可以轻易地获取。幸运的是,J2EE 提供了几种治理会话状态的方法 —— 状态可以存储在数据层,用 Servlet API 的 HttpSession 接口存储在 Web 层,用有状态会话 bean 存储在 Enterprise JavaBeans(EJB)层,甚至用 cookie 或者隐藏表单字段将状态存储在客户层。不幸的是,会话状态治理不当会带来严重的性能问题。
假如应用程序能够在 HttpSession 中存储用户状态,这种方法通常比其他方法更好。在客户端用 HTTP cookie 或者隐藏表单字段存储会话状态有很大的安全风险 —— 它将应用程序的一部分内部内容暴露给了非受信任的客户层。(一个早期的电子商务网站将购物车内容(包括价格)存储在隐藏表单字段中,从而可以很轻易被非法利用,让任何了解 Html 和 HTTP 的用户可以以 0.01 美元购买任何商品。噢)此外,使用 cookie 或者隐藏表单字段很混乱,轻易出错,并且脆弱(假如用户禁止在浏览器中使用 cookie,那么基于 cookie 的方法就完全不能工作)。
在 J2EE 应用程序中存储服务器端状态的其他方法是使用有状态会话 bean,或者在数据库中存储会话状态。虽然有状态会话 bean 在会话状态治理方面有更大的灵活性,但是在可能的情况下,将会话状态存储在 Web 层仍然有好处。假如业务对象是无状态的,那么通常可以仅仅添加更多 Web 服务器来扩展应用程序,而不用添加更多 Web 服务器和更多 EJB 容器, 这样的成本一般要低一些并且轻易完成。使用 HttpSession 存储会话状态的另一个好处是 Servlet API 提供了一种会话失效时通知的轻易方法。在数据库中存储会话状态的成本可能难以承受。
servlet 规范没有要求 servlet 容器进行某种类型的会话复制或者持久性,但是它建议将状态复制作为 servlet 首要 存在理由(raison d'etre) 的重要部分,并且它对作为进行会话复制的容器提出了一些要求。会话复制可以提供大量好处 —— 负载平衡、伸缩性、容错和高可用性。相应地,大多数 servlet 容器支持某种形式的 HttpSession 复制,但是复制的机制、配置和时间是由实现决定的。
HttpSession API
简单地说,HttpSession 接口支持几种方法,servlet、JSP 页或者其他表示层组件可以用这些方法来跨多个 HTTP 请求维护会话信息。会话绑定到特定的用户,但是在 Web 应用程序的所有 servlet 中共享 —— 不特定于某一个 servlet。一种考虑会话的有用方法是,会话像一个在会话期间存储对象的 Map —— 可以用 setAttribute 按名字存储会话属性,并用 getAttribute 提取它们。HttpSession 接口还包含会话生存周期方法,如 invalidate() (它通知容器应丢弃会话)。清单 1 显示 HttpSession 接口最常用的元素:
清单 1. HttpSession API
public interface HttpSession {
Object getAttribute(String s);
Enumeration getAttributeNames();
void setAttribute(String s, Object o);
void removeAttribute(String s);
boolean isNew();
void invalidate();
void setMaxInactiveInterval(int i);
int getMaxInactiveInterval();
...
}
理论上,可以跨群集一致性地完全复制会话状态,这样群集中的所有节点都可以服务任何请求,一个简单的负载平衡器可以以轮询方式传送请求,避开有故障的主机。不过,这种紧密的复制有很高的性能成本,并且难于实现,当群集接近某一规模时,还会有伸缩性的问题。
一种更常用的方式是将负载平衡与会话相似性(affinity) 结合起来 —— 负载平衡器可以将会话与连接相关联,并将会话中以后的请求发送给同一服务器。有很多硬件和软件负载平衡器支持这个功能,并且这意味着只有主连接主机和会话需要故障转移到另一台服务器时才访问复制的会话信息。
复制方式
复制提供了一些可能的好处,包括可用性、容错和伸缩性。此外,有大量会话复制的方法可用:方法的选择取决于应用程序群集的规模、复制的目标和 servlet 容器支持的复制设施。复制有性能成本,包括 CPU 周期(存储在会话中的序列化对象)、网络带宽(广播更新),以及基于磁盘的方案中写入到磁盘或者数据库的成本。
几乎所有 servlet 容器都通过存储在 HttpSession 中的序列化对象进行 HttpSession 复制,所以假如是创建一个分布式应用程序,应当确保只将可序列化对象放到会话中。(一些容器对像 EJB 引用、事务上下文、还有其他非可序列化的 J2EE 对象类型有非凡的处理。)
基于 JDBC 的复制
一种会话复制的方法是序列化会话内容并将它写入数据库。这种方法相当直观,其优点是不仅会话可以故障转移到其他主机,而且即使整个群集失效,会话数据也可以保存下来。基于数据库的复制的缺点是性能成本 —— 数据库事务是昂贵的。虽然它可以在 Web 层很好地伸缩,但是它可能在数据层产生伸缩问题 —— 假如群集增长大到一定程度,扩展数据层以容纳会话数据会很困难或者成本无法接受。
基于文件的复制
基于文件的复制类似于使用数据库存储序列化的会话,只不过是使用共享文件服务器而不是数据库来存储会话数据。这种方式的成本一般比使用数据库的成本(硬件成本、软件许可证和计算开销)低,其代价则是可靠性(数据库可提供比文件系统更强的持久化保证)。
基于内存的复制
另一种复制方式是与群集中的一个或者多个其他服务器共享序列化的会话数据副本。复制所有会话到所有主机中提供了最大的可用性,并且负载平衡最轻易,但是因为复制消息所消耗的每个节点的内存和网络带宽,最终会限制群集的规模。一些应用服务器支持与“伙伴(buddy)”节点的基于内存的复制,其中每一个会话存在于主服务器上和一台(或更多)备份服务器上。这种方案比将所有会话复制到所有服务器的伸缩性更好,但是当需要将会话故障转移到另一台服务器上时会使负载平衡任务复杂化,因为它必须找出另外哪一台(几台)服务器有这个会话。
时间考虑
除了决定如何存储复制会话数据,还有什么时候复制数据的问题。最可靠但也最昂贵的方法是每次数据改变时复制它(如每次 servlet 调用结束)。不那么昂贵、但是在故障时会有丢失一些数据的风险的方法是在每超过 N 秒时复制数据。
与时间问题有关的问题是,是复制整个会话还是只试尝复制会话中改变了的属性(它包含的数据会少得多)。这些都需要在可靠性和性能之间进行取舍。Servlet 开发人员应当熟悉到在故障转移时,会话状态可能变得“过时”(是几次请求前的复制),并应当预备处理不是最新的会话内容。(例如,假如一个interview 的第 3 步产生一个会话属性,而用户在第 4 步时,请求被故障转移到一个具有两次请求之前的会话状态复制的系统上,那么第 4 步的 servlet 代码应预备在会话中找不到这个属性,并采取相应的行动 —— 如重定向,而不是认定它会在那里、并在找不到它时抛出一个 NullPointerException。)
容器支持
Servlet 容器的 HttpSession 复制选项以及如何配置这些选项是各不相同的。IBM WebSphere? 提供的复制选项是最多的,它提供了在内存中复制或者基于数据库的复制、在 servlet 末尾或者基于时间的复制时间、传播全部会话快照(JBoss 3.2 或以后版本)或者只传播改变了的属性等选择。基于内存的复制基于 JMS 发布-订阅,它可以复制到所有克隆、一个“伙伴”复制品或者一个专门的复制服务器。
WebLogic 还提供了一组选择,包括内存中(使用一个伙伴复制品)、基于文件的或者基于数据库的。JBoss 与 Tomcat 或者 Jetty servlet 容器一同使用时,进行基于内存的复制,可以选择 servlet 末尾或者基于时间的复制时间,而快照选项(在 JBoss 3.2 或以后版本)是只复制改变了的属性。Tomcat 5.0 为所有群集节点提供了基于内存的复制。此外,通过像 WADI 这样的项目,可以用 servlet 过滤机制将会话复制添加到像 Tomcat 或者 Jetty 这样的 servlet 容器中。