秒杀系统高并发优化实战(C++ / Drogon):3.3 短信验证码接口:生成 / 限流 / 存储 / 腾讯云直连
注册/登录要不要验证码?要——但验证码接口本身就是个高价值攻击面:它既能当"免费短信轰炸机"打别人的手机,又能被脚本爆破出 6 位验证码。这一篇把验证码接口做完整:生成(CSPRNG)→ 限流(冷却 + 每日上限)→ 存储(Redis)→ 发送(腾讯云直连),每一道都对应一个具体的攻击面。
配套代码:
src/service/SmsService.{h,cc}(生成/限流/存储/校验)、src/service/SmsSender.{h,cc}(腾讯云直连)、src/controllers/SmsController.cc、src/service/SmsSender.cc的 TC3 签名。
一、短信验证码接口:是什么、坑在哪、本质一句话
是什么:POST /api/sms/send 给指定手机号生成 6 位验证码,限流后存入 Redis(有效期 + 重发冷却 + 每日上限),再通过腾讯云短信 API 发出去。校验在注册(3.1)/登录(3.2)流程里调 verifyCode。
坑在哪:
- 短信轰炸:攻击者拿你的接口狂发别人的手机号,败坏口碑 + 账单是你的。
- 验证码爆破:6 位数字仅 100 万组合,不限次数脚本几十分钟撞出来。
- 一码多用:GET→比对→DEL 三步分开,并发下同一验证码被兑换两次(注册场景可绕过验证码重复注册)。
- 同步阻塞 IO 线程:libcurl 是同步的,直接
curl_easy_perform会把 Drogon IO 线程卡几十毫秒。
本质一句话:短信验证码接口的本质是"在易用与安全之间画线"——CSPRNG 生成 + Redis 三道限流闸门 + Lua 原子校验 + 发送挪到工作线程。
二、三条安全红线:限流闸门
SmsService::Config 把三条红线固化成配置:
| 红线 | 配置 | 防什么 |
|---|---|---|
每日发送上限 dailyLimit |
默认 10 | 短信轰炸:打别人手机刷爆账单 |
重发冷却 resendInterval |
默认 60s | 轰炸另一半:1 秒发 60 条把上限瞬间打满 |
校验次数上限 maxVerifyAttempts |
默认 5 | 验证码爆破:超限即作废该码 |
验证码本身用 CSPRNG(OpenSSL RAND_bytes) 生成 6 位,且用拒绝采样保证均匀——std::rand()%1000000 既可预测又有取模偏斜,绝不能用。
1 | // src/service/SmsService.cc(节选) |
三、发送限流:一个 Lua 把"限流 + 写码"原子化
发送流程的限流与写码必须在同一个 Lua 脚本里完成,否则"配额已扣但写码失败"或"并发同时过限流"都会出乱子。Lua 在 Redis 单线程里执行,"读-判-写"不可分割。
1 | -- kSendScript(KEYS: codeKey/cooldownKey/dailyKey;ARGV: code/ttl/cd/dailyLimit/dailyTtl) |
C++ 侧拿到 {0, n} 才真的去发短信;返回 {-1, cdTtl} 即 TooFrequent,{-2, used} 即 DailyLimit。
1 | // src/service/SmsService.cc(节选) |
四、校验必须原子:防"一码多用"
校验与消费(DEL)必须在同一个 Lua 里完成,杜绝"两个并发都 GET 到正确码、都通过、都 DEL"——注册场景这意味着同一手机号绕过验证码重复注册。校验通过即删,一次性。
1 | -- kVerifyScript(KEYS: codeKey/tryKey;ARGV: inputCode/maxAttempts) |
五、发送:直连腾讯云,不走官方 SDK
SmsSender 直连腾讯云 API 3.0,自实现 TC3-HMAC-SHA256 签名,约 120 行,依赖只有 libcurl + OpenSSL。官方 tencentcloud-sdk-cpp 要拉 core 模块(数百文件、依赖 protobuf),编译十几分钟,而我们要的只是「签一个 TC3 签名 + POST 一段 JSON」。
关键工程约束:libcurl 是同步阻塞的,必须丢进独立工作线程,完成后 queueInLoop 切回 IO 线程——否则短信 API 往返几十毫秒会卡死同线程排队的秒杀请求。
1 | // src/service/SmsSender.cc(核心约束) |
降级策略:
sms.enabled=false或凭据为空时,SmsSender不真发短信,只在日志里打SMS_MOCK phone=... code=...——本地开发/压测能跑通整条链路,也不误发产生费用。验证码从这条日志里读即可(bash scripts/verify-auth.sh一键自检就是这么干的)。
六、可运行验证
1 | # 发验证码(mock 模式只写日志,不真发) |
功能抉择(本篇核心权衡)
① 为什么限流 + 写码必须一个 Lua,而不是先 INCR 再 SET?
三条命令分开做,并发下会有两类竞态:计数竞态(10 个请求同时读到 4、各自 +1 写回 5,实际失败 10 次却只记 5 次);锁定竞态(两个请求同时跨阈值各自 SETEX)。Lua 在 Redis 单线程里把"读-改-写"变成不可分割的一步,从根上消除。
② 为什么校验也要 Lua,而不是 GET→比对→DEL?
三步分开,两个并发请求可能同时 GET 到同一正确码、都通过、都 DEL——一个验证码被兑换两次。注册场景下同一手机号能绕过验证码重复注册。Lua 让"比对与删除"同一步完成,一次性消费。
③ 为什么短信自实现腾讯云直连,而不引官方 SDK?
官方 C++ SDK 编译十几分钟、依赖 protobuf 等重;我们要的只是「TC3 签名 + POST JSON」,自实现约 120 行、依赖只有已在装的 libcurl + OpenSSL。零编译负担、签名可审计、出问题能直接打印待签串比对。
④ 为什么发送要丢进工作线程,而不直接在 IO 线程 curl?
libcurl curl_easy_perform 同步阻塞几十毫秒,会卡死 Drogon IO 线程上排队的秒杀请求。短信低频、建线程 ~50us 可忽略,用一次性线程 + queueInLoop 切回最省心。
小结
- 验证码用 CSPRNG 拒绝采样生成 6 位,均匀且不可预测;绝不
std::rand()%1e6。 - 三道限流闸门(重发冷却 / 每日上限 / 校验次数)全在 Lua 原子脚本里完成,消除并发竞态。
- 校验与消费同一 Lua 完成,一次性消费防"一码多用"。
- 发送直连腾讯云 + 自实现 TC3 签名(~120 行),工作线程执行不阻塞 IO 线程;无凭据降级为只打日志(mock)。🐾

