秒杀系统高并发优化实战(C++ / Drogon):3.4 注册验证码校验 & Token 持久化到 Redis
3.1 把注册流程的"验证码分支"留了空(先查重再哈希再落库,requireSmsOnRegister=true 时走 sms_->verifyCode);3.2 把"登录成功写 Redis 会话"说了。这一篇把这两件事的接缝焊死:验证码怎么校验、Token 怎么持久化、退出怎么吊销,以及它们背后的 key 约定与配置开关。
配套代码:
src/service/UserService.cc(registerUser的verifyCode分支、login的sessions_->save、logout的sessions_->revoke)、src/service/SessionStore.{h,cc}、配置项requireSmsOnRegister/requireSmsOnLogin。
一、本篇在链路里的位置:是什么、坑在哪、本质一句话
是什么:把"验证码校验"接到注册/登录流程里(挡脚本批量注册),把"登录签发的 JWT"的 jti 持久化进 Redis(sess:{jti},可吊销权威态),并把"退出登录"实现为删这条 key。
坑在哪:
- 验证码只校验不消费:同一码被用 N 次,等于没验证码。
- Token 只签发不持久化:那就退回纯无状态 JWT,无法吊销。
- 持久化与签发不是原子:签发成功但写 Redis 失败,客户端拿到的 Token 永远验不过——必须显式报错而非返回"成功"。
本质一句话:本篇是接缝——验证码"校验即消费",Token"签发即持久化、退出即吊销",三者都靠 Redis 单一态兜底。
二、验证码校验接入注册(校验即消费)
3.1 的 registerUser 在 requireSmsOnRegister=true 时,先调 sms_->verifyCode,通过才执行 doRegister 闭包。校验结果直接映射成业务码:
1 | // src/service/UserService.cc(节选) |
关键点:校验与消费在
SmsService的 Lua 里原子完成(见 3.3/3.5),所以这里拿到的Ok意味着"这个码已经被删掉"——同一验证码第二次来就是EmptyCode,杜绝"一码多用"。
三、Token 持久化:登录写 sess:{jti}
登录成功时,UserService::login 先 genJti() 生成 32 字符唯一 ID,再 jwt_->sign(userId, jti) 签发,最后 sessions_->save(jti, userId) 把权威态写进 Redis。save 用 SETEX sess:{jti} ttl userId,TTL 与 JWT 的 exp 对齐。
1 | // src/service/UserService.cc(登录成功节选) |
Redis key 全景(排障时直接查):
1 | sess:{jti} 会话(value=userId,TTL 与 JWT exp 对齐)—— 权威态 |
四、退出即吊销(revoke 预览)
退出登录 = 校验 Token → 删 Redis 会话。JWT 本身无法被"改",但此后每次鉴权都会因 EXISTS sess:{jti} 失败而被拒——旧 Token 立即失效。
1 | // src/service/UserService.cc(logout 节选) |
五、配置开关:验证码在哪里强制
config.json 的 custom_config.auth 段控制验证码的强制范围,默认"注册强制、登录不强制"——因为登录已有 3.6 的错误次数锁定兜底,登录态加验证码会牺牲体验。
| 键 | 默认 | 说明 |
|---|---|---|
require_sms_on_register |
true |
注册要验证码:防脚本批量注册 |
require_sms_on_login |
false |
登录默认不要:有错误次数锁定兜底 |
min_password_length |
6 |
密码最小长度 |
六、对比表(无验证码/无持久化 vs 本文)
| 维度 | 不接验证码 + 纯无状态 JWT | 本文(校验即消费 + 持久化) |
|---|---|---|
| 批量注册 | 脚本可狂注 | 注册强制验证码挡住 |
| 验证码复用 | 一码多用 | Lua 原子消费,一次性 |
| Token 吊销 | 改密/封号后仍有效到 exp | DEL sess:{jti} 立即失效 |
| 写 Redis 失败 | — | 显式 REDIS_ERROR,不返回假成功 |
功能抉择(本篇核心权衡)
① 为什么验证码"校验即消费"要在 Lua 里做,而不是应用层先 GET 再 DEL?
应用层三步分开,并发下两个请求同时 GET 到同一正确码、都通过、都 DEL——注册场景同一手机号绕过验证码重复注册。SmsService 的 kVerifyScript 把"比对 + 删除"放进 Redis 单线程,一次性消费,从根上消除。
② 为什么 Token 持久化与签发必须同一次成功才算成功?
若签发成功但 save 失败,客户端拿到的 Token 每次鉴权都会因 EXISTS 失败被拒,等于发了个废品。这里选择显式返回 REDIS_ERROR:session save failed,让客户端知道"这次登录没生效",而不是回 {code:0} 骗它——否则用户会困惑"为什么刚登录就被踢"。
③ 为什么登录默认不强制验证码,注册强制?
注册是"建身份",一旦被脚本批量注册,后续一人一单、风控全崩,所以必须验证码。登录是"验身份",已有 3.6 的错误次数 + 临时锁定兜底,再加验证码只是牺牲体验、收益边际。开关化让两者可独立调整。
小结
- 验证码接入注册:校验通过才执行
doRegister,且校验在 Lua 里"比对即消费",一次性。 - Token 持久化:登录
genJti → sign → save(SETEX sess:{jti}),TTL 与 exp 对齐;写失败显式报错,不返回假成功。 - 退出登录 =
verify → revoke(DEL),旧 Token 立即失效。 - Redis key 全景七类(会话/验证码/冷却/试错/每日/失败计数/锁定),排障直接查。
- 验证码强制范围开关化:注册强制、登录默认不强制(锁定兜底)。🐾
配套源码
本文对应代码已开源:Hespethorn/seckill-cpp
C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:
- 阶段一(直打数据库):QPS≈440,0 超卖
- 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS
如果对你有帮助,欢迎 star ⭐

