Servlet Cookie 全链路:属性、编码与删除排查
Servlet里有一类对象,你天天在用,但很少认真翻过它的文档,Cookie算一个。多数人第一次接触javax.servlet.http.Cookie,是在做登录功能的时候:跟着教程敲三行代码,new 一个、setMaxAge、addCookie,页面刷新一下确实生效了,然后这事就翻篇了。等到项目丢到真实环境里,“记住我”忽然不灵;或者同一个应用,首页能读到值、二级目录下的页面读不到;再或者 value 里塞了中文,容器直接甩一个 500 出来——这时候才回头翻规范,发现这个小东西的属性项比想象中多得多,行为边界也比想象中窄得多。
这篇内容就围绕 Servlet 编程里的 Cookie 对象展开,讲清楚它为什么存在、服务端与浏览器之间的读写链路长什么样、那几个属性分别在什么时机起作用、中文值和特殊字符为什么会被容器直接拒掉,以及删除不干净、同名冲突、path 对不上这几类问题的排查顺序。适合已经能跑通一个 Hello World 级别登录 Demo、但对 Cookie 的认知还停留在“能存能取”这一层的同学;也适合工作一两年,想把会话跟踪这块基础彻底钉死的人。文中所有代码都基于标准 Servlet API,不绑定具体容器版本,但涉及版本差异的地方我会单独点出来。
1. 无状态协议下,“记住用户”这件事到底由谁承担
1.1 一次请求走完,服务端就把你忘光了
HTTP 的设计哲学是“一问一答,答完即忘”。浏览器发一个请求,服务端处理、返回响应,连接关掉,这次交互在服务端的内存里几乎不留痕迹。这套模型在拉取静态资源时极其高效,但一旦碰到“这个用户刚才是不是登录过”这种问题,就立刻暴露出短板:服务端没有任何内建机制能识别出“这两个请求来自同一个人”。
这件事的性质需要说清楚:不是 Servlet 缺功能,而是 HTTP 协议本身就不提供状态。所以任何一个会话跟踪方案,本质上都是在客户端和服务端之间约定一个“暗号”,让服务端能凭这个暗号把前后的请求串起来。业界的做法大致就那么几种:URL 重写(把 sessionId 拼在链接后面)、隐藏表单域(把标识塞进 form 的 hidden input)、Cookie,以及后来的各种 Token 方案。前两种只在特定场景下能用——URL 重写会污染地址栏、隐藏域只能跟着表单走,用户一旦手动敲地址或者点收藏夹就断链。Cookie 之所以成为 Servlet 规范里最正统的方案,是因为它把“携带标识”这件事从业务代码里剥离出来,交给了浏览器自动完成。
Servlet 容器启动、处理第一个请求、创建HttpSession、往响应里塞一个Set-Cookie: JSESSIONID=xxx——这一整套动作,背后站着的就是Cookie对象。所以你把 Cookie 搞明白了,Session 那一节的很多“玄学现象”也顺带就通了。
1.2 Cookie 在浏览器与服务端之间往返的那条链路
把链路拆开看,Cookie 的一生只有两个方向的动作:
服务端 → 浏览器:响应头里出现Set-Cookie,浏览器收到后按域名和路径把这条记录存进自己的 Cookie 存储区。注意是“存”,不是“用”,这一步纯粹是落盘。
浏览器 → 服务端:之后每一次请求,只要目标域名和路径匹配,浏览器会自动把这条 Cookie 放进请求头Cookie: name=value里带上去。这个过程完全不需要前端写任何代码,fetch、XMLHttpRequest、表单提交、直接在地址栏敲回车,全都一样。
这里有个容易被忽略的细节:请求头里的 Cookie 只带 name 和 value,不带任何属性。这意味着服务端拿到手的只是一个字符串键值对,至于这条 Cookie 当初设了多长的有效期、挂在哪个路径下、是不是 HttpOnly,服务端一概无从得知。这些属性是给浏览器看的,是浏览器判断“要不要存”“什么时候过期”“什么时候发出去”的依据,服务端只能通过request.getCookies()拿到最终的键值对结果。
理解这一点,后面很多问题就顺了。比如“为什么我读不到自己设置的 path”——因为 path 根本不在请求头里传输;“为什么我删了 Cookie 但它还在”——因为你只是在服务端发了个删除指令,浏览器有没有照做,取决于你那条指令的域名、路径、名字是否和被删的那条完全对上。
再看一次典型的往返:
第一次请求(登录成功) POST /app/login <-- 200 OK Set-Cookie: uid=10086; Max-Age=604800; Path=/app; HttpOnly 后续请求(自动携带) GET /app/profile Cookie: uid=10086 <-- 200 OK整个机制的巧妙之处在于“自动”二字,风险也藏在“自动”二字里:只要你浏览这个域名,浏览器就会主动把 Cookie 交出去,不管发起请求的是你自己写的页面,还是别的站点诱导浏览器发出的请求。Cross-Site Request Forgery 这类问题能成立,根子上就是这条自动携带的规则。
2. Cookie 对象在 Servlet 里的读写全流程
2.1 服务端把 Cookie 交给浏览器:new 与 addCookie 之间发生了什么
Cookie在 Java 侧就是一个普普通通的 POJO,构造方法只有两个:Cookie(String name, String value)和已经废弃的Cookie(String name, String value, String path)。new 出来之后,它只是堆里一个对象,和响应没有任何关系,必须显式调用response.addCookie(cookie),容器才会在生成响应头的时候把它序列化成Set-Cookie。
这段代码几乎是所有教程的起点:
@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String remember = req.getParameter("remember"); if ("on".equals(remember)) { Cookie cookie = new Cookie("username", URLEncoder.encode(username, "UTF-8")); cookie.setMaxAge(60 * 60 * 24 * 7); // 单位是秒,这里是 7 天 cookie.setPath(req.getContextPath().isEmpty() ? "/" : req.getContextPath()); cookie.setHttpOnly(true); resp.addCookie(cookie); } resp.sendRedirect(req.getContextPath() + "/index.jsp"); } }几个点值得单独拎出来说。setMaxAge的单位是秒,不是毫秒也不是天,写成60 * 60 * 24 * 7是七天,写成1000 * 60 * 60 * 24 * 7就变成七十天了,这种错很常见。setPath我用了req.getContextPath()而不是硬编码/,是为了避免应用部署在/app这种子路径下时,Cookie 溢出去污染同域下的其他应用。setHttpOnly(true)是防 XSS 顺手偷 Cookie 的第一道闸,只要这个值不需要前端 JS 读,就应该加上。
还有一点:同一个响应里对同名 Cookie 多次addCookie,结果不一定如你所愿。较老的规范实现里,两个同名 Cookie 会连着输出两个Set-Cookie头,浏览器收到后通常以后者为准,但顺序由实现决定;新版 Servlet 规范对这块做了收敛。稳妥的做法是保证一个响应里不重复设置同名 Cookie,需要覆盖就直接算好最终值再塞一次。
2.2 服务端从请求里把 Cookie 捞回来:getCookies 的边界
读侧的 API 只有一个:request.getCookies(),返回Cookie[]。它的两个陷阱必须记住:
第一,没有 Cookie 时返回的是null,不是空数组。直接for (Cookie c : req.getCookies())会喜提 NPE。这是新手最常踩的一个,尤其出现在“第一次访问”的场景里——你自己测试的时候浏览器可能已经有旧 Cookie 了,换个无痕窗口立刻崩。
第二,返回的数组里只有 name 和 value 有值,getMaxAge()永远返回 -1,getPath()永远返回 null。前面已经解释过原因,这里再强调一遍,是因为很多人会写出if (c.getName().equals("uid") && c.getMaxAge() > 0)这种永远不会成立的判断。
实用写法是把读取封装成一个小工具:
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())) { return c.getValue(); } } return null; }遍历顺序上有个隐含前提:理论上不可能出现两个 name 完全相同的 Cookie 同时出现在请求头里——因为浏览器判定 Cookie 唯一性的依据是name + domain + path 三元组,同名但 path 不同的两条 Cookie 是可以共存的,而它们只在路径同时匹配的请求里才会一起发出去。如果你在/app和/下各存了一条uid,访问/app/x时两条都会命中,浏览器按路径长度从长到短排列(RFC 6265 规定更具体的路径优先),于是getCookies()里先出现的会是/app那条。这个顺序在排查“值怎么不对”时非常有用,不是乱序的。
3. 决定 Cookie 实际行为的几个属性
3.1 maxAge:正数、负数、0 是三件完全不同的事
maxAge是 Cookie 上语义最有意思的一个属性,三种取值对应三种完全不同的生命周期:
| 取值 | 含义 | 存储位置 | 典型用途 |
|---|---|---|---|
| 正数 N | 从现在起 N 秒后过期 | 落盘,浏览器进程关掉也不丢 | 记住用户名、免登录令牌 |
| 负数(含默认 -1) | 会话 Cookie,浏览器关掉即失效 | 内存 | 临时会话标识、单次流程状态 |
| 0 | 立即过期,等于删除 | —— | 退出登录、清理痕迹 |
会话 Cookie 这个行为值得展开一句。它所谓“关掉浏览器就消失”,准确说是“关闭整个浏览器进程”,而不是关掉标签页。有些浏览器提供了“恢复上次会话”的功能,会把内存里的会话 Cookie 一起恢复,导致你以为失效了的东西其实还在。如果要严格保证某条 Cookie 在服务端可控的时限内有效,就别依赖会话 Cookie 的隐式过期,老老实实给一个正数。
另一个常被写错的地方是:setMaxAge的值最终会以Max-Age响应头输出,而部分老浏览器和某些代理对Max-Age的支持并不一致,容器可能会同时输出一个Expires头做兼容。你在抓包里看到两个头并存,是正常现象,不是配错了。
3.2 path 与 domain:设了读不到,多半栽在这里
path用一句话概括:它限制的是“哪些请求会自动带上这条 Cookie”,不是“哪些代码能读到它”。默认情况下,如果你不调用setPath,浏览器会按 RFC 6265 的规则,取发出该响应的请求 URI 的目录部分作为默认 path。比如你在/app/user/login这个地址返回了Set-Cookie,默认 path 就是/app/user,那么访问/app/index时浏览器不会带上它——现象就是“明明设置成功了,首页死活读不到”。
这个问题在本地开发时特别隐蔽:你可能正好只在同一个目录下测试,怎么都对。一上线,前端页面和接口不在一个路径前缀下,立刻暴露。
domain则是控制 Cookie 的作用域能否跨子域。默认情况下,/app下设置的 Cookie 只能被www.example.com这个精确主机读取。如果希望a.example.com和b.example.com共享,需要显式设置cookie.setDomain(".example.com")(注意前导点,不同容器对是否保留前导点的处理略有差异)。但这里有个硬性限制:你只能把 domain 设成当前域名本身或它的父域,不能设成兄弟域或完全不相干的域。a.example.com能设置.example.com,但设不了.other.com。这个限制由浏览器执行,是 Cookie 无法被当作跨站追踪手段的根本原因之一。
还有一条实践建议:能精确就不要放大。网关层、鉴权层和应用层往往部署在同一域的不同路径下,如果所有 Cookie 都往/上怼,键名一冲突就是一场灾难。给每个业务模块一个专属前缀路径,是对未来的自己好。
3.3 Secure、HttpOnly 与 SameSite:三个不该等到出事才补的开关
这三个属性都不是“功能开关”,而是“风险收敛开关”,上线前不补,事后补的代价通常包括改代码、改部署、清存量 Cookie。
HttpOnly打开后,document.cookie读不到这条 Cookie。它挡不住 XSS 本身,但能把 XSS 的危害从“窃取会话”降级到“篡改当前页面”。成本几乎为零,只要这条值不需要前端 JS 直接读,一律加上。
Secure打开后,浏览器只在加密通道下才发送这条 Cookie。它的作用不是加密内容(Cookie 内容本身在传输层就已经被保护了),而是防止你在明文通道下漏出标识。开发环境用本地地址跑的时候要留意,某些配置下SecureCookie 在明文环境下会被浏览器直接丢弃,导致“本地怎么都存不上”,这时应该按环境区分配置,而不是把Secure关掉。
SameSite控制跨站请求时是否携带 Cookie,取值一般是Strict、Lax、None。设置为None时通常必须同时打开Secure。在 Servlet 6.0 之前,规范里的Cookie类没有直接暴露这个设置入口,只能靠容器配置或在响应头里手工拼;Servlet 6.0 之后提供了cookie.setAttribute("SameSite", "Lax")这样的通用属性设置方法,用起来顺多了。如果项目跑在较老的规范上,那就去容器配置里统一设默认值,别在业务代码里手工拼Set-Cookie字符串——转义和格式出错的概率太高。
4. 中文值与特殊字符:最容易被容器直接拒掉的地方
4.1 value 里塞中文为什么直接 500
经常看到的现象是:存英文没问题,改成中文立刻抛异常,堆栈里指向response.addCookie那一行,异常信息大意是“cookie value 包含非法字符”。
根因在于 Cookie 的语法基础是 RFC 6265,它对cookie-value允许出现的字符做了严格限定:只能是一段被称为 cookie-octet 的 ASCII 可见字符子集,明确排除空格、双引号、逗号、分号、反斜杠这些。中文是多字节字符,完全超出这个集合,现代容器在输出前会做校验,不合法就直接拒绝,而不是像早期那样悄悄截断或乱码。这是好事,它把问题暴露在了开发阶段,而不是等到线上才发现 Cookie 被浏览器丢掉。
顺便说一句,非 ASCII 的问题不止出现在value上,name同样受限,而且name的限制更严:不能为空、不能以$开头、不能包含分隔符。给 Cookie 起名字这件事,稳妥的做法是只用字母、数字、下划线和短横线。
4.2 URLEncoder 与 Base64 两条路怎么选
解决中文问题有两条常见路径,各有适用场景:
第一条是URLEncoder/URLDecoder。把原始值按 UTF-8 编码成百分号转义序列,读回来的时候再解一次。它的优点是结果对人可读性尚可、调试时看得懂;缺点是编码后的字符串会变长(一个中文字符变成 9 个字符),而且URLEncoder原本是为表单编码设计的,它会把空格编码成+而不是%20,虽然对 Cookie 场景影响不大,但解码时要注意别用错方法。
String raw = "张三"; String encoded = URLEncoder.encode(raw, StandardCharsets.UTF_8); // %E5%BC%A0%E4%B8%89 Cookie c = new Cookie("nickname", encoded); // 读取侧 String value = getCookieValue(req, "nickname"); String nickname = value == null ? null : URLDecoder.decode(value, StandardCharsets.UTF_8);第二条是 Base64。更通用的做法,把任意字节序列转成纯 ASCII,天生满足 Cookie 的字符约束。它比 URL 编码更短(一个中文字符变 8 个字符左右),而且不依赖字符集的语义,适合存放结构化内容。
String raw = "{\"uid\":10086,\"n\":\"张三\"}"; String encoded = Base64.getUrlEncoder().withoutPadding() .encodeToString(raw.getBytes(StandardCharsets.UTF_8)); Cookie c = new Cookie("profile", encoded);注意我用的是getUrlEncoder().withoutPadding()而不是标准 Base64——标准 Base64 会输出+、/、=这三个字符,+在某些解码链路上会被当作空格处理,=放在 Cookie 值末尾虽然大部分情况没事,但没必要给自己埋雷。URL 安全变体把+换成-、/换成_,配合去掉填充,输出就是纯字母数字加两个符号,非常稳。
选择上我的建议是:纯文本值用 URLEncoder,结构化内容(JSON、序列化对象)用 URL 安全的 Base64。另外无论选哪种,读侧一定要做空值判断和异常兜底——Cookie 是用户完全可控的输入,你把一段乱码丢给URLDecoder.decode,它会抛IllegalArgumentException,这个异常如果没接住,整个请求就崩了。用户手工改一下 Cookie 就能让你的接口 500,这属于典型的输入校验缺失。
5. 三个可以直接抄的场景实现
5.1 记住用户名:从勾选到回填的完整闭环
这是最经典的入门场景,但真要做到“干净”,需要考虑三件事:什么时候写、什么时候读、什么时候清。
写:登录成功且用户勾选了“记住我”时写入,有效期给 7 到 30 天,值做 URL 编码,加HttpOnly(因为回填是服务端渲染时做的,不需要 JS 参与)。这里有个细节要注意,存的应该是用户名而不是密码,也不要存“用户名+密码的某种组合”然后自动登录,那等于把长期凭证明文放在用户机器上。
读:在渲染登录页之前读取。如果是 JSP 渲染,可以在页面顶部用脚本片段取值,但更推荐的做法是让 Servlet 先取好,通过request.setAttribute传给视图层,避免视图里出现大段 Java 代码。
清:用户主动取消勾选、或者退出登录时,发一条同名同路径、Max-Age=0的 Cookie 覆盖掉。
// 清除 Cookie c = new Cookie("username", ""); c.setMaxAge(0); c.setPath(req.getContextPath().isEmpty() ? "/" : req.getContextPath()); resp.addCookie(c);这段代码里setPath那一行是灵魂。删除 Cookie 的本质是发一条同名、同域、同路径、maxAge 为 0 的 Cookie 去覆盖它,这三个维度只要有任何一个对不上,浏览器就会认为这是两条不同的 Cookie,于是旧的还在,新的白删。我见过太多“退出登录之后用户名还在”的案例,最后都是 path 写成了/而当初写入时用的是/app。
5.2 免登录令牌:Cookie 里该放什么、不该放什么
“下次自动登录”这个功能,很多人的第一版实现是往 Cookie 里塞username=xxx; password=xxx,这属于把家门钥匙焊在门口。正确的心智模型是:Cookie 里放的应该是一个无意义的随机串,服务端拿这个串去查自己的存储,才知道它对应谁。
一个可落地的简化方案:
- 登录成功时生成一个足够长的随机串(比如 32 字节的安全随机数,转成十六进制或 Base64),存进服务端的令牌表,记录
token -> userId + 过期时间 + 设备标识。 - 把 token 写进 Cookie,
maxAge给 14 天,HttpOnly必开,Secure视环境开。 - 每次请求进来,先查会话,没有会话就查 Cookie 里的 token,命中且未过期就重建会话。
- 用户改密码、点“退出所有设备”时,把令牌表里对应的记录全部作废。
这个方案的关键在于令牌可撤销。它比“把签名信息塞进 Cookie 自己做校验”要笨一些,需要一张表,但换来了“随时踢人下线”的能力,这在真实业务里几乎是一定会被提的需求。
这里必须提一句边界:不要把敏感信息直接放进 Cookie,哪怕做了编码。Base64 是编码不是加密,atob一下全出来了。Cookie 存储在任何时候都应该被当作“用户可见、用户可改”的公开区域。你如果非要放结构化数据,至少做一次服务端签名,读的时候验签,验不过就当没有。
5.3 浏览记录:列表型数据的塞法
“最近浏览过的商品”这类需求,很多人第一反应是存 Session,但 Session 一过期就没了,而且多节点部署还得考虑共享。用 Cookie 落盘反而更合适——数据本来就跟用户设备绑定,不需要跨设备同步。
方案是把列表序列化成 JSON,做 URL 安全的 Base64,塞进一条 Cookie:
List<Long> ids = new ArrayList<>(recentIds); ids.add(0, currentProductId); // 去重并截断到 10 条 List<Long> trimmed = ids.stream().distinct().limit(10).collect(Collectors.toList()); String json = JSON.toJSONString(trimmed); String value = Base64.getUrlEncoder().withoutPadding() .encodeToString(json.getBytes(StandardCharsets.UTF_8)); Cookie c = new Cookie("recent", value); c.setMaxAge(60 * 60 * 24 * 30); c.setPath("/"); resp.addCookie(c);这里必须精打细算长度。单个 Cookie 的容量上限在主流浏览器里大约是 4KB,包含 name、value 和属性。10 个商品 ID 大概 80 字节,编码后 110 字节左右,非常安全;但如果你的“浏览记录”里存的是商品标题、图片地址、价格,几十个商品就能轻松顶到上限,超出的部分会被浏览器直接丢弃,而且不报错——现象是“有时候记录全没了”,极难排查。所以列表型 Cookie 只存 ID 这类轻量标识,展示数据走一次查询,别偷懒。
6. Cookie 删不掉、删不干净的真实排查链路
6.1 删除动作的本质是覆盖
前面提过一次,这里把完整的排查顺序列清楚,因为这是实践中最容易卡住的一类问题。现象是:调用退出接口,响应 200,刷新页面,用户名还在。
第一步,抓包看响应头里到底有没有Set-Cookie。有时候是业务代码走了 else 分支,根本没执行删除逻辑,先排除这个。
第二步,看这条Set-Cookie的Max-Age是不是 0,或者Expires是不是一个过去的时间点。setMaxAge(0)是正确写法;写成setMaxAge(-1)那是会话 Cookie,不但不删,还会把原来的持久化 Cookie 覆盖成会话级别。
第三步,也是最关键的一步,逐字段比对删除用的 Cookie 和当初写入的 Cookie,重点看 name、path、domain 三项。name 必须完全相同,大小写敏感;path 必须完全相同,/app和/app/在某些实现里会被视为不同值;domain 必须完全一致,一个有前导点一个没有,浏览器可能认为是两个域。
第四步,确认没有别的地方在重复写入。比如拦截器、过滤器里给每个响应都补了一条同名 Cookie,你在业务层删了,过滤器又给写回去了,抓包会看到响应里同时有两条Set-Cookie且顺序在业务逻辑之后。
如果这四步都排除了还是删不掉,那就要怀疑是不是有同名的 Cookie 在另一个路径下存在,你以为删的是 A,实际读的时候拿到的是 B。这时候在浏览器开发者工具里看 Cookie 列表,把 Path 那一列展开,通常一眼就能看出问题。
6.2 同名 Cookie 的覆盖顺序与多应用共存的坑
“同名覆盖”这件事有一个常被误解的点:两个同名 Cookie 是否互相覆盖,取决于它们的 domain 和 path 是否也相同。三元组完全相同才会覆盖,否则就是两条独立记录共存。
这个特性会在一种场景下咬人:你有一个老系统部署在/legacy下,新系统部署在/下,两边都用uid这个键名。用户在两个系统间跳转的时候,浏览器会在访问/legacy/*时同时带上两条uid,而这两个值是完全不同步的。表现出来就是“用户在新系统登录了,进老系统还是旧身份”,或者“在老系统退出了登录,新系统看着还登着”。排查这种问题时,如果只盯着自己那一侧代码,永远找不到原因。
处理办法有两个方向:一是加前缀,新系统统一用app_uid这类带业务前缀的键名,物理隔离,成本最低;二是收窄 path,让每个应用的 Cookie 只在自己路径下生效,但这条路有个前提,就是所有相关请求都必须落在该路径下,网关转发、静态资源这些容易漏。实际项目里我更推荐第一种,改个名字的事,别跟路径较劲。
还有一条:Cookie 的数量限制是按域名算的,不是按 path 算。主流浏览器每个域名能存的 Cookie 数量在几十到上百条的量级,具体数值各家不同。一个长期运营的项目,如果每个功能模块都随手塞一两百字节的 Cookie 且只增不减,几年下来真的会碰到上限。到那时候的表现是“新功能的 Cookie 存不进去,而且没有任何错误提示”,非常难查。所以从一开始就养成习惯:给每条 Cookie 一个明确的生命周期,用完就清,别把它当免费的本地存储。
7. Cookie 的能力边界与 Session 的分工
7.1 为什么登录状态这件事不该塞进 Cookie
回到最开始那个链路:浏览器会自动携带 Cookie,且把 Cookie 内容原封不动交给服务端。这两条决定了 Cookie 值天然是不可信输入,任何直接信任其中内容的写法都是在裸奔。
于是就有了分工:Cookie 只负责“带一个标识”,真正的状态数据放在服务端,这个组合就是 Session。Session 的工作方式是,容器生成一个 sessionId,通过 Cookie(默认键名通常类似JSESSIONID)下发给浏览器,服务端自己维护sessionId -> 数据的映射。用户看到的是“登录状态保持了”,实际发生的是“每次请求带个号码,服务端拿号码查自己的本子”。
这个分工带来三个直接好处:状态数据不出服务端,攻击面小得多;数据量不受 4KB 限制;可以随时在服务端作废。代价是服务端要有地方存这些数据,单机时代无所谓,多节点时代就要引入共享存储或者会话粘滞。这就是为什么很多项目最终走向了“Cookie 里放一个签名的 Token,状态数据放服务端缓存”的混合方案——它保留了 Cookie 的轻量,又拿回了无状态扩展的能力。
理解这条演进线,比背下 Session 的 API 有用得多。因为你在设计任何一个需要“跨请求记住点什么”的功能时,脑子里应该先问三个问题:这个数据敏感吗?它需要被服务端撤销吗?它会有多大?三个问题的答案基本就决定了该往 Cookie 里放什么、放多少。
7.2 浏览器侧的几个硬限制与扩容思路
最后把几个硬性边界集中列一下,都是开发和运维都会碰到的:
| 限制项 | 大致量级 | 触发后的表现 |
|---|---|---|
| 单条 Cookie 大小 | 约 4KB(含 name、value、属性) | 超出部分被静默丢弃,只在控制台留提示 |
| 单域名 Cookie 条数 | 几十到上百条,随浏览器而异 | 新 Cookie 无法写入,无错误抛出 |
| 域与路径匹配 | 只能设置当前域或其父域 | 设置被浏览器忽略 |
| 值字符集 | 受限 ASCII 子集 | 容器侧直接抛异常,或浏览器侧丢弃 |
碰到容量不够,常规的扩容思路有几种:拆分多条 Cookie(把一个大 JSON 拆成data_0、data_1),这只解决单条超限,不解决条数超限;把数据挪到服务端,Cookie 只留一个 key,这是最干净的方案;再有就是把这类数据从 Cookie 迁移到浏览器本地存储,但本地存储不会被自动携带,需要前端配合,而且同样要面对 XSS 的风险。
我个人的经验是,只要某个 Cookie 的值开始接近 1KB,就应该停下来想一想是不是放错地方了。Cookie 最适合的形态始终是“短小、无意义、只作标识”的一小段字符串,它天生就不是用来存数据的容器。把它用在正确的定位上,前面那些 path 对不上、删不掉、容量爆掉的问题,绝大多数根本不会发生。