应将封禁逻辑分层:中间件负责ip提取、调用策略函数及返回状态码,策略函数(isbanned)实现可插拔的差异化判定;redis计数与封禁标记分离,用lua保证原子性,ip识别需结合可信代理列表与x-forwarded-for校验。

封禁逻辑该放在中间件还是业务层?
直接放业务层会污染核心逻辑,重复写限流判断;但全扔进中间件又难适配不同接口的差异化策略(比如登录接口要更严,静态资源可以宽松)。实际做法是:用中间件统一做请求计数和基础判定,把“是否封禁”的决策权交给可插拔的策略函数。
关键点在于中间件只负责调用 isBanned(ip, path),而这个函数内部可按 path 查配置、读 Redis 中的频次记录、甚至查数据库白名单。避免中间件里硬编码路径判断或 if-else 堆砌。
- 中间件只做三件事:提取客户端 IP(注意 X-Forwarded-For 处理)、调用策略函数、返回 429 或 403
- 策略函数返回
bool, error,便于后续扩展为异步检查(比如调用风控服务) - 不要在中间件里直接操作 Redis —— 封装成独立的
RateLimiter结构体,方便单元测试和替换实现
如何准确提取真实客户端 IP?
Go 的 r.RemoteAddr 在有反向代理(Nginx、Cloudflare)时拿到的是代理 IP,不是用户真实 IP。必须解析 X-Forwarded-For,但不能无脑取第一个值——它可能被伪造。
正确做法是信任你控制的上游代理(比如 Nginx),只取其添加的最后一个可信段。假设你的 Nginx 配置了 proxy_set_header X-Forwarded-For $remote_addr;,那就要结合 X-Real-IP 和可信代理列表来判断。
- 用
net.ParseIP()校验每个候选 IP 是否合法,丢弃非法字符串 - 维护一个可信代理 CIDR 列表(如
10.0.0.0/8,172.16.0.0/12),只信任来自这些网段的X-Forwarded-For最后一跳 - 若所有头都不可信,fallback 到
r.RemoteAddr的 host 部分(strings.Split(r.RemoteAddr, ":")[0]),并打日志告警
Redis 计数器怎么设计才不误杀、不漏判?
用 INCR + EXPIRE 是常见思路,但存在竞态:两次并发请求可能让计数器超阈值后才设置过期时间,导致后续请求永远无法重置。更稳妥的是用 Lua 脚本原子执行。
推荐结构:rate:ip:192.168.1.100:login,TTL 设为滑动窗口长度(如 60 秒),每次请求执行 Lua 脚本完成“读+增+设 TTL”三步。
- Lua 脚本里用
redis.call("INCR", KEYS[1]),再用redis.call("EXPIRE", KEYS[1], ARGV[1]),确保原子性 - 封禁不是靠计数器本身,而是单独存一个封禁标记键(如
banned:ip:192.168.1.100),TTL 设为封禁时长(如 30 分钟) - 每次请求先查
EXISTS banned:ip:xxx,命中则直接拒绝,不走计数逻辑 —— 避免封禁期间还刷计数器
为什么封禁后还要保留计数器?
单纯删掉计数器键,用户解封后第一次请求会从 0 开始计,容易被绕过。保留计数器能延续历史频次,让封禁更“有记忆”。但要注意:封禁期间计数器仍可能增长,需在解封时清零或重置。
- 解封动作触发点有两个:一是封禁键自动过期,二是主动调用
DEL banned:ip:xxx同时DEL rate:ip:xxx:xxx - 如果使用滑动窗口(如 Redis ZSET),解封后应清理对应 score 区间的成员,而不是整个 key
- 日志里务必记录封禁触发的原始计数值(如 “IP 192.168.1.100 在 60s 内请求 login 接口 127 次,超阈值 100”),否则排查时找不到依据
动态封禁真正的难点不在代码量,而在 IP 识别的可靠性、Redis 原子操作的完备性、以及封禁与计数生命周期的解耦。漏掉任意一环,都可能造成大面积误封或形同虚设。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











