资讯动态

拆解二维码扫码登录:临时票据、状态机、轮询与安全并发

发布时间:2026/10/29 18:38:13 来源:尧图企业网站定制
二维码扫码登录这东西几乎每个做过 Web 端的人都用过但真被面试官追着问细节的时候能从头到尾讲圆的人并不多。我见过不少候选人在这个问题上翻车不是不知道流程而是把扫码登录当成了一个黑盒只知道手机扫一下、电脑就登进去了一旦被问到二维码里到底写了什么PC 端怎么知道手机扫完了同一个二维码被两个人同时扫会怎样就开始含糊其辞。其实这道题之所以被反复拿出来考是因为它刚好卡在几个能力的交叉点上对状态机的理解、对临时凭证生命周期的设计、对跨设备身份传递的建模、以及在安全边界上的敏感度。这篇文章我打算把二维码扫码登录拆到底从票据生成讲到状态跃迁从轮询与长连接的取舍讲到并发与劫持的防御中间穿插一些我在实际项目里踩过的坑以及面试官在这道题上常见的追问链路。不管你是准备面试还是真的要给自家产品做一套扫码登录看完应该都能有个能直接落地的方案。1. 面试官问出这道题时他在等什么样的回答1.1 表层是流程题里层是凭证转移问题大多数人第一次回答这道题会画一条线PC 端生成二维码手机扫码手机确认PC 端登录。这条线本身没错但它只是现象不是原理。面试官想听的是这条线背后的东西PC 端在生成二维码的那一刻它在服务端留下了什么手机扫码这个动作对服务端来说改变了哪个字段用户点确认登录的时候服务端凭什么相信这个确认是来自一个已经登录过的人PC 端拿到的东西和手机端当前的登录态之间是什么关系把这些问题串起来你会发现扫码登录的本质其实是一次跨设备的凭证转移A 设备手机已经持有一个合法的身份凭证B 设备PC还没有B 设备通过展示一个临时的、可被 A 设备识别的接头暗号让 A 设备把自己的身份背书给 B 设备服务端在中间做仲裁和签发。想清楚这一点后面所有的状态、接口、安全校验都是从这句话推导出来的而不是凭空设计出来的。这也解释了为什么扫码登录和 OAuth 授权码模式长得那么像。两者都是一端生成一个临时凭证、另一端带着身份去换、最后由服务端签发正式令牌。区别在于目标不同OAuth 是第三方应用想拿到你在这个平台上的部分资源访问权扫码登录是同一个身份体系内部让一台未登录设备获得和已登录设备相同的登录态。理解了这个同构关系你在回答时就能自然地把授权码和二维码票据对应起来面试官会觉得你是在用一套统一的模型思考问题而不是背了一个流程。1.2 面试官真正在考察的四项能力我复盘过不少这类面试发现追问的落点基本集中在四块。第一块是状态建模能力你能不能说清楚这个临时凭证有哪几个状态每个状态之间的跃迁条件是什么哪些跃迁是非法的。第二块是实时性方案选型能力PC 端怎么感知手机端的动作轮询、长轮询、SSE、WebSocket 各自适合什么场景代价在哪。第三块是安全意识二维码是公开可见的任何人都能扫你怎么防止别人拿你的二维码去钓鱼怎么防止票据被枚举怎么防止同一个票据被重复消费。第四块是工程细节过期时间设多长、并发确认怎么处理、多端登录态怎么保持一致、票据清理怎么做。这四块里前两块大多数人能说个七七八八后两块才是真正的分水岭。我见过一个候选人把轮询讲得很漂亮连指数退避都提到了但被问到PC 端轮询接口返回 token 有没有问题的时候愣住了——这就是安全意识没跟上。轮询结果很容易被写进访问日志、被浏览器缓存、被打点上报把正式令牌放在轮询响应里等于给自己埋了个长期的泄露面。这类细节不是看几篇博客能补上的得真的想过这个问题。1.3 一个能撑住追问的回答骨架如果让我在面试现场组织语言我会按这个顺序讲先说清楚二维码里装的是什么临时票据不是账号信息再说清楚票据有几个状态以及每个状态的判定依据然后说 PC 端用什么方式感知状态变化并解释为什么这么选最后主动补一段安全边界的处理把话题引到自己熟悉的地方。这个骨架的好处是它天然覆盖了面试官可能追问的所有方向而且逻辑是递进的——你在讲状态的时候已经埋了并发问题在讲轮询的时候已经埋了令牌交付问题面试官顺着问下去你答起来是顺势的不是被动的。反过来最忌讳的回答方式是平铺流程把五个步骤念一遍每个步骤一句话听起来很完整但每一句话都是一个可以被深挖的坑面试官随便挑一个问下去你就得现场编。与其这样不如主动挑两三个点讲透剩下的点到为止把节奏控制在自己手里。2. 二维码里装的不是账号而是一张一次性通行证2.1 为什么二维码内容必须是无意义的随机串先说一个很多人没想明白的点二维码本身是不加密的它就是一张能被任何摄像头读出来的黑白图。你把二维码拍下来、截图、打印出来贴在墙上任何人拿到都能扫而且扫出来的内容和你自己扫到的一模一样。这意味着二维码里绝对不能放任何敏感信息——不能放用户 ID不能放手机号不能放加密后的密码更不能放一个长期有效的令牌。那放什么放一个服务端生成的、无业务含义的随机字符串业内一般叫 ticket、scene_id 或者 qr_token。它的唯一作用是在服务端的一次查询中定位到一条记录就像你去银行办事拿到的那张排队号——号本身没有价值价值在于柜台系统里这个号对应的那笔业务。号丢了、被抄走了只要业务已经办完或者已经作废别人拿着也没用。这个设计还有一个附带好处二维码的容量控制和安全性解耦了。因为内容是随机串长度可以固定二维码的版本和纠错级别也就可以固定扫码识别率稳定。如果内容里塞了用户信息长度会随着昵称、头像 URL 变化二维码密度跟着变低端机扫起来就容易失败这是个很实际的问题。2.2 ticket 的长度、熵与二维码的可扫性ticket 要多长这里有个需要权衡的地方。太短容易被枚举——假设你用 8 位纯数字那就是一亿种可能听起来很多但如果攻击者能高频请求状态查询接口配合分布式来源是存在被撞出来的风险的。太长又会让二维码内容变长密度上升影响识别速度和成功率。我一般的做法是用128 位以上的随机熵编码成 URL 安全的字符串。比如 32 个字符的 base62 字符串熵大约是 190 bit这个量级下枚举在实际工程中不可行。也有人直接用 UUID v4128 bit去掉横线后 32 个字符效果差不多胜在实现简单、不用自己写随机源。具体到二维码的生成如果票据是以 URL 形式承载的比如https://xxx.com/qr?txxxxx整段内容大概在 60 到 80 个字符之间。用 QR Code 的 M 级纠错大约能容忍 15% 的污损这个长度大概落在版本 4 到版本 5尺寸 33x33 到 37x37 模块。这个版本对绝大多数手机相机来说都是秒识别的水平。如果非要压到版本 3 以下就得缩短域名或者改用短链跳转我个人不太建议为了省那一点点尺寸去加一层跳转多一跳就多一个故障点收益不值。提示如果你们的产品要在弱光、远距离、屏幕反光的环境下扫码比如电视上展示二维码把纠错级别提到 Q 或 H代价是版本上升、码变密需要实测识别率再定。2.3 票据在服务端的存储结构票据落库或者落缓存的时候我习惯用一张逻辑上的票据表字段大致是这样ticket主键、status状态枚举、user_id确认后写入的登录用户、device_info扫码设备的 UA、IP 归属地、设备型号用于二次确认展示、created_at、expires_at、confirmed_at。状态我用五个值WAITING已生成待扫描、SCANNED已扫描待确认、CONFIRMED用户已同意、USED已消费票据作废、EXPIRED已过期。存储介质上绝大多数场景我会选 Redis 而不是关系库。原因很直接这张表的数据是短命的、高并发的、写多读也多的。一张二维码一般活 60 到 300 秒过期就该消失用关系库的话你得跑定时任务清理还得担心索引膨胀。Redis 的 TTL 天然适配这个场景写进去设个过期时间到点自动消失连清理逻辑都省了。而且状态跃迁可以用 Lua 脚本做原子 CAS避免并发下的状态错乱这一点后面会细讲。不过如果你的系统对审计有要求——比如需要事后追溯这个账号在什么时间被哪台设备扫码登录过——那还是要落一份持久化的流水但那份流水是异步写的、只用于审计不参与实时状态判断。实时判断永远走 Redis两者职责分开别混在一起。3. 从待扫描到已登录四次状态跃迁的完整拆解3.1 WAITING 到 SCANNED扫码只证明有人看到了这张码PC 端调生成接口服务端创建票据、状态置为WAITING、返回票据内容PC 端把它渲染成二维码。到这里服务端对谁在登录一无所知它只知道有一台设备想要登录我给了它一张号。手机端扫码之后会解析出票据然后带着自己当前的登录态去调扫码接口。服务端收到请求先校验票据存在且未过期再把状态从WAITING推到SCANNED同时把扫码者的user_id和device_info写进去。这里有个非常关键的设计点也是我最想强调的扫码这个动作本身不能直接导致登录。原因很现实——很多人扫完码会把手机放下看一眼电脑屏幕甚至直接把页面关掉。如果扫码即登录就等于把用户看到二维码和用户同意授权这两件事混为一谈了中间少了最关键的那一次人的决策。所以SCANNED状态只是告诉 PC 端有人扫了你等一等他确认绝不能在这个状态下签发任何凭证。另外WAITING到SCANNED这个跃迁要保证只能发生一次。如果同一张码被第二个人扫服务端应该拒绝或者把前一个扫码记录覆盖掉并让 PC 端重新等待确认。我倾向前者——直接返回该二维码已被扫描避免出现两个人都以为自己在登录的混乱局面。3.2 SCANNED 到 CONFIRMED这才是用户做出授权决定的一刻用户看到确认页页面上应该展示什么这是安全设计里最容易被忽略、但价值最高的地方。我通常会放三类信息第一类是被登录设备的特征比如Windows 10 / Chrome 浏览器 / 上海让用户判断这是不是自己刚打开的那台电脑第二类是扫码设备自身的信息让用户确认是自己本人操作的第三类是明确的动作按钮一个确认登录一个取消。这三类信息不是凑数的。真实场景里二维码劫持攻击是这样发生的攻击者在自己的电脑上生成一张登录二维码然后把这张图通过各种方式发给受害者伪装成帮我扫个码扫一下领福利受害者用自己的账号扫了并点了确认结果登录的是攻击者的电脑。防御这件事技术手段能做到的有限因为所有请求在协议层面都是合法的唯一的拦截点就是让用户在确认页上看到这台设备不是我正在用的那台然后自己点取消。所以SCANNED到CONFIRMED的跃迁必须由用户在手机端明确点击触发接口要携带手机端的登录凭证和票据服务端校验两者匹配后才推进状态。同时这个状态推进也要原子理由和上面一样防止用户手抖连点两下。3.3 CONFIRMED 到 USED凭证的交付与票据的自毁现在 PC 端已经能感知到用户同意了但它手里还只有一个票据没有登录态。接下来这一步是很多人讲不清楚的地方正式令牌怎么交到 PC 端手上。有两种做法。一种是在轮询响应里直接把令牌带回去PC 端拿到即用。这种最省事但我前面提过问题在于轮询响应会经过浏览器的各种中间环节容易被记录。另一种是 PC 端在感知到CONFIRMED之后主动调一个兑换接口带着票据去换令牌。服务端校验票据状态为CONFIRMED签发令牌同时把票据状态置为USED并立即删除。我强烈建议第二种。除了避免令牌出现在轮询日志里它还有一个好处兑换动作可以附加额外的校验比如校验发起兑换的设备和当初生成票据的设备是同一个通过一个存在 Cookie 或 localStorage 里的设备标识来比对这样就多了一层防护防止有人拿到票据之后在另一台机器上抢先兑换。票据的删除必须是原子且即时的。举个反例如果兑换接口先签发令牌、再更新状态中间哪怕有 10 毫秒的窗口两个并发请求都可能各自拿到一份令牌这就变成了重放漏洞。正确做法是用 Redis 的GETDEL或者 Lua 脚本在一个原子操作里完成读取状态 校验 删除谁先删成功谁赢另一个请求只能拿到空直接拒绝。4. PC 端怎么知道手机扫完了三种通道方案的真实取舍4.1 短轮询简单到不能再简单但代价要算清楚最朴素的方案就是 PC 端每隔一两秒调一次状态查询接口服务端返回当前状态。这个方案的优点是实现极其简单浏览器兼容性零问题服务端也不用维护什么长连接网关、负载均衡、CDN 全都不用改配置。代价是什么呢算一笔账假设一次登录平均需要 15 秒轮询间隔 2 秒那就是 7 到 8 次请求。如果你们的登录 QPS 是每秒 1000 次这意味着一秒钟有七八千次状态查询请求打在服务端——而这些请求里有 99% 以上返回的都是还没扫码。这不是不能承受但确实是一种浪费而且会干扰你们对真实业务 QPS 的判断做容量规划的时候容易被这个数字误导。优化方向有两个。一是退避刚开始 1 秒一次10 秒后降到 2 秒30 秒后降到 3 秒。因为用户从看到二维码到扫完绝大多数人的动作是在前 10 秒内完成的后面还没扫的基本就是没在扫了。二是状态码精简轮询响应体要尽可能小别把用户信息、设备信息一股脑塞进去只返回一个状态枚举就够真正需要的信息等兑换接口再取。4.2 长轮询与 SSE把没变化的请求挂起来短轮询最浪费的地方在于大部分请求都是空转。长轮询的思路是PC 端发起请求服务端不立刻返回而是把请求挂着最多挂 25 到 30 秒期间一旦状态发生变化就立刻返回如果超时还没变化就返回一个未变化的空响应PC 端再发起下一轮。这样同样 30 秒的等待短轮询要发 15 次请求长轮询只要 1 次。这个方案在移动网络和弱网环境下体验提升很明显因为减少了连接建立的次数。但它的坑在于会占住服务端的连接资源。挂起的请求不是免费的每个都占着一条连接、一份内存、一个线程或者一个协程。如果你们的服务是同步阻塞模型长轮询会很快把线程池打满这时候就得换成异步模型改造成本不小。SSEServer-Sent Events算是长轮询的进化版本质是一条 HTTP 长连接服务端可以持续往里推消息。它比长轮询更干净因为连接建立一次就能反复推送不用反复建连。缺点是单向的只能服务端推客户端不过这刚好匹配扫码登录的场景——PC 端除了发起查询不需要往服务端推任何东西。它的另一个好处是基于标准 HTTP过网关、过 CDN 基本不用特殊配置比 WebSocket 省心。4.3 WebSocket能力最强但你未必真的需要WebSocket 是全双工的实时性最好服务端可以主动推。如果你的场景里扫码之后还有一连串的交互比如扫码支付要展示金额、要二次输入密码、要展示倒计时那 WebSocket 会更顺手因为它可以在一条连接上把多个消息来回串起来。但我个人的判断是单纯的扫码登录WebSocket 有点杀鸡用牛刀。它带来的是连接保活、断线重连、心跳、多实例下的连接路由用户连在 A 节点但业务逻辑跑在 B 节点需要一层 pub/sub 转发这一整套复杂度。尤其是多实例部署的时候你得让持有票据状态的那个服务知道该往哪个连接推消息这中间要么走 Redis 订阅要么走专门的推送网关。对于一个登录场景来说这套东西的维护成本往往超过它带来的收益。我的选型习惯是这样如果系统里已经有成熟的 WebSocket 基础设施比如已经有 IM、有实时通知那复用现成的通道扫码登录顺手搭车就行。如果是从零开始而且只需要告诉我状态变了这一种消息SSE 或者长轮询完全够用别为了追求技术上的先进去引入一套新的运维负担。面试的时候这么回答也比单纯吹 WebSocket 更显成熟——能说清楚什么情况下不用比什么情况下用更能体现判断力。5. 我见过翻车的几个地方基本都栽在状态和并发上5.1 二维码被劫持技术防不住的那部分得靠产品设计补前面提过二维码劫持的原理这里说说具体怎么防。技术上能做的有三件事。第一票据必须与生成它的那个浏览器会话绑定最简单的做法是在生成票据时下发一个 HttpOnly 的 Cookie里面放一个随机设备标识兑换令牌的时候校验这个标识是否匹配。这样即使攻击者拿到了票据他也没法在自己的浏览器里兑换出令牌因为他没有那个 Cookie。第二兑换接口校验请求来源票据生成时的 IP 归属地和兑换时的 IP 归属地做粗粒度比对跨城市直接拒绝同城放行避免误伤正常用户比如家里用了代理。第三票据的有效期压短从生成到消费的总时长控制在 2 分钟以内超时就必须重新生成缩短攻击窗口。但更重要的其实是产品侧的设计。确认页上一定要让用户看到这是哪台设备在请求登录而且要显眼。我见过一些产品的确认页只有一行小字确认登录用户根本不会去看详情闭着眼睛就点了。这种设计下任何技术防护都形同虚设。我一个朋友所在团队做过一个改动把登录设备信息从灰色小字挪到了按钮上方的主视觉位置加了设备和地理位置的图标劫持相关的用户投诉直接降了一多半。这个经验说明安全设计的最后一公里其实是交互设计。5.2 同一个票据被并发确认CAS 没做对就是灾难这个坑我亲身踩过。早期的一个实现里状态更新是这么写的先GET查状态判断是不是SCANNED是的话再SET成CONFIRMED。逻辑看起来没问题压测也没问题但上线之后偶发出现两次确认都成功了导致同一张票据对应了两条登录流水。原因很简单GET和SET之间有一个时间窗口两个请求几乎同时到达都读到了SCANNED都判断通过都执行了写入。这就是典型的检查与使用分离TOCTOU问题。修复方案有两种。一种是用 Redis 的 Lua 脚本把读 判断 写打包成一个原子操作因为 Redis 执行 Lua 是单线程的中间不会被插入其他命令-- KEYS[1] 票据 key -- ARGV[1] 期望的当前状态 -- ARGV[2] 目标状态 -- 返回 1 表示成功0 表示状态不匹配-1 表示票据不存在 local current redis.call(HGET, KEYS[1], status) if current false then return -1 end if current ~ ARGV[1] then return 0 end redis.call(HSET, KEYS[1], status, ARGV[2]) return 1另一种是用数据库的乐观锁加一个version字段UPDATE ... WHERE ticket ? AND status ? AND version ?靠影响行数判断是否成功。两种都能解决问题Redis 方案性能更好数据库方案更适合需要持久化的场景。关键是任何状态跃迁都不能用先查后写的方式实现这条规则在分布式系统里几乎没有例外。5.3 过期清理Redis TTL 不是万能药用 Redis 存票据设 TTL 到点自动删看起来很美好。但有几个边角情况要注意。第一如果业务上需要在票据过期时给 PC 端一个明确的二维码已失效请刷新的提示那你就不能只依赖 TTL——票据被删了之后查询接口返回的是不存在PC 端没法区分这是过期了还是票据被消费了。我的做法是在 Redis 里多留一个短期的墓碑键TTL 比主键长 60 秒标记这个票据是过期还是已使用供查询接口区分。第二Redis 的过期删除是惰性加定时采样理论上存在已经过期但内存里还在的短暂窗口。对于登录场景这个不影响正确性我们自己的校验逻辑也会看expires_at字段但如果有人拿这个做风控统计统计数字可能会有轻微偏差心里要有数。第三如果票据量大到一定程度Redis 的过期键集中淘汰可能造成毛刺。缓解办法是给 TTL 加一点随机抖动比如基准 180 秒实际设 170 到 190 秒之间的随机值打散过期时间点。这个小技巧在缓存场景里很常见搬到票据场景同样管用。5.4 多端登录态不一致确认之后谁说了算还有一个容易被忽略的点用户在手机上点了确认登录但这时候手机端的登录态可能刚好过期了或者用户在另一台设备上刚刚退出了登录。这种情况下确认动作到底算不算数我的处理原则是以确认那一刻服务端校验的结果为准。手机端调确认接口时服务端要实时校验它携带的令牌是否有效无效就直接拒绝把票据状态回滚到SCANNED或者干脆标记为失效。不能因为用户刚刚扫过码就放宽校验那是把安全性建立在时间窗口上很脆弱。另外一种情况是同一个账号在多个地方同时发起扫码登录。这时候需要一套冲突策略是允许同时登录多端共存还是后登录的踢掉先登录的单端独占。这个策略取决于产品定位没有标准答案但一定要明确地定义并实现不能让它随机发生。我见过一个系统因为没定义这个规则导致同一账号在浏览器和桌面客户端之间来回互踢用户一天被踢下线十几次投诉量爆炸。6. 一套可以直接照着写的接口与数据结构6.1 五个接口的职责边界把整个流程拆成接口我一般是这么划分的每个接口只干一件事职责非常清晰接口调用方职责是否携带身份POST /qr/ticketPC 端生成票据返回二维码内容否GET /qr/statusPC 端查询票据当前状态否POST /qr/scan手机端上报已扫描写入设备信息是POST /qr/confirm手机端用户确认或拒绝是POST /qr/exchangePC 端用票据换取正式令牌否但校验设备标识这里有两个设计细节值得说一下。/qr/scan和/qr/confirm拆成两个接口而不是合并成一个是因为扫码和确认之间隔着一个用户看确认页、做判断的过程中间可能隔几秒甚至几十秒。合并的话用户扫码就等于自动确认安全性直接归零。/qr/exchange不携带身份令牌是因为 PC 端本来就没有登录态它靠的是票据加设备标识这两样东西来证明我就是当初生成这张码的那台机器。状态查询接口的响应体我建议保持极简只返回状态和必要的最小信息{ status: SCANNED, expiresIn: 143 }用户昵称、头像这些信息等状态变成CONFIRMED之后再通过一次单独的请求拉取或者直接放在兑换接口的响应里。这样做的目的是让高频接口的响应体尽可能小减少带宽也减少信息在中间环节的暴露。6.2 Redis 的键设计与原子脚本键的命名我用qr:ticket:{ticket}这种带前缀的形式方便运维的时候用SCAN按前缀统计。值用 Hash 结构因为字段会随着状态推进不断增加Hash 比 JSON 字符串更适合局部更新HSET qr:ticket:abc123xyz status WAITING created_at 1700000000 expires_at 1700000180 EXPIRE qr:ticket:abc123xyz 200确认阶段需要同时写入用户 ID 和设备信息用一个 Lua 脚本把校验和写入合并-- KEYS[1] 票据 key -- ARGV[1] 期望状态 -- ARGV[2] 目标状态 -- ARGV[3] user_id -- ARGV[4] device_info local current redis.call(HGET, KEYS[1], status) if current false then return -1 end if current ~ ARGV[1] then return 0 end redis.call(HSET, KEYS[1], status, ARGV[2], user_id, ARGV[3], device_info, ARGV[4]) return 1兑换阶段用GETDEL语义Redis 6.2 以上直接有GETDEL命令低版本可以用 Lua 模拟local data redis.call(HGETALL, KEYS[1]) if #data 0 then return nil end local status nil for i 1, #data, 2 do if data[i] status then status data[i 1] end end if status ~ CONFIRMED then return nil end redis.call(DEL, KEYS[1]) return 1注意这里先判断状态再删除两步在同一个脚本里中间不会有其他命令插进来保证了只有一个人能兑换成功。6.3 参数取值过期时间、轮询间隔与超时兜底参数这东西没有绝对正确的答案但我可以给一组经过验证的参考值。票据有效期设180 秒理由是这个时长足够覆盖用户打开页面、掏手机、解锁、打开扫码工具、对准、确认的全流程再长就只是延长了攻击窗口。轮询间隔初始1.5 秒20 秒后退避到3 秒60 秒后退避到5 秒。前端在收到EXPIRED之后自动刷新一次二维码但刷新次数要限制比如最多连续刷新 3 次之后提示用户请手动点击刷新避免页面挂在那里无人值守还疯狂请求。服务端还要有一层兜底任何涉及状态推进的接口进入业务逻辑之前先校验expires_at过期直接返回明确错误码。不能只依赖 Redis 的 TTL因为 TTL 是最终一致的而登录这种事必须强一致。7. 沿着这道题往下追问的三层提前准备好答案7.1 第一层追问流程细节能不能说圆第一层的问题通常很直接二维码里放的是什么用户扫码之后服务端做了什么PC 端怎么知道状态变了这些答清楚就行关键是别含糊。我建议在回答里主动带上状态枚举的名字比如这时候状态从 WAITING 变成 SCANNED这种表达会让面试官觉得你脑子里有一个明确的模型而不是在复述现象。这一层最容易被抓的漏洞是扫码即登录的表述。如果你说用户扫码之后服务端就签发令牌了面试官大概率会追问那用户要是扫完不确认呢你就得现场补救。所以从一开始就把 SCANNED 和 CONFIRMED 分开讲能省掉很多麻烦。7.2 第二层追问为什么是这个方案不是那个第二层会开始问选型。比如为什么用轮询不用 WebSocket为什么票据存 Redis 不存数据库为什么兑换令牌要单独一个接口。这类问题的回答必须带上对比和代价只说优点是不够的。比如回答轮询我会说轮询的代价是请求量大、实时性差一到两秒但在登录这个场景里两秒的延迟用户完全感知不到而换来的是零连接维护成本性价比更高如果系统里已经有 WebSocket 通道那我肯定会复用因为这时候边际成本几乎为零。再比如票据存储如果用数据库好处是天然持久化、方便审计、事务保证强坏处是高频写入压力大、需要额外的清理任务、状态 CAS 要靠乐观锁实现。Redis 反过来性能好、TTL 省心、Lua 原子性好但数据是易失的得接受重启可能丢票据这个前提——而这对登录场景来说完全可接受大不了用户重新扫一次。把这种取舍讲出来比单纯说Redis 快有说服力得多。7.3 第三层追问出问题的时候怎么兜底第三层是最能拉开差距的通常会问一些异常场景。比如用户确认之后PC 端刚好断网了怎么办Redis 挂了登录还能不能用如果攻击者拿到了票据但没拿到 Cookie他能做什么。断网的情况票据状态已经是 CONFIRMEDPC 端恢复网络后重新调兑换接口只要票据还没被消费、还在有效期内就能正常换到令牌。这就是把确认和兑换拆成两步带来的好处——中间状态是持久的可以重试。如果合并成一步断网就意味这次登录彻底失败用户得重来一遍。Redis 挂掉的情况短期内的新登录会全部失败因为票据写不进去。这时候要有降级策略比如临时切换到账号密码登录或者短信验证码登录作为备用通道别让用户直接卡死。这个设计在面试里提一句会显得你有生产环境的意识。票据被拿到但没 Cookie 的情况他调兑换接口会被设备标识校验拦住调状态查询只会看到状态拿不到任何敏感信息。如果他抢先调了兑换接口会怎样不会成功因为他没有那个 HttpOnly 的 Cookie。这就是为什么我坚持把设备标识放在 HttpOnly Cookie 而不是 localStorage 里——前者脚本读不到XSS 偷不走。8. 最后说几句我在实际项目里的体会扫码登录这套东西实现起来真的不难代码量可能就几百行。难的是把它做稳而稳不稳往往取决于那些看起来不起眼的细节状态跃迁有没有做原子化、票据有没有做到用后即焚、确认页有没有把设备信息展示到位、过期之后前端有没有正确的引导。我见过太多系统主流程跑得飞起一到并发或者异常路径就出问题最后排查半天发现是某个接口的GET和SET之间漏了一个原子操作。还有一个我越来越深的感受这类涉及身份的设计不要试图用纯技术手段解决所有问题。二维码劫持这种事你技术上加多少层校验都挡不住用户闭着眼睛点确认。真正有效的防线是让用户在确认的那一刻能清楚地看到我正在授权哪台设备并且这个信息足够显眼、足够易懂。技术负责把信息准确地送到用户眼前决策权还是要交回给用户。这一点想明白了很多设计上的纠结反而迎刃而解。如果你正在准备面试我的建议是别去背流程而是自己拿张纸把票据的五个状态画出来然后对每一对相邻状态问自己三个问题谁触发、怎么校验、失败了怎么办。这三个问题能答上来这道题基本就稳了。如果你是在做实际项目那就再多想一层你的系统在 Redis 挂掉、在用户断网、在票据被第三方拿到的情况下分别会发生什么。能把这三种情况都兜住这套登录才算是真的能上生产。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑