技术争论里最容易吵起来的,往往不是某个方案能不能用,而是大家拿着不同的前提在争同一个词。
比如一句“把 token 存到 Redis,登录一次变一次,旧 token 请求接口就作废”,有人会立刻说:这不就是 Session 吗?JWT 的优势全被你干没了。另一边也会反驳:我就是要单点登录、踢人、封号、改密后立即失效,不查 Redis 怎么保证?
真正的问题不在于“JWT 能不能存 Redis”,而在于你必须承认自己到底在做什么。
JWT + Redis 不是错误方案,但它已经不是纯无状态认证。它是以 JWT 作为票据格式,以 Redis 作为会话控制面的混合式鉴权。
这句话把争端基本就解开了。JWT 不是宗教,Session 也不是落后。无状态不是银弹,有状态也不等于低级。它们只是把系统复杂度放在了不同位置。
争论的核心:JWT 是票据,不是会话治理
如果你的系统只需要普通 API 鉴权,用户登录后拿一个短期 access_token,服务端只校验签名和过期时间,不关心旧 token 是否立刻失效,那么纯 JWT 很舒服。任意节点只要拿到密钥或公钥,就能独立完成校验,横向扩容也简单。
但如果你的系统要求一个账号只能同时登录一个设备,新登录后旧登录立刻失效,修改密码、封号、踢人必须实时生效,WebSocket 长连接还要能被服务端主动断开,那就不要再执着“纯无状态”了。你天然需要服务端状态。
这不是退步,而是需求本身决定的。
所谓工程判断,本质上就是承认代价,然后把代价放在最可控的位置。对于登录态这种安全边界,很多时候把状态放回服务端,反而比假装自己无状态更可靠。
实际项目里,这个分歧通常不是出现在登录接口刚写完的时候,而是出现在需求开始“长牙”以后。第一版只需要登录、带 token 访问接口、过期重新登录,看起来 JWT 非常漂亮;第二版产品说一个账号只能一个设备在线;第三版安全同学说改密后所有旧登录态必须立刻失效;第四版运营后台要支持封号、踢人、冻结高风险用户;第五版你又接入了 WebSocket,用户已经连上服务器,不会等下一次 HTTP 请求才暴露问题。
到这个阶段,继续坚持“服务端绝不保存状态”就不再是架构洁癖,而是在和需求作对。系统当然还能硬撑,比如把 access token 做得极短,靠频繁刷新缩小失效窗口;或者维护一张黑名单,所有请求先查 token 是否被吊销;或者在 JWT 里塞一个用户版本号,每次请求再去查版本是否一致。但只要你开始做这些事,本质上就已经承认:身份凭证之外,还需要一个服务端控制面。
所以这篇文章真正想讨论的不是 JWT 和 Session 谁赢谁输,而是一个更朴素的问题:当业务需要强控制时,状态应该放在哪里,怎么放,失败时怎么办。
“无状态认证”的核心是:服务端不保存每一个客户端的会话事实,每次请求都携带足够的信息,服务端通过签名、时间和声明完成判断。“有状态认证”的核心是:服务端保存会话事实,客户端只持有一个索引或票据,每次请求都要回到服务端状态存储里确认它是否仍然有效。
这两种模型的差异可以简单拆开:
| 维度 | 偏无状态认证 | 偏有状态认证 |
|---|---|---|
| 典型技术 | 短期 JWT access token | Session + Cookie / Redis Session |
| 服务端是否保存登录态 | 理论上不保存单个会话事实 | 保存会话事实 |
| 横向扩容 | 容易,任意节点能验签 | 需要共享 Session 或集中存储 |
| 主动踢下线 | 不自然,需要额外状态 | 状态设计到位后更直接 |
| 修改密码后立即失效 | 不自然,需要版本号或黑名单 | 可通过删除会话或递增版本实现 |
| 单端登录 | 需要记录当前有效会话 | 可按用户或设备维度控制 |
| 每次请求成本 | 本地验签即可 | 通常要查 Redis / DB |
| 故障依赖 | 依赖密钥、时间和算法安全 | 依赖会话存储和网络稳定性 |
争论的关键不是“谁更高级”,而是你到底要吞下哪一种复杂度。纯 JWT 把复杂度放在 token 生命周期上:过期前很难强制收回。有状态 Session 把复杂度放在集中存储上:每次请求都要依赖 Redis、DB 或 Session 服务。
这也是很多讨论容易误导人的地方。有人说“JWT 不用查库,所以性能好”,这句话只在纯本地验签的前提下成立;如果你每次都拿 jti 查 Redis,它就已经不是那个性能模型了。有人说“Session 不能分布式”,这句话也只在 Session 存单机内存时成立;如果 Session 本来就在 Redis 或专门的会话服务里,多节点并不是问题,真正的问题变成了集中依赖、容量、延迟和故障恢复。
这也是为什么真实工程里最常见的不是二选一,而是混合模型。
JWT 的全称是 JSON Web Token。它本质上是一种紧凑的、可签名的声明载体,常见结构是:
它的价值在于可以把 userId、sessionId、role、exp 等声明放进 token;服务端可以通过签名确认内容没有被篡改;多个服务只要共享密钥或公钥,就能独立完成基础校验。它非常适合 API、移动端、微服务内部身份传递。
但 JWT 并不自动等于无状态。只要你的验证逻辑变成这样:
那这个系统就已经引入了服务端状态。
这没有问题。问题只在于你不能一边每次请求都查 Redis,一边还宣称自己是纯无状态架构。准确的说法应该是:
我们使用 JWT 作为客户端凭证格式,但登录态有效性由 Redis 集中控制。
这句话才是工程语言。
JWT 还有一个经常被忽略的安全边界:它不是加密。普通签名 JWT 的 payload 可以被任何拿到 token 的人解码查看,所以不要往里面塞手机号、身份证号、密码、密钥、支付信息这类敏感数据。JWT 防篡改,不防读取。除非你使用的是 JWE 这类加密型 token,否则它只是“签名票据”,不是“保险箱”。
Session 也不是落后技术。很多人嫌 Session 老,是因为早期 Session 经常和单体应用、浏览器 Cookie、服务端内存绑定在一起。一旦上了多节点,内存 Session 就会遇到负载均衡、节点重启、会话丢失的问题。但这不是 Session 模型的问题,而是存储位置的问题。
在现代架构里,Session 完全可以放在 Redis:
客户端只拿一个 sid,服务端每次通过 sid 去 Redis 查真实会话。登出时删除 Session,踢人时删除 Session,封号时删除所有 Session,单端登录时只保留当前 Session,权限变化时更新服务端状态。它的控制力很强,代价也很直接:Redis 成了认证链路上的关键依赖。Redis 延迟、抖动、网络分区,都会影响认证稳定性。
这里也要说得准确一点:Session 不是“天然”解决所有问题,而是它把问题放在服务端以后,更容易用一致的方式解决。单端登录不是随便用了 Session 就自动出现,你仍然要设计 userId -> sid 的映射;改密后全部失效也不是魔法,你仍然要删除用户所有 session,或者递增用户版本号;权限实时生效也不是凭空发生,你仍然要决定权限是每次读取,还是写入 session 后通过事件刷新。Session 的优势不在于免设计,而在于它承认服务端有最终控制权。
所以,JWT 适合身份声明,Session 适合会话控制。真正复杂的系统,往往两个都要。
混合方案:短 JWT、可吊销 refresh token、Redis 会话控制面
对于“登录一次变一次,旧 token 立即废掉”的需求,我更推荐一个混合方案:
这里的关键不是“把完整 JWT 原文扔进 Redis”,而是把会话控制权放在 Redis。登录成功后生成一个 sid,JWT 里不要只放 userId,还要放会话标识和版本号:
Redis 里维护当前用户的有效会话:
每次请求的校验流程也很清楚:
这样做以后,能力边界非常清晰:用户重新登录时覆盖 current_sid,旧 token 立即失效;用户修改密码时递增 user_version,所有旧 token 立即失效;管理员封号时删除 session 并标记用户状态,所有请求立即失败;单端登录只保留一个 current_sid;多端登录则把 current_sid 改成按设备维度管理的集合。
sid 和 version 的职责最好分开理解。sid 解决的是“这一次登录会话是不是当前有效会话”,适合单端登录、设备管理、主动踢某个设备。version 解决的是“这个用户整体安全状态有没有变化”,适合改密、封号、权限体系大变更。只靠 sid,你可以踢掉某次登录,但处理“所有历史 token 一起失效”会比较笨;只靠 version,你可以批量失效,但很难精细地区分某台设备。
刷新 token 的流程也应该和这个模型对齐。客户端拿短期 access_token 请求接口;快过期时,用 refresh_token 去认证服务换新的 access token;认证服务验证 refresh token hash、会话状态、用户版本和设备状态都正常以后,才签发新的 access token。更严格的系统会做 refresh token rotation:每次刷新都废掉旧 refresh token,签发一个新的,并记录 token 家族。如果发现一个已经轮换过的 refresh token 又被使用,说明可能发生泄露,应该吊销整个会话家族。
这样写的好处,是你没有把长期凭证明文放在服务端;即便 Redis 或数据库中的记录泄露,攻击者也不能直接拿记录去冒充用户。它不能消灭所有风险,但把风险从“拿到即用”降成了“还需要原始 token”。
这套方案牺牲了纯 JWT 的“完全本地验签”,换来了服务端对会话生命周期的强控制。这就是交易。没有免费的架构。
真正成熟的选型,不是先站队,而是先看场景:
| 场景 | 更推荐的方案 | 原因 |
|---|---|---|
| 普通开放 API | 短期 JWT | 易扩展,服务端无须保存每个会话 |
| 移动端 App | JWT access token + 可吊销 refresh token | access token 短期有效,refresh token 可控 |
| 管理后台 | Session / JWT + Redis | 需要踢人、封号、权限实时生效 |
| 单点登录 SSO | JWT + 中央认证服务 + 会话状态 | 跨系统传递身份,同时保留统一注销能力 |
| 微服务内部调用 | JWT / opaque token + 网关鉴权 | 适合跨服务传递身份上下文 |
| 单账号单端登录 | JWT + Redis 当前会话 | 纯 JWT 很难优雅完成 |
| WebSocket 长连接 | 握手鉴权 + Redis 会话 + 节点连接表 | 长连接必须能被服务端主动管理 |
这里还要补一个很容易被忽略的点:refresh_token 不应该被服务端当明文长期保存。更稳的做法是保存它的 hash、家族关系、轮换版本和吊销状态。客户端侧也要看场景处理:浏览器里优先使用 HttpOnly、Secure、SameSite 合理配置的 Cookie,移动端放系统安全存储,不要把长期 refresh token 随手塞进普通本地存储。
access_token 则应该短。10 到 30 分钟是很多系统能接受的折中,具体要看风险等级和刷新成本。短 token 不能解决所有问题,但它能把泄露窗口压小,也能让黑名单不至于变成主路径。
如果你做的是后台管理、资金操作、企业内控系统,access token 可以更短,甚至在高风险操作前要求二次确认或重新认证。如果只是普通内容型 App,过短的 access token 会带来频繁刷新、移动网络抖动、用户体验下降的问题。这里没有一个放诸四海皆准的时间,只有风险和体验之间的账。
Redis 不该当垃圾桶:状态、黑名单与故障边界
很多人一上来就说“把 token 存 Redis”。这句话本身太粗糙了。
如果你把完整 JWT 原文直接存进去,然后每次拿客户端传来的 token 做字符串比对,也能工作,但不够优雅,也增加了敏感凭证泄露后的损害面。更好的方式是存 sid、jti 或 token hash:
或者只存当前有效的 sid:
Redis 里也不应该塞无限增长的黑名单。黑名单适合处理短期异常,比如用户主动退出后,在 access token 剩余的几分钟内阻断它;如果 token 生命周期本身长达几天,再靠黑名单兜底,最后一定会变成运维债务。
黑名单最大的问题不是“不能用”,而是很容易被误用成主架构。它适合处理例外:某个 token 被怀疑泄露,用户主动登出后希望剩余几分钟内也不能用,或者安全系统临时拦截某个高风险凭证。它不适合承担所有会话生命周期控制。你越依赖黑名单,就越依赖每次请求都查黑名单;你 token 有效期越长,黑名单保留时间越长;用户越多,黑名单越像一个不断膨胀的安全垃圾场。
更稳的做法是:access_token 保持短期,refresh_token 支持轮换和吊销,会话控制通过 sid 和 version 完成,黑名单只处理极端安全事件,不做主路径设计。
同时,既然 Redis 成了会话控制面,就不能只讲能力,不讲故障。Redis 挂了怎么办?网络抖动怎么办?认证服务和 Redis 之间出现短暂分区怎么办?这些问题不提前定好,线上事故时就会变成临时拍脑袋。
对普通业务,我更倾向于认证链路 fail closed:Redis 查不到、查不动、查超时,就拒绝高风险请求,让用户重新认证。对低风险读接口,可以根据业务容忍度做短暂缓存或降级,但必须非常克制。因为登录态不是普通缓存,它是安全边界。你可以为了体验吞一点延迟,不能为了体验默认放行一个无法确认有效性的身份。
当然,fail closed 也不是一句口号就结束了。你要给 Redis 设置合理超时,不能让认证链路被一个慢查询拖死;要区分“Redis 明确返回会话不存在”和“Redis 暂时不可用”;要给登录、刷新、鉴权这些路径分别打指标,监控 Redis 延迟、命中率、错误率和 401/403 异常波动。很多认证事故不是因为方案错,而是因为系统不知道自己正在坏。
Redis key 也要有明确生命周期。auth:session:{sid} 应该带 TTL,auth:current_sid:{userId} 要和 session 生命周期保持一致,用户封号、改密、注销时要清理或递增版本。否则你以为自己获得了控制力,实际上只是把状态垃圾换了一个地方堆起来。
在多端登录场景里,key 结构还要再细一点。比如按设备保存 session,既能踢掉单台设备,也能在用户改密时一次性递增用户版本,让所有设备失效:
这样,你可以清楚表达不同操作的影响范围:用户自己退出当前设备,只删当前 sid;管理员踢掉某台设备,只删对应 session;改密或封号,递增 user_version 并清理所有 session。状态不是越多越好,而是每一份状态都要有明确语义。
这部分的核心不是“Redis 很强”,而是“Redis 让状态变得可控”。可控的前提,是你承认它会失败,并且给失败留出边界。
WebSocket:长连接让状态无法回避
HTTP 请求是短连接语义。一次请求进来,验完就走。WebSocket 不一样,它是一条长时间挂在服务端进程里的连接。只要连接不断,用户就可能继续收消息、发消息、保持在线状态。
这意味着 WebSocket 天然有状态:哪个用户连在哪个节点,一个用户有几个连接,连接是否还活着,用户权限变了以后如何通知连接断开,某条消息应该推到哪些节点,节点重启后如何清理连接表。你不能拿“JWT 是无状态的”一句话解决这些问题。
握手阶段当然要鉴权,但 token 传递方式要谨慎。wss://example.com/ws?token=xxx 很常见,也很容易写进示例,但它不应该成为默认推荐,因为 query 参数容易进入日志、代理、监控和浏览器历史。浏览器场景可以考虑安全 Cookie、一次性握手 ticket,或者连接建立后的认证消息;非浏览器客户端可以使用握手 Header。确实要用 query,也应该使用短期、一次性、只用于换取连接身份的 ticket,而不是长期 access token。
如果 WebSocket 依赖 Cookie 鉴权,还要记得校验 Origin。浏览器发起跨站 WebSocket 时也可能自动带上 Cookie,服务端如果只看 Cookie 不看来源,就可能让恶意页面借用户身份建立连接。Cookie 方案不是不能用,但它必须和 Secure、SameSite、Origin 校验、TLS 一起讨论。
一个更稳的握手流程可以拆成两步:客户端先用正常 HTTP 请求向认证服务申请一次性 ws_ticket,服务端确认当前 access token、sid、version 都有效后,签发一个几十秒内有效、只能使用一次的 ticket;客户端再带这个 ticket 建立 WebSocket;WS 节点消费 ticket,换取用户身份并立刻作废。这样即便握手 URL 被日志记录,泄露窗口也很小。
握手通过后,本机内存保存真实 socket,Redis 只保存路由索引:
这里必须强调一句:真正的 WebSocket 连接不能存在 Redis。
Redis 只能告诉你“这个用户的连接在哪个节点”,不能替你跨进程操作 socket。真正能给客户端发消息的,永远是持有那条连接的进程。
单点登录、封号、修改密码后的踢下线,需要走事件通知:
关闭时可以定义业务关闭码:
这样客户端可以根据关闭码做不同提示,而不是一句笼统的“连接断开”。
对于长连接,还要考虑 token 续期。第一种做法,是让 WebSocket 连接生命周期不超过 access token 生命周期,到期前客户端主动重连。它简单,适合小系统和对重连不敏感的业务。第二种做法,是连接内增加 refresh_auth 消息,客户端拿到新 access token 后发给服务端,服务端重新校验签名、sid、version 和用户状态,并更新连接上下文里的 expireAt。它更复杂,但适合 IM、协作编辑、交易行情这类不希望频繁断线重连的系统。
这一步不能只校验签名。用户可能已经被封号,密码可能已经修改,当前 sid 可能已经被新登录覆盖。如果连接内续期只看 JWT 是否没过期,就又绕回了“只认票据,不认会话事实”的老问题。
但这里还有一个工程细节:如果你用 Redis Pub/Sub 做踢下线通知,要承认它不持久化。节点重启或短暂掉线时,事件可能错过。因此小系统可以用 Pub/Sub,但最好配合心跳重验、定期校验 sid/version 或连接 TTL;如果踢下线必须可靠,就应该考虑 Redis Stream、RabbitMQ fanout/topic、NATS JetStream 或 Kafka 这类具备持久化或可回放能力的机制。
群聊和消息推送也是同一个原则:消息靠队列或事件总线路由,连接靠本机进程持有。节点 A 收到用户 A 的群消息后,可以写入 DB,投递 group_message 事件,事件里只放 messageId 和路由信息,消费者再去消息存储读取正文。这里不能笼统写“所有 WS 节点消费同一个队列”,因为普通队列通常是竞争消费,不会让每个节点都拿到事件。你要么使用 Pub/Sub、fanout exchange、topic 广播,要么提前算出目标 nodeId,把消息路由到持有连接的节点。
消息系统的选择也要围绕需求,而不是围绕名气。踢下线、权限刷新、在线状态广播,如果允许偶发事件通过心跳兜底,Redis Pub/Sub 可以先用;中小型 IM 需要可靠投递和消费确认,Redis Stream 或 RabbitMQ 更稳;大型群聊、消息审计、历史回放、跨服务追踪,Kafka 才更有意义;强实时推送和低延迟事件分发,可以评估 NATS。不要为了“架构漂亮”一上来就 Kafka,Kafka 解决的是大规模日志型事件和可回放,不是所有实时系统的默认答案。
连接索引也必须有清理机制。ws:conn:{connId}、ws:user:{userId}、ws:node:{nodeId} 这类 key 要有 TTL,连接心跳要刷新 TTL,正常断连要主动删除,节点宕机后要通过节点租约或定期扫描清理残留连接。否则 Redis 里的“在线用户”会慢慢变成一堆假在线。
更麻烦的是,连接状态往往和业务状态互相影响。用户已经离线但 Redis 里还残留连接,消息服务就可能继续向一个不存在的节点投递;用户已经被封号但某个 WS 节点错过踢下线事件,连接就可能继续存在;节点重启后本机连接全没了,但 Redis 还以为它持有一批 connId。分布式系统里,最危险的不是“没有状态”,而是“状态过期了却没人知道”。
所以分布式 WebSocket 的核心不是“所有节点都能发所有消息”,而是“所有节点都能知道消息该路由到哪里,然后由持有连接的节点完成投递”。这仍然是在讲会话控制:JWT 只能证明连接建立时“你是谁”,不能替服务端维护连接事实。
最后的判断:边界清晰比口号重要
如果要把上面的讨论落成一套可交付方案,我会这样设计:
这套方案里的 JWT 仍然有价值:它让客户端凭证标准化,让网关和微服务可以快速解析身份,让跨服务调用不必每次都去数据库查用户。
但 Redis 也不可替代:它提供“当前是否还有效”的最终判断,提供踢人、封号、单端登录、WebSocket 连接路由这些强控制能力。
说白了,JWT 负责“你是谁”,Redis 负责“你现在还算不算数”。
很多技术争论最后都会变成口号对撞。有人说 JWT 高级,有人说 Session 稳妥;有人说无状态利于扩容,有人说有状态才方便管理。这些话单独看都对,但放到真实系统里都不完整。
纯无状态的代价是回收能力弱。只要 token 没过期,你很难优雅地让它立刻失效。纯有状态的代价是集中依赖强。只要 Redis 或 Session 服务出问题,认证链路就会被拖下水。
如果只是普通 API,短期 JWT 就足够。
如果要单端登录、踢人、封号、WebSocket 长连接、群聊分发,那就必须引入服务端状态。
如果用了 JWT + Redis,就大方承认它是混合架构,不要硬说自己纯无状态。
无状态不是银弹。它解决的是扩展性问题,不负责替你解决会话治理、主动吊销和长连接控制。
工程世界里没有神兵,只有边界清晰的取舍。能把需求、代价、故障模式和演进路径说清楚,比在群里争一个词更重要。