PC 出码 · 手机确认 · 网页怎么知道扫成功了扫码登录难的不是二维码本身而是PC 端如何拿到手机端的确认结果。短轮询、长轮询、SSE 都能做选错的代价是要么体验发闷要么 Tomcat 连接被占满、集群里找不到那条挂起的请求。推送通道之前还有一条更硬的约束二维码里的loginId不是登录凭证。只拿得到loginId就能换 Token等于把登录态印在屏幕上。目录问题在哪扫码登录五步安全硬杠杠loginId 不是登录凭证短轮询长轮询SSE服务器推送怎么选集群与网关结语问题在哪PC 打开登录页屏幕上出现一个二维码用户掏出已经登录过的 App 扫一下、点确认网页就进系统了。出码本身很简单后端生成一个一次性loginIdUUID把loginId画进二维码。真正要设计的是后面这件事手机确认发生在另一台设备、另一条链路上。PC 浏览器当时正停在登录页它怎么知道「可以发 Token 了」这就是长短轮询、后来的 SSE 要回答的问题。WebSocket 能做但对一次性、单向、二维码几十秒就过期的场景通常是杀鸡用牛刀。扫码登录五步把两端用loginId钉死流程可以收成五步1. PC 向后端申请二维码服务端生成 loginId并把它绑到「这台浏览器」的会话Cookie / waitToken 2. 页面把 loginId 画成二维码然后开始「等结果」轮询 / 长轮询 / SSE 3. 手机扫码并确认App 侧已登录→ 后端把该 loginId 标成 CONFIRMED写入 userIdTTL 仍在 4. PC 等到「已确认」用「本机会话 loginId」调换票接口 5. 后端校验会话绑定且状态为 CONFIRMED原子消费 loginId签发 Token手机 AppRedis后端PC 浏览器手机 AppRedis后端PC 浏览器申请二维码SET loginId {WAITING, sessionId} TTL120sloginId Set-Cookie(session)等待扫码结果带 Cookie扫码确认(loginId App 登录态)CAS WAITING/SCANNED → CONFIRMED userId已确认换 TokenCookie loginIdGETDEL loginId须 CONFIRMED 且 session 匹配Token状态建议至少四档后面选推送方案时会用到状态含义PC 上该显示什么WAITING已出码没人扫二维码SCANNED扫了手机还没点确认「请在手机上确认」CONFIRMED已确认尚未换票调换票接口EXPIRED/CANCELLED过期或拒绝刷新二维码只关心「成没成功」时短轮询够用。若要扫完立刻把 PC 画面切成「待确认」短轮询的间隔会被用户感知到这时长轮询或 SSE 更合适。状态只能单向走WAITING → SCANNED → CONFIRMED不允许回退换票后立刻删除。用 RedisGETDEL或 Lua做一次性消费不要GET后再DEL——中间会插进第二次换票。安全硬杠杠loginId 不是登录凭证一个常见误区是用loginId换 Token和账密登录没有本质区别。查找用户的钥匙确实换了但账密是保密因子二维码是亮在屏幕上的。谁拍到码、谁从访问日志里抄到loginId谁就能冒充「正在等结果的那台 PC」。USENIX Security 2025 对真实站点扫码登录的审计里最常见的两类缺陷就是QrId 没有绑 sessionId、登录成功后 QrId 还能再用。对应到实现就是下面三条。1. 出码时绑定「这台浏览器」申请二维码时下发或写入 HttpOnly Cookie一个sessionId/waitTokenRedis 里loginId → sessionId。之后无论是查状态、挂长轮询、开 SSE还是换 Token都要带上这个会话对不上就当不存在。否则典型攻击是攻击者抄到受害者屏幕上的loginId或状态接口 URL自己也开始等受害者扫码确认后攻击者抢先换票把登录态领到自己的浏览器。2. 换票只能发生一次且只能发给出码的那台 PC换票接口Cookie.sessionId loginId 校验Redis 中状态 CONFIRMED 且 sessionId 匹配 动作GETDEL 原子删掉 loginId再签发 Token状态接口只返回枚举状态不返回 Token、不返回 userId。Token 只走换票接口。3. 手机确认页要展示「正在登录哪一台」反向钓鱼更常见攻击者在自己电脑打开登录页把二维码发给受害者扫。受害者一确认攻击者的 PC 就拿到受害者账号。App 确认页必须展示设备 / 地点 / 时间并默认「未确认不登录」。SCANNED和CONFIRMED拆开就是为这一步服务的。另外loginId必须服务端生成、不可预测UUID 即可不要自增EventSource / 短轮询把loginId放在 query 上会进访问日志——所以更要靠 Cookie 绑定而不是把loginId当成保密材料。原生EventSource不能自定义 Authorization 头扫码这种匿名等待场景Cookie 比把长期 Token 塞进 URL 合适。短轮询典型微信公众号管理后台一类的扫码登录。前端每隔固定时间13 秒常见问一次GET /scan/status?loginId...带 Cookie。后端立刻返回当前状态不把请求挂住。GetMapping(/scan/status)publicRScanStatusVOstatus(RequestParamStringloginId,HttpServletRequestrequest){StringsessionIdreadSessionId(request);ScanRecordrecordscanLoginStore.get(loginId);if(recordnull||!record.getSessionId().equals(sessionId)){returnR.data(ScanStatusVO.expired());}returnR.data(ScanStatusVO.of(record.getStatus()));// 只给状态不给 Token}说明优点不长期占用 TCP实现简单集群下只要 Redis 里有状态哪台机器查都可以不需要消息广播缺点实时性完全取决于间隔间隔 2 秒用户最多愣 2 秒。间隔再短空转请求就上去了二维码 TTL 按 120 秒、间隔 2 秒算一次登录大约60 次 Redis GET。扫码登录 QPS 通常不高这点流量可以忽略。这也是观察下来多数网站选短轮询的原因TCP 和实现复杂度都比「再快 1 秒」更值钱。长轮询典型政务码登录页一类「扫了必须马上跳」的场景。前端发一个请求后端不马上回。在浏览器里这条请求会停在 pending等待窗口内进入终态CONFIRMED/CANCELLED或需要立刻刷新 UI 的SCANNED→立刻写回连接结束等到超时仍无变化 → 返回「本次请求超时」不是二维码过期前端再发下一个长轮询超时建议2025 秒卡在 Nginx / 网关常见的 60 秒读超时之前。这和二维码 120 秒 TTL 不是一回事TTL 没到前端继续挂下一次即可。效果接近长连接推送但协议仍是普通 HTTP 请求/响应。Spring 里对应DeferredResultServlet 异步工作线程先还回去超时或业务完成后再提交响应。Filter 要支持 async否则异步派发会被包装 Filter 挡住。GetMapping(/scan/wait)publicDeferredResultRScanStatusVOwaitScan(RequestParamStringloginId,HttpServletRequestrequest){DeferredResultRScanStatusVOresultnewDeferredResult(25_000L);result.onTimeout(()-result.setResult(R.data(ScanStatusVO.requestTimeout())));ScanRecordrecordrequireBound(loginId,readSessionId(request));if(recordnull){result.setResult(R.data(ScanStatusVO.expired()));returnresult;}// 已是终态或已扫待确认立刻返回不要当成 WAITING 挂住if(record.getStatus()!ScanStatus.WAITING){result.setResult(R.data(ScanStatusVO.of(record.getStatus())));returnresult;}scanWaitRegistry.register(loginId,result);returnresult;}注意两处record null不能写成current ! WAITING——空值会 NPE也会把「码已过期」和「还在等」混在一起。返回SCANNED之后连接就拆了。前端若还要等确认必须再挂一次长轮询。这就是后面 SSE 更顺的原因一条流上可以先推SCANNED再推CONFIRMED。配置中心如 Apollo的客户端拉配置走的也是「挂起 → 有变更立刻返回 → 再挂起」。扫码登录可以当同一模式的一次性版本。说明优点窗口内结果立刻到达体验接近推送缺点 1占 TCP。Servlet 工作线程可以还回去连接本身还在。人一多就是大量 ESTABLISHED缺点 2集群要对号入座。确认发生在节点 B长轮询挂在节点 AB 必须把事件广播出去A 才能setResult。这和 WebSocket 集群是一类问题所以长轮询不是「免费的实时」。你省下的是空转请求换来的是连接占用和广播。SSE服务器推送短轮询一次一问一答长轮询一次挂起、一次返回、连接就拆掉。SSEServer-Sent Events是第三条路浏览器打开一条 HTTP 长连接text/event-stream服务端可以连续推多条事件直到主动关掉。扫码登录恰好是服务端 → PC 的单向通知先推SCANNED再推CONFIRMED。手机确认走的是 App 自己的接口不需要这条连接反着写。这种「只要推、不要双工」的场景SSE 比 WebSocket 轻。浏览器侧是标准EventSource。默认会自动重连——服务端超时把连接掐掉后若不close()浏览器过几秒又会打过来。二维码都过期了还在重连等于给自己制造空转和日志噪音。终态必须由前端关掉。constesnewEventSource(/scan/subscribe?loginId${loginId},{withCredentials:true});functionstop(){es.close();}es.addEventListener(scan,(e){const{status}JSON.parse(e.data);if(statusSCANNED)showPleaseConfirm();if(statusCONFIRMED){stop();exchangeToken(loginId);// 走带 Cookie 的换票接口不要信 SSE 里的 Token}if(statusEXPIRED||statusCANCELLED){stop();refreshQrCode();}});es.onerror(){// 服务端已 complete / 码已失效时不要靠默认重连续命if(qrAlreadyTerminal())stop();};服务端用 SpringSseEmitter。超时对齐二维码 TTL不要开成「永不超时」。超时回调里推一条EXPIRED再complete让前端走停机逻辑而不是静默断线后被 EventSource 拉起来。GetMapping(value/scan/subscribe,producesMediaType.TEXT_EVENT_STREAM_VALUE)publicSseEmittersubscribe(RequestParamStringloginId,HttpServletRequestrequest,HttpServletResponseresponse){response.setHeader(X-Accel-Buffering,no);response.setHeader(Cache-Control,no-cache);ScanRecordrecordrequireBound(loginId,readSessionId(request));SseEmitteremitternewSseEmitter(120_000L);if(recordnull){send(emitter,ScanStatus.EXPIRED);emitter.complete();returnemitter;}scanSseRegistry.register(loginId,emitter);emitter.onCompletion(()-scanSseRegistry.remove(loginId,emitter));emitter.onTimeout(()-{send(emitter,ScanStatus.EXPIRED);emitter.complete();scanSseRegistry.remove(loginId,emitter);});emitter.onError((ex)-scanSseRegistry.remove(loginId,emitter));send(emitter,record.getStatus());returnemitter;}/** 手机确认只写 Redis 广播。谁挂着连接谁推避免本节点推完又收到订阅再推一次。 */publicvoidonAppConfirmed(StringloginId,LonguserId){scanLoginStore.casConfirm(loginId,userId);// 仅 WAITING/SCANNED → CONFIRMEDredis.publish(scan-login,loginId);}RedisSubscribe(scan-login)publicvoidonScanEvent(StringloginId){ScanRecordrecordscanLoginStore.get(loginId);SseEmitteremitterscanSseRegistry.get(loginId);if(recordnull||emitternull){return;}send(emitter,record.getStatus());if(record.getStatus().isTerminal()){emitter.complete();}}privatevoidsend(SseEmitteremitter,ScanStatusstatus){synchronized(emitter){// 心跳线程和业务线程可能同时 sendtry{emitter.send(SseEmitter.event().name(scan).data(ScanStatusVO.of(status)));}catch(IOExceptionex){emitter.completeWithError(ex);}}}和长轮询比SSE 多出来的能力是同一条连接上推两次事件1 scan {status:SCANNED} ← 手机刚扫PC 立刻改文案 事件2 scan {status:CONFIRMED} ← 点了确认PC 换 Token、关掉 EventSource短轮询要靠下一次间隔才能看到「已扫」长轮询一次响应结束连接中间态还得再挂一次。SSE 把「待确认」做成一等公民这是它在扫码场景里最值得用的理由。说明优点单向推送语义干净可推多段状态仍是 HTTP网关比 WebSocket 好过缺点 1一样占长连接集群一样要广播资源模型和长轮询同类缺点 2只支持服务端 → 客户端IE / 部分 App WebView 没有EventSource缺点 3代理默认会缓冲、会按读超时掐连接不改 Nginx / Gateway 就会「推了但前端收不到」缺点 4浏览器自动重连服务端超时后若不推终态、前端不close()会空转重连缺点 5HTTP/1.1 同域默认最多 6 条连接登录页若还开着别的长连接SSE 可能被堵住HTTP/2 无此问题SSE 不是「更高级的长轮询」它是同一条连接上的事件流。只推一次就complete和长轮询几乎等价要中间态它才比长轮询顺。「浏览器自带重连」写在优点里不合适扫码是一次性订阅重连是要显式管住的行为不是白捡的能力。怎么选先把四种手段放到一张表里。WebSocket 写进来是为了明确扫码登录通常用不上它。短轮询长轮询SSEWebSocket方向拉拉挂起服务端推双工实时取决于间隔窗口内立刻立刻可连续多条立刻TCP短连接挂起期间占用占用到关闭占用到关闭集群只读 Redis要对号入座要对号入座要对号入座中间态下次才看到一次响应拆连接一条流推多次可以但偏重扫码登录默认首选强实时、只要终态要「已扫待确认」一般不必落地时按业务问三句1. 只要成功/失败不要中间态用短轮询。实现短、好水平扩展和网上大多数扫码登录一致。2. 扫完必须立刻提示「请在手机确认」确认后再进系统用 SSE。一次订阅覆盖SCANNED→CONFIRMED比套两次长轮询干净。3. 页面上还有聊天、协同、双向指令那已经不是扫码登录这道题了再上 WebSocket。不要因为「以后可能实时」就把登录页绑到 WS。经验值登录页峰值不高、二维码 TTL 两分钟以内短轮询的空转成本几乎可以忽略长轮询 / SSE 的成本在连接数和集群广播。强实时的政务码一类可以上长连接管理后台、内部系统短轮询通常更合适。只问终态 → 短轮询 终态 已扫待确认 → SSE 只要终态但要零等待 → 长轮询或 SSE 推一条就关 还要客户端回写 → WebSocket上面任何一种换票规则都一样Cookie 绑定 GETDEL。通道只负责把状态送到 PC不负责发 Token。集群与网关长轮询和 SSE 在单机上很好写ConcurrentHashMaploginId, DeferredResult/SseEmitter。一到多实例确认请求打到 B挂起的连接在 AMap 里是空的。做法和 WebSocket 集群同一类状态放 Redis事件走 Pub/Sub或 MQ。手机确认 → 节点 B 写 Redis CONFIRMED → PUBLISH scan-login {loginId} → 每个节点查本地 Map命中则推 → 不要「本节点先 send 再 publish」——订阅者包含自己会 double complete没有广播就会出现「手机已确认、PC 还在转圈直到超时」。短轮询没有这个问题PC 下一次 GET 打到任何节点读的都是 Redis。粘滞会话ip_hash/ cookie sticky只能降低「连在 A、确认打到 B」的概率不能替代广播App 确认接口和 PC 的长连接本来就不是同一个入口。SSE 还有两处网关坑长轮询相对少见1. 反向代理把流攒在缓冲区里Nginx 默认proxy_buffering on上游已经send了浏览器要等缓冲区满或连接结束才收到。扫码场景的表现就是「明明确认了页面过半天才动」。Spring Cloud Gateway / 前面的 CDN 同样会缓冲或按读超时掐流要单独放行这条路径。location /scan/subscribe { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 180s; # 略大于二维码 TTL 心跳间隔不要开到 3600s }应用里再加X-Accel-Buffering: no给 Nginx 一次按请求关闭缓冲的机会。登录页不该挂十几分钟的流。2. 中间代理按「一段时间没数据」掐连接无事件时发心跳。SSE 注释行客户端不可见适合保活emitter.send(SseEmitter.event().comment(keepalive));1520 秒一次低于常见 60 秒的proxy_read_timeout。心跳和业务send必须串行上面的synchronized否则偶发IOException连接被掐掉后 EventSource 又去重连。长连接还要记得TomcatmaxConnections和线程池不是一回事。异步请求能把工作线程还回去TCP 连接仍占用。扫码页同时在线人数就是潜在连接数评估时按「二维码 TTL 内的并发登录页」估不要按 QPS 估。结语扫码登录要回答两个问题不要混成一个谁有资格拿到 Token—— 出码会话绑定、状态机单向、换票GETDELPC 怎么尽快看到状态变化—— 短轮询 / 长轮询 / SSE你真正要的就用实现简单、连接少、集群无脑扩短轮询窗口内立刻拿到终态长轮询终态之外还要「已扫待确认」SSE双向实时别绑在登录页上另开 WebSocket多数网站选短轮询是因为 TCP 和复杂度比那 12 秒更贵。SSE 没有推翻这个结论它只是把「中间态也要即时」从两次长轮询里拆出来变成一条事件流。先画状态机再决定连不连、连多久、断了怎么办。通道再实时也换不来「没绑会话的 loginId」。