秒杀系统高并发优化实战(C++ / Drogon):3.5 验证码安全加固:Redis Lua 原子校验 + 每日发送上限 + 试错上限
3.3 把短信接口的流程跑通了(生成→限流→存储→发送)。这一篇专门挖安全加固这一层:为什么限流和校验必须写成 Lua、每日上限的 key 为什么要计算"距次日 0 点"的 TTL、6 位验证码在数学上意味着什么、以及"发送失败不回滚配额"这个有意的取舍。
配套代码:
src/service/SmsService.cc的kSendScript/kVerifyScript/secondsUntilTomorrow/genNumericCode。
一、为什么需要加固:三个攻击面
| 攻击面 | 后果 | 加固手段 |
|---|---|---|
| 短信轰炸 | 拿接口打别人手机,败坏口碑 + 账单是你的 | 重发冷却 + 每日上限(都在 kSendScript) |
| 验证码爆破 | 6 位仅 1M 组合,脚本几十分钟撞出 | 校验次数上限(kVerifyScript 的 maxVerifyAttempts) |
| 一码多用 | 并发下同一码被兑换两次 | 校验与消费同一 Lua 原子完成 |
二、发送侧 Lua:冷却 + 每日上限原子化
kSendScript 在一个脚本里完成"查冷却 → 查每日计数 → 写验证码 → 设冷却 → 计当日"。三条命令若分开做,并发下要么计数竞态(实际失败 10 次只记 5 次),要么锁定竞态(同时跨阈值各自 SETEX)。Lua 在 Redis 单线程里把"读-判-写"变不可分割。
1 | -- KEYS: codeKey/cooldownKey/dailyKey;ARGV: code/ttl/cd/dailyLimit/dailyTtl |
自然日 TTL 的坑:每日上限如果"从第一次发送起滚动 24 小时",语义就变成"任意 24 小时内 10 条",而不是"今天 10 条"。secondsUntilTomorrow 算出"距本地次日 0 点"的秒数作为 key 的 TTL,跨天自动清零,才是真正的自然日配额。
1 | // src/service/SmsService.cc(节选) |
三、校验侧 Lua:比对 + 消费原子化
kVerifyScript 把"读码 → 判试错次数 → 比对 → 删除"放进单线程:试错超限直接作废该码(逼攻击者重新走发送流程,而发送有上限卡着);比对通过则删 code 与 try,一次性消费。
1 | -- KEYS: codeKey/tryKey;ARGV: inputCode/maxAttempts |
四、爆破的数学:6 位 = 1,000,000
6 位数字验证码搜索空间仅 10⁶。若不限次数,假设网络/脚本每秒试 100 次,理论 ~2.7 小时可穷举——实际还有网络延迟、风控,但"不限次数"本身不可接受。maxVerifyAttempts=5 意味着:错 5 次该码直接作废,攻击者必须重新走发送(而发送有每日上限 10 条卡着),把"爆破"成本抬高到不划算。
五、发送失败不回滚配额的取舍
sendCode 在 Lua 拿到 {0, n}(配额已扣、码已存)后才真发短信。若发送失败(腾讯云超时/凭据错),码仍在 Redis 但用户没收到——这里选择不回滚配额。理由:回滚要再写一个 Lua 且要处理"部分失败",而配额本就是防轰炸的粗粒度闸门,多扣一条无实质损失;真用户下次重发走正常流程即可。
六、对比表(朴素实现 vs Lua 加固)
| 维度 | 朴素(GET→比对→DEL 分开) | Lua 原子加固(本文) |
|---|---|---|
| 限流计数竞态 | 有(并发少计) | 无(单线程原子) |
| 一码多用 | 可能(并发双兑) | 不可能(比对即删) |
| 每日上限语义 | 易退化成滚动 24h | 自然日(距次日 0 点 TTL) |
| 爆破成本 | 低(不限次) | 高(5 次作废 + 每日 10 条) |
功能抉择(本篇核心权衡)
① 为什么限流/校验必须用 Lua 而不能用"多条命令 + 应用层判断"?
Redis 单线程执行 Lua,脚本内的"读-判-写"对外部观察者是不可分割的原子操作。用多条独立命令,并发请求会在命令之间插空,导致计数少计、锁定竞态、验证码双兑——这些都不是"偶发",是必然会在高并发下出现。
② 为什么每日上限要计算"距次日 0 点"而不是固定 86400 秒?
固定 24h 滚动会让"今天 23:00 发的 10 条"和"明天 00:00 发的 10 条"实际上是连续两波,失去"每天 10 条"的约束意义。secondsUntilTomorrow 让 key 恰好在跨天清零,才是真正的自然日配额。
③ 为什么发送失败不回滚配额?
配额是防轰炸的粗粒度闸门,多扣一条对用户无实质影响;为回填写第二个 Lua + 处理部分失败,复杂度和收益不成正比。取舍清晰:宁可偶发多扣一条,也不引入回滚的并发复杂度。
小结
- 限流与校验全在 Lua 原子脚本完成,消除计数竞态、锁定竞态、一码多用。
- 每日上限用自然日 TTL(距次日 0 点),不是滚动 24h——语义才是"今天 10 条"。
- 6 位验证码搜索空间 1M,试错 5 次作废 + 每日 10 条双层叠加,把爆破成本抬到不可接受。
- 发送失败不回滚配额:粗粒度闸门多扣一条无实质损失,换回滚的复杂度不划算。🐾

