技术演进中的开发沉思-151 java-servlet:会话管理
怎么理解会话状况呢:“Web 是无状态的,就像餐馆里的客人吃完就走,服务员记不住谁是谁 —— 会话管理就是给客人发张会员卡,下次来能认出他,还能想起他的偏好。”
这句话成了我理解会话管理的钥匙。而真正让我吃透这个概念的,是当年写的那几个简单却经典的 Servlet:用来做访问计数的Counter、能查看所有会话的Killer、排查 Cookie 问题的Cookies…… 这些代码没有复杂的框架,却像 “后厨的基础厨具”,帮我摸清了会话管理的每一个细节。

今天,咱们就结合这些当年的 “实战代码”,聊聊会话管理:从 “会员档案” 的创建与维护,到 “会员卡”(Cookie)、“账单写号”(URL 重写)的底层逻辑,再到会话事件的监听,带大家感受 “用基础 API 解决实际问题” 的乐趣。
当年做电商项目时,我最头疼的就是 “无状态” 这个特性。HTTP 协议就像餐馆里的 “一次性消费”:客人(浏览器)发一次请求(点一次菜),服务器(后厨)响应一次(上一次菜),之后就忘了这个客人是谁。直到我写了第一个会话相关的 Servlet——Counter,才真正明白:会话管理不是抽象的理论,而是 “用代码让服务器记住客人” 的具体操作。
那个Counter servlet 很简单,就是统计用户在一次会话中访问页面的次数。当我第一次刷新页面,看到计数从 1 变成 2,关了浏览器再打开(设置了 Cookie 有效期后),计数接着从 3 开始涨时,我突然懂了:原来 “会话” 就是服务器给客人建的 “电子档案本”,Session ID就是档案本的编号,Cookie 就是用来装编号的 “会员卡”。
一、 管理会话数据
会话数据就是 “会员档案” 里的内容:客人的登录名、购物车商品、访问次数。管理会话数据,就是用HttpSession API“写档案、读档案、清档案”。当年我就是靠Counter servlet,把这些操作摸得明明白白。
1.1 操作会话数据:用Counter servlet 学 “存读写”
Counter servlet 是我写的第一个会话相关代码,核心逻辑就是 “记录访问次数”,现在翻出当年的代码,还能看到注释里的错别字:
package javaservlets.session;import javax.servlet.*;import javax.servlet.http.*;public class Counter extends HttpServlet {// 定义计数的键,当年怕和其他servlet冲突,特意加了类名前缀static final String COUNTER_KEY = "Counter.count";public void doGet(HttpServletRequest req, HttpServletResponse resp)throws ServletException, java.io.IOException {// 获取会话:true表示没有就新建(当年踩过坑,设成false导致空指针)HttpSession session = req.getSession(true);resp.setContentType("text/html");java.io.PrintWriter out = resp.getWriter();// 读计数:当年用getValue,后来Servlet 2.4才改成getAttributeint count = 1;Integer i = (Integer) session.getValue(COUNTER_KEY);if (i != null) {count = i.intValue() + 1;}// 写计数:对应putValue,后来改成setAttributesession.putValue(COUNTER_KEY, new Integer(count));// 输出结果:显示会话ID和访问次数out.println("<html><body>");out.println("你的会话ID是 <b>" + session.getId() + "</b><br>");out.println("本次会话中,你访问了本页面 <b>" + count + "</b> 次");out.println("<form method=GET action=\"" + req.getRequestURI() + "\">");out.println("<input type=submit value=\"再点一次\">");out.println("</form></body></html>");out.flush();}}
这段代码让我明白三个关键问题:
- “档案本” 怎么拿:req.getSession(true)是 “拿档案本”,没有就新建;当年我误写成getSession(false),客人第一次访问时拿不到档案本,存计数时报空指针,调试了半小时才发现。
- “档案” 怎么写:session.putValue(键, 值)是 “写档案”,就像在档案本上写 “访问次数:3”;后来 Servlet API 更新,为了和Map接口统一,改成了setAttribute,但核心逻辑没变。
- “档案” 怎么读:session.getValue(键)是 “读档案”,拿不到就返回 null;比如第一次访问时i是 null,计数从 1 开始。
当年我把这个 servlet 部署到服务器,刷新页面时看着计数涨,关了浏览器再打开(后来加了 Cookie 有效期),计数还能接着涨,那种 “服务器认得出我” 的感觉,比现在用 Spring Session 还兴奋 —— 因为这是用最基础的 API,亲手实现了 “记住用户”。
1.2 会话的生存期:“档案本” 多久过期
会员档案不能永久保存,不然服务器内存会被占满 —— 就像餐馆不会保留几年没来的客人档案。会话的生存期就是 “档案本” 的有效期,超过时间没访问,服务器自动销毁。
当年我用Counter servlet 测试生存期:在web.xml里加了配置:
<session-config><session-timeout>30</session-timeout> <!-- 单位:分钟 --></session-config>
然后访问页面,计数到 5,半小时不操作,再刷新,计数又从 1 开始 —— 这说明 “档案本” 过期被销毁了,服务器给了新的档案本。
session.setMaxInactiveInterval(30 * 60); // 单位:秒,和xml配置效果一样
来我还试过用代码设置生存期:
当年踩过一个误区:以为setMaxInactiveInterval(-1)是 “永久有效”,就像给档案本盖了 “永久保存” 的章。结果部署后没几天,服务器内存就满了 —— 老同事告诉我:“哪有永久的档案?就算是会员,几年不来也得清理,不然储物间会满。” 后来我才明白,生产环境里绝对不能设-1,必须给会话设过期时间。
6.1.3 浏览会话:用Killer servlet 看 “所有档案本”
当年排查 “购物车消失” 问题时,我写了个Killer servlet—— 它能列出服务器上所有的会话,还能手工销毁会话,就像 “档案管理员” 能看所有客人的档案本,还能手动撕了过期的。
Killer的核心代码是用HttpSessionContext获取所有会话 ID:
// 当年用HttpSessionContext拿所有会话ID,现在已经弃用了HttpSession session = req.getSession(true);HttpSessionContext context = session.getSessionContext();java.util.Enumeration enum = context.getIds(); // 拿到所有会话IDwhile (enum.hasMoreElements()) {String sessionID = (String) enum.nextElement();HttpSession curSession = context.getSession(sessionID);// 输出会话ID和最后访问时间out.println("<tr><td>" + sessionID + "</td>");out.println("<td>" + new java.util.Date(curSession.getLastAccessedTime()) + "</td></tr>");}
第一次运行Killer时,我看到页面上列出了十几个会话 ID,还能勾选后点 “销毁会话”,觉得特别酷 —— 就像有了 “上帝视角”。结果老领导看到后,劈头盖脸一顿骂:“这太不安全了!要是被人恶意销毁正在支付的用户会话,客人付了钱却没订单,你负责?”
后来我才知道,Servlet API 2.1 后就把HttpSessionContext弃用了 —— 就是因为它能泄露所有用户的会话信息,有安全风险。但这个 “不安全” 的 servlet,却帮我排查了不少问题:比如测试小哥说购物车空了,我用Killer看他的会话 ID,发现每次访问都变,再查 Cookie,才知道他浏览器禁用了 Cookie,导致服务器每次都给新档案本。
现在回想,Killer servlet 虽然过时了,但它教会我的 “排查思路” 却一直有用:会话有问题,先看会话 ID 是否不变,再看标识(Cookie/URL)是否传递 —— 这个逻辑到现在都没改。
二、Cookies
Cookies 是服务器发给浏览器的 “小文本文件”,里面存着 “会员号”(Session ID)—— 就像餐馆给客人发的实体会员卡,客人下次来自动出示,服务员不用再问 “你是谁”。当年我就是靠Cookies servlet,摸清了 Cookie 的 “脾气”。
用Cookies servlet 查 “会员卡”
Cookies servlet 很简单,就是读取请求中的所有 Cookie,输出名称、值、注释、有效期:
package javaservlets.session;import javax.servlet.*;import javax.servlet.http.*;public class Cookies extends HttpServlet {public void doGet(HttpServletRequest req, HttpServletResponse resp)throws ServletException, java.io.IOException {resp.setContentType("text/html");java.io.PrintWriter out = resp.getWriter();Cookie[] cookies = req.getCookies(); // 读取所有Cookieout.println("<html><body><center><h1>请求中的Cookie</h1>");if (cookies == null || cookies.length == 0) {out.println("没找到Cookie");} else {out.println("<table border><tr><th>名称</th><th>值</th><th>注释</th><th>有效期</th></tr>");for (Cookie c : cookies) {out.println("<tr>");out.println("<td>" + c.getName() + "</td>");out.println("<td>" + c.getValue() + "</td>");out.println("<td>" + c.getComment() + "</td>");out.println("<td>" + c.getMaxAge() + "</td>"); // 秒,-1表示会话级out.println("</tr>");}out.println("</table>");}out.println("</center></body></html>");out.flush();}}
当年我用这个 servlet 排查 “购物车消失” 问题:访问/Cookies,发现JSESSIONID这个 Cookie 的MaxAge是-1(会话级),关了浏览器就失效 —— 这就是为什么购物车空了。后来我在Counter servlet 里加了代码,把JSESSIONID的有效期设为 7 天:
// 找到JSESSIONID Cookie,设有效期7天Cookie[] cookies = req.getCookies();if (cookies != null) {for (Cookie c : cookies) {if (c.getName().equals("JSESSIONID")) {c.setMaxAge(7 * 24 * 60 * 60); // 7天c.setPath("/"); // 全站有效,当年漏了这个,导致/cart路径读不到resp.addCookie(c);break;}}}
加了这段代码后,再用Cookies servlet 查看,JSESSIONID的MaxAge变成了604800(7 天秒数),关了浏览器再打开,购物车终于不丢了 —— 那一刻,我才算真正懂了 Cookie 和会话的关系。
当年踩过的 “Cookie 坑”
用Cookies servlet 调试多了,我总结出几个 Cookie 的 “坑”:
- 路径坑:默认 Cookie 只在当前路径有效,比如在/login发的 Cookie,在/cart读不到 —— 就像会员卡只能在一楼用,二楼不认。解决方法是c.setPath("/"),让 Cookie 全站有效。
- 域名坑:如果有子域名(www.example.com和shop.example.com),默认 Cookie 不共享 —— 就像总店的会员卡,分店不认。要设c.setDomain(".example.com")(前面的点不能少)。
- 大小坑:每个 Cookie 不能超过 4KB,当年想存购物车所有商品,结果 Cookie 存不下,后来才知道 Cookie 只能存 “会员号” 这种小数据,大数据要存在服务器会话里。
还有个细节:当年做支付模块时,老领导让我给 Cookie 加setSecure(true),说 “敏感 Cookie 要走 HTTPS”—— 就像会员卡只在餐馆内部用,不允许带出,防止被别人截取。现在想起来,这些细节都是 “安全意识” 的启蒙。
三、URL Rewriting
有些客人禁用了 Cookie(不带会员卡),服务器怎么识别?答案是 URL 重写:把 “会员号”(Session ID)拼在 URL 后面,就像客人没带会员卡,服务员在账单上写会员号,下次客人拿着账单来,服务员就能认出他。当年我用CounterRewrite servlet,实现了这个逻辑。
CounterRewrite servlet 的 “账单写号”
CounterRewrite是Counter的修改版,核心是用resp.encodeURL()把 Session ID 拼到 URL 里:
package javaservlets.session;import javax.servlet.*;import javax.servlet.http.*;public class CounterRewrite extends HttpServlet {static final String COUNTER_KEY = "CounterRewrite.count";public void doGet(HttpServletRequest req, HttpServletResponse resp)throws ServletException, java.io.IOException {HttpSession session = req.getSession(true);resp.setContentType("text/html");java.io.PrintWriter out = resp.getWriter();// 计数逻辑和Counter一样int count = 1;Integer i = (Integer) session.getValue(COUNTER_KEY);if (i != null) count = i.intValue() + 1;session.putValue(COUNTER_KEY, new Integer(count));// 关键:用encodeURL重写表单action,自动拼Session IDString url = req.getRequestURI();String encodedUrl = resp.encodeURL(url); // 重写后的URLout.println("<html><body>");out.println("你的会话ID是 <b>" + session.getId() + "</b><br>");out.println("访问次数:<b>" + count + "</b> 次");// 表单action用重写后的URLout.println("<form method=POST action=\"" + encodedUrl + "\">");out.println("<input type=submit value=\"再点一次\">");out.println("</form></body></html>");out.flush();}// POST请求也用doGet处理,避免漏写重写public void doPost(HttpServletRequest req, HttpServletResponse resp)throws ServletException, java.io.IOException {doGet(req, resp);}}
当年我测试时,故意禁用浏览器 Cookie,访问CounterRewrite,看到地址栏的 URL 变成了:/CounterRewrite;jsessionid=3F9A7C2D1E4B5F6A7C8D9E0F1A2B3C4D——Session ID 被拼在了 URL 后面,刷新页面,计数能正常涨,而原来的Counter servlet 此时计数一直是 1。
当年的 “重写坑”
URL 重写虽然能兜底,但坑也不少:
- 所有 URL 都要重写:当年我漏重写了 “帮助中心” 的链接,客人点了之后,URL 里没 Session ID,服务器给了新会话,购物车就空了 —— 排查半天才发现是这个原因。后来我养成习惯:所有链接、表单 action,都用encodeURL()处理。
- POST 请求也要重写:虽然 POST 参数在请求体里,但第一次访问时,客人还没有 Session ID,需要通过 URL 传递 —— 所以CounterRewrite里特意加了doPost方法,调用doGet,避免漏重写。
- URL 变长不美观:拼上 Session ID 后,URL 又长又乱,客人看着不舒服 —— 所以 URL 重写是 “兜底方案”,只在 Cookie 禁用时用,优先还是用 Cookie。
当年我总结了个 “最佳实践”:先用 Cookie,用Cookies servlet 检测 Cookie 是否启用,禁用了再自动切换到 URL 重写 —— 就像餐馆先给客人发会员卡,客人说 “我不带”,再在账单上写会员号。
四、不使用浏览器的会话跟踪
现在的开发有 APP、小程序,但当年我做过一个桌面工具,要和 Servlet 通信 —— 没有浏览器,不能用 Cookie,怎么跟踪会话?答案是 “手工传 Session ID”,就像电话订餐的客人,报手机号当 “会员号”,服务员通过手机号查档案。当年我用CounterJava(Servlet)和CounterApp(桌面程序),实现了这个逻辑。
CounterJava:给桌面程序 “发会员号”
CounterJava不返回 HTML,而是用二进制流返回计数,核心是保持会话逻辑不变:
package javaservlets.session;import javax.servlet.*;import javax.servlet.http.*;import java.io.*;public class CounterJava extends HttpServlet {static final String COUNTER_KEY = "CounterJava.count";public void service(HttpServletRequest req, HttpServletResponse resp)throws ServletException, java.io.IOException {HttpSession session = req.getSession(true);int count = 1;Integer i = (Integer) session.getValue(COUNTER_KEY);if (i != null) count = i.intValue() + 1;session.putValue(COUNTER_KEY, new Integer(count));// 返回二进制数据,不是HTMLresp.setContentType("application/octet-stream");DataOutputStream out = new DataOutputStream(resp.getOutputStream());out.writeInt(count); // 写计数到流里out.flush();out.close();}}
CounterApp:桌面程序 “报会员号”
CounterApp是独立 Java 程序,核心是 “手工解析 Cookie、传 Cookie”:
package javaservlets.session;import java.io.*;import java.net.*;public class CounterApp {private String url;private String cookie; // 手工存Cookie(Session ID)public static void main(String[] args) throws Exception {CounterApp app = new CounterApp("http://localhost:8080/CounterJava");// 调用5次,看计数是否连续for (int i = 1; i <= 5; i++) {int count = app.getCount();System.out.println("第" + i + "次调用,计数:" + count);}}public CounterApp(String url) {this.url = url;}public int getCount() throws Exception {URL u = new URL(url);URLConnection con = u.openConnection();// 关键:手工设置Cookie请求头,传Session IDif (cookie != null) {con.setRequestProperty("Cookie", cookie);}// 发送请求con.setDoOutput(true);DataOutputStream out = new DataOutputStream(con.getOutputStream());out.flush();out.close();// 读取响应:计数DataInputStream in = new DataInputStream(con.getInputStream());int count = in.readInt();in.close();// 第一次调用:手工解析set-cookie头,拿到Session IDif (cookie == null) {String setCookie = con.getHeaderField("Set-Cookie");if (setCookie != null) {// 截取Session ID(比如jrunsessionid=123; path=/ → 取前面部分)cookie = setCookie.split(";")[0];System.out.println("拿到Session ID:" + cookie);}}return count;}}
当年运行CounterApp,输出是这样的:
拿到Session ID:jrunsessionid=917315535100303809第1次调用,计数:1第2次调用,计数:2第3次调用,计数:3第4次调用,计数:4第5次调用,计数:5
看到计数连续涨,我特别兴奋 —— 这说明 “手工传 Session ID” 成功了!这个例子让我彻底明白:会话跟踪的核心不是 Cookie 或 URL,而是 “传递唯一标识(Session ID)”—— 不管是浏览器自动传(Cookie)、URL 拼(重写),还是程序手工传(请求头),本质都一样。
五、 会话事件
会话事件是 “档案本” 的 “动态通知”:客人办卡(会话创建)时送优惠券,客人长期不来(会话销毁)时清理档案。当年我用Binder servlet 和SessionObject,实现了这种 “通知”。
SessionObject:绑定 / 解绑时 “发通知”
SessionObject实现了HttpSessionBindingListener接口,在 “绑定到会话” 和 “从会话解绑” 时触发方法:
package javaservlets.session;import javax.servlet.http.*;import java.util.Date;public class SessionObject implements HttpSessionBindingListener {// 绑定到会话时触发(客人办卡,存档案)public void valueBound(HttpSessionBindingEvent event) {System.out.println(new Date() + ":对象绑定到会话 " + event.getSession().getId());// 当年在这里初始化资源,比如打开数据库连接}// 从会话解绑时触发(会话销毁,清档案)public void valueUnbound(HttpSessionBindingEvent event) {System.out.println(new Date() + ":对象从会话 " + event.getSession().getId() + " 解绑");// 当年在这里释放资源,比如关闭数据库连接}}
Binder servlet:给会话 “绑对象”
Binder servlet 很简单,就是把SessionObject绑定到会话:
package javaservlets.session;import javax.servlet.*;import javax.servlet.http.*;public class Binder extends HttpServlet {public void doGet(HttpServletRequest req, HttpServletResponse resp)throws ServletException, java.io.IOException {HttpSession session = req.getSession(true);// 把SessionObject绑定到会话,触发valueBoundsession.putValue("Binder.object", new SessionObject());resp.setContentType("text/html");PrintWriter out = resp.getWriter();out.println("<html><body>对象已绑定到会话 " + session.getId() + "</body></html>");out.flush();}}
当年运行Binder,服务器控制台会输出:
2005-10-26 20:30:00:对象绑定到会话 3F9A7C2D1E4B5F6A7C8D9E0F1A2B3C4D
等会话过期(30 分钟后),又会输出:
2005-10-26 21:00:00:对象从会话 3F9A7C2D1E4B5F6A7C8D9E0F1A2B3C4D 解绑
这个例子让我明白:会话事件不是 “花架子”,而是 “资源管理的好工具”—— 比如在valueBound里打开数据库连接,valueUnbound里关闭,避免会话销毁后连接泄漏。当年做电商的订单模块,我就是用这个逻辑管理数据库连接,没再出现过 “连接池满” 的问题。
最后小结:
当年写这些 Servlet 时,还在用 JDK 1.4、Servlet 2.3,没有 Spring Boot,没有 Redis,全靠基础 API 一点点拼。但正是这些简单的代码,让我吃透了会话管理的核心:
- “记住” 的本质:给用户分配唯一的 Session ID,通过 Cookie、URL 等方式传递,服务器用 ID 找对应的 “档案本”(会话数据)。
- “工具” 的选择:Cookie 是首选(自动传、不影响 URL),URL 重写是兜底(兼容禁用 Cookie 的场景),手工传 ID 是特殊场景(APP、桌面程序)。
- “细节” 的重要:Cookie 的路径、域名、有效期,会话的过期时间,资源的绑定 / 解绑 —— 这些细节决定了会话管理的稳定性和安全性。
现在的开发用 Spring Session、Redis 存会话,用 JWT 替代 Session ID,但底层逻辑和当年的Counter、Cookies servlet 没区别。就像现在的餐馆用电子会员卡(小程序),但 “记住客人” 的核心需求没变,只是 “会员卡” 的形式变了。
这些当年的代码,就像 “后厨的基础厨具”—— 虽然简单,但教会我的不只是 API,还有 “解决问题的思路”:排查会话问题,就从 “Session ID 是否不变→标识是否传递→数据是否存对” 一步步查。这种思路,比任何框架都重要。
