秒杀系统高并发优化实战(C++ / Drogon):3.6 登录安全加固:密码错误次数限制 + 账号临时锁定
登录接口(3.2)能比对密码了,但还差最后一道防线的"门闩":密码错误次数限制 + 账号临时锁定。没有它,攻击者可以对一个手机号无限次试密码(撞库);有了它,连续错 N 次就临时锁几分钟,把撞库成本抬高到不划算。
配套代码:
src/service/LoginGuard.{h,cc}(checkLocked/onFailure/onSuccess+kOnFailureScript/kCheckScript)、UserService::login里埋的guard_->调用。
一、是什么、坑在哪、本质一句话
是什么:LoginGuard 用 Redis 记录每个手机号的密码失败次数,连续失败达 max_fail(默认 5)就 SETEX login:lock:{phone} 锁定 lock_seconds(默认 600);登录成功清计数;登录前先查锁定。
坑在哪:
- 计数放进程内存:多实例部署时每台各记各的,"5 次就锁"变成"5×N 次才锁"。
- 计数竞态:10 个请求同时读到 4、各自 +1 写回 5,实际失败 10 次却只记 5 次。
- 锁定检查卡死登录:Redis 挂了时把全部登录拒掉,业务损失比撞库更大。
本质一句话:登录加固的本质是"用 Redis 共享计数 + Lua 原子阈值 + 临时锁定"抬高撞库成本——计数必须共享、判定必须原子、查锁失败要 fail-open。
二、LoginGuard 三接口
1 | // src/service/LoginGuard.h |
FailResult 区分三种结果:Counting(还在累加,附 failCount)、LockedNow(本次触发锁定)、AlreadyLocked(此前已锁)。
三、onFailure 的 Lua:INCR + 判阈 + SETEX 锁
onFailure 的脚本把"查锁 → 计数 +1 → 判阈值 → 锁定"放进单线程原子执行。failKey 的 TTL 也设成 lockSeconds——这是滑动窗口语义:用户第 1 次输错后,只要 lockSeconds 内凑不满 max_fail 次,计数自然清零;设太短攻击者慢慢撞,设太长误输用户会被莫名锁。与锁定窗口取齐最省心。
1 | -- kOnFailureScript(KEYS: lockKey/failKey;ARGV: maxFail/lockSeconds) |
1 | // src/service/UserService.cc(密码错分支,3.2 已埋) |
四、fail-open vs fail-close:两条红线不能混
LoginGuard 在 Redis 异常时的取舍与 SessionStore 故意相反:
- 锁定检查
checkLocked:fail-open(放行)。锁定是"可用性"维度的保护,Redis 挂掉时把所有登录拒掉,业务损失比撞库更大;且撞库本身还有failKey计数在兜。 - 会话校验
SessionStore::exists:fail-close(拒绝)。鉴权是"安全性"维度,宁可误杀已登录用户让其重登,也不能在 Redis 抖动时放行进被封禁的会话。
一句话:鉴权要收紧(宁可误杀),限流要放松(宁可放过),两者不能混为一谈——这是安全设计里最容易踩的反直觉点。
五、与登录主链路集成
UserService::login 在 3.2 里已经埋好三处调用:① 进门先 checkLocked(被锁直接拒,连密码都不查);② 密码错 onFailure(记次数 / 触发锁定);③ 成功 onSuccess(清计数)。本篇只是把这套 Lua 原子逻辑讲透。
六、对比表
| 维度 | 进程内存计数 | Redis + Lua(本文) |
|---|---|---|
| 多实例语义 | 失效(5×N 次才锁) | 共享单一态,不变 |
| 计数竞态 | 有(并发少计) | 无(Lua 原子) |
| Redis 挂了 | — | 锁定检查 fail-open,不阻断登录 |
| 计数清零 | 需额外定时 | 滑动窗口 TTL 自动清零 |
功能抉择(本篇核心权衡)
① 为什么计数必须放 Redis 而不是进程内存?
进程内存只对单实例有效。攻击者把撞库请求打散到多实例,每台各记各的,"5 次就锁"变成"5×N 次才锁"。Redis 是共享单一计数源,多实例部署语义不变——这也是秒杀要上多实例的前提。
② 为什么"INCR + 判阈 + SETEX"必须一个 Lua?
三条命令分开做有两类竞态:计数竞态(10 请求同时读 4、各 +1 写回 5,实际失败 10 次只记 5);锁定竞态(两请求同时跨阈值各自 SETEX)。Lua 在 Redis 单线程把"读-改-写"变不可分割,从根消除。
③ 为什么锁定检查要 fail-open、会话校验要 fail-close?
两者保护维度不同:锁定是可用性(放过比拒掉损失小),鉴权是安全性(拒掉比放过损失小)。混用任一方都会出事——锁检查 fail-close 会在 Redis 抖动时全员登不进;会话校验 fail-open 会在 Redis 抖动时放进被封禁会话。
小结
- 登录加固 = Redis 共享失败计数 + Lua 原子阈值 + 临时锁定(
max_fail=5/lock_seconds=600)。 - 计数用滑动窗口 TTL(与锁定窗口取齐),
lockSeconds内凑不满阈值自动清零。 - fail-open vs fail-close 故意相反:锁定检查放松(Redis 挂不阻断登录),会话校验收紧(Redis 挂也拒进封禁会话)。
- 三处调用已埋进
UserService::login:进门查锁、密码错记次、成功清计数。🐾
配套源码
本文对应代码已开源:Hespethorn/seckill-cpp
C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:
- 阶段一(直打数据库):QPS≈440,0 超卖
- 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS
如果对你有帮助,欢迎 star ⭐

