秒杀系统高并发优化实战(C++ / Drogon):3.1 用户注册接口:PBKDF2 加盐哈希 + 自实现 JWT + 可吊销会话
10000 个人抢 100 件商品,最后卖出去 137 件——这句"段子"我们在表设计那篇就破了。但秒杀真正的第一道门,不是库存,是身份:你是谁?你有没有资格抢?如果你连"注册"这一步都把明文密码写进库,那后面无论 Redis 预扣、Lua 原子、MQ 削峰做得再漂亮,用户表一旦泄露就是全裸——所有"高并发安全"都建立在沙滩上。
本文是「秒杀系统(C++ / Drogon)」系列第三章(登录模块)的开篇。配套仓库
seckill-cpp。当前阶段一v0.1.x已落地登录 / 短信模块,目录为sql/user_schema.sql、src/service/UserService.{h,cc}、src/controllers/UserController.{h,cc}、src/service/password.{h,cc}、src/service/Jwt.{h,cc}、src/service/SessionStore.{h,cc},路由在src/main.cc注册。所有方案均为原创、不依赖任何外部教程。
一、注册接口:是什么、坑在哪、本质一句话
是什么:用户注册接口 = 把"账号(手机号 + 密码)"安全地落库,并为后续登录签发 Token 准备好身份基础。它是整个鉴权链路的"建身份"环节,本身不发 Token(Token 留给登录)。
坑在哪:
- 明文密码:直接
INSERT password=用户输入,一旦拖库,所有账号裸奔。这是最不该犯的错。 - 弱哈希:用
MD5(password)甚至不加盐,彩虹表一秒反查;用固定 salt 等于没加盐。 - 查重与并发:两个请求同时用同一手机号注册,没唯一索引就会插出两条——后面"一人一单"的资格校验全崩。
- "无状态 JWT"假象:很多人以为 JWT 无状态就够,但 JWT 一旦签发无法主动吊销(改密码/封号后旧 Token 仍有效),必须有一层可吊销的会话态。
本质一句话:注册接口的本质是"安全地建立账号身份"——密码永远不落明文,会话用 Redis 做可吊销的权威态、JWT 只做不可伪造的凭证。
二、用户表设计
密码哈希用 PBKDF2-HMAC-SHA256,派生长度 32 字节 → hex 编码 64 字符;salt 16 字节 → hex 32 字符。字段定长 CHAR 比 VARCHAR 更省心,也避免长度漂移。
1 | -- sql/user_schema.sql |
uk_phone是唯一索引,不只是为了查重快,更是最后一道并发防线:即使应用层查重有竞态,数据库的唯一约束会让第二个 INSERT 直接报ER_DUP_ENTRY,绝不会插出两条。
三、密码哈希:自实现 PBKDF2(OpenSSL),不引第三方 bcrypt 库
C++ 没有 Spring Security 的 BCrypt,但 OpenSSL 自带工业级 KDF。我选 PBKDF2-HMAC-SHA256 而非直接引 bcrypt-cpp,理由是:依赖更少(OpenSSL 本就是 Drogon/Redis/hiredis/libcurl 的底层依赖,几乎必然在场)、可调迭代次数对抗硬件升级、算法透明可审计。代价是要自己管好 salt 与 hex 编码——而这恰恰是本系列想讲透的"自实现功力"。
1 | // src/service/password.h(登录模块) |
1 | // src/service/password.cc(核心两行) |
C++ 细节:
RAND_bytes来自 OpenSSL 的 CSPRNG,比std::rand()安全得多——后者是线性同余、可预测,拿来做 salt 等于给攻击者减难度。salt 与哈希分离存储,验证时重新派生比对,绝不在库里存"明文密码 + 一次性比较"。
四、UserService::registerUser:查重 → 哈希 → 落库(含验证码分支)
沿用本仓库既有风格:服务层只做业务,不碰 HTTP;回调语义统一 (bool ok, string msg)。Drogon 的 DbClient 用流式回调;空结果集走成功回调(size()==0),不是异常回调。
一个容易被忽略的性能坑:PBKDF2 是设计成慢的(12 万次迭代 ≈ 30~60ms CPU)。它必须丢进独立工作线程,算完用 drogon::app().getLoop()->queueInLoop 切回 IO 线程——否则登录接口的慢会传染给同线程排队的秒杀请求。
1 | // src/service/UserService.cc(节选) |
并发保险:即使两个相同手机号请求在
SELECT处竞态都"查不到",第二个INSERT也会撞uk_phone唯一索引报错,掉进DB_ERROR分支并识别成PHONE_REGISTERED——应用层查重是快路径,数据库唯一约束是兜底,两层都不会放过重复注册。
五、UserController + 路由注册
本仓库控制器不继承 HttpController(Drogon v1.9.10 起对带依赖注入、无默认构造的 HttpController 子类调 registerController 会静态断言失败),改为在 main.cc 用 registerHandler 手动挂路由,lambda 转交给实例。
1 | // src/main.cc(登录模块路由,Redis 不可用时整体 503,不影响秒杀链路) |
下面把整条注册链路画成时序图——重点看密码哈希发生在哪一层(独立工作线程),以及明文密码的"存活半径"被压缩到什么范围。
六、可吊销会话的"契约"设计(为登录铺垫,注册不发 Token)
注册只建身份、不发 Token——Token 是登录成功后的凭证。但我们要现在就把会话层的契约定下来,否则登录代码会反过来改表结构。本系列实际落地的契约如下(与原定方案已不同,见下节):
- JWT 只做凭证,不做权威态:
Jwt(自实现 HS256)签发sub=userId, exp, jti,客户端每次请求带在Authorization头。JWT 本身无状态、无法吊销。 - Redis 做权威态:登录成功后写
sess:{jti} → userId,设 TTL = Token 有效期。jti是 JWT 唯一 ID。 - 吊销 = 删 Redis key:改密码/封号时
DEL sess:{jti},旧 Token 即使未过期,网关校验EXISTS sess:{jti}失败即拒绝——可吊销性由此而来。 - Key 规范:
sess:{jti},value =userId(字符串),EXPIRE与 JWT 的exp对齐。
1 | // src/service/Jwt.h(自实现 HS256,不引 jwt-cpp) |
为什么注册不发 Token:注册成功立刻发 Token 等于"建完身份马上给进门钥匙",既违反最小权限(注册接口不该隐含登录态),也让"注册即登录"成为刷号脚本的便利通道。身份与凭证在时序上分离,是安全设计的基本纪律。
七、三处选型变更(相对博客 3.1 原定方案)
博客 3.1 最初定的是 redis-plus-plus + jwt-cpp,实际落地时三处都改了。这是故意的、有理由的改,不是中途翻车——核心约束是:Drogon 的 IO 线程上只允许非阻塞操作,任何同步阻塞的第三方 API 都会结构性地卡死整条链路。
| 环节 | 原定方案 | 实际采用 | 改的理由 |
|---|---|---|---|
| Redis 客户端 | redis-plus-plus |
Drogon 内置 nosql::RedisClient |
redis-plus-plus 是同步 API。Drogon handler 跑在 IO 线程(threads_num=4),在 IO 线程里同步等一次 Redis 往返,会把这个线程上排队的请求全部卡住——这是结构性问题,不是"多一个依赖"的量级。内置客户端异步、与事件循环同构、零额外依赖(只需 libhiredis-dev) |
| JWT 签发 | jwt-cpp |
自实现 HS256(service/Jwt.*) |
HS256 全部内涵就是「base64url(头).base64url(体).base64url(HMAC-SHA256)」,三个动作各十几行,用已在场的 OpenSSL 就能写。引 header-only 库要 FetchContent 从 GitHub 拉,WSL 网络是额外风险点;自己写编译期零外部依赖、签名过程完全可审计 |
| 短信发送 | (原定未涉及 SDK) | 直连腾讯云 API 3.0 + 自实现 TC3-HMAC-SHA256 签名 | 官方 tencentcloud-sdk-cpp 要拉 core 模块(数百文件、依赖 protobuf 等),编译十几分钟,而需要的只是「签一个 TC3 签名 + POST 一段 JSON」。自实现约 120 行,依赖只有 libcurl + OpenSSL——这两个本来就在装 |
净效果:登录模块引入的新依赖只有 libhiredis-dev 与 redis-server,其余全部复用既有组件。
八、可运行验证步骤
1 | # 1) 建表(先有 user_schema.sql) |
九、两张对比表
表 1:密码存储方案对比
| 方案 | 抗彩虹表 | 抗暴力破解 | 加盐 | 评价 |
|---|---|---|---|---|
| 明文 | ✗ | ✗ | ✗ | 拖库即全裸,绝不可取 |
| MD5(密码) | ✗ | ✗ | ✗ | 秒级反查,已淘汰 |
| MD5(密码+salt),salt 固定 | △ | ✗ | 弱 | 固定 salt = 全库共用,仍可被批量跑 |
| bcrypt | ✓ | ✓ | ✓ | 自带 salt + 慢哈希,工业标准但需引库 |
| PBKDF2-HMAC-SHA256(本文) | ✓ | ✓ | ✓ | 算法透明、依赖少(OpenSSL 自带)、迭代可调 |
表 2:会话模型对比
| 模型 | 可吊销 | 实现复杂度 | 适用 |
|---|---|---|---|
| 纯无状态 JWT | ✗(改密/封号后仍有效) | 低 | 短期、低风险 API |
| JWT + Redis 会话(本文) | ✓(DEL key 即吊销) | 中 | 秒杀等需强管控场景 |
| 纯 Redis Session(不签发 JWT) | ✓ | 低 | 单体,无跨服务诉求 |
功能抉择(本篇核心权衡)
① 为什么自实现 HS256 + Drogon Redis 会话,而不套 jwt-cpp + redis-plus-plus?jwt-cpp / redis-plus-plus 都是优秀的库,但它们分别带来"FetchContent 从 GitHub 拉"和"同步 API 卡 IO 线程"两个本可避免的代价。HS256 三个动作各十几行、OpenSSL 已在场;Drogon 内置 nosql::RedisClient 异步且与事件循环同构。把"凭证不可伪造"和"会话可吊销"两件事拆开各司其职,既贴合 C++ 技术栈,也把鉴权链路每一行都摊开讲透——代价是多写几百行基础设施代码,换来对安全语义的完全掌控。
② 为什么密码哈希选 PBKDF2(OpenSSL) 而非直接引 bcrypt 库?
OpenSSL 是 Drogon/Redis/hiredis/libcurl 的底层依赖,几乎必然在场,引 bcrypt-cpp 是额外一项依赖与编译面;PBKDF2 算法公开可审计、迭代次数随硬件升级上调即可,没有"库停更就裸奔"的风险。代价是自己管理 salt 生成与 hex 编码,而这正是本系列想练的底层功力。
③ 为什么注册不发 Token,把"身份"和"凭证"在时序上分开?
注册只回答"你是谁",登录才回答"你现在能进门"。注册即发 Token 既违反最小权限,又给刷号脚本开了"注册即得会话"的便利。分两步后,登录模块才能独立引入风控(验证码、错误次数限制),注册接口保持纯粹的"建身份"。
④ 为什么短信也自实现腾讯云直连,而不引官方 SDK?
官方 C++ SDK 要拉 core 模块(数百文件、依赖 protobuf 等),编译十几分钟,而需要的只是「签一个 TC3 签名 + POST 一段 JSON」。自实现约 120 行,依赖只有 libcurl + OpenSSL——这两个在 setup-wsl.sh 里本来就装了。换来的是:零编译负担、签名过程完全可审计、出问题能直接打印待签串比对。
小结
- 注册接口的本质是安全地建立账号身份:明文密码绝不落库,PBKDF2 加盐哈希是底线。
- 查重靠应用层快路径 +
uk_phone唯一索引兜底双层防护,并发下也不会插重;验证码默认强制(防脚本批量注册)。 - 密码哈希选 PBKDF2-HMAC-SHA256(OpenSSL 自带),且必须丢进工作线程算,否则慢哈希会传染同 IO 线程的秒杀请求。
- 会话采用 JWT(自实现 HS256,凭证不可伪造)+ Redis(Drogon nosql::RedisClient,权威态可吊销) 双层模型,注册不发 Token,身份与凭证时序分离。
- **三处选型变更(Redis 客户端 / JWT / 短信)**均为了"IO 线程零阻塞 + 编译期零额外外部依赖",详见第七节对照表。
- 控制器沿用本仓库非
HttpController+registerHandler的已验证模式,与SeckillController完全对齐。🐾
配套源码
本文对应代码已开源:Hespethorn/seckill-cpp
C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:
- 阶段一(直打数据库):QPS≈440,0 超卖
- 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS
如果对你有帮助,欢迎 star ⭐

