2019 年我做一个网站的登录功能:用户输对账号密码,服务器要"记住"他已经登录了,之后每个请求都认得他。这个"记住"怎么实现?这件事我压根没多想。第一版我做得很顺手:用户登录成功后,我在服务器内存里建一个全局的 sessions 字典,生成一个随机字符串当 key,把用户信息当 value 存进去;再把这个随机字符串通过 Set-Cookie 发给浏览器。之后每个请求,浏览器自动带上这个 Cookie,我拿里面的字符串去 sessions 字典里一查,就知道是谁了。就完事了。本地测一测——真不错:登录、刷新、跳页面,登录态稳稳地在,我心里很踏实:"会话嘛,不就是服务器拿个字典记一下谁登录了?"可等这网站真正上线、扩到多台服务器、扛真实流量,一串问题冒了出来。第一种最先把我打懵:用户疯狂反馈"老是莫名其妙就退出登录了"——刷新一下还在,再刷一下就掉了,一会儿登录一会儿没登录,毫无规律。第二种最难缠:每次我一发版、重启服务,所有在线用户瞬间全被踢下线,得重新登录。第三种最头疼:服务器跑上十天半个月,内存只涨不跌,最后涨到告警——我那个 sessions 字典,存进去的东西从来没被清理过。第四种最莫名其妙:一次安全测试甩给我两条问题:我的登录 Cookie 能被页面上的 XSS 脚本直接读走,还能在用户不知情时被别的网站借用。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"会话,就是服务器拿个内存字典记一下谁登录了"。这句话把"会话状态"这个需要被很多东西共享、需要活得很久的东西,塞进了一个单进程的、易失的内存字典里。可它根本不是这么回事。我脑子里,服务器就是"那一个进程",会话状态记在它内存里天经地义、随取随用。可这个想法,从根上低估了"会话状态"对存储的三个硬性要求。第一,它要求被"共享":线上从来不是一台服务器,而是一个负载均衡后面挂着好几个实例,用户这次请求被分到实例 A、下次可能被分到实例 B——你的 sessions 字典只活在某一个进程的内存里,实例 A 存的 session,实例 B 根本看不见,于是用户被分到 B 时就成了"未登录",这就是他"一会儿登录一会儿没登录"的全部真相。第二,它要求"活过进程的生死":内存是易失的,进程一重启,内存里的一切灰飞烟灭,所以你每次发版,等于把全站的登录态清零了一次。第三,它要求"自己会过期":一个内存字典只会被你 put,不会自己 delete,你不主动清,过期的 session 就永远赖在内存里,内存只涨不跌是必然结局。会话状态的本质,是一份"需要跨实例共享、需要跨重启存活、需要自动过期"的数据——而单进程内存,这三条一条都不满足。它必须被放进一个独立于任何应用进程之外的、专门的共享存储里。而那个发给浏览器的 Cookie,从头到尾都不是会话状态本身,它只是一张"提货凭证",一个不痛不痒的随机字符串,真正的状态在服务端。我一直把这张凭证和它对应的货物混为一谈了。真正把登录会话做扎实,核心不是"找个地方记一下谁登录了",而是认清 Cookie 与 Session 是两样东西:Session 是服务端的会话状态,它必须放进一个独立的共享存储(比如 Redis),才能跨实例、跨重启、自动过期;Cookie 只是浏览器替你保管、并在每次请求自动带上的那张凭证,它本身还是一组必须正确配置的安全开关。这篇文章就把 Cookie 与 Session 这个坑梳理一遍:为什么"session 存内存"一上线就崩、Cookie 与 Session 到底是什么关系、怎么把 Session 搬到 Redis、Cookie 的 HttpOnly / Secure / SameSite 安全属性、Session 的过期续期与注销,以及一些把会话做扎实要避开的工程坑。
问题背景
这个坑普遍,是因为"服务器拿个字典记一下"这个直觉太顺了——它在你写第一版、在本地单进程跑的时候,完全正确、毫无破绽。它错得隐蔽,是因为单机开发环境永远测不出来:你本地只有一个进程,不存在"分到别的实例",你也很少在开发时频繁重启再去看登录态,内存涨也得跑很久才显形。它只在上线、多实例部署、长时间运行之后才一起爆发,而那时登录这种核心功能出问题,影响的是每一个用户。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:用户登录态时有时无、一会儿掉一会儿在;每次发版重启全站被踢下线;服务器内存只涨不跌;登录 Cookie 能被 XSS 脚本读走、能被跨站请求盗用。
- 错误认知一:以为线上只有"那一台服务器"。真相是负载均衡后挂着多个实例,存在 A 实例的内存 session,B 实例看不到。
- 错误认知二:以为内存里的状态会一直在。真相是内存易失,进程一重启就全没了,会话状态必须能活过进程的生死。
- 错误认知三:以为 Cookie 就是会话状态本身。真相是 Cookie 只是一张提货凭证,真正的状态在服务端,且 Cookie 自身是一组安全开关。
- 真相:会话状态需要跨实例共享、跨重启存活、自动过期,必须放进独立的共享存储;Cookie 与 Session 是凭证与货物的关系,要分开理解、各自配对。
一、为什么"session 存在内存里"一上线就崩
先把第一版那个内存 session 摆出来。它的思路就是字面意思:一个全局字典,登录时写入,请求时查询。
// 第一版:把 session 存在进程内存的一个全局字典里(反面教材)
const sessions = {}; // 全局内存字典,key 是 session id
app.post('/login', (req, res) => {
const user = checkPassword(req.body);
if (!user) return res.status(401).send('账号或密码错误');
const sid = crypto.randomBytes(16).toString('hex'); // 生成随机 session id
sessions[sid] = { userId: user.id, name: user.name }; // 把用户信息存进内存字典
res.cookie('sid', sid); // 把 sid 发给浏览器
res.send('登录成功');
});
app.get('/profile', (req, res) => {
const sess = sessions[req.cookies.sid]; // 拿 cookie 里的 sid 回字典查
if (!sess) return res.status(401).send('未登录');
res.send(`你好,${sess.name}`);
});
这段代码在本地单进程跑,完美无缺。它的崩塌,要看清一个事实才显形:线上的"服务器"从来不是一个进程。为了扛流量、为了高可用,线上是一个负载均衡器后面挂着多个应用实例,用户的每个请求,被负载均衡随机或轮流分给其中一个。而 sessions 这个字典,是进程级的——实例 A 的进程和实例 B 的进程,各有各的 sessions 字典,互不相通。于是下面这一幕,每天在线上无数次上演:
用户登录:请求被负载均衡分到【实例 A】
→ 实例 A 在自己的内存字典里写入 sessions[sid] = {...}
→ 浏览器拿到 cookie: sid=abc123
用户点"个人主页":请求被负载均衡分到【实例 B】
→ 实例 B 在自己的内存字典里查 sessions["abc123"]
→ 实例 B 的字典里压根没有这个 key → 返回"未登录"
用户一脸懵:我明明刚登录过 —— 这就是"一会儿登录一会儿掉"的真相
同一个道理,也解释了另外两个怪现象。发版重启全站掉线:内存是易失存储,你重启实例 A 的进程,它内存里的 sessions 字典连同所有登录态,瞬间归零。内存只涨不跌:这个字典只有登录时的 sessions[sid] = ...,从来没有一句把过期项删掉的代码,用户来了一拨又一拨,字典只增不减,内存自然一路涨到告警。
这里要建立的第一个、也是最重要的认知是:当你决定"把一个状态存在哪里"时,你必须先问清楚这个状态对存储有什么硬性要求,再去挑存储——而不是顺手就用最近的那个内存变量。会话状态,它的要求其实相当苛刻,有三条。第一条是"可共享":它不能只属于某一个进程,因为读它、写它的会是负载均衡背后的任意一个实例,这要求存储是一个所有实例都能平等访问的、公共的地方。第二条是"可持久":它不能随着应用进程的重启而消失,因为应用进程会频繁地发版、重启、扩缩容,而用户的登录态不该被这些运维动作波及,这要求存储的生命周期独立于应用进程。第三条是"可过期":它里面的每一项都该有寿命,到点自己消失,不该靠应用代码记得去清理,这要求存储自带 TTL 这种过期机制。进程内存这个"存储",这三条要求一条都满足不了——它私属于单个进程、它随进程生死、它不会自动清理。第一版最根本的错,不在于代码写得不好,而在于它选错了存储:它拿一个"私有、易失、不过期"的内存,去装一个要求"共享、持久、过期"的会话状态,需求和载体从根上就不匹配。所以解决问题的方向,不是去把内存字典修补得更好,而是换一个真正满足这三条要求的存储——下一节会讲到,这个存储通常就是 Redis。
二、Cookie 与 Session:一个在浏览器,一个在服务端
要把会话做对,得先把 Cookie 和 Session 这两个被很多人混着说的词彻底分开。它们是两样东西,待在两个地方,各管一摊。
- Session(会话):存在服务端的会话状态——"谁登录了、他是什么身份、有什么权限"。这是真正的、敏感的数据,它不会发给浏览器。
- Cookie:存在浏览器的一小段数据。服务端通过响应头
Set-Cookie让浏览器存下它,之后浏览器在每次请求该站点时,自动通过请求头Cookie把它带上。
它俩的关系是:Cookie 里只装一个不痛不痒的随机 session id,这个 id 本身没有任何意义,它只是一张"提货凭证";真正的"货"——用户信息——在服务端的 Session 存储里,凭这张凭证去取。HTTP 协议本身是无状态的,每个请求互相独立、服务端默认不认识谁是谁,而 Cookie + Session 这套配合,就是给无状态的 HTTP 接上"记忆"的标准做法。一次完整的请求,这套机制是这样运转的:
看清这张图,一个关键点就立住了:把用户信息直接塞进 Cookie,是错的。Cookie 存在浏览器,用户能看、能改,你要是把 userId=1001 这种东西明文放 Cookie 里,用户把它改成 1002 就冒充了别人。所以 Cookie 里只能放那个猜不出、改了也没用的随机 session id——它的全部作用就是"指向"服务端的某条 Session,而不"包含"任何信息。
这里要建立的认知是:Cookie 与 Session 的关系,本质是"凭证"与"实体"的分离,理解这个分离,是把会话安全做对的地基。为什么一定要分离?因为这两样东西所处的环境,信任级别天差地别。服务端是你能完全掌控的、可信的环境,把敏感的会话状态放在那里,用户碰不到、改不了。而浏览器是一个你完全不可信的环境——它在用户的机器上,用户可以打开开发者工具查看、修改里面的任何 Cookie,页面上的 JavaScript(包括可能被注入的恶意脚本)也能读写它。一个朴素的安全原则是:任何放在不可信环境里的东西,你都必须假设它会被查看、被篡改。所以你绝不能把"用户是谁、有什么权限"这种一旦被篡改就能越权的信息,放进 Cookie。你能放进 Cookie 的,只能是一个满足两个条件的东西:一,它必须无法被猜到(所以用足够长的密码学随机数);二,它必须改了也没用(它只是个指针,用户把 abc123 改成 abc124,顶多是指向一条不存在的 Session,得到"未登录",而不可能指向"另一个用户")。session id 恰好同时满足这两条。把这层想透,你就再也不会纠结"这个信息能不能放 Cookie"——凡是被篡改后会造成危害的,一律留在服务端;Cookie 里永远只放那张又随机、又无害的提货凭证。
三、把 Session 搬到 Redis:让所有实例共享
认知理顺了,解法就清晰了:把 Session 存储从"进程内存"换成一个独立的、所有实例都能连的共享存储。这个存储,绝大多数项目用 Redis——它在内存里、读写极快,它是独立进程、不随应用重启而消失,而且它天生支持 key 过期,会话需要的"共享、持久、自动过期"三条,它一次满足。
把第一版的内存字典换成 Redis,代码改动很小,但性质彻底变了。关键是写入时用 SET ... EX 带上过期秒数:
const redis = require('redis');
const client = redis.createClient({ url: process.env.REDIS_URL });
await client.connect();
const SESSION_TTL = 7 * 24 * 3600; // 会话有效期:7 天(秒)
app.post('/login', async (req, res) => {
const user = checkPassword(req.body);
if (!user) return res.status(401).send('账号或密码错误');
const sid = crypto.randomBytes(32).toString('hex'); // 32 字节,足够随机
// 关键:写进 Redis,并带上 EX 过期时间 —— 到点 Redis 自己删,不用你操心
await client.set(`sess:${sid}`, JSON.stringify({ userId: user.id, name: user.name }),
{ EX: SESSION_TTL });
res.cookie('sid', sid);
res.send('登录成功');
});
读取端同样改成查 Redis。因为 Redis 是独立于所有应用实例的,无论这个请求被负载均衡分到哪个实例,它们查的都是同一个 Redis,拿到的都是同一份 Session:
// 把"取当前用户"做成一个中间件,所有需要登录的接口都复用它
async function authMiddleware(req, res, next) {
const sid = req.cookies.sid;
if (!sid) return res.status(401).send('未登录');
const raw = await client.get(`sess:${sid}`); // 任何实例查的都是同一个 Redis
if (!raw) return res.status(401).send('登录已过期,请重新登录');
req.user = JSON.parse(raw); // 把用户信息挂到 req 上,供后续使用
next();
}
app.get('/profile', authMiddleware, (req, res) => {
res.send(`你好,${req.user.name}`); // 直接用 req.user,干净
});
就这么一换,第一版那三个怪现象同时消失了:多实例下,大家查同一个 Redis,session 不再"找不到";应用进程重启,Redis 进程没动,登录态毫发无损;每条 session 都带了 EX 过期时间,到点 Redis 自动删除,内存不再只涨不跌。
这里要建立的认知是:第一版到这一版,代码的改动量小得惊人——无非是把对一个内存字典的读写,换成了对 Redis 的读写——可它带来的,是系统能力的一次质变。这件事里藏着一个值得反复回味的工程观念:"有状态"和"无状态",是衡量一个服务好不好横向扩展的命脉。第一版的应用实例是"有状态"的——每个实例的内存里,都揣着一份只有它自己知道的会话数据,这份私有状态像一道枷锁,让实例之间无法互相替代:用户在 A 上登录,就被绑死在了 A 上,负载均衡一旦把他分给别人,登录态就丢。而把 Session 外置到 Redis 之后,应用实例就变成了"无状态"的——它自己不再私藏任何会话数据,所有实例面对同一个 Redis,变得完全等价、可以互相替代。一个无状态的服务,你想加机器就加机器、想重启就重启、想杀掉一个实例就杀掉,用户毫无感知,因为没有任何用户的状态是绑在某台具体机器上的。所以"把 Session 搬到 Redis"这件事,你不能只把它理解成"修了一个 bug",它的真正意义是"把应用从有状态改造成了无状态",而无状态,正是一个服务能够自由地扩容、缩容、滚动发布、容灾的前提。记住这条通用法则:想让你的服务能横向扩展,就让应用实例不要持有任何私有状态——把状态全部externalize 到 Redis、数据库这类共享存储里去,让每一个实例都成为可以随时被替换的、干净的副本。
四、Cookie 的安全属性:HttpOnly、Secure、SameSite
现在回到第四个怪现象。Session 搬走了,可那张"提货凭证"——session id——本身也极其敏感:谁拿到它,谁就能冒充用户。res.cookie('sid', sid) 这种裸写的 Cookie,等于把凭证毫不设防地暴露出去。Cookie 有几个安全属性,每一个都堵一类攻击,必须配齐:
res.cookie('sid', sid, {
httpOnly: true, // 禁止页面 JS 用 document.cookie 读到它 —— 防 XSS 偷 Cookie
secure: true, // 只在 HTTPS 连接下才发送这个 Cookie —— 防中途被明文窃听
sameSite: 'lax', // 跨站请求时不带上它 —— 防 CSRF 借用用户的登录态
maxAge: 7 * 24 * 3600 * 1000, // 浏览器侧的过期时间(毫秒)
path: '/', // 这个 Cookie 对哪些路径生效
});
逐个说清楚每个属性在防什么。httpOnly: true:它让 Cookie 对 JavaScript 隐身——页面里的脚本调 document.cookie 也读不到它。这正是第四个怪现象的第一条:不加 httpOnly,一旦你的页面有 XSS 漏洞,注入的恶意脚本一行 document.cookie 就把用户的 session id 偷走了。secure: true:它让 Cookie 只在 TLS,在 HTTP 之上加一层 TLS 加密,防止中间人窃听和篡改。">HTTPS 下传输,绝不走明文 HTTP,防止 session id 在网络中途被人嗅探到。sameSite:它管的是"跨站请求要不要带上这个 Cookie"。这正是第四个怪现象的第二条——CSRF 攻击:别的网站放一个指向你站点的请求,浏览器默认会自动带上你站点的 Cookie,于是用户在不知情时就"被操作"了。sameSite: 'lax' 让大多数跨站请求不再自动带 Cookie,这条攻击链就断了。三种取值的区别要记清:
SameSite 的三种取值:
Strict 最严:任何跨站请求都不带 Cookie。安全,但从外部链接点进来也算未登录,体验差
Lax 默认推荐:普通跨站请求(如别站的图片、表单 POST)不带;
用户主动点链接跳转过来时带。安全与体验的平衡点
None 完全不限制跨站携带,但必须同时设 Secure。仅用于确实需要跨站的场景
这里要建立的认知是:Cookie 的这几个属性,不是一组"抄一遍就完事"的样板配置,它们是浏览器提供给你的、针对不同攻击面的几道独立闸门,你得知道每一道闸门挡的是哪个方向来的人。换个角度看就清楚了:你的 session id 这张凭证,会面临三类截然不同的窃取途径。一类是"从页面内部偷"——你的网页若有 XSS 漏洞,恶意脚本就在用户的页面里运行,它会试图用 document.cookie 读走凭证,挡这条路的闸门是 HttpOnly,它让凭证对脚本彻底隐身。一类是"从传输途中偷"——凭证在网络上传输时,若走的是明文 HTTP,中间任何一个节点都能嗅探到它,挡这条路的闸门是 Secure,它强制凭证只在加密的 HTTPS 通道里走。还有一类不是"偷"而是"借"——攻击者不需要拿到你的凭证,他只要诱导你的浏览器,在你不知情时带着凭证去访问你的站点,就能冒用你的身份,挡这条路的闸门是 SameSite,它切断了跨站请求自动携带 Cookie 的默认行为。这三道闸门,各挡一个方向,缺任何一道,就漏一个方向。第一版那句裸写的 res.cookie,等于三道闸门全开着。所以正确的心态是:发 Cookie 时,默认就要把 HttpOnly、Secure、SameSite 三件套配齐,这不是"加强",这是 Cookie 本来就该有的样子——一张凭证,理应被严严实实地保护起来,而不是裸奔在外。
五、Session 的过期、续期与主动注销
会话不是登录了就一劳永逸,它有完整的生命周期要管。第一件事是过期:上一节 Redis 的 EX 已经做了——一条 session 写进去,7 天后 Redis 自动删,用户就得重新登录。但只有"绝对过期"会带来一个体验问题:一个每天都在用的活跃用户,到了第 7 天也会被强制踢下线。解法是滑动续期——用户每次有活动,就把过期时间重新往后推:
async function authMiddleware(req, res, next) {
const sid = req.cookies.sid;
if (!sid) return res.status(401).send('未登录');
const raw = await client.get(`sess:${sid}`);
if (!raw) return res.status(401).send('登录已过期,请重新登录');
// 滑动续期:用户只要还在活动,就把这条 session 的过期时间重新延长
await client.expire(`sess:${sid}`, SESSION_TTL);
req.user = JSON.parse(raw);
next();
}
第二件事是主动注销。用户点"退出登录",你要做的是把服务端那条 Session 真正删掉,而不只是让浏览器忘掉 Cookie。这一点极其关键——只清 Cookie 不删 Session,那条 session id 在 Redis 里依然有效,谁要是之前截获过它,照样能继续用:
app.post('/logout', async (req, res) => {
const sid = req.cookies.sid;
if (sid) {
await client.del(`sess:${sid}`); // 关键:把服务端的 Session 删掉,凭证当场作废
}
res.clearCookie('sid'); // 再让浏览器清掉 Cookie
res.send('已退出登录');
});
还有一种重要的注销是"踢人":用户改密码后,要让所有旧会话立刻失效。因为 Session 在服务端,你完全做得到——找出这个用户的所有 session 并删除,或者给用户记一个"会话版本号",改密码就让版本号加一,校验时版本对不上的 session 一律失效。这正是服务端 Session 相比无状态 token 的一大优势:可以被服务端随时、精确地撤销。
这里要建立的认知是:很多人把"会话"理解成一个二元状态——要么登录、要么没登录——这个理解太粗了。一个会话,其实是一个有完整生命周期的对象:它出生(登录)、它存活并需要被不断延续(滑动续期)、它会自然死亡(过期)、它也可能被提前赐死(主动注销、改密码踢人)。第一版只草草处理了"出生"这一个环节,把后面整段生命周期全忽略了,于是每一个被忽略的环节都变成了一个坑:不管"自然死亡",内存就爆;不管"延续",活跃用户被误杀;不管"赐死",用户点了退出却退不干净、改了密码旧会话还能用。把会话当成一个有生命周期的对象来对待,你就会主动地为它的每一个阶段都安排好处理:登录时设定它的初始寿命,每次活动时为它续命,到期让存储自动收走它,用户主动退出或修改密码时果断地终结它。而这里面藏着一个更深的好处:正因为 Session 实体牢牢地待在你可控的服务端,它的整个生命周期才是"可被你掌控"的——你能随时查看它、延长它、销毁它。这种"可被服务端随时撤销"的能力,是会话安全里非常宝贵的一项,它意味着一旦出事(凭证泄露、账号被盗),你有一个确定的、立即生效的手段把会话掐断。把会话的生命周期完整地管起来,你管的不只是用户体验,更是一道随时能拉下的安全闸门。
六、工程里那些 Cookie 与 Session 的坑
会话的主体逻辑对了,落地时还有几个工程坑反复咬人。第一个,session id 必须有足够的随机性。它是冒充用户的唯一钥匙,如果你用自增数字、时间戳、或太短的随机串,攻击者就能猜测、枚举。必须用密码学安全的随机数,且足够长:
// 对:用密码学安全的随机源,生成足够长的 session id
const sid = crypto.randomBytes(32).toString('hex'); // 32 字节 = 256 位,无法枚举
// 错:这些都能被猜到或枚举,绝不能用作 session id
// const sid = ++counter; // 自增,直接能猜
// const sid = Date.now().toString(); // 时间戳,范围很窄
// const sid = Math.random().toString(); // Math.random 不是密码学安全的
第二个,登录成功后要换一个新的 session id。如果用户未登录时你也发了一个 sid,登录后必须重新生成、把旧的废掉——否则有"会话固定攻击":攻击者预先把一个他知道的 sid 塞给受害者,受害者用这个 sid 登录后,攻击者就持有了一个已登录的会话。第三个,Redis 是会话的单点,要做高可用:Session 全靠 Redis,Redis 一挂就全站掉线,生产环境的 Redis 必须有主从、有持久化。第四个,Cookie 的 Domain 要设对:多个子域名要共享登录态(如 www 和 api),得把 Domain 设成父域;设错了会导致 Cookie 带不过去,又是一种"假性未登录"。第五个,别在 Session 里塞大对象:Session 每次请求都要读,只放必要的 userId、必要的身份标识,大数据该查数据库查数据库。
这里要建立的认知是:会话这套机制,它的安全强度,完全由"那个 session id 有多难被拿到、被冒用"决定——它本质上是一道单点防线,这道防线一旦被突破,前面所有的 HttpOnly、Secure、SameSite 就都绕过去了。所以做会话,你的脑子里要一直绷着一个攻击者视角:有没有什么办法,能拿到一个有效的 session id?顺着这个问题想,这一节的几个坑就全串起来了。session id 能不能被"猜出来"?——所以它必须是又长又密码学随机的,堵掉枚举这条路。session id 能不能被攻击者"提前设定好再塞给用户"?——所以登录成功这个关键时刻必须换一个全新的 id,堵掉会话固定这条路。这两个坑,加上前面讲的 XSS 偷、传输途中嗅探、CSRF 借用,合起来就是攻击者能拿到 session id 的全部主要途径,你把每一条都堵死,这道单点防线才算立住。这里要建立的更高一层的认知是:做任何涉及身份和凭证的功能,你都不能只用"功能视角"去想"它能不能正常工作",你必须切换到"攻击者视角"去想"它能不能被滥用"——前者让你写出能登录的代码,后者才让你写出安全的代码。会话安全不是某一个属性、某一行配置,它是你把自己想象成攻击者、把每一条可能的攻击路径都预先堵上之后,留下的那个结果。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| Session 会话 | 存在服务端的会话状态 谁登录了 | 敏感数据 绝不发给浏览器 |
| Cookie | 存在浏览器 每次请求自动带上的数据 | 只放随机 session id 这张提货凭证 |
| 内存 session 的崩点 | 进程私有 易失 不自动过期 | 多实例查不到 重启全掉线 内存只涨 |
| 无状态应用 | 实例自身不持有私有会话状态 | 横向扩展 滚动发布 容灾的前提 |
| Redis 存 Session | 独立共享存储 带 EX 自动过期 | 跨实例共享 跨重启存活 一次满足 |
| HttpOnly | 禁止页面 JS 读取该 Cookie | 防 XSS 脚本偷走 session id |
| Secure | 该 Cookie 只在 HTTPS 下发送 | 防 session id 明文传输被嗅探 |
| SameSite | 跨站请求是否自动携带 Cookie | Lax 推荐 防 CSRF 借用登录态 |
| 滑动续期 | 用户每次活动就延长 Session 过期时间 | 避免活跃用户到点被强制踢下线 |
| 主动注销 | 退出或改密码时删掉服务端 Session | 只清 Cookie 不删 Session 凭证仍有效 |
避坑清单
- 不要把 Session 存在进程内存里。多实例下查不到、重启全掉线、内存只涨不跌,三个问题一起爆。
- 把 Session 外置到 Redis 等共享存储,让应用实例无状态,才能横向扩展、滚动发布。
- Cookie 里只放随机 session id,绝不明文放 userId 等可被篡改后越权的信息。
- session id 用密码学安全随机数且足够长,绝不用自增、时间戳、Math.random。
- Cookie 必须配齐 HttpOnly、Secure、SameSite,分别堵 XSS 偷、明文嗅探、CSRF 借用。
- Session 写入要带过期时间,并对活跃用户做滑动续期,避免误踢。
- 注销和改密码要删服务端 Session,只清浏览器 Cookie 不算真正退出。
- 登录成功后重新生成 session id,废掉旧的,防会话固定攻击。
- Redis 作为会话单点要做高可用,主从加持久化,否则它一挂全站掉线。
- Session 里只放必要的身份标识,别塞大对象,它每个请求都要被读取。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为"会话"就是服务器顺手拿个内存字典记一下谁登录了。可会话状态对存储有三条硬性要求——要能跨多个实例共享、要能活过进程重启、要能自己到期清理——而进程内存这三条一条都不满足。我没有先问清楚"这个状态需要什么样的存储",就顺手抓了最近的那个内存变量来装它。问题从来不在"代码写得对不对",而在我把一个要求苛刻的状态,放进了一个根本配不上它的地方。
真正把登录会话做扎实,工作量不在"写多少行代码",而在两个观念的转变:一是承认 Cookie 和 Session 是两样东西——一张在浏览器、可被篡改的提货凭证,一份在服务端、必须可控的真实状态;二是承认会话状态必须外置,应用实例不该私藏任何状态。一旦接受这两点,该做的事就都浮现出来了——把 Session 搬进 Redis 并带上过期、Cookie 里只放随机 id、给 Cookie 配齐三道安全闸门、把会话的续期与注销当成生命周期来管。每一步都不复杂,难的是先承认:你的"服务器"从来不是一个进程,你的会话状态不能跟着某个进程一起生死。
我后来常拿剧院的衣帽寄存处来想这件事。你进场前把大衣交给寄存处,他们给你一个号码牌。这个号码牌本身不是你的大衣——它只是一个号码,你拿它什么也穿不了;你的大衣,好端端地挂在寄存处的衣架上。散场时你凭这个牌子,去取回属于你的那件。这里头每一样东西都对得上:号码牌就是 Cookie,它在你手上、可能被人看见,所以它只是个无意义的号码;大衣就是 Session,它是真东西,锁在你管不到的、可信的寄存处里。第一版的错,就像一家剧院开了好几个互不相通的寄存处,你在一号窗口存的大衣,拿着牌子去三号窗口,人家说查无此衣——这就是多实例各存各的内存 session。而正确的做法,是让所有窗口都连到同一个公共的衣帽间,那个公共衣帽间,就是 Redis。
这类问题最咬人的地方,在于它在本地单机开发时几乎永远是"对"的:你本地只有一个进程,不存在请求被分到别的实例,你也很少在开发时反复重启再去盯登录态,内存涨更要跑很久才显形。功能演示一切完美,谁也想不到这里有雷。它只在上线、扩到多实例、长时间运行之后才集中爆发,而那时出问题的是"登录"这个每个用户都要过的核心环节。所以别等用户开始投诉"老是掉登录"才想起会话的存储:做登录功能的第一天,就该把"会话状态要放在一个独立的共享存储里"当成和登录逻辑本身同等重要的事来设计——它不该是一个"以后上线了再优化"的事项,而该是你写第一行会话代码时就定下的地基。把这件事在一开始就想清楚,你才算真正跳出了那个几乎人人都会踩、却要等到上线才追悔莫及的"session 存内存"。
—— 别看了 · 2026