秒杀系统高并发优化实战(C++ / Drogon):3.10 注册安全加固:同 IP 注册频控 + 反代真实客户端 IP 解析
验证码改成自签发 / 日志模式后(3.3),"送达"不再是外部约束——注册接口等于裸奔在公网:攻击者拿一堆手机号从同一个 IP 批量注册,就能刷出成千上万个账号。这篇补两道闸门:① 同 IP 注册频控堵批量刷号;② 反代真实客户端 IP 解析,否则频控的 key 拿到的是代理 IP,等于没防。
配套代码:
src/service/RegisterGuard.{h,cc}(check/markSuccess+kCheckScript/kMarkScript)、src/controllers/UserController.cc的clientIp()、UserService::registerUser最前的频控闸门。阈值走config.json的register_limit(max_per_ip=5/window_seconds=3600)。
⚑ 2026-09-17 勘误(重要)
本文初版主张「取X-Forwarded-For首段即最原始客户端」,这个结论是错的。XFF 首段由调用方控制、可任意伪造:直连时一行 header 就能换掉频控 key(频控失效);反代时 Nginx 的$proxy_add_x_forwarded_for是追加语义,伪造值反而被拼在最左,取首段拿到的仍是伪造值。正确做法是先校验直连方是否为可信代理,再从右往左取首个不可信 IP——Drogon 官方RealIpResolver插件(v1.9.10 内置)已实现该逻辑。下文相关段落与代码已就地修正;完整的坑、攻击复现与原理见 3.11 X-Forwarded-For 取首段的陷阱:一次真实的注册频控绕过。
一、是什么、坑在哪、本质一句话
是什么:注册链路在"手机号格式校验之前"先过一道同 IP 固定窗口频控(RegisterGuard),限制一个 IP 在 window_seconds(默认 3600s)内能建成的账号数(max_per_ip,默认 5);超限直接 429。IP 本身由 UserController::clientIp() 提供——它内部调用 Drogon 官方 RealIpResolver 插件:先校验直连方是否为可信代理,命中才解析 X-Forwarded-For 并从右往左取首个不可信 IP。注意:HttpRequest 确实没有 getClientIp(),但框架以插件形式内建了这个能力,初版漏掉它是本文最大的坑(见 3.11)。
坑在哪:
- 频控 key 拿错 IP:走 Nginx / 网关反代后,
req->getPeerAddr()拿到的是代理 IP,所有请求共享同一个 key,频控要么全放过、要么全误杀。必须解析X-Forwarded-For——但不能盲目取首段:首段由调用方控制、可任意伪造,取了等于把频控 key 交出去(见 3.11)。 - 误判"框架不提供该能力":曾有代码写
req->getClientIp(),编译直接挂——HttpRequest确实只有getPeerAddr(),没有getClientIp()/getRealIp()。但据此断定"框架不内建从 XFF 解析真实 IP 的能力"是错的:答案是官方RealIpResolver插件,它不在HttpRequest的方法列表里,翻头文件容易漏掉。 - 计尝试数而非成功数:计尝试数会被错误验证码刷爆配额(DoS 掉正常用户名额);本意是限制"一个 IP 能建几个号",不是"能试几次"。
- Redis 挂了全拒:频控属可用性维度,Redis 不可用时若 fail-close,注册全断,损失比被刷号更大。
本质一句话:注册安全 = 反代真实 IP 作 key + Redis 共享计数 Lua 原子 + 固定窗口限成功数 + fail-open——IP 取错一切归零,计数必须共享、判定必须原子、限流必须放松(宁可放过)。
二、clientIp():交给官方 RealIpResolver,别自己抠首段
真实部署里请求都过一层反代,req->getPeerAddr() 拿到的是代理 IP,所以必须看 X-Forwarded-For(XFF)。但 XFF 的语义比"取首段"复杂得多。
XFF 由每一跳追加自己的地址,形如 client, proxy1, proxy2——坑在于最左那段是"客户端自称"的地址:任何调用方都能自己发一个 X-Forwarded-For: 1.2.3.4 把它写死;而 Nginx 最常见的配置 $proxy_add_x_forwarded_for 又是追加而非覆盖,伪造值会被原样拼在最左、真实地址反而排在后面。
正确姿势是两道保险:① 先看直连方(TCP 对端)是不是可信代理,不是就完全不看 XFF;② 是的话,从右往左跳过可信代理链、取第一个不可信 IP。Drogon 官方 RealIpResolver 插件做的就是这件事,v1.9.10 起内置。
下面这张图是「客户端未伪造 XFF」的理想情形——也正是初版误以为"取首段就够了"的原因:
1 | // src/controllers/UserController.cc |
在 config.json 里启用插件:
1 | { |
trust_ips 的语义是**「我信任谁的 X-Forwarded-For」。本项目当前不挂反向代理**,所以取 []——列表为空则 matchCidr 永远不成立,XFF 一律被忽略,伪造没有入口;将来接入 nginx 再把它的地址填进去。
反代侧仍需配
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;(让链路可回溯);但请注意它是追加语义——正因为如此,服务端绝不能取首段,必须按"验可信代理 + 从右往左"解析。另外,接入反代后记得在config.json的trust_ips里登记反代地址,否则插件不会采信 XFF——真实 IP 取不到,所有请求会退化成同一个代理 IP,同 IP 频控误伤全部用户。但也别把范围填宽:把客户端也能连到的地址写进列表,等于让那个来源决定频控 key,伪造当场生效。
三、RegisterGuard:固定窗口限"成功注册数"
为什么计成功数而不计尝试数?计尝试数会被错误验证码刷爆配额(攻击者随便填码就能把正常用户的名额耗尽),本质是 DoS;计成功数直接约束"一个 IP 能建几个账号",这才是真正的威胁面。
1 | // src/service/RegisterGuard.h |
计数放 Redis 而非进程内存:多实例部署时进程内存只对单机有效,打散到 N 台实例就形同虚设,Redis 是共享单一计数源(与 3.6 的 LoginGuard 同理)。
四、两段 Lua:纯读比对 + INCR 定窗
check 的脚本只比对不改计数(真正的 +1 只在 markSuccess 成功注册时做),避免"判定可注册"和"记一次数"跨两次调用的竞态。markSuccess 的脚本 INCR 后,仅在 n == 1(窗口内首次)设置 EXPIRE——这就是固定窗口:窗口内第一个成功注册创建计数键并定长过期,之后同 IP 只累加,过期后自动清零。
1 | -- kCheckScript(KEYS[1]=key;ARGV[1]=maxPerIp)纯读比对,不改计数 |
1 | -- kMarkScript(KEYS[1]=key;ARGV[1]=windowSeconds)INCR,首次定窗 |
1 | // src/service/RegisterGuard.cc |
五、fail-open vs fail-close:与 3.6 同一条红线
RegisterGuard::check 在 Redis 异常时的取舍,与 3.6 的 LoginGuard::checkLocked 一致、与 SessionStore::exists 故意相反:
- 注册频控
check:fail-open(放行)。频控属"可用性"维度保护,Redis 挂掉时把全部注册都拒掉,业务损失比被刷号更大;且刷号本身还有验证码在兜。 - 会话校验
SessionStore::exists:fail-close(拒绝)。鉴权是"安全性"维度,宁可误杀已登录用户让其重登,也不能在 Redis 抖动时放行进被封禁的会话。
一句话:鉴权要收紧(宁可误杀),限流要放松(宁可放过)——两条红线不能混,这是安全设计里最反直觉也最容易踩的点。
六、与主链路集成:闸门最前 + 429
频控闸门放在 UserService::registerUser 的第一行——被限的 IP 连手机号格式校验、验证码校验都不该再消耗后续资源。只有真正落库成功的那一刻才 markSuccess(ip) 计一次数(失败回滚不计数)。
1 | // src/service/UserService.cc(registerUser 开头) |
UserController::statusForFailure 把 REGISTER_IP_LIMITED 映射成 HTTP 429(频控触发,客户端应放缓后重试),与业务拒绝 409、系统错误 500 严格区分。
一个有意的取舍:因为闸门在最前、先于查重,所以一个 IP 成功注册满 5 次后,连"重复注册"都会被 429 挡掉,到不了
PHONE_REGISTERED分支。这是安全设计(频控优先于查重,批量刷号防护更稳);若你更希望"已注册用户重复提交"被回以PHONE_REGISTERED,需把查重提到 IP 闸门之前——但会削弱批量刷号防护,本项目暂不动。
七、对比表
| 维度 | 进程内存计数 | getPeerAddr() 作 key |
Redis + Lua(本文) |
|---|---|---|---|
| 多实例语义 | 失效(5×N 次才限) | 全员共享代理 IP key | 共享单一态,不变 |
| 取 IP 正确性 | — | 错(拿到代理 IP) | 对(RealIpResolver:验可信代理 + 从右往左取首个不可信 IP) |
| 计数竞态 | 有(并发少计) | — | 无(Lua 原子) |
| Redis 挂了 | — | — | 频控 fail-open,不阻断注册 |
| 计数清零 | 需额外定时 | — | 固定窗口 TTL 自动清零 |
功能抉择(本篇核心权衡)
① 为什么不能只用 getPeerAddr(),也不该自己抠 XFF 首段?
因为手上的 Drogon 1.9.10 根本没有 getClientIp()——HttpRequest 只暴露 getPeerAddr()(TCP 对端)。走反代时它就是代理 IP,直接拿来做频控 key 会让所有用户"共享一个配额",防护形同虚设。但"框架不提供该能力"是误判——答案是官方 RealIpResolver 插件(它不在 HttpRequest 的方法列表里,翻头文件容易漏掉)。而自己解析时取首段同样是错的:首段由调用方控制、可任意伪造,正确姿势是"验可信代理 + 从右往左取首个不可信 IP",插件的实现正是如此。这个双重误判,就是本篇最该记的坑。
② 为什么计"成功注册数"而不是"尝试次数"?
计尝试数意味着攻击者用一串错误验证码就能把配额刷爆,把正常用户的注册名额活活 DoS 掉;而真正要防的是"一个 IP 能建几个账号"。计成功数直接约束威胁面,且不给攻击者用错误请求消耗配额的机会。
③ 为什么 Redis 挂了要 fail-open(放行)而不是 fail-close(拒绝)?
频控和鉴权保护的是不同维度:频控是"可用性"(放过比拒掉损失小),鉴权是"安全性"(拒掉比放过损失小)。把频控设成 fail-close,Redis 一抖全员注册不了,业务损失远大于被刷几个号;刷号本身还有验证码这层兜底。两者红线不能混。
小结
- 注册安全双保险 =
clientIp()取反代真实 IP(走官方RealIpResolver插件,而非手抠 XFF 首段)+RegisterGuard同 IP 固定窗口限成功注册数(max_per_ip=5/window_seconds=3600)。 - 频控 key 用
reg:ip:<ip>;check纯读 Lua 比对、markSuccess用INCR + 首次 EXPIRE实现固定窗口;Redis 挂时 fail-open 放行,超限回 429REGISTER_IP_LIMITED。 - 闸门放
UserService::registerUser最前,先于手机号校验与查重;只有真正落库成功才计数——这是"频控优先于查重"的有意设计。 - Drogon 1.9.10 的
HttpRequest无getClientIp(),但框架以官方插件RealIpResolver内建了从 XFF 解析真实 IP 的能力——搜不到不代表没有,翻方法列表最容易漏掉。X-Forwarded-For取首段可被伪造,必须"验可信代理 + 从右往左",否则频控等于把 key 交给调用方。🐾
配套源码
本文对应代码已开源:Hespethorn/seckill-cpp
C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:
- 阶段一(直打数据库):QPS≈440,0 超卖
- 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS
如果对你有帮助,欢迎 star ⭐

