Servlet编程里有一类东西,你天天在用,但真让你把它讲清楚,很多人会卡在“它和Session到底啥区别”这种问题上。Cookie对象就是典型。作为会话跟踪最古老也最基础的一环,它几乎出现在每一个Web项目的请求头里,可很多人对它的理解还停留在“response.addCookie() 写进去,request.getCookies() 读出来”这一步,至于Path写错为什么读不到、MaxAge设成0为什么不生效、中文为什么会变成一串百分号、前端为什么用 document.cookie 拿不到值,这些问题一旦在线上冒出来,排查起来能耗掉半天。这篇内容就把 Servlet 里的 Cookie 对象从头到尾捋一遍,从浏览器实际收到的 Set-Cookie 报文长什么样,到 Java 侧每个 API 的行为边界,再到记住密码、未登录购物车、多语言偏好这些真实场景怎么落地,最后把我在实际项目里踩过的坑整理成一张排查速查表。不管你是刚学 JavaWeb、正准备把会话管理这块补齐的新手,还是写了几年业务代码但对 Cookie 细节一直含糊的开发者,都能从里面拿到能直接抄的东西。内容会以 Servlet 原生 API 为主线,涉及配置和参数的地方都会给到具体的数值和写法,尽量做到看完就能在自己的工程里跑起来。
1. 为什么Servlet阶段必须把Cookie吃透
1.1 Cookie补的到底是什么窟窿
HTTP 是无状态协议,这句话被讲烂了,但很多人并没有真正理解它的重量。无状态意味着服务端处理完一次请求就把这次交互忘得干干净净,下一次请求进来,它不知道你是谁、你上次看到哪、你购物车里放了什么。而在业务上,几乎所有的功能都要求“连续”:登录后要保持登录态、分页要有页码记忆、表单校验失败要回显上次填的内容、电商要有购物车。这就是会话跟踪要解决的问题。
实现会话跟踪有几条路:URL重写(把 sessionId 拼在地址后面)、隐藏表单域、Cookie、Session、Token。URL重写的问题是地址丑陋且容易泄露,隐藏域只能覆盖表单提交,而 Cookie 是唯一一个由浏览器自动携带、对所有请求类型都生效、对用户基本无感的方案。它在响应头里以Set-Cookie的形式下发,浏览器按域名和路径存到本地,之后每次匹配的请求都会自动在Cookie请求头里带上。Servlet 规范把它抽象成javax.servlet.http.Cookie这个类,配合HttpServletResponse.addCookie()和HttpServletRequest.getCookies()两个方法完成读写。
关键在于,Session 本身也依赖 Cookie 才能工作。容器生成JSESSIONID后,就是靠一个名为JSESSIONID的 Cookie 下发给浏览器的。如果用户禁用了 Cookie,容器会退化到 URL 重写(response.encodeURL())。所以哪怕你平时只写request.getSession(),底层跑的还是 Cookie 这套机制。不理解 Cookie,你对 Session 的理解就是悬空的——为什么换个端口 Session 就丢了、为什么换个子域名就共享不了,这些问题的答案全在 Cookie 的 Domain 和 Path 属性里。
我在带新人的时候有个固定的判断标准:如果一个人讲不清 Cookie 的 Domain 和 Path 分别控制什么,那他遇到会话相关的问题基本只能靠重启和删缓存碰运气。这不是夸张,线上环境里“登录状态莫名其妙掉了”这类工单,八成以上最后定位到 Cookie 的 Path 或者 Secure 属性上。
1.2 Cookie、Session、Token、本地存储怎么选
这一节我把几个容易混淆的方案摊开对比一下,因为选型选错了,后面写多少代码都是白搭。很多人以为 Cookie 和 Session 是二选一的对立关系,其实它们是上下游关系:Cookie 是客户端存储的载体,Session 是服务端存储的数据,两者靠一个 ID 串起来。
| 方案 | 数据存哪 | 服务端压力 | 跨域支持 | 失效控制 | 典型适用 |
|---|---|---|---|---|---|
| 纯 Cookie | 浏览器 | 无 | 受同源与Domain限制 | 靠 MaxAge,用户可删 | 非敏感偏好、埋点标识 |
| Cookie + Session | 浏览器存ID,服务端存数据 | 有,且要考虑集群同步 | 同 Cookie | 服务端可主动失效 | 后台管理系统、单体应用 |
| Token(JWT等) | 浏览器(Cookie或Header) | 无状态,几乎为零 | Header方案更灵活 | 靠过期时间,难主动撤销 | 前后端分离、多端接入 |
| localStorage | 浏览器 | 无 | 严格同源 | 只能手动清 | 前端缓存、非鉴权数据 |
选型的核心判断是两点:数据敏不敏感,以及要不要服务端主动踢人。像“用户上次选的语言”“列表页每页显示多少条”这种数据,塞 Cookie 里完全没问题,简单直接。但像权限、余额、购物车明细(涉及价格计算)这类,一律不能放 Cookie,因为 Cookie 存在用户机器上,改起来毫无门槛——你打开开发者工具改个值刷新一下,服务端如果直接信任,那就是个洞。
注意:Cookie 里的任何内容都视为不可信输入,服务端拿到后必须做校验、签名或者只当作索引去查真实数据,绝不能拿来直接参与业务判断。
至于 Token,它和 Cookie 也不是互斥的。很多项目把 Token 放在 HttpOnly 的 Cookie 里下发,既享受了浏览器自动携带的便利,又规避了 XSS 直接读走的风险,这是一种很务实的折中。而 localStorage 存 Token 的做法在安全圈里争议一直很大,因为它对 XSS 完全裸奔。这块不展开,但从选型角度记住一句话就够了:能不能被 JavaScript 读到,是一道重要的安全分界线,这一点在后面的 HttpOnly 属性里还会详细说。
1.3 上手前需要准备的东西
实操部分我用的是最朴素的组合,避免引入框架干扰对原生行为的理解:JDK 8 或以上、Servlet API 4.0(javax.servlet包),容器用 Tomcat 8.5/9.x 即可,构建工具 Maven,IDE 随意。如果你用的是 Spring Boot,把spring-boot-starter-web引进来就行,它内嵌的 Tomcat 一样跑 Servlet 规范,只是包名变成了jakarta.servlet(Spring Boot 3 之后)。这一点要特别注意:Servlet 5.0 起包名从 javax 改成了 jakarta,网上大量老教程还是 javax,你直接复制到新项目里会报找不到符号,这不是代码写错,是命名空间换了。
Maven 依赖大致是这样:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>scope必须是provided,因为容器本身自带这套 API,你打进 war 包里反而可能和容器版本冲突,出现ClassNotFoundException或者方法签名不匹配的诡异错误。这个坑我在早期项目里踩过,表现是本地跑得好好的,部署到服务器就 500,日志里一堆NoSuchMethodError,最后发现是 war 里带了个低版本的 servlet-api。
另外建议准备一个能直接看原始 HTTP 报文的手段,浏览器开发者工具的 Network 面板就够用:看Response Headers 里的 Set-Cookie和Request Headers 里的 Cookie。这两个地方是你排查所有 Cookie 问题的第一现场,比在 Java 代码里打日志直观得多。
2. Cookie对象的底层结构与核心API拆解
2.1 一条Set-Cookie报文到底写了什么
很多人写 Cookie 只知道 name 和 value,实际上服务端下发一条 Cookie,响应头长这样:
Set-Cookie: theme=dark; Path=/; Domain=.example.com; Max-Age=604800; Expires=Wed, 21 Oct 2026 07:28:00 GMT; HttpOnly; Secure; SameSite=Lax这一行里每个分号隔开的部分都是一个属性,浏览器解析后会存成一条记录(name、value、domain、path、expires、secure、httponly、samesite)。注意name + value 是唯一的业务数据,其余全是元信息,用来控制这条数据“在什么范围生效、活多久、能不能被脚本读”。
有一个特别容易混的点:Expires 和 Max-Age 是二选一,同时出现时 Max-Age 优先。Servlet 的setMaxAge(int seconds)生成的是Max-Age,而setMaxAge传负数表示“会话 Cookie”(关闭浏览器即失效),传 0 表示立即删除。为什么 Java API 里没有直接设 Expires 的方法?因为早期规范就是按秒来算的,而 Expires 需要格式化 GMT 时间字符串,容易写错时区。这也是为什么用 Servlet 原生 API 你没法直接设置 SameSite——Servlet 4.0 的 Cookie 类压根没这个方法,得走容器的 CookieProcessor 或者手动拼响应头,这个后面单独讲。
还有一点值得说清楚:请求头里的 Cookie 只有 name=value,不带任何属性。浏览器自动携带时会把当前域名和路径下所有匹配的 Cookie 用;拼成一行发过去,服务端拿到的就是一堆 name=value。所以你在 Java 侧request.getCookies()拿到的 Cookie 数组,Path、Domain、MaxAge 全是空的,因为这些信息在请求阶段已经没意义了——浏览器已经帮你筛过了。新手常犯的错是想在读取时判断某个 Cookie 的 Path,那是拿不到的,必须在下发时自己心里有数。
2.2 Cookie类的构造与常用方法逐条过
Cookie的构造只有一种:new Cookie(String name, String value)。两个参数都有硬性约束,这是 Servlet 规范里明写的:
- name不能为空,不能以
$开头($Version、$Path、$Domain是保留名),不能包含逗号、分号、等号、空格、制表符、双引号、反斜杠这些分隔符。实际上很多容器更严格,Tomcat 直接抛IllegalArgumentException。 - value同样不能包含分号、逗号、空格、双引号。中文和特殊字符必须编码,否则容器可能抛异常,或者浏览器解析出错。
这些限制不是故意刁难,因为 Set-Cookie 头是靠分号和逗号分隔的,你的值里要是带了分号,浏览器就会把后半截当成新属性,直接串味。
核心方法我列一下,配上行为说明:
| 方法 | 作用 | 关键细节 |
|---|---|---|
getName()/getValue() | 取名字和值 | 请求阶段只有这两样有效 |
setValue(String) | 改值 | 值约束同上,注意编码 |
setMaxAge(int) | 存活秒数 | 负数=会话级,0=删除,正数=指定秒 |
setPath(String) | 生效路径 | 不设时浏览器默认取当前请求的目录部分 |
setDomain(String) | 生效域名 | 必须以点开头才能覆盖子域,或者干脆不设 |
setHttpOnly(boolean) | 禁止 JS 读取 | 只在服务端写有效,读取时无意义 |
setSecure(boolean) | 仅 HTTPS 传输 | 本地 http 调试时设了会发不出去 |
setComment(String) | 注释 | 基本废弃,现代浏览器忽略 |
特别说一下setMaxAge的三个取值区间,这个是高频考点也是高频事故点:
- 正数 n:持久化 Cookie,写入磁盘,n 秒后过期。
setMaxAge(7 * 24 * 3600)就是七天。 - 0:删除。但注意,删除必须 name、path、domain 三者完全一致,否则你只是新建了一条同名不同路径的空 Cookie,原来那条纹丝不动。
- 负数:会话 Cookie,存在内存里,浏览器进程结束就没了。默认值就是 -1。
setHttpOnly这个我要重点强调。它的作用是让浏览器禁止 JavaScript 通过 document.cookie 读取这条 Cookie,但浏览器在发请求时仍然会正常携带。这意味着它能有效缓解 XSS 之后的会话劫持——攻击者即使注入了脚本,也读不到JSESSIONID。但它防不了 CSRF,因为 CSRF 的请求是从用户浏览器发出去的,Cookie 照样会带上。这两个概念被混讲的情况太多了,记住:HttpOnly 防“读”,SameSite 防“借”。
2.3 Path、Domain、Secure、SameSite这几个属性才是真正的分水岭
我把这四个单独拎出来,因为它们决定了 Cookie 的“作用域”,而作用域问题占了实际故障的绝大多数。
Path的默认行为很多人不知道:Servlet 规范里,如果不显式调用setPath(),容器会把当前请求 URI 的目录部分作为 Path。假设你在/app/user/login这个地址下写了 Cookie 又没设 Path,那这条 Cookie 的 Path 就是/app/user,意味着只有在/app/user/**下的请求才会带上它。你跳转到/app/order/list时就拿不到,于是“登录成功了但订单页显示未登录”。这个 bug 极难发现,因为代码里完全看不出问题。
实操心得:项目里所有业务 Cookie一律显式
setPath("/"),除非你有明确的路径隔离需求。一行代码,省掉无数扯皮。
Domain的规则是:不设置时,浏览器默认等于当前域名,并且不含子域,也就是a.example.com写的 Cookie,b.example.com读不到。要跨子域共享,必须设置成.example.com(前面带点,或者现代浏览器允许不带点也表示包含子域,但带点兼容性更好)。这里有个硬限制:你只能设置当前域名或其父域,a.example.com的页面无法把 Cookie 写到.other.com上,否则整个世界就乱套了。另外,纯 IP 地址的站点设置 Domain 通常无效,这也是本地用127.0.0.1和localhost测出来的结果经常不一样的原因——在浏览器眼里它们是两个不同的域。
Secure属性决定这条 Cookie 是否只在 HTTPS 下发送。本地开发用 http,如果你手贱设了setSecure(true),会发现 Cookie 怎么都发不出去,Network 面板里 Set-Cookie 明明有,后续请求就是不带。这不是 bug,是浏览器按规矩办事。与之配套的还有SameSite=None也强制要求 Secure,加了 None 没加 Secure,浏览器会直接拒绝这条 Cookie,控制台会给你一条黄色警告。
SameSite是这几年最值得关注的变化,因为它直接影响到跨站场景下的 Cookie 携带,也影响到前后端分离项目的联调。三个取值:
Strict:最严,任何跨站请求都不带 Cookie,包括从别的站点点链接跳过来。Lax:默认值(现代浏览器),顶级的 GET 导航会带,POST、iframe、AJAX 跨站不带。None:完全放开,任何跨站都带,但必须同时是 Secure。
如果你的项目是前端a.com、后端api.b.com这种跨站部署,那 Cookie 必须设成SameSite=None; Secure,否则登录态携带不上,表现为“登录接口返回成功,下一个请求就是 401”。这个现象在没有配置好的前后端分离项目里极其常见。
Servlet 4.0 的Cookie类没有setSameSite(),两种解法。第一种是配容器,Tomcat 在META-INF/context.xml里加:
<Context> <CookieProcessor className="org.apache.tomcat.util.http.Rfc6265CookieProcessor" sameSiteCookies="none"/> </Context>第二种是手动拼响应头,更通用但要注意别和容器的自动处理重复:
Cookie cookie = new Cookie("token", encodedValue); cookie.setPath("/"); cookie.setMaxAge(7200); cookie.setHttpOnly(true); cookie.setSecure(true); response.addCookie(cookie); // 追加 SameSite,Servlet 原生 API 不提供 response.addHeader("Set-Cookie", "token=" + encodedValue + "; Path=/; Max-Age=7200; HttpOnly; Secure; SameSite=None");手动拼的时候有个坑:response.addCookie()和addHeader("Set-Cookie", ...)会生成两条Set-Cookie,同名的两条会让浏览器行为变得不可预期(通常是后者覆盖或产生重复)。所以要手动拼就彻底手动拼,别混着来。Servlet 6.0 之后Cookie类终于有了setAttribute("SameSite", "Lax"),新项目可以直接用,老项目还是得走上面两条路。
3. 从零手写一套Cookie读写实操
3.1 工程骨架与一个最小可跑的Servlet
先搭个最小环境。用 Maven 的 webapp 骨架,pom.xml里引上面说的 servlet-api,web.xml里注册 Servlet 或者直接用@WebServlet注解。我习惯用注解,代码少。
先写一个最朴素的写入端,用来观察报文:
@WebServlet("/cookie/write") public class CookieWriteServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/html;charset=UTF-8"); Cookie theme = new Cookie("theme", "dark"); theme.setPath("/"); // 全站生效 theme.setMaxAge(7 * 24 * 60 * 60); // 7天 theme.setHttpOnly(true); resp.addCookie(theme); Cookie lang = new Cookie("lang", "zh-CN"); lang.setPath("/"); lang.setMaxAge(-1); // 会话级,关浏览器就没 resp.addCookie(lang); resp.getWriter().write("cookie written"); } }访问一下/cookie/write,打开 Network 面板看响应头,应该能看到两条 Set-Cookie。这里theme带了Max-Age=604800、Path=/、HttpOnly,而lang没有 Max-Age(说明是会话 Cookie)。这一步看着简单,但它验证了你对每个属性到底映射成报文里哪个字段的理解,是后面所有排查的基础。
有一个细节要留意:setMaxAge(7 * 24 * 60 * 60)我写成了表达式而不是604800。这不是炫技,是因为这类数字如果直接写死,三个月后你自己都不确定它到底是七天还是七小时。能用表达式就用表达式,可读性比省那点字符重要得多。
3.2 读取Cookie:遍历、空指针和匹配逻辑
读取端比写入端略麻烦,因为getCookies()返回的是数组,而且没有任何 Cookie 时返回的是 null 而不是空数组。这个设计坑了无数人:
@WebServlet("/cookie/read") public class CookieReadServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/html;charset=UTF-8"); Cookie[] cookies = req.getCookies(); // 可能为 null StringBuilder sb = new StringBuilder(); if (cookies != null) { for (Cookie c : cookies) { sb.append(c.getName()).append(" = ").append(c.getValue()).append("<br/>"); } } else { sb.append("no cookie found"); } resp.getWriter().write(sb.toString()); } }空指针就是这个if (cookies != null)。第一次访问没有 Cookie,直接 for 循环就会 NPE。我见过不止一个项目因为漏了这个判断,在“用户首次访问”这个必然出现的场景下报错。
另一个容易忽略的点是读取时的编码问题。写入时如果你对值做了 URL 编码,读取出来就必须手动解码,getCookies()不会帮你还原。而解码时还要处理一种情况:容器可能已经自动解码过一次(取决于Connector的URIEncoding和useBodyEncodingForURI配置),重复解码会让%变成乱码或者抛IllegalArgumentException。稳妥的做法是自己控制:写入时统一 URLEncoder.encode,读取时统一 URLDecoder.decode,中间不信任何自动行为。
我习惯封装一个工具方法,把读取逻辑收敛掉:
public static String getCookieValue(HttpServletRequest req, String name) { Cookie[] cookies = req.getCookies(); if (cookies == null) return null; for (Cookie c : cookies) { if (name.equals(c.getName())) { try { return URLDecoder.decode(c.getValue(), "UTF-8"); } catch (Exception e) { return c.getValue(); // 解码失败退化为原值,避免整个请求崩掉 } } } return null; }这个封装的几个设计考虑:返回null而不是空串,让调用方明确知道“没有这条 Cookie”;解码失败不抛异常而是退化返回,因为一个脏 Cookie 不应该导致整个页面 500——这条经验是线上事故换来的,有用户手动改过 Cookie 值,导致解码直接抛异常,结果首页白屏。
3.3 更新与删除:同名同路径才有效
更新 Cookie 的方式很反直觉:没有“更新”这个操作,所谓更新就是用同样的 name 和 path 再发一条。浏览器收到后会按 (name, domain, path) 三元组判断是否覆盖。所以:
// 更新 theme Cookie c = new Cookie("theme", "light"); c.setPath("/"); c.setMaxAge(7 * 24 * 60 * 60); resp.addCookie(c);只要三元组一致,旧值就被替换,MaxAge 也一起被覆盖。如果三元组有一个不一致,就变成两条不同的 Cookie,浏览器会两条都带上,服务端getCookies()拿到的数组里会有两个同名项,你按顺序取第一个还是最后一个,就看实现细节了——这是典型的“更新不生效”故障。
删除同理,三步必须完全对齐:
public static void deleteCookie(HttpServletRequest req, HttpServletResponse resp, String name, String path, String domain) { Cookie c = new Cookie(name, ""); c.setPath(path); if (domain != null) c.setDomain(domain); c.setMaxAge(0); // 关键:0 表示立即过期 resp.addCookie(c); }注意删除时值给空串是有意的,因为删 Cookie 的本质是“用一条立即过期的同三元组 Cookie 覆盖”。
实操心得:把 Cookie 的 name、path、domain 定义成常量集中管理,比如
CookieConst.THEME_NAME、CookieConst.ROOT_PATH。散落在各处的字符串字面量是三元组对不上的根源,而这种错误在测试环境往往“碰巧”能用,一到线上多路径部署就暴露。
还有一个“会话 Cookie 删不掉”的经典问题:如果你的 Cookie 是会话级的(MaxAge 为负),setMaxAge(0)未必能让它立刻消失,某些浏览器对会话 Cookie 的删除处理比较随意。稳妥做法是删除时同时设setMaxAge(0)并把值置空,再配合业务逻辑上的忽略(比如服务端把空值也当作不存在处理),做到双保险。
4. 典型场景落地:从记住我到最后访问时间
4.1 七天免登录的“记住我”到底该怎么写
这是最经典的 Cookie 应用,也是最容易被写歪的。新手版本通常是:把用户名和密码直接写进 Cookie,下次自动填充登录。这个做法绝对不能上生产,因为 Cookie 明文存在用户机器上,同机器上任何人都能看到。
正确思路是:Cookie 里只放一个不可推导的令牌(token),真实身份存在服务端。流程是这样:
- 用户勾选“记住我”并登录成功,服务端生成一个随机串(UUID 或者
SecureRandom生成的十六进制串),存到服务端(数据库或缓存),key 是令牌,value 是用户ID + 过期时间。 - 服务端把令牌写入 Cookie,
MaxAge设七天,HttpOnly打开,Path=/。 - 用户下次访问,过滤器检测到未登录但有该 Cookie,拿令牌去服务端查,命中则重建会话。
- 用户主动退出时,服务端删掉这条令牌记录,同时下发 MaxAge=0 的同名 Cookie。
这样即使 Cookie 被拿走,你也能在服务端一键失效,而纯前端令牌做不到这点。这里有个细节值得强调:令牌在服务端存储时要当密码对待,不要明文存库,至少做一次哈希。原因是一旦数据库被拖走,明文令牌等于所有人的登录凭证。
String token = UUID.randomUUID().toString().replace("-", ""); Cookie remember = new Cookie("remember_token", token); remember.setPath("/"); remember.setMaxAge(7 * 24 * 60 * 60); remember.setHttpOnly(true); // 生产环境建议同时 setSecure(true) resp.addCookie(remember); // 服务端落库:token -> {userId, expireAt},此处略还有个体验上的小技巧:令牌用一次就换一个(滚动刷新)。每次自动登录成功后下发新令牌、作废旧令牌,这样即使令牌泄露,攻击窗口也很短。代价是需要处理并发请求下旧令牌被提前作废的问题,一般做法是给旧令牌留一个短暂的宽限期(比如 30 秒),这两者的平衡我在项目里试过好几轮,宽限期短了容易误杀,长了安全性打折。
4.2 未登录购物车:Cookie 最合适也最容易翻车的场景
用户没登录,加了几件商品,关掉页面第二天回来还在——这个需求用 Cookie 做很自然:Cookie 里只存商品ID和数量,价格一律不存,服务端拿到后再查实时价格。
数据结构上我建议用紧凑格式,比如skuId:qty,skuId:qty,既省空间又好解析:
// 写入 String cart = "1001:2,1002:1"; Cookie c = new Cookie("cart", URLEncoder.encode(cart, "UTF-8")); c.setPath("/"); c.setMaxAge(30 * 24 * 60 * 60); // 30天 resp.addCookie(c);这里必须编码,因为逗号和冒号在 Cookie 值里虽然不违规,但分号绝对不行,而商品数据里经常混进各种字符,统一编码一劳永逸。
翻车点主要有两个。第一个是容量上限:单个 Cookie 大约是 4KB,一个域名下的 Cookie 总数各浏览器限制在 50 个上下(老 IE 是 20 个)。购物车稍微放多一点就超了,超了之后浏览器会静默丢弃,你完全收不到错误,表现就是“加购的某些商品凭空消失”。所以我的做法是给购物车 Cookie 设一个硬上限,比如最多 20 条 SKU,超了就提示用户登录后合并。
第二个是登录后的合并逻辑。用户登录后,Cookie 购物车和服务端购物车要合并,合并规则要想清楚:同 SKU 数量取大值还是求和?我的经验是取大值更符合直觉——用户加购两次可能是误触,求和容易出现“数量变成 4”这种让用户困惑的结果。合并完成后记得把 Cookie 里的购物车清掉,否则下次登出又会冒出来。
4.3 语言偏好、主题与访问痕迹这类轻量场景
这类数据的共同特点是:不敏感、只需客户端记住、丢失无严重后果。它们才是 Cookie 最舒服的用武之地。
以语言偏好为例,用户在页面上切了“English”,你把lang=en-US写进 Cookie,MaxAge给一年。用户下次进来,服务端过滤器读 Cookie,命中就用对应语言包,没命中就用Accept-Language请求头兜底。这套逻辑二十行代码就能写完,比存数据库轻量得多。
Cookie lang = new Cookie("lang", "en-US"); lang.setPath("/"); lang.setMaxAge(365 * 24 * 60 * 60); resp.addCookie(lang);“最后访问时间”这个场景也常见,但要提醒一句:不建议用来做“欢迎回来,你上次是 X 月 X 日来的”这种展示,因为用户清理浏览器数据后就会重置,展示出来反而奇怪。它更适合做服务端的辅助判断,比如判断用户是不是长时间未活跃。
还有一个场景值得一提:A/B 实验分组。把用户分到实验组 A 还是 B 写进 Cookie,保证同一个用户看到一致的版本。这里的关键是分流算法要稳定,通常用用户ID或设备指纹做哈希取模,而不是随机数——随机数会导致同一个用户刷新一次就换一个版本,数据全乱。Cookie 在这里的作用只是缓存这个分组结果,真正的分组依据还是哈希值。
5. 编码、容量与故障排查实录
5.1 中文乱码:从百分号到问号的完整链路
Cookie 中文乱码几乎是每个 JavaWeb 新手必经的一课。现象是:写入“张三”,读出来变成%E5%BC%A0%E4%B8%89,或者干脆变成一个问号。
原因在于Cookie 的规范本身只支持 ASCII,RFC 6265 里对 value 的字符集没有强约束,但实践中浏览器和容器都按 ASCII 处理。Java 侧的Cookie类不会帮你编码,你塞进去什么它就把什么拼进响应头,非 ASCII 字节在不同环节被不同方式解释,结果就乱了。
标准的处理方式是两端都做 URL 编解码:
// 写 String name = "张三"; Cookie c = new Cookie("nick", URLEncoder.encode(name, "UTF-8")); c.setPath("/"); resp.addCookie(c); // 读 String raw = getCookieValue(req, "nick"); // 内部已 decode有个历史遗留的坑要提一下:老版本 Tomcat 的Cookie实现会自动按 ISO-8859-1 处理值,导致你编码后取出来还得再转一次。现在的 Tomcat 8 以上基本不会了,但如果你的项目还在跑很老的容器,遇到“编码后仍然乱码”,可以试试先按 ISO-8859-1 取字节再按 UTF-8 构造字符串。这种救火方案不值得推广,只在维护旧系统时用。
另一个更隐蔽的问题是容器层面的 URI 编码配置影响%的解析。如果Connector上配了URIEncoding="UTF-8"且开了某些自动解码选项,你的值可能在到达 Servlet 之前就被解过一次,导致你URLDecoder.decode时二次解码报错。判断方法很简单:在 Servlet 里把原始值打印出来,看它是%E5%BC%A0还是已经变成中文了,一眼就知道该不该解码。
注意:
URLEncoder.encode会把空格编码成+而不是%20,这在 Cookie 场景下通常没问题,但如果你的值里真的有加号,解码后要手动把+还原成空格,否则数据会错。涉及可能含加号的场景(比如 base64 字符串),改用%20更稳妥。
5.2 容量上限与浏览器差异:那些不会报错的静默失败
Cookie 的容量限制是所有“不报错的 bug”里最气人的一类,因为你在服务端完全看不出异常,代码逻辑全对,数据就是没了。
我整理了一份实际观察到的经验值(各浏览器版本有差异,这些是相对保守的安全线):
| 限制项 | 经验值 | 超出后的表现 |
|---|---|---|
| 单条 Cookie 大小 | 4KB(含 name、value、属性) | 该条被静默丢弃 |
| 单域名 Cookie 数量 | 约 50 条 | 最旧的或最不常用的被淘汰 |
| Cookie 总大小 | 各浏览器策略不一 | 部分被丢弃 |
| name 长度 | 建议 32 字符内 | 过长可能被拒 |
| value 中禁用字符 | 分号、逗号、空格、双引号 | 解析截断或整条失效 |
“单域名 50 条”这个数字要特别注意,因为很多前端库会悄悄写 Cookie(埋点、实验、客服 SDK),你自己可能只写了 5 条,实际已经七十多条了。排查方法是在浏览器控制台跑document.cookie.split(';').length,一眼看数量。如果数量爆了,先找那些第三方脚本的锅。
还有一个常被忽略的点:Cookie 会在每个请求里都带上。你如果往 Cookie 里塞了 3KB 的数据,那么该域名下的每个图片、每个 CSS、每个 AJAX 请求都要多传 3KB。一个页面 50 个资源请求,就是 150KB 的额外开销。这在弱网环境下体感非常明显。所以 Cookie 里只放必要的、小的标识,大块数据交给服务端或者 localStorage。
实测下来,我做过的项目里 Cookie 内容控制在 200 字节以内比较理想。超过这个量级就该反思是不是用错了工具。
5.3 排查速查表与几条踩坑经验
下面这张表是我这些年遇到 Cookie 问题时的第一反应清单,按现象查原因,命中率挺高:
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 写了但下次请求不带 | Path 不匹配 | 显式setPath("/") |
| 子域读不到 | Domain 未设为父域 | setDomain(".example.com") |
| 删除后仍然存在 | name/path/domain 不一致 | 三元组严格对齐,MaxAge 设 0 |
| 更新后出现两条同名 | path 或 domain 不同 | 统一用常量管理三元组 |
| 本地 http 下完全失效 | 设了 Secure | 本地环境关闭 Secure |
| 前端 document.cookie 读不到 | HttpOnly | 确认是否真的需要前端读 |
| 跨站请求不带 Cookie | SameSite 默认 Lax | 设 SameSite=None 且加 Secure |
| 中文变百分号或问号 | 未编码或重复解码 | 统一 URLEncoder/Decoder |
| 值被截断 | 含分号、逗号等禁用字符 | 全量 URL 编码 |
| 数量超限静默丢失 | 第三方脚本写了太多 | 控制总量,清理无用 Cookie |
再补充几条不在表里的经验,都是实际踩出来的:
第一条,永远先看报文再看代码。我见过太多人对着 Java 代码找半天,最后发现是浏览器压根没存。判断方法很简单:写完 Cookie 后,在浏览器设置里的站点数据里搜一下域名,看那条 Cookie 在不在。不在就是下发环节的问题(属性非法、Secure 限制),在但请求不带就是匹配问题(Path、Domain)。这个二分法能砍掉一半排查时间。
第二条,本地开发环境的域名一定要和线上一致。localhost、127.0.0.1、192.168.x.x在浏览器眼里是完全不同的域,Cookie 完全不共享。有些问题只有在你把本地 hosts 配成和线上同样的域名结构时才能复现,比如子域共享失效这类。
第三条,别用 Cookie 存“状态机”。比如“当前步骤是第 3 步”这种,用户手动改了 Cookie 就能跳到任意步骤。这类状态必须放服务端。Cookie 只适合存“偏好”和“标识”。
第四条,清理逻辑要有。项目迭代过程中 Cookie 的名字和结构会变,老版本的 Cookie 残留在用户机器上会引发各种奇怪行为。养成习惯:新版本上线时,把废弃的 Cookie 用setMaxAge(0)清一遍。这件事花五分钟,能省掉后面很多“线上有问题本地复现不了”的工单。
第五条,测试要覆盖“没有 Cookie”这个分支。用无痕窗口跑一遍完整流程,很多 NPE 和空判断问题在这时候就会暴露。这个习惯我坚持了很多年,收获比任何代码审查都大。
最后说个我个人的体会。Cookie 这个技术看起来很老,语法简单到几乎是“反技术”的,但它的每一个属性背后都对应着一类真实的攻击或一类真实的兼容性事故。Path 和 Domain 是为了隔离,HttpOnly 是为了防读取,Secure 是为了防窃听,SameSite 是为了防冒充。你把这些属性当成“安全边界”而不是“可选配置”来看,写出来的代码质量会完全不一样。我刚入行时也觉得这些东西麻烦,能省就省,后来几次安全扫描报告把我教育明白了——那时候补上的每一个属性,现在都成了我新建项目时的模板代码。