登录会话存储完全指南:从一次"用户老是莫名其妙就退出登录"看懂 Cookie 与 Session

2019 年我做一个网站的登录功能用户输对账号密码服务器要记住他已经登录了之后每个请求都认得他这个记住怎么实现这件事我压根没多想第一版我做得很顺手用户登录成功后我在服务器内存里建一个全局的 sessions 字典生成一个随机字符串当 key 把用户信息当 value 存进去再把这个随机字符串通过 Set-Cookie 发给浏览器之后每个请求浏览器自动带上这个 Cookie 我拿里面的字符串去字典里一查就知道是谁了本地测一测真不错登录刷新跳页面登录态稳稳地在可等这网站真正上线扩到多台服务器扛真实流量一串问题冒了出来第一种最先把我打懵用户疯狂反馈老是莫名其妙就退出登录了刷新一下还在再刷一下就掉了毫无规律第二种最难缠每次我一发版重启服务所有在线用户瞬间全被踢下线第三种最头疼服务器跑上十天半个月内存只涨不跌最后涨到告警第四种最莫名其妙一次安全测试甩给我两条问题我的登录 Cookie 能被页面上的 XSS 脚本直接读走还能在用户不知情时被别的网站借用我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为会话就是服务器拿个内存字典记一下谁登录了可它根本不是这么回事会话状态的本质是一份需要跨实例共享需要跨重启存活需要自动过期的数据而单进程内存这三条一条都不满足它必须被放进一个独立于任何应用进程之外的专门的共享存储里而那个发给浏览器的 Cookie 从头到尾都不是会话状态本身它只是一张提货凭证真正把登录会话做扎实核心不是找个地方记一下谁登录了而是认清 Cookie 与 Session 是两样东西 Session 是服务端的会话状态它必须放进一个独立的共享存储比如 Redis 才能跨实例跨重启自动过期 Cookie 只是浏览器替你保管并在每次请求自动带上的那张凭证它本身还是一组必须正确配置的安全开关本文从头梳理为什么 session 存内存一上线就崩 Cookie 与 Session 到底是什么关系怎么把 Session 搬到 Redis Cookie 的 HttpOnly Secure SameSite 安全属性 Session 的过期续期与注销以及一些把会话做扎实要避开的工程坑

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:一个在浏览器,一个在服务端

要把会话做对,得先把 CookieSession 这两个被很多人混着说的词彻底分开。它们是两样东西,待在两个地方,各管一摊。

  • 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 要设对:多个子域名要共享登录态(如 wwwapi),得把 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 凭证仍有效

避坑清单

  1. 不要把 Session 存在进程内存里。多实例下查不到、重启全掉线、内存只涨不跌,三个问题一起爆。
  2. 把 Session 外置到 Redis 等共享存储,让应用实例无状态,才能横向扩展、滚动发布。
  3. Cookie 里只放随机 session id,绝不明文放 userId 等可被篡改后越权的信息。
  4. session id 用密码学安全随机数且足够长,绝不用自增、时间戳、Math.random。
  5. Cookie 必须配齐 HttpOnly、Secure、SameSite,分别堵 XSS 偷、明文嗅探、CSRF 借用。
  6. Session 写入要带过期时间,并对活跃用户做滑动续期,避免误踢。
  7. 注销和改密码要删服务端 Session,只清浏览器 Cookie 不算真正退出。
  8. 登录成功后重新生成 session id,废掉旧的,防会话固定攻击。
  9. Redis 作为会话单点要做高可用,主从加持久化,否则它一挂全站掉线。
  10. Session 里只放必要的身份标识,别塞大对象,它每个请求都要被读取。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我以为"会话"就是服务器顺手拿个内存字典记一下谁登录了。可会话状态对存储有三条硬性要求——要能跨多个实例共享、要能活过进程重启、要能自己到期清理——而进程内存这三条一条都不满足。我没有先问清楚"这个状态需要什么样的存储",就顺手抓了最近的那个内存变量来装它。问题从来不在"代码写得对不对",而在我把一个要求苛刻的状态,放进了一个根本配不上它的地方。

真正把登录会话做扎实,工作量不在"写多少行代码",而在两个观念的转变:一是承认 Cookie 和 Session 是两样东西——一张在浏览器、可被篡改的提货凭证,一份在服务端、必须可控的真实状态;二是承认会话状态必须外置,应用实例不该私藏任何状态。一旦接受这两点,该做的事就都浮现出来了——把 Session 搬进 Redis 并带上过期、Cookie 里只放随机 id、给 Cookie 配齐三道安全闸门、把会话的续期与注销当成生命周期来管。每一步都不复杂,难的是先承认:你的"服务器"从来不是一个进程,你的会话状态不能跟着某个进程一起生死。

我后来常拿剧院的衣帽寄存处来想这件事。你进场前把大衣交给寄存处,他们给你一个号码牌。这个号码牌本身不是你的大衣——它只是一个号码,你拿它什么也穿不了;你的大衣,好端端地挂在寄存处的衣架上。散场时你凭这个牌子,去取回属于你的那件。这里头每一样东西都对得上:号码牌就是 Cookie,它在你手上、可能被人看见,所以它只是个无意义的号码;大衣就是 Session,它是真东西,锁在你管不到的、可信的寄存处里。第一版的错,就像一家剧院开了好几个互不相通的寄存处,你在一号窗口存的大衣,拿着牌子去三号窗口,人家说查无此衣——这就是多实例各存各的内存 session。而正确的做法,是让所有窗口都连到同一个公共的衣帽间,那个公共衣帽间,就是 Redis。

这类问题最咬人的地方,在于它在本地单机开发时几乎永远是"对"的:你本地只有一个进程,不存在请求被分到别的实例,你也很少在开发时反复重启再去盯登录态,内存涨更要跑很久才显形。功能演示一切完美,谁也想不到这里有雷。它只在上线、扩到多实例、长时间运行之后才集中爆发,而那时出问题的是"登录"这个每个用户都要过的核心环节。所以别等用户开始投诉"老是掉登录"才想起会话的存储:做登录功能的第一天,就该把"会话状态要放在一个独立的共享存储里"当成和登录逻辑本身同等重要的事来设计——它不该是一个"以后上线了再优化"的事项,而该是你写第一行会话代码时就定下的地基。把这件事在一开始就想清楚,你才算真正跳出了那个几乎人人都会踩、却要等到上线才追悔莫及的"session 存内存"。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LLM 应用可观测性完全指南:从一次"用户说答案错了,我却完全不知道当时发生了什么"看懂链路追踪

2026-5-22 17:59:08

技术教程

大模型推理服务并发完全指南:从一次"十个用户同时提问,GPU 直接 OOM 崩溃"看懂 KV Cache 与显存调度

2026-5-22 18:13:33

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索