上一章讲缓存一致性时,我们一直在区分“数据库里的事实”和“Redis 里的副本”。到了登录态,这条边界更敏感:GET /me 能不能认出用户、后台能不能立刻踢掉一台丢失的手机、用户点退出后旧 Cookie 还能不能继续使用,都取决于 Redis 里那条会话记录。
把 Session 从应用内存搬到 Redis,确实解决了一个显眼的问题。用户登录命中实例甲,下一次请求被负载均衡送到实例乙,乙也能从 Redis 找回登录态。但共享存储只是起点。TTL 怎么续、同时登录几台设备、怎样撤销、Redis 故障时放行还是拒绝、故障转移丢了一条写入会发生什么,这些才是上线后真正会追着你跑的问题。
这一章继续使用用户 u1001,把登录接口、当前用户接口和退出接口连成一条完整链路。浏览器只保存一个随机 Session ID;身份、权限、设备和过期边界都留在服务端。这样设计会多一次 Redis 查询,却换来了清楚的撤销能力。
先看一次很普通的扩容。服务原来只有实例甲,用户登录后,进程内的 Map 保存了会话。扩成甲、乙两台以后,POST /login 可能落在甲,紧接着的 GET /me 却落在乙。乙的内存里没有那条记录,只能返回 401。负载均衡没有做错事,真正的问题是两台进程拥有彼此看不见的状态。
粘性会话能让同一个用户尽量回到原实例,但它把登录态绑在了一台机器上。实例重启,绑定它的用户仍会集体掉线;滚动发布时还要迁移连接;热点用户也可能让流量倾斜。它可以是临时过渡,不适合替代共享状态。

Redis 把状态从“某个进程的内存”挪到“所有实例共同访问的服务”后,甲写入 session:<随机ID>,乙也能读取。应用实例因此可以保持近似无状态:扩容、缩容和重启不需要搬运本机会话。
但别急着把“能共享”理解成“已经可靠”。现在每个受保护请求都多了一条外部依赖链:浏览器携带 Cookie,应用解析 Session ID,Redis 返回会话,应用再构造当前用户。链上任何一环超时、过期、淘汰或被错误续期,用户看到的都是登录态变化。
下面的路由实验可以切换进程内存与 Redis 共享存储,再调整实例数量、粘性路由和实例重启。重点观察同一个 Session ID 在不同存储方案里能否被下一台实例识别。
不要给 Redis Session 再加一层跨请求的本地缓存。管理员刚删除 Redis 会话,实例甲若还保留几十秒本地副本,就会继续接受已撤销的 Session ID。登录态的“立即失效”能力,正是本地缓存最容易悄悄破坏的部分。
我们用三条接口定下边界:POST /login 创建会话,GET /me 读取会话,POST /logout 撤销当前会话。Cookie 里只放不可猜测的 Session ID,不放 userId、角色、昵称,更不把整个用户对象塞进去。Cookie 是一张取号凭证,真正的登录态在 Redis。
每台设备独立登录时,一次登录对应一个 key:
session:<sessionId>这里的 <sessionId> 应由密码学安全随机源生成。32 个随机字节编码成 Base64URL 后可以直接放进 Cookie。它不是自增 ID,也不拼用户 ID、手机号或时间戳。服务日志同样不应打印完整值;排障时可以记录不可逆摘要或很短的前缀。
会话本体使用 Hash,字段保持小而稳定:
不要把密码散列、支付信息、完整用户档案塞进 Session。字段越多,每次读取和网络传输越重;用户资料更新后还会出现“Session 里是旧昵称,数据库里是新昵称”的第二套一致性问题。会话适合保存识别请求所需的最小集合。
下面是 Node.js 与 ioredis 的核心实现。创建会话时把 HSET 与 EXPIRE 放进同一个事务队列,避免只写入 Hash 却没设置 TTL。登录成功前若浏览器已经带来旧 ID,就先让旧会话失效,再签发全新的 ID。这一步同时处理重新登录与固定会话攻击。
import Redis from "ioredis";
import { randomBytes } from "node:crypto";
const redis = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
lazyConnect: true
POST /login:认证成功后生成新会话用户名和密码的校验仍由用户数据库或身份服务负责。Redis 不负责判断密码对不对;它只在认证成功后保存这次登录产生的运行时状态。
app.post("/login", async (req, res) => {
const user = await verifyPassword(req.body.account, req.body.password);
if (!user) return res.status(401
GET /me:Cookie 不是身份,Redis 记录才是应用收到 Cookie 后不能只检查“它非空”。要读取对应 Hash,确认会话存在且没有越过绝对期限。读取不到就返回 401;Redis 自己不可用则是依赖故障,返回受控的 503,不要把两者混成“用户没登录”。
async function readSession(sessionId) {
if (!sessionId) return null;
const key = sessionKey(sessionId);
const session = await redis.hgetall(key);
if (!session.userId) return null;
const
POST /logout:服务端删除与浏览器清理缺一不可只清 Cookie,Redis 里的旧 ID 仍然有效;只删 Redis,浏览器还会不断携带一枚失效 Cookie。退出要同时处理两边,而且接口应保持幂等:会话本来就不存在,再点一次退出也返回成功。
app.post("/logout", async (req, res) => {
const sessionId = readSessionId(req.headers.cookie);
if (sessionId) await redis.del(sessionKey(sessionId));
res.clearCookie(
把这三条路径连起来运行,可以看到服务端撤销才是真正的失效点:
POST /login -> 200
Set-Cookie -> __Host-sid=<随机值>; HttpOnly; Secure; SameSite=Lax; Path=/
Redis -> session:<随机值>,字段 absoluteExpiresAt, createdAt, deviceName, lastSeenAt, role, userId,TTL=1800
GET /me -> 200 {"userId":"u1001","role":"member"}
POST /logout -> 204
Redis key exists -> 0
GET /me(沿用旧 Cookie)-> 401 {"code":"SESSION_INVALID"}这段结果来自 Node.js v25.2.1、ioredis 5.7.0 与 Redis 8.2.2。Session ID 在日志中被遮掉了;教学重点是 key 的生命周期,不是泄露一枚还能被重放的凭证。
EXPIRE session:<id> 1800 看起来只是让 Redis 自动清垃圾,实际上它定义了“用户多久没操作就要重新登录”。TTL 太短,读一篇长文或填写长表单就会突然掉线;TTL 太长,丢失设备上的 Cookie 会拥有更久的可利用时间。
实用的会话通常同时有两条线:
只有空闲期限并在每次请求后无条件续到 30 分钟,会产生一个容易忽略的结果:只要攻击者持续使用偷来的 Cookie,这条会话可以永远活着。absoluteExpiresAt 是服务端明确的刹车。它不能只写进 Hash 而不检查;GET /me 每次恢复会话时都要比较当前时间。
无条件对每个请求执行 EXPIRE 还会把纯读接口变成 Redis 写流量。更稳妥的做法是设置续期阈值:30 分钟空闲期只在剩余 TTL 低于 15 分钟时续一次,并且绝不能越过绝对期限。
const renewScript = `
local absolute = tonumber(redis.call("HGET", KEYS[1], "absoluteExpiresAt"))
if not absolute then return 0 end
local now = tonumber(ARGV[1])
if now >= absolute then
redis.call("DEL", KEYS[1])
return -1
end
local ttl = redis.call("TTL", KEYS[1])
if ttl <= 0 or ttl >= tonumber(ARGV[2]) then return 0 end
local nextTtl = math.min(tonumber(ARGV[3]), absolute - now)
redis.call("HSET", KEYS[1], "lastSeenAt", ARGV[1])
redis.call("EXPIRE", KEYS[1], nextTtl)
return nextTtl
`;
async function renewWhenNeeded({ key, now }) {
如果浏览器 Cookie 也设置了 Max-Age,服务端续期时还要在同一响应里重发 Cookie,否则 Redis 里的会话还活着,浏览器却先把 Session ID 丢掉。另一种简单做法是不设置持久化期限,让浏览器关闭后丢弃 Cookie,由 Redis 独立控制空闲与绝对期限。两种方式都可以,关键是客户端期限和服务端期限不能各走各的。
并发请求会让续期变得更细。两个请求同时看到 TTL 低于阈值,都去更新 lastSeenAt 并续期,通常只是多两次写;真正危险的是管理员撤销之后,一个早已读到会话的请求又用 HSET 把 key 创建回来,只留下 lastSeenAt 这样的残缺字段。上面的 Lua 把“会话仍存在、绝对期限检查、按阈值续期”放进 Redis 的一次执行中;删除先完成时,脚本看不到 absoluteExpiresAt,不会救活已撤销会话。
下面的时间轴把空闲期限、绝对期限、每次续期和阈值续期放到同一条线上。试着连续点击受保护接口,再把时间推进到绝对上限之后,你会看到活跃请求也不能无限延长一条会话。
框架替你接管 Session 后,仍要确认它怎样定义“活动”。有的实现只在 Session 被修改时保存,有的可以配置每个请求都保存;有的把最大不活跃时间映射为 Redis TTL,有的还维护额外的过期索引。不要凭“已经接入 Spring Session、Django Session 或分布式缓存”猜测续期语义,要用真实请求观察 Cookie、TTL 和 Redis 写入。
一个用户在手机、平板和电脑上登录三次,最清楚的模型是三条独立会话,而不是三个设备共用一条 session:u1001。独立会话让“退出当前设备”只删一条 key,让“丢失手机后强制下线”不会误伤电脑,也能分别记录创建时间、最近活动和设备名称。

要从用户反查所有会话,可以增加一个索引集合:
session:<sessionId> -> Hash,会话本体与 TTL
user_sessions:<userId> -> Set,保存该用户的 sessionId登录成功时同时写会话和 SADD user_sessions:u1001 <sessionId>。退出当前设备时 DEL session:<id> 并从集合 SREM;退出全部设备时读取集合成员,批量删除对应会话,再删除索引集合。索引集合自己也需要清理:会话自然过期后,如果没有同步移除成员,集合会逐渐积累失效 ID。因此列出设备时要过滤不存在的会话并顺手清理,或者使用能让成员随会话生命周期退出的另一套索引维护机制。
这里有三个常被混在一起的产品策略:
“允许几台设备”不是 Redis 命令层面的答案,而是产品与安全策略。Redis 只负责把策略需要的状态表示出来。
下列事件通常需要主动撤销:用户点退出、密码被重置、账户被冻结、管理员踢人、检测到异常登录、角色从管理员降为普通成员。只等待 TTL 到期,会把明确的风险事件拖成一个最长 30 分钟或 8 小时的窗口。
如果系统的要求只是“撤销一台设备”,删除对应 key 足够。如果要求“某个用户的所有旧凭证立即失效”,除了遍历会话集合,还可以给用户维护 sessionVersion。每条会话记录保存创建时的版本,每次请求与当前用户版本比较;密码重置时只递增版本,所有旧会话都失效。它把批量删除变成一次版本更新,却也让每次验证多一次读取,并需要处理版本数据自身的可靠性。
无论用集合还是版本号,都要考虑并发顺序。管理员刚发出“退出全部设备”,同时另一个登录请求正在创建新会话:新会话应保留还是一起撤销?先定义一个可审计的业务顺序,再用 Lua、事务或带版本的条件写入实现。单纯把几条命令排在代码里,不会自动得到这个顺序。
服务端 Session 的 Cookie 不含用户资料,并不等于它不敏感。Session ID 是持有者凭证:谁拿到它,谁就能请求 Redis 中对应的身份。安全设计的重点不是把 ID 编码得看不懂,而是让它难以预测、难以泄露、被发现后能撤销。
假设攻击者先让受害者浏览器带上一枚攻击者知道的 Session ID。若登录成功后服务端沿用原 ID,只是在它对应的记录中补上 userId=u1001,攻击者再提交同一 ID,就继承了受害者刚完成的登录。密码校验完全正确,漏洞仍然成立,因为匿名态到认证态之间没有更换凭证。
正确做法是在权限边界变化时轮换 Session ID:登录成功、二次认证完成、角色提升后都生成全新的随机 ID,并让旧 ID 失效。不要接受 URL 参数传来的 Session ID,也不要允许客户端指定新 ID。

推荐的浏览器 Cookie 形态是:
Set-Cookie: __Host-sid=<随机值>; Secure; HttpOnly; SameSite=Lax; Path=/Secure 让浏览器只通过 HTTPS 发送 Cookie。整个会话期间都必须使用 HTTPS,不能只保护登录页面。HttpOnly 阻止页面脚本通过 document.cookie 直接读出 ID。它降低 XSS 窃取凭证的后果,但 XSS 仍可能借用户浏览器发请求,所以它不是 XSS 修复方案。SameSite=Lax 能拦住一部分跨站携带 Cookie 的请求,并兼顾常见站外跳转。高敏感、完全同站的后台可评估 Strict;确实需要跨站发送时用 None,同时必须启用 Secure。Path=/ 让应用所有路径使用同一会话。__Host- 前缀还要求 Secure、Path=/ 且不设置 Domain,避免宽泛的父域与子域覆盖这枚 Cookie。SameSite 是一道防线,不应代替 CSRF 校验。修改邮箱、改密码、下单这类状态变更请求仍应使用 CSRF Token、校验来源并避免用 GET 执行副作用。Cookie 也不应进入 localStorage:去掉 HttpOnly 后,站内任意一处脚本注入都能直接读走长期凭证。
服务端日志、APM 标签、错误上报和网关访问日志也可能泄露 ID。不要把完整 Cookie、Set-Cookie 响应头或 session:<完整ID> 放入可被多人检索的日志。安全问题经常不是随机数不够强,而是凭证被观察系统完整复制了很多份。
缓存 Redis 故障时,某些读接口还能限速回源数据库;Session Redis 故障时,没有第二个可信来源告诉你“这个 ID 当前是否有效”。如果应用把异常吞掉并临时相信 Cookie,就等于在最需要收紧边界时绕过了撤销检查。

更安全的默认行为是失败关闭:受保护接口快速返回 503 或统一的认证依赖故障,不把它伪装成 401,也不悄悄放行。401 表示凭证无效,503 表示系统暂时无法验证,两者的监控、用户提示和重试策略不同。登录接口同样不能在 Redis 写失败后仍返回成功,否则浏览器拿到一枚服务端从未保存的 Cookie。
ioredis 会处理断线重连,但应用必须给等待设上限。让命令无限排队,常见结果是 HTTP 请求已经超时,进程内还堆着等待重发的认证命令;Redis 恢复瞬间,大量旧请求一起涌入。会话校验适合短超时、很少的单请求重试,并配合有抖动的重连退避。
maxRetriesPerRequest: 1 限制单条命令在断线中的等待轮次,enableOfflineQueue: false 避免未连接时继续积压新命令。具体数值要小于上游 HTTP 超时,并经过故障演练。不要把“客户端最终会重连”当成“本次用户请求一定值得一直等”。
若 Session 与普通缓存共用一个配置了 allkeys-lru、allkeys-lfu 或随机淘汰的实例,内存达到 maxmemory 后,仍在 TTL 内的会话也可能被驱逐。缓存 key 被淘汰只会多一次回源;Session key 被淘汰会让用户突然掉线。两种数据的故障代价不同,最好使用独立实例或独立的容量与淘汰边界。会话专用实例通常更关注 noeviction、明确的容量告警和写入失败处理,而不是用淘汰换取“继续写”。
监控至少要覆盖:
used_memory、maxmemory、evicted_keys 与过期速率;Redis 主从复制通常是异步的。主节点刚写入新会话或刚执行删除,写入还没到副本,主节点就故障;副本提升后,可能看不到这次变化。丢掉登录写入会让用户重新登录,体验不好但边界偏保守;丢掉退出或封禁删除更危险,因为已经要求撤销的会话可能重新出现。
持久化、复制和自动故障转移可以缩小窗口,不能把它消成数学上的零。安全要求高的系统还可以叠加短绝对期限、用户会话版本、关键撤销事件的可靠记录,并定期演练“登录后主节点立即故障”“退出后立即故障”。会话能不能丢不是 Redis 的默认答案,而是业务必须明确接受或补偿的风险。
不要在 Redis 验证超时时降级为“只要 Cookie 格式正确就算登录”。Cookie 里的随机 ID 不带可验证身份,格式正确只说明攻击者会复制字符串。此时继续访问受保护资源,相当于把认证系统的故障变成认证绕过。
“JWT 不查 Redis,所以一定更先进”是一个很容易把系统带偏的结论。Session 把身份状态留在服务端,每次请求用随机 ID 查询;JWT 把声明放进令牌,通过签名让服务端离线验证。两者改变的是状态放在哪里,以及撤销要付出什么代价。
JWT 的签名保证内容没有被篡改,不会自动加密 Payload。拿到令牌的人通常能读到其中的声明,也能在有效期内重放。签发后若不再查询任何中心状态,管理员就很难让某一枚尚未过期的 JWT 立即失效。增加拒绝名单后又需要查询共享存储;使用用户版本号也要读取版本。系统重新获得撤销能力的同时,也重新引入了状态。
混合方案常常更诚实:浏览器到网关使用 HttpOnly Cookie 和 Redis Session,网关验证后给下游签发只活几分钟、受众明确的内部令牌。这样外部会话可以立即撤销,下游不必每跳都访问 Redis。代价是网关成为关键认证边界,需要保护签名密钥、限制受众并处理时钟偏差。
不管选择哪种方案,都要先回答同一组问题:用户改密码后多久失效?管理员能否只踢一台设备?凭证被偷后最长能用多久?中心存储故障时哪些接口必须停止?这些答案比令牌名字更能决定架构。
下面的权衡台允许你调整立即踢人、离线验签、同源浏览器和 Redis 故障容忍需求。它不会宣布某个方案永远最好,而是把你选择后必须承担的状态与撤销成本摆出来。
一个可交付的 Redis Session 方案,不是 SET ... EX 1800 能运行就结束。上线前至少把下面几条串起来走一遍:登录后是否轮换 ID;实例甲登录后实例乙能否读取;空闲期限和绝对期限是否按预期工作;当前设备退出后旧 Cookie 是否立刻 401;退出全部设备与并发新登录谁先谁后;Redis 超时时是否快速 503;达到内存上限会拒绝写还是淘汰会话;故障转移后刚登录与刚退出的会话会怎样。
先从一次真实登录记录 Redis key、Hash 字段和 TTL,但把 Session ID 脱敏。确认 Cookie 中没有用户资料,Redis 中没有密码和可从数据库按需读取的大对象。
再从另一台应用实例调用 GET /me,证明登录态来自共享存储。随后重启原实例,确认用户不会因为进程退出而掉线。
删除 Redis 会话后沿用旧 Cookie 请求,结果必须是 401;断开 Redis 再请求,结果应是受控的 503。前者是凭证失效,后者是无法验证,监控里要能分开。
最后演练单设备退出、全部设备退出、密码重置和主从切换,检查撤销有没有被续期、本地缓存或复制窗口悄悄抵消。
Redis 让多实例共享登录态,却也把认证变成一条需要容量、故障和安全设计的外部链路。真正稳妥的方案并不追求“用户永远不掉线”,而是明确何时必须重新认证、何时可以立即撤销,以及无法验证身份时系统怎样安全地失败。下一章的计数器与限流会继续使用 Redis 的共享状态与原子操作,但那时 key 丢失的代价,将和会话完全不同。