HTTP 请求本身是"用完即忘"的,服务器处理完一个请求就把你忘了,下一个请求进来它根本认不出你是谁。会话对象(Session and Cookie)这套机制,就是在这个"失忆"的协议上,硬生生给每个用户挂上一块写着身份的门牌。做 Web 开发的人几乎每天都要跟它打交道:登录怎么记住状态、购物车为什么关了浏览器还在、后台管理系统为什么十分钟不点就自动登出,答案都在这里。这篇内容我打算把 Cookie 和 Session 从"能用"讲到"知道为什么必须这么用",包括属性含义、存储选型、分布式踩坑、压测配置和安全加固,面向的是刚入行的后端和前端开发、做接口测试的同学,以及被"登录态总是失效"折磨过的运维。你在别处看到的多数教程只告诉你setCookie怎么写,我这里会把每次选择背后的取舍也一并说清楚。
1. 会话对象到底解决了什么问题
1.1 HTTP 无状态这个"坑"是怎么来的
要理解会话对象,得先接受一个前提:HTTP 是无状态协议。这句话不是设计缺陷,而是当年为了让服务器能扛住海量请求刻意做的简化——每个请求独立处理,处理完立刻释放资源,服务器不需要为"你"保存任何上下文。好处是扩展性极强,坏处是一旦业务需要"连续性",它就抓瞎了。
我举个特别直观的例子。你打开一个电商网站,把商品加了购物车,然后点结算。这个过程至少发出三个请求:加入购物车、查询购物车、生成订单。如果服务器完全无状态,那第二个请求进来的时候,它根本不知道这个请求和刚才加购的是同一个人,购物车就是空的。登录更是如此,你输入账号密码通过验证之后,下一个请求访问个人中心,服务器又把你当陌生人了。
解决思路其实只有一个方向:让客户端每次请求都主动带上一个凭证。服务端不记人,那就让客户端自己"证明我是谁"。这个凭证就是会话标识,围绕它衍生出了 Cookie 存储、Session 服务端保存、Token 自包含等一整套技术。可以说,现代 Web 里所有的"登录态"概念,本质上都是在给无状态协议打补丁。
这个补丁要同时满足三个条件才算及格:凭证不能被别人轻易猜到或伪造;凭证要有明确的失效时间,不能让一次登录永久有效;凭证的传递要尽量自动化,不能让用户每次点链接都手动输入。Cookie 之所以能成为主流方案,就是因为它天然满足第三条——浏览器会自动把它带上。
1.2 Cookie 与 Session 的分工:谁存什么、谁管什么
很多新手会把 Cookie 和 Session 混为一谈,觉得"反正都是登录用的"。它们其实是两个位置上的两样东西,配合使用。
Cookie 是存放在客户端浏览器里的一小段文本,由服务器通过响应头Set-Cookie下发,浏览器保存后,后续对同域名的请求会自动通过Cookie请求头带回去。它容量很小,单个 Cookie 一般限制在 4KB 左右,每个域名下的数量和总大小也有上限。
Session 是存放在服务端的一份数据,通常是一个键值结构,键是会话 ID,值是用户相关的状态,比如用户 ID、权限、登录时间。服务端拿到客户端带上来的会话 ID,去自己的存储里一查,就能还原出"这是谁"。
它们的关系可以这样理解:Session 是一间寄存柜里的包裹,Cookie 是那张取件码小票。小票本身不包含你的东西,但它能让你取到东西。这个类比的妙处在于,它顺带解释了安全性——小票丢了别人也能取件,所以取件码必须足够随机、必须能作废;而包裹放在柜子里(服务端),即使有人拿到小票,也只能取到那一份,拿不到柜子的钥匙。
这里有个常被忽略的点:Cookie 里装的不一定是会话 ID。你完全可以把一些非敏感的偏好设置直接存在 Cookie 里,比如语言选择、主题色、列表每页显示多少条。这类数据放服务端反而浪费存储和查询开销。判断标准很简单:涉及身份、权限、金额的,一律放服务端;纯展示偏好的,放 Cookie 无妨。
1.3 四种主流会话方案横向对比
选方案之前先看清楚每种方案的代价。下面这张表是我自己在做技术评审时常用的对照,覆盖了绝大多数业务场景。
| 方案 | 状态存放位置 | 服务端是否需要存储 | 水平扩展难度 | 主动失效能力 | 典型适用场景 |
|---|---|---|---|---|---|
| 纯 Cookie 明文 | 客户端 | 不需要 | 容易 | 几乎做不到 | 主题、语言等偏好 |
| 服务端 Session + Cookie 存 ID | 服务端 | 需要 | 需要共享存储 | 强,可即时踢人 | 后台管理系统、传统 Web |
| 签名 Cookie(防篡改) | 客户端 | 不需要 | 容易 | 弱,只能等过期 | 轻量级登录态 |
| 自包含令牌 | 客户端 | 不需要(或仅黑名单) | 容易 | 依赖黑名单 | 前后端分离、多端登录 |
这张表里最关键的一列是"主动失效能力"。很多团队一开始图省事选了自包含令牌,等到出了安全事故需要立刻让某个用户的登录态作废时才发现,令牌已经在用户手里了,只要你没有额外的黑名单机制,就只能干等它自然过期。服务端 Session 在这件事上优势明显,删掉存储里的那条记录,用户下一个请求立刻被拒。
所以我的建议是分场景:内部管理后台、金融类业务,优先用服务端 Session,因为可管控性最重要;面向公众的高并发接口,可以考虑自包含令牌减少存储压力,但一定要配一套黑名单或者短过期时间加刷新机制。不要一上来就追求"先进",先想清楚你的业务能不能接受"踢不掉人"。
2. Cookie 的每个属性都值得单独说一遍
2.1 从 Set-Cookie 那一行说起
服务器下发 Cookie 的原始形态就是一行响应头:
Set-Cookie: sid=8f3a9c1e7b2d4a6f; Path=/; Domain=example.com; Max-Age=7200; HttpOnly; Secure; SameSite=Lax分号分隔的每一段都是一个属性。很多人写代码时只关心sid=xxx这一部分,后面的属性全靠框架默认值,这是埋雷的开始。因为框架默认值往往是"兼容性优先",不是"安全性优先"——默认不加 HttpOnly、默认不加 Secure、SameSite 默认值还随浏览器版本变过。
我的习惯是:只要这个 Cookie 和身份有关,属性全部显式写出来,一个都不靠默认。多写几十个字符,换来的是排查问题时不必猜测"到底生效了什么"。后面几节我把每个属性拆开讲,重点关注那些"不加会出事、加错了也会出事"的。
还有一点要提醒:Set-Cookie不能像其他响应头那样用逗号合并多条。你要下发多个 Cookie,就得写多行Set-Cookie。这个细节在做接口调试时很容易踩——把两个 Cookie 合并成一行发送,浏览器只会认第一个。
2.2 HttpOnly、Secure、SameSite 三件套
这三个属性是目前 Cookie 安全的基石,我逐个说清楚它们拦住的是什么。
HttpOnly的作用是禁止 JavaScript 通过document.cookie读取该 Cookie。它防的是跨站脚本注入——攻击者往你的页面里注入一段脚本,脚本第一件事往往就是读取 Cookie 然后发到自己的服务器。加上 HttpOnly 之后,脚本读到的document.cookie里就没有这个会话 ID 了。要注意它的边界:HttpOnly 只挡读取,不挡"利用"。如果站点存在跨站请求伪造漏洞,攻击者不需要读到 Cookie,只要让受害者的浏览器自动带上 Cookie 发请求就行。这就是为什么还需要 SameSite。
Secure表示这个 Cookie 只在加密连接下发送。不加这个属性,会话 ID 有可能在明文链路上被中间节点截获。本地开发时用http://localhost调试,加了 Secure 会导致 Cookie 存不下来,这是新手最常遇到的"本地好好的,一上线就正常"或者反过来的情况。解决办法是本地开发环境单独判断,只在非本地域名时加 Secure。
SameSite控制跨站请求是否携带该 Cookie,取值有三个:
Strict:完全禁止跨站携带。安全性最高,但用户体验有损——从外部链接点进你的网站,第一跳是不带登录态的,用户会看到"未登录"的页面,需要再点一次。Lax:允许顶级导航的 GET 请求携带,POST 跨站请求不携带。这是目前多数浏览器的默认值,也是绝大多数业务场景的平衡点。None:完全放开跨站携带,但必须同时带 Secure,否则浏览器直接拒绝存储。
判断标准我给你一个简化的口诀:普通业务用 Lax;需要内嵌到第三方页面的场景才考虑 None,并且一定要配合其他校验手段;纯粹的管理后台可以上 Strict,反正用户都是直接访问。
2.3 Domain 与 Path 的匹配规则,最容易踩的坑
Domain 决定这个 Cookie 会被发送到哪些域名,Path 决定会被发送到哪些路径。这两个属性的匹配逻辑有个反直觉的地方:它们不是"精确相等",而是"后缀匹配"。
Domain 设为example.com,那么www.example.com、api.example.com都会带上这个 Cookie。Domain 设为www.example.com,那api.example.com就带不上。如果你不写 Domain,浏览器默认取当前域名,且是"主机精确匹配",也就是www.example.com下发的 Cookie 不会自动发给api.example.com。
这里有个特别隐蔽的坑:父域下发的 Cookie 会自动被子域共享。如果你的主站和某个子域是不同团队维护,父域下发的会话 Cookie 会一并出现在子域的请求里,子域一旦被攻破,主站的会话 ID 就泄露了。所以给子域配置时,尽量用主机级 Cookie,不要图省事写父域。
Path 的匹配同样是前缀规则。Path=/admin的 Cookie 会在/admin、/admin/users、/adminx上都发送——注意最后这个/adminx,它并不在直觉上的管理路径里,但因为字符串前缀匹配上了,Cookie 照样会带。如果你用 Path 来做隔离,路径末尾最好带上明确的边界。
2.4 Max-Age 与 Expires:浏览器关掉之后还在不在
这两个属性都用来指定过期时间,区别在于Max-Age是从当前时刻起算的秒数,Expires是一个绝对时间点。当两者都存在时,Max-Age优先。同时不写这两个属性,就是会话级 Cookie,浏览器进程关闭后清除。
"关闭浏览器就清除"这件事其实不太可靠。现代浏览器普遍有"恢复上次会话"的功能,进程退出时会话 Cookie 可能被一并恢复。所以不要依赖这个行为来实现登出——真正要登出,必须在服务端把对应的会话记录删掉,而不是指望浏览器帮你清。
反过来,如果你要实现"记住我",就把Max-Age设长一些,比如 7 天或者 30 天。但要注意,一个有效期 30 天的会话 ID 一旦泄露,攻击者就有 30 天的时间窗口。做法是把这个长期凭证和普通会话凭证拆开:长期的那个只用来换取新会话,本身不直接作为身份凭证,并且每次使用后轮换。这样即使泄露,也能通过使用痕迹发现异常。
我给一个实际项目里的取值参考:管理后台会话 30 分钟无操作过期;普通用户登录态 2 小时滑动续期;"记住我"凭证 14 天且单次有效。这三个数字不是标准答案,但作为一个起点比拍脑袋定要好。
3. Session 服务端怎么存:选型与权衡
3.1 内存、文件、数据库、Redis 四种存法
会话数据存哪儿,直接决定了你的系统能撑多大、能扛多快。我把四种常见方案的特点摆出来。
进程内存是最简单的,一个哈希表搞定,读写都在纳秒级。缺点是进程重启就全丢,而且多实例之间不共享。适合单机部署的小项目或者本地开发。
文件系统把每个会话写成一个小文件。优点是重启不丢,实现也不复杂。缺点是并发读写下文件锁容易成为瓶颈,而且多实例部署时需要共享文件系统,运维复杂度上来了。PHP 早期默认就是这个方案,能扛住一定量但不是长久之计。
关系型数据库把会话存成一张表。优点是持久、可查询、方便做审计;缺点是每个请求都要打一次数据库,会话读写会占用宝贵的数据库连接数。如果你的接口本身 QPS 不高,这个方案没毛病;一旦并发上来,会话表很容易变成热点。
Redis是目前最主流的选择。单机读写十万级 QPS 很轻松,天然支持过期时间(直接设 TTL 就行,不用自己写清理任务),还支持主从和集群。它的问题是要多维护一个组件,以及要注意持久化配置——Redis 如果没开持久化,宕机重启后所有人的登录态都会消失。
我的实际选择顺序是:单体小项目先用内存或文件,别过度设计;一旦涉及多实例部署,直接上 Redis,不要犹豫。中间状态最难受,比如先用数据库撑一撑,结果上线后发现数据库连接被会话查询占满,回头迁移又是个大工程。
3.2 分布式环境下为什么会"一刷新就掉线"
这是会话相关问题里最高频的一个,几乎每个团队都遇到过:单机测试一切正常,部署成两台机器挂在负载均衡后面,用户登录后刷新页面就掉登录态,再刷一次又好了。
原因就是负载均衡默认轮询,用户的第一个请求落在 A 机器,会话存在 A 的内存里;第二个请求落到 B 机器,B 的内存里没有这个会话 ID,判定为未登录。刷新一次请求又回到 A,就"恢复"了。表现出来就是随机掉线,规律性极差。
解决路径有三条,按推荐度排序:
第一条,会话外置。把会话统一存到 Redis 或数据库,所有实例都读同一份数据。这是最正统的做法,扩展性最好,加机器不用改任何配置。代价是多一次网络往返,但 Redis 内网延迟通常在亚毫秒级,对绝大多数业务可以忽略。
第二条,会话粘滞。在负载均衡层配置根据 Cookie 或来源地址把同一用户的请求固定转发到同一台机器。好处是零代码改动,坏处是这台机器挂了,上面的用户全部掉线,而且扩容时流量分布容易不均。
第三条,客户端自包含。把状态编码进令牌本身,服务端不存。彻底解决共享问题,但前面提过的"踢不掉人"的代价要自己承担。
注意:会话粘滞经常被当成"临时方案"用,然后一用就是三年。它的问题不在于不能用,而在于它把可用性风险集中到了单台机器上。如果你的业务对掉线敏感,别选它。
3.3 会话 ID 的生成与轮换策略
会话 ID 是可以被猜到的吗?如果生成方式不对,真的可以。早期有系统用递增整数或者时间戳做会话 ID,攻击者只要注册两个账号,观察一下自己的 ID 变化规律,就能推测出别人的 ID,这属于典型的可预测性问题。
行业内的正确做法是使用密码学安全的随机数生成器,长度至少 128 位。换算成十六进制字符串是 32 个字符。我通常用 32 字节的随机数据做十六进制编码,得到 64 个字符,留足余量。不要用普通的伪随机函数,也不要自己发明编码规则——随机性这种事,不要自己造轮子。
会话 ID 轮换是另一个必须做的动作。用户在未登录状态下访问页面时,服务端通常会先分配一个匿名会话 ID。用户随后登录成功,如果继续沿用这个 ID,就给了攻击者可乘之机:攻击者可以先诱导用户使用一个自己已知的会话 ID 访问站点,等用户在这个会话下登录后,攻击者拿着同一个 ID 也能进入登录态。这类攻击的核心就是"会话 ID 在权限提升时没有变化"。
防御办法很直接:在权限发生变化的时刻(登录、登出、切换账号、提权)重新生成会话 ID,同时把旧 ID 对应的数据迁移或销毁。这个动作成本极低,但能挡掉一整类攻击。很多框架已经内置了这个行为,你要做的是确认它确实开启了,而不是被某处配置关掉了。
4. 从零跑通一套登录态:实操流程
4.1 服务端下发与校验的最小可用实现
我用一段精简的服务端代码把完整链路串起来,框架细节可以替换,重点是那几行设置 Cookie 的地方。
import secrets from flask import Flask, request, make_response app = Flask(__name__) SESSIONS = {} # 生产环境请换成 Redis @app.route("/login", methods=["POST"]) def login(): username = request.form.get("username") password = request.form.get("password") if not check_user(username, password): return {"code": 401, "msg": "账号或密码错误"}, 401 # 生成密码学安全的会话 ID sid = secrets.token_hex(32) SESSIONS[sid] = {"user": username, "login_at": now_ts()} resp = make_response({"code": 0, "msg": "登录成功"}) resp.set_cookie( "sid", sid, max_age=7200, httponly=True, secure=True, samesite="Lax", path="/", ) return resp @app.route("/profile") def profile(): sid = request.cookies.get("sid") session = SESSIONS.get(sid) if not session: return {"code": 401, "msg": "请先登录"}, 401 return {"code": 0, "user": session["user"]}这里有三个点值得单独说。secrets.token_hex(32)用的是密码学安全的随机源,不是random模块。httponly=True是必须的,少这一行,注入攻击就能直接读取会话 ID。samesite="Lax"是显式声明,不依赖框架默认值。
校验部分有个常见错误是把 Cookie 直接当身份用,比如把用户名明文写进 Cookie 然后读取。这就等于把身份证复印件贴在门上,谁都能改。正确姿势永远是:Cookie 只放不可预测的随机 ID,身份信息全部从服务端存储里查。
4.2 登录成功后的会话 ID 轮换
接着上面的代码补上轮换动作。假设未登录用户访问首页时会先分配一个匿名会话:
@app.route("/login", methods=["POST"]) def login(): # ... 前面的账号密码校验 ... old_sid = request.cookies.get("sid") # 关键一步:无论旧 ID 是否存在,都重新生成 new_sid = secrets.token_hex(32) SESSIONS[new_sid] = {"user": username, "login_at": now_ts()} # 旧会话立刻销毁,不给复用窗口 if old_sid and old_sid in SESSIONS: del SESSIONS[old_sid] resp = make_response({"code": 0}) resp.set_cookie("sid", new_sid, max_age=7200, httponly=True, secure=True, samesite="Lax", path="/") return resp顺序很重要:先生成新 ID 并写入存储,再删除旧 ID,最后下发新 Cookie。如果反过来先删旧的再建新的,中间出现异常就会导致用户既没有旧会话也没有新会话。
顺带提一个登出的实现。登出时要做两件事:服务端删除会话记录,客户端把 Cookie 置空。只做客户端置空是不够的,因为会话 ID 可能已经被复制走了,服务端不删,它依然有效直到自然过期。
@app.route("/logout") def logout(): sid = request.cookies.get("sid") if sid: SESSIONS.pop(sid, None) resp = make_response({"code": 0}) resp.delete_cookie("sid", path="/") return resp4.3 前端携带凭据的正确姿势
浏览器同源请求会自动带 Cookie,这块不用管。真正容易出问题的是跨源场景,比如前端部署在app.example.com,后端在api.example.com。这时需要在请求里显式开启凭据传递。
fetch("https://api.example.com/profile", { method: "GET", credentials: "include", // 关键:不加这行,跨源请求不带 Cookie headers: { "Accept": "application/json" } }) .then(res => res.json()) .then(data => console.log(data));服务端也要配合返回允许凭据的响应头,并且此时Access-Control-Allow-Origin不能写*,必须写明确的来源域名。这个限制是浏览器强制的,因为通配符加上凭据传递等于对任何站点开放。
还有一点很多人在联调时踩过:credentials: "include"只对设置了SameSite=None; Secure的 Cookie 生效。如果后端下发的 Cookie 是SameSite=Lax,跨源请求仍然不会携带。前后端必须对齐这个配置,否则就是前端改了后端没改,两边都觉得自己没问题。
提示:本地联调时把 Cookie 的 Secure 属性关掉,同时保持 SameSite 为 Lax,可以避免大量"本地怎么都登不上"的无效排查。上线前务必检查这个开关没有被带到生产配置里。
4.4 用 JMeter 压测带会话的接口
接口测试工具默认是不带 Cookie 的,所以直接拿 JMeter 跑登录后的接口,会得到一片 401。要让 JMeter 携带会话,需要配置 HTTP Cookie 管理器。
具体步骤是这样的:在线程组上右键,添加"配置元件",选择"HTTP Cookie 管理器"。把这个元件放在线程组层级,它会对组内所有请求生效。每个虚拟用户线程会维护自己独立的 Cookie 存储,这一点很关键——不同线程之间的会话天然隔离,不会互相串号。
然后编排请求顺序。第一个请求是登录接口,JMeter 收到响应后会把Set-Cookie里的内容自动存入 Cookie 管理器。后续请求如果访问同一域名,Cookie 会自动附加。这里要注意"同一域名"这个条件——如果你的登录接口和业务接口域名不同,Cookie 管理器的默认策略可能不生效,需要在管理器里调整 Cookie 策略,允许跨域存储。
压测时还有一个容易忽略的点:如果你的服务端在登录成功后做了会话 ID 轮换,那么第二个请求必须使用新 ID。JMeter 的 Cookie 管理器会自动用最新的值覆盖旧的,所以正常流程没问题。但如果你在测试计划里手动写死了 Cookie 值,轮换之后就会失效。手动写死 Cookie 的做法在调试单个接口时很方便,用在压测里纯属自找麻烦。
再补充一个数据上的预期管理。会话存储如果是单个 Redis 实例,压测时 QPS 上不去,先看 Redis 的 CPU 和连接数,而不是先怀疑应用代码。会话读写在每个请求上都要发生一次,它是最先成为瓶颈的组件。
5. 常见问题与排查实录
5.1 登录态莫名失效的六条排查路径
"用着用着就掉线了"这类问题排查起来最费时间,因为现象不稳定。我整理了一套从快到慢的排查顺序,按这个走基本能定位到。
第一,看 Cookie 有没有被下发。打开浏览器开发者工具的"应用"面板,看对应域名下有没有那个会话 Cookie。没有的话,问题在下发环节——检查响应头里有没有Set-Cookie,检查 Domain 和 Path 是否匹配当前页面。
第二,看 Cookie 有没有被带上。在一个需要登录的请求里看请求头,确认Cookie字段里有没有会话 ID。没有的话,多半是 Secure、SameSite 或者跨源配置的问题。
第三,看是不是多实例导致。这个最好判断:刷新几次,如果登录态时有时无,基本可以确定是会话没共享。
第四,看会话存储有没有过期或被清理。Redis 的 TTL 设得过短,或者内存满了触发淘汰策略,都会导致会话被静默删除。检查一下淘汰策略是不是设成了随机淘汰,如果是,会话可能被当成普通缓存清掉。
第五,看时间对不对。服务器时间漂移会导致 Cookie 的过期判断出错,也会让令牌校验失败。这个原因很隐蔽,容易被忽略。
第六,看是不是被安全策略拦了。某些网关或安全组件会对携带特定 Cookie 的请求做拦截,返回的可能是登录页而不是业务响应。看响应状态码和内容就能区分。
5.2 异常速查表
把常遇到的几种表现和原因对起来,出问题的时候直接查表能省不少时间。
| 现象 | 可能原因 | 快速验证方式 |
|---|---|---|
| 登录成功但下一个请求就未登录 | Cookie 未下发或未携带 | 开发者工具看请求响应头 |
| 刷新页面随机掉线 | 多实例会话未共享 | 查看请求落到了哪台机器 |
| 本地正常线上异常 | Secure 属性或域名配置差异 | 对比两地 Cookie 属性 |
| 跨源请求不携带凭据 | 未设置 credentials 或 SameSite 不匹配 | 检查请求是否带 Cookie 头 |
| 一段时间后必然掉线 | 会话 TTL 到期或滑动续期未实现 | 查看存储中的过期时间 |
| 登出后仍可访问 | 仅清了客户端,服务端未删 | 用旧 ID 手动请求接口 |
| 携带 Cookie 的请求被拒 | 网关或安全策略拦截 | 看状态码与响应内容 |
5.3 安全加固:会话固定与 Cookie 窃取
前面零散提过两个攻击方向,这里集中说一下防御要点,因为它们是会话体系里最需要绷紧的两根弦。
会话固定的核心是攻击者让受害者使用一个攻击者已知的会话 ID。防御手段前面讲过,就是权限变化时轮换 ID。除此之外,还要注意不要接受来自网址参数或请求体里的会话 ID——如果服务端支持从 URL 里读会话 ID,攻击者只要构造一个带自己 ID 的链接发给受害者就行。会话 ID 只从 Cookie 里读,这一条要硬性执行。
Cookie 窃取的主要途径是脚本注入。防御分三层:第一层是 HttpOnly,让脚本读不到;第二层是内容安全策略,限制页面能加载和执行哪些脚本,从源头上减少注入成功的可能;第三层是给会话加额外的绑定信息,比如把会话和用户代理特征做弱绑定,如果请求特征突变就判定为异常并要求重新登录。
这里要提醒一个容易被做过头的地方。有些团队为了实现"绝对安全",把会话和客户端地址做严格绑定,结果用户在移动网络下地址频繁变化,登录态不断失效,体验一塌糊涂。地址绑定适合内网管理系统的场景,公网业务慎用。安全策略的强度要匹配业务场景,过度防御带来的体验损失也是成本。
还有一个细节:日志里不要打印完整的会话 ID。排查问题时习惯性地把整个请求头打出来,会话 ID 就跟着进了日志系统。日志的访问权限往往比会话存储宽松得多,这等于把钥匙抄了一份放在人多的房间里。要打就打前几位加后缀,能定位到是哪条会话就够了。
5.4 本地会话资源占用过高与终端会话建立失败怎么办
会话这个概念不只存在于 Web 里。操作系统和终端工具也用会话来描述连接状态,这部分问题在热词里出现频率同样很高,我一并说说。
在部分桌面系统里,会有一个专门管理本地登录会话的进程。正常情况下它的资源占用很低,如果发现它长期占用较高的处理器资源,常见的诱因有这几类:后台有远程连接服务在持续扫描或等待连接;某个会话内的程序陷入了异常循环;系统更新后驱动不匹配。处理顺序上,先看是哪个会话在消耗资源,再判断是正常业务还是异常进程,最后才考虑重启相关服务。直接重启服务是最省事的做法,但它会中断所有正在进行的会话,动手之前要确认没有同事正在用。
终端工具报会话建立失败,或者提示按回车退出、按某个键重启会话,通常不是网络断了,而是这个会话在服务端的状态已经异常。常见原因包括:连接数达到了服务端的上限,前面的会话没有正常释放;认证环节失败,比如凭证过期;服务端的会话资源被占满。排查思路是先确认是不是只有自己连不上——如果同事也连不上,问题在服务端;如果只有自己,先检查本地是否有残留的会话进程没有退出,把它们清掉往往就好了。
关于"设备会话资源"相关的提示,本质上说的是同一件事:会话是有配额的。无论是操作系统、数据库还是应用服务器,对同时存在的会话数都有上限。设计系统时,会话的创建和销毁必须成对出现,任何一条只创建不销毁的路径,长期运行后都会把配额耗光。我在做长连接服务的时候专门加过会话泄漏的监控,就是被这类问题教育过。
5.5 在移动端和桌面端查看会话数据
测试和排查时经常需要直接看会话内容。桌面浏览器的开发者工具已经很好用,移动端稍微绕一点。
有一种通用的做法是借助桌面浏览器的远程调试功能。手机开启调试模式后连接电脑,在桌面浏览器里就能看到手机页面的完整开发者工具,包括存储面板里的 Cookie 列表。这种方式能得到的信息最全,也能直接修改值来验证假设。
如果只是临时看一眼,很多移动浏览器的地址栏支持查看页面信息来源,可以读到当前页面的部分存储信息,但完整度不如远程调试。还有一种思路是用同款浏览器的桌面版登录同一账号,多数情况下能看到相同结构的 Cookie,便于对照分析。
要提醒的是,查看会话数据本身涉及账号安全,操作最好在自己的测试账号上进行。看到别人的会话 ID 并拿去使用,属于越权行为,这个边界必须清楚。
6. 我在会话设计上的几条个人经验
做了这些年下来,关于会话有几条体会是我反复验证过的,写在这里作为收尾。
第一条,会话的过期时间不要设成一个固定值就完事。用户活跃时应该续期,长时间无操作才过期。实现方式是每次校验通过后更新存储里的过期时间。如果你用的是 Redis,一个EXPIRE命令就够了。这个改动很小,但能把"用户正在填表单,突然被登出"这种投诉消灭掉。
第二条,给会话加一个"最后活跃时间"字段,排查问题时价值极高。当用户说"我明明刚登录就掉线了",你查一下这个字段就知道他上一次真正发请求是什么时候。没有这个字段,你只能在日志里翻,效率差很多。
第三条,不要在一个项目里混用两套会话机制。我见过一个系统,老的模块用服务端 Session,新的模块用自包含令牌,用户在两套模块之间的登录态互不认可,用起来非常割裂。迁移可以分阶段做,但过渡期一定要有兼容层,不能两套并行裸露给用户。
第四条,会话存储的容量要提前估算。假设每个会话的数据平均 500 字节,10 万在线用户就是 50MB,看起来不多。但如果你的会话里塞了权限列表、菜单树这类大对象,单个会话涨到 10KB 很常见,10 万用户就是 1GB。会话存储不是业务数据库,别往里塞太多东西,只放真正需要跨请求共享的字段。
最后分享一个排查小技巧。当你完全搞不清楚登录态为什么丢失时,把整个流程的请求头和响应头完整抓下来,按时间顺序排好,然后逐行找Set-Cookie和Cookie。多数情况下,你会在某一行发现一个不该出现的Set-Cookie——某个接口在不经意间重新下发了一个会话 Cookie,把原来的覆盖掉了。这类问题特别隐蔽,因为每个接口单独测都是好的,只有按用户实际操作顺序串起来才会暴露。