☰
Redis+Lua实现登录防撞库:IP失败计数与临时封禁实战
2026/10/26 11:35:38 网站建设 项目流程

登录接口被撞库这件事,做过 C 端业务的同行基本都遇到过。凌晨三点被告警叫醒,一看日志,同一个出口 IP 在半小时里拿几百个手机号试了上万次密码,失败率 99%。当时我们的防护只有一张数据库表记录“某账号连续失败 5 次锁定 30 分钟”,颗粒度全压在账号上——攻击者换账号就跟换衣服一样,压根拦不住。后来把维度切到 IP,用 Redis 加一段 Lua 脚本做失败计数与临时封禁,才算把这条口子堵住。下面这套东西,就是那段时间从踩坑到稳定跑在生产上的完整复盘:失败次数怎么记、Key 怎么设计、Lua 脚本里哪些写法会出事、集群模式下为什么要加 hash tag、Redis 挂了怎么办。适合已经能写业务代码、但对原子性和分布式限流细节还没完全吃透的同学,也适合想给现有登录接口补一层防护的老手对照检查。

1. 为什么失败次数非得扔进 Redis 里数

1.1 单机内存计数撑不住的三件事

最早我们试过ConcurrentHashMap<String, AtomicInteger>这种最省事的写法,本地缓存里存 IP 到失败次数的映射,定时任务清理过期数据。在单实例部署、日活几万的场景下它确实能跑,但很快就撞墙了。

第一件事是扩容。服务从 2 个实例扩到 6 个实例之后,同一个 IP 的请求被负载均衡打到不同节点,每个节点各记各的账。攻击者只要把请求打散,每个实例上的计数都到不了阈值,等于没限。要解决就得引入一致性哈希做 IP 粘滞,但那样又会带来热点问题——某几个出口 IP 承载了大量用户,全压在一个节点上,反而更脆。

第二件事是重启。发一次版,容器一滚,内存里的计数全清零,攻击者卡着发版窗口猛冲,防护形同虚设。虽然可以用本地文件持久化,但多实例之间还是要同步,绕回来还是得有个共享存储。

第三件事是阈值调整。运营那边想临时把某个活动的失败阈值从 5 次放宽到 10 次,改内存里的变量得走配置中心推送加监听刷新,链路长、生效慢,还容易漏掉某个实例。放到 Redis 里,改一下脚本的 ARGV 参数就完事,热生效。

所以结论很直接:失败计数本质上是一份需要跨进程共享、带过期语义、高频读写的短期状态。这三个特征几乎就是 Redis 的标准使用场景。计数器用INCR,过期用EXPIRE,读性能靠单线程模型保证,天然贴合。

1.2 先 GET 再 INCR 和一段 Lua 的差距在哪

确定了用 Redis,很多人的第一版代码长这样:

Integer count = redisTemplate.opsForValue().get(key); if (count == null) { redisTemplate.opsForValue().set(key, 1, 15, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().increment(key); if (count + 1 >= 5) { redisTemplate.opsForValue().set(banKey, 1, 30, TimeUnit.MINUTES); } }

这段代码在低并发下没问题,但它有两条命令之间的空隙。假设攻击者用 20 个并发线程同时打过来,20 个线程几乎同时读到count = 4,然后各自判断4 + 1 >= 5成立,于是 20 个线程全部进入封锁分支——封锁本身不算错,但你原本想要的“第 5 次失败才封”变成了“第 5 次失败时可能触发 20 次写操作”,而且计数会一路涨到 24,后面做统计和告警的时候数据全是脏的。

更隐蔽的是过期时间的问题。上面这段代码只在count == null时设置过期,看起来没问题。但如果你写成每次INCR之后都补一句EXPIRE,那就变成了滑动窗口的假象:每次失败都把 TTL 重置回 15 分钟,攻击者只要每 14 分钟试一次,这个 key 就永远不过期,计数一路累积到阈值然后永久封禁,正常用户忘记密码试几次就被锁死一天。这个坑我们在灰度阶段真实踩到过,客服工单里一堆“我明明只输错一次就被锁了”。

Lua 脚本把这些操作打包成一个整体交给 Redis 执行。Redis 执行脚本期间不会插入其他命令,INCR、EXPIRE、SET三步要么全做完,要么全不做(脚本中途报错会中止执行,但已经执行的写命令不会回滚,这一点要注意,所以脚本里不适合放可能失败的复杂逻辑)。同时它把 3 到 4 次网络往返压缩成 1 次,在登录这种高频接口上,省下来的 RTT 是实打实的。

注意:Lua 脚本保证的是“脚本内部命令序列的原子性”,不是“业务逻辑的原子性”。脚本执行完到你的密码校验逻辑跑完之间,仍然存在时间窗口,并发请求可能同时通过检查。这部分要靠数据库层的最终校验和幂等设计兜底,不能指望限流脚本解决。

1.3 这套方案能防住什么,防不住什么

写防护方案最怕的是自我感觉良好。把边界说清楚,比堆功能重要。

能防住的:单一 IP 对单一账号或批量账号的暴力枚举;脚本小子用固定代理池低速撞库;某个出口 IP 因为内网中毒导致的机器式重试;以及配合账号维度后,针对特定账号的定向爆破。

防不住的:分布式代理池(几千个 IP 每个只试一两次);已经拿到正确密码的撞库(一次就中,计数根本没机会触发);针对短信验证码接口的轰炸(那是另一个限流维度);以及最麻烦的——攻击者故意用你的限流机制去锁死特定用户的账号,也就是所谓的“反向利用”。最后这一类只能靠组合维度、验证码升级和风控画像来缓解,单靠 IP 计数解决不了。

所以我的定位一直是:这一层是成本最低、见效最快的第一道闸,不是全套风控。它的价值在于把 90% 的低成本攻击挡在业务逻辑之外,让剩下的 10% 进入更贵的检测链路。

2. Key 设计与限流模型:账算清楚了再动手

2.1 Key 的命名结构与 hash tag 预埋

Key 的结构决定了后面能不能扩展,也决定了集群模式下会不会报错。我们的命名规则是业务前缀:维度:标识:类型,具体到登录失败场景:

用途Key 模板示例过期策略
IP 失败计数login:fail:ip:{ip}login:fail:ip:{10.0.0.7}窗口期,如 900 秒
IP 封禁标记login:fail:ip:{ip}:banlogin:fail:ip:{10.0.0.7}:ban封禁时长,如 1800 秒
账号失败计数login:fail:uid:{uid}login:fail:uid:{u10086}窗口期
账号封禁标记login:fail:uid:{uid}:banlogin:fail:uid:{u10086}:ban封禁时长

注意到示例里那对大括号没有。它不是装饰,是 Redis Cluster 的hash tag语法:Redis 只对第一个{和第一个}之间的内容做 CRC16 取模来决定 slot。把 IP 或 uid 包进大括号里,同一个用户的两个 key 就一定会落在同一个 slot 上。这一点非常关键——如果计数 key 和封禁 key 落在不同节点,你的 Lua 脚本里同时操作这两个 key 就会直接报CROSSSLOT Keys in request don't hash to the same slot。很多人在单机 Redis 上测得好好的,一上集群就炸,就是这里。

还有个细节是 IP 里的点号和冒号。IPv4 用点号没影响,但 IPv6 地址本身带冒号,如果直接用冒号做分隔符,key 读起来会乱。我们统一做了归一化:IPv6 走InetAddress.getByName(ip).getHostAddress()转成标准压缩格式再拼,避免同一个地址因为写法不同(::1和0:0:0:0:0:0:0:1)被当成两个 key 分别计数,那就等于给攻击者留了后门。

2.2 固定窗口、滑动窗口和退避封禁怎么选

限流模型的选择直接决定了误伤率和拦截率之间的平衡。我把常见的三种摊开对比一下:

模型实现方式优点缺点适用场景
固定窗口INCR+ 首次EXPIRE命令少、内存占用小、逻辑简单临界点双倍放行,窗口边界可被卡绝大多数登录失败限制
滑动窗口ZSET存每次失败时间戳边界平滑,无突刺内存占用高,每次失败一条记录高安全等级接口、支付密码
退避封禁失败次数阶梯对应递增封禁时长对慢速攻击有压制力阈值表需要调,逻辑稍复杂有明显攻击特征时启用

固定窗口的临界点问题是这样的:窗口是 0 到 900 秒,攻击者在第 890 秒失败 4 次,窗口重置后在第 910 秒再失败 4 次,那么在 20 秒内他实际上试了 8 次,但两个窗口各自都没到阈值。严格说这个漏洞可以被利用,但对撞库来说,攻击者需要精确知道你的窗口起点,成本不低。我们评估后选择了固定窗口,理由是:登录接口的 QPS 峰值本来就不高,内存敏感度高于安全极致;而且真正的攻击者不会精细到这个程度。

滑动窗口我们用ZSET实现,每次失败往里塞一条score = 当前毫秒时间戳的成员,然后用ZREMRANGEBYSCORE清掉窗口外的旧记录,ZCARD拿当前失败次数。代价是内存——每个失败记录占几十字节,如果有个 IP 疯狂失败,这个集合会一直涨到阈值才停,所以阈值本身也是内存保护。我们在支付密码这类接口上用这个模型,登录接口不用。

退避封禁是我们后来加的一层补充:前 5 次失败封 10 分钟,第 6 到 10 次封 1 小时,10 次以上封 24 小时。实现上不难,在封禁 key 里存一个“封禁等级”的值,读出来之后按等级查表算时长。这一层主要是对付那些“封完 10 分钟继续来”的耐心型攻击者。

2.3 阈值和时长的参数推演

阈值不能拍脑袋。我们的推演过程是这样的:

先看正常用户。根据历史工单统计,忘记密码的用户里,90% 在第 3 次以内成功,第 5 次还没成功的用户里,有相当一部分是记错了账号。所以IP 维度阈值定 10 次/15 分钟,比账号维度(5 次)宽一倍——因为一个出口 IP 背后往往不止一个人。

再看内网 NAT。某企业客户的办公网出口是一个 IP,内部有两百多号员工同时使用。假设登录高峰集中在早上 9 点,两百人里有 3% 会输错密码,那就是 6 次失败集中在几分钟内。如果阈值定 5,这个 IP 会直接被封,整个公司的人都登不上,这就是典型的误伤。所以对有 NAT 特征的 IP 段,我们走的是另一套阈值(30 次/15 分钟),通过一个静态 IP 段配置来区分。

再看攻击侧。假设攻击者单线程每秒试 1 次,10 次阈值意味着 10 秒后被封,封禁 30 分钟,那么他一天最多成功尝试的次数是(24 * 60 / 30) * 10 = 480次。如果密码空间是 6 位数字,480 次尝试的命中率约 0.048%,可以接受。如果是多线程并发,虽然会更快触发封禁,但有效尝试次数还是被压在同一个量级。

封禁时长选了阶梯式 10 分钟 / 60 分钟 / 24 小时。为什么不直接上 24 小时?因为客服成本。真实用户被锁 24 小时会大量打电话投诉,10 分钟足够让正常人冷静下来走找回密码流程,也足够让自动化脚本的节奏被打断。

一个必须提醒的点:别只按 IP 限。攻击者用 IP 池攻击固定账号时,每个 IP 只失败一次,永远不会触发 IP 维度的封禁。所以实际部署里我们是 IP 和账号两个维度同时跑,任一维度命中就拒绝,两个计数器各自独立。这也解释了为什么上面的 Key 表里有两套。

3. Lua 脚本逐行拆解与落地代码

3.1 校验和记录,拆成两个脚本更稳

先看“检查是否被封禁”的脚本,这个最短也最容易写错:

-- login_check.lua -- KEYS[1] = 封禁标记 key -- 返回 {是否封禁(0/1), 剩余秒数} local ttl = redis.call('TTL', KEYS[1]) if ttl > 0 then return {1, ttl} end return {0, 0}

TTL的返回值有三种:正整数表示剩余秒数,-1表示 key 存在但没设过期,-2表示 key 不存在。所以判断条件必须是ttl > 0而不是ttl ~= -1——后者在 key 不存在时(-2)会误判成已封禁,这个 bug 一旦上线,所有新用户第一次登录都会被拒绝。我们代码评审时专门抓过这个。

再看记录失败的脚本:

-- login_record.lua -- KEYS[1] = 失败计数 key -- KEYS[2] = 封禁标记 key -- ARGV[1] = 阈值(次数) -- ARGV[2] = 计数窗口(秒) -- ARGV[3] = 封禁时长(秒) -- 返回 {是否触发封禁(0/1), 当前失败次数, 剩余秒数} local maxFail = tonumber(ARGV[1]) local windowSec = tonumber(ARGV[2]) local banSec = tonumber(ARGV[3]) -- 先补一次过期兜底:防止 key 因异常原因变成永久 key local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('EXPIRE', KEYS[1], windowSec) end local ttl = redis.call('TTL', KEYS[1]) if ttl < 0 then redis.call('EXPIRE', KEYS[1], windowSec) ttl = windowSec end if count >= maxFail then redis.call('SET', KEYS[2], '1', 'EX', banSec) redis.call('DEL', KEYS[1]) return {1, count, banSec} end return {0, count, ttl}

逐行说几个关键决策。

if count == 1 then EXPIRE这个判断是核心。只有在计数从 0 变 1 的那一刻才设置过期,后续的失败不会重置 TTL,窗口才是真正的固定窗口。很多人把EXPIRE写在INCR外面每一次都执行,那就退化成“最后一次失败后 15 分钟才重置”,攻击者可以通过持续低速失败让窗口无限延后。

ttl < 0那段是兜底。理论上count == 1时一定设了过期,但如果 Redis 内存紧张触发了maxmemory-policy allkeys-lru把过期时间信息弄丢了,或者有人在运维时手动改过 key,就可能出现永久 key。这个兜底在正常路径上不会走到,但真出事的时候能救命。代价是每次多一次TTL调用,对 Redis 来说可以忽略。

count >= maxFail之后先SET封禁标记再DEL计数 key,顺序不能反。如果先DEL再SET,中间脚本报错的话,计数清了但封禁没设,攻击者白赚一轮。虽然 Lua 脚本内部不会被其他命令打断,但脚本自身抛异常是会中止的。

返回值设计成纯整数的数组{int, int, int},这是因为 Redis 的 Lua 解释器把 table 转成 RESP 数组时,数字会被截断成 64 位整数,字符串原样返回。混着返回的话 Java 侧拿到的对象类型不一致,反序列化容易出问题。全用整数最省心。

脚本里不要用TIME、RANDOMKEY、SRANDMEMBER这类不确定命令。Redis 5.0 改成 effects replication 之后这些命令在脚本里放开了,但为了兼容老版本和主从复制的一致性,我建议一律从应用层把时间戳当 ARGV 传进来,脚本只做纯计算。

3.2 Java 侧调用:序列化配置是最容易翻车的地方

先说结论:用StringRedisTemplate调脚本,或者给RedisTemplate显式配置StringRedisSerializer作为 key 和 value 的序列化器。如果你用默认的JdkSerializationRedisSerializer或者 Jackson,传进去的 ARGV 会被序列化成带引号的字符串或者二进制字节,Lua 里tonumber('"5"')返回 nil,然后count >= nil直接抛attempt to compare number with nil。这个报错信息极不直观,我们排查了快两个小时才发现是序列化器的问题。

配置部分:

@Configuration public class LoginLimitScriptConfig { @Bean("loginCheckScript") public DefaultRedisScript<List> loginCheckScript() { DefaultRedisScript<List> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/login_check.lua")); script.setResultType(List.class); return script; } @Bean("loginRecordScript") public DefaultRedisScript<List> loginRecordScript() { DefaultRedisScript<List> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/login_record.lua")); script.setResultType(List.class); return script; } }

setResultType必须设置。不设的话 Spring Data Redis 在某些版本里会拿不到执行结果,集群模式下还可能因为无法判断是否只读而把脚本广播到所有节点执行。

调用侧封装成一个组件,把 key 拼接、hash tag、维度判断都收进去:

@Component public class LoginFailLimiter { private final StringRedisTemplate redis; private final DefaultRedisScript<List> checkScript; private final DefaultRedisScript<List> recordScript; // 阈值配置,实际项目里从配置中心拉 private static final int IP_MAX_FAIL = 10; private static final int IP_WINDOW_SEC = 900; private static final int IP_BAN_SEC = 1800; public LoginFailLimiter(StringRedisTemplate redis, @Qualifier("loginCheckScript") DefaultRedisScript<List> checkScript, @Qualifier("loginRecordScript") DefaultRedisScript<List> recordScript) { this.redis = redis; this.checkScript = checkScript; this.recordScript = recordScript; } private String banKey(String dim, String id) { return "login:fail:" + dim + ":{" + id + "}:ban"; } private String cntKey(String dim, String id) { return "login:fail:" + dim + ":{" + id + "}"; } /** 登录前调用,返回 >0 表示还在封禁中,值为剩余秒数 */ public long remainBanSeconds(String dim, String id) { List<?> r = redis.execute(checkScript, Collections.singletonList(banKey(dim, id))); if (r == null || r.isEmpty()) { return 0L; } Long banned = (Long) r.get(0); return (banned != null && banned == 1L) ? (Long) r.get(1) : 0L; } /** 密码校验失败后调用,返回 true 表示本次触发了封禁 */ public boolean recordFailure(String dim, String id, int maxFail, int windowSec, int banSec) { List<String> keys = Arrays.asList(cntKey(dim, id), banKey(dim, id)); List<?> r = redis.execute(recordScript, keys, String.valueOf(maxFail), String.valueOf(windowSec), String.valueOf(banSec)); if (r == null || r.isEmpty()) { return false; } Long banned = (Long) r.get(0); return banned != null && banned == 1L; } }

注意Arrays.asList(cntKey(...), banKey(...))这里两个 key 共享同一个 hash tag,集群模式下才会落到同一 slot。另外redis.execute的第三个参数是可变参数Object...,我特意传的是String.valueOf(...),配合StringRedisTemplate的字符串序列化器,保证 ARGV 在脚本里拿到的是干净的"10"而不是"\"10\""。

再来是业务接入的顺序,这个顺序排错了整个防护就是摆设:

public LoginResult login(String account, String password, String clientIp) { // 1. 先查封禁,命中直接拒绝,不要去碰数据库 long ipBan = limiter.remainBanSeconds("ip", clientIp); if (ipBan > 0) { return LoginResult.fail("尝试过于频繁,请 " + ipBan + " 秒后再试"); } long uidBan = limiter.remainBanSeconds("uid", account); if (uidBan > 0) { return LoginResult.fail("账号已被临时锁定,请稍后再试"); } // 2. 走正常校验 User user = userService.findByAccount(account); if (user == null || !passwordEncoder.matches(password, user.getPassword())) { // 3. 失败了才计数,成功不计数 limiter.recordFailure("ip", clientIp, 10, 900, 1800); limiter.recordFailure("uid", account, 5, 900, 1800); // 4. 统一返回泛化错误,不区分账号不存在和密码错误 return LoginResult.fail("账号或密码不正确"); } // 5. 成功则清掉该账号维度的计数 redis.delete("login:fail:uid:{" + account + "}"); return LoginResult.ok(user); }

第 4 步那句“不区分账号不存在和密码错误”很重要。如果你返回“账号不存在”,攻击者就能拿这个接口做账号枚举,把有限次数的尝试用在真实账号上,限流的意义就打了对折。

第 5 步只清账号维度不清 IP 维度,是有意的。IP 维度的计数代表这个出口的“整体可疑程度”,不应该因为某一个账号登录成功就一笔勾销。如果误清,攻击者只要手里有一个自己的正常账号,就能拿它当钥匙反复重置 IP 计数。

3.3 脚本预加载与 EVALSHA 的取舍

Spring Data Redis 的DefaultScriptExecutor内部走的是“先EVALSHA,遇到NOSCRIPT再EVAL”的流程,脚本 SHA1 缓存由它自己维护,正常情况下不需要手动SCRIPT LOAD。

但如果你的 Redis 前面挂了中间件做了命令拦截,或者用了某些云上的托管实例对SCRIPT系列命令有特殊限制,就可能出现每次都在EVAL全量传脚本的情况。我们的脚本文件加起来不到 2KB,单看一次没啥,但登录接口峰值 QPS 上千的时候,这 2KB 乘以 QPS 就是实打实的带宽和解析开销——Redis 每次都要重新计算 SHA1、解析脚本、生成函数。所以上线前建议用redis-cli --latency配合SLOWLOG GET 10看一眼,确认脚本走的是EVALSHA而不是EVAL。

还有一种情况是 Redis 重启。如果没开 AOF 或者没配 RDB 持久化,重启后脚本缓存全空,第一批请求会集中打到EVAL分支,同时NOSCRIPT异常会让 Spring 的重试逻辑多走一次往返。这个抖动在正常情况下感知不到,但如果你的 Redis 部署在容器里、经常被 OOM Kill,就要留意了——上层的熔断器阈值别设得太敏感,否则一次重启能触发大面积降级。

4. 真实 IP 的获取与绕过风险

4.1 取 IP 的顺序必须定死,而且只信最后一跳

限流脚本写得再漂亮,如果拿到的 IP 是伪造的,整套防护就是一个可以被任意绕过的空壳。这个问题的根源在于X-Forwarded-For是可伪造的请求头。

我们一开始的代码是request.getHeader("X-Forwarded-For").split(",")[0],取第一个。这写法在有代理的情况下看起来对,实际上攻击者只要在自己请求里带一个X-Forwarded-For: 1.2.3.4,经过我们的 Nginx 之后,头会变成1.2.3.4, 真实IP,取第一个就是攻击者随便写的值。每次换一个伪造 IP,限流完全失效。

正确的取法取决于你的网络拓扑。我们的部署是“四层负载均衡 -> Nginx -> 应用”,所以规则定死为:

public static String resolveClientIp(HttpServletRequest request) { // 只信 Nginx 覆写的那一个头,且必须是 Nginx 配置里 set 的 String ip = request.getHeader("X-Real-IP"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } // 归一化,去掉 IPv6 的方括号和端口 if (ip != null && ip.startsWith("[") && ip.contains("]")) { ip = ip.substring(1, ip.indexOf(']')); } return ip; }

配套的 Nginx 配置里必须有proxy_set_header X-Real-IP $remote_addr;。这里的关键是$remote_addr取的是 TCP 连接的对端地址,客户端伪造不了。而X-Forwarded-For是$proxy_add_x_forwarded_for,它会把客户端传来的值原样保留再追加,天生不干净。

一个血泪教训:我们曾经在同一个应用里,登录限流取的是X-Real-IP,而风控埋点取的是X-Forwarded-For的第一个值。结果风控报表里出现了一批根本不存在的 IP,排查了半天才发现是两个模块的取值逻辑不一致。IP 解析逻辑必须收敛到一个工具类,全项目只允许有一个出口。

4.2 NAT 共享出口下的误伤控制

拿到真实 IP 只是第一步,更难的是判断这个 IP 背后到底是一个人还是一群人。

判断依据我们用了三个。第一是活跃账号数:在 Redis 里维护一个ip -> 当日登录过的账号集合(用HyperLogLog做基数统计,内存占用极小),如果某个 IP 的基数超过 20,标记为疑似共享出口,走宽松阈值。第二是网段:把已知的企业网段、机房出口段、运营商 CGNAT 段配成一份静态名单,定期从流量里挖掘更新。第三是行为特征:如果同一个 IP 的失败请求里,用户名的分布高度分散(比如 100 次失败涉及 80 个不同账号),基本可以判定是撞库而不是真人输错。

对于判定为共享出口的 IP,我们的处理是不封 IP,只封账号。因为封了 IP 会误伤几百人,而攻击者在这个 IP 上能造成的主要危害是撞账号,账号维度封住就够了。反过来,如果某个 IP 的失败请求集中在极少数账号上(比如 100 次失败只涉及 2 个账号),那更可能是针对特定人的定向爆破,这时候两个维度都封。

这套判断逻辑放在限流脚本之外,用定时任务离线算,把结果写进一个ip -> 策略等级的 Redis Hash 里,登录时查一次。虽然多了一次读,但比在 Lua 里实时算要可靠得多——Lua 脚本应该保持短小纯粹,复杂判断放外面。

补充一个容易忽略的点:IPv6 的地址空间极大,一个用户可能分到一整个 /64 前缀,每次连接换一个地址。如果你的限流按完整 IPv6 地址计数,那等于没限。我们的做法是对 IPv6 取前 64 位作为限流粒度,也就是按子网而不是按地址算。

5. 集群、降级与监控:上线前必须补的三块

5.1 Cluster 模式的 slot 问题与脚本缓存

前面提过 hash tag,这里把它说透。Redis Cluster 把 16384 个 slot 分散到多个节点,一次EVAL里涉及的所有 key 必须在同一个 slot,否则直接报CROSSSLOT。这是硬约束,没有配置能绕开。

我们的 key 已经加了{ip}或{uid},但这还不够。假设ip = 10.0.0.7,那么login:fail:ip:{10.0.0.7}和login:fail:ip:{10.0.0.7}:ban的 hash tag 都是10.0.0.7,CRC16 取模结果相同,落在同一个 slot 上。但如果你在脚本里又偷偷访问了一个没加 hash tag 的 key,比如统计用的login:total:fail,那就又跨 slot 了。

所以规则很明确:脚本里只允许出现通过 KEYS 参数传进来的 key,不允许在脚本内部做字符串拼接生成新 key。这条规则在单机 Redis 上不强制,但在集群上是生死线。我们后来的做法是在代码里加了一层断言,脚本执行前校验所有 key 的 hash tag 是否一致,不一致直接抛异常,把问题挡在开发阶段。

还有一个集群特有的坑:DefaultRedisScript如果在脚本执行时 keys 为空,Spring Data Redis 的RedisClusterScriptExecutor会对所有节点执行脚本,这在读场景下可能被放大成 N 倍的请求。我们的 check 脚本只传一个 key,不会触发;但如果你写了一个“统计全集群失败总数”的脚本又没传 key,就要小心了。

5.2 Redis 挂了,登录是放行还是拒绝

这是个必须在上线前开会定下来的事。我们当时讨论了两个方案:

策略行为优点风险
fail-openRedis 异常时跳过限流,正常校验密码不影响正常用户登录,业务可用性优先攻击窗口期完全敞开
fail-closeRedis 异常时直接拒绝所有登录安全优先一次 Redis 抖动导致全站无法登录,事故等级极高

我们的选择是fail-open 加告警加补偿。理由是登录是核心链路,全站不可登录的损失远大于一次攻击窗口。但为了降低风险,做了三件事:

第一,本地兜底。在每个应用实例内维护一个Caffeine缓存做二级计数,阈值设得比 Redis 侧宽(比如 IP 维度给 20 次),只在 Redis 不可用时生效。虽然多实例之间不共享,拦截效果打折,但至少挡住了单实例上最粗暴的攻击。

第二,异常分级。RedisConnectionFailureException和RedisSystemException走 fail-open,但如果是脚本执行报错(比如CROSSSLOT或返回值类型不对),走 fail-close 并且立刻告警。因为这类错误说明代码有问题,继续放行等于裸奔。

第三,降级期间升级验证码。一旦限流降级开关打开,登录接口自动要求图形验证码或短信验证码。这一步能极大提高自动化脚本的成本,比单纯计数有效得多,而且不依赖 Redis。

public long remainBanSecondsSafe(String dim, String id) { try { return remainBanSeconds(dim, id); } catch (RedisConnectionFailureException | QueryTimeoutException e) { // 连接层问题:降级,记指标,放行 metrics.counter("login.limit.degraded").increment(); degradeSwitch.turnOn(Duration.ofMinutes(2)); return 0L; } catch (Exception e) { // 脚本本身的问题:不放行,交给上层提示 log.error("login limit script error, dim={}, id={}", dim, id, e); throw new IllegalStateException("风控组件异常"); } }

5.3 监控埋点和告警阈值

限流这东西最怕“静默失效”——上线之后一切正常,其实是因为阈值配错或者脚本根本没执行,等到真被打了才发现防护是空的。

我们埋了这几个指标,每个都能定位一类问题:

指标名含义异常信号建议告警
login_fail_total累计失败次数突增 5 倍以上5 分钟内环比涨 300%
login_ban_hit_total命中封禁的次数与失败数比例失衡占比超过 30%
login_ban_unique_ip被封禁的独立 IP 数短时间大量不同 IP 被封10 分钟内超过 500
login_limit_script_cost_ms脚本执行耗时P99 超过 10msP99 > 20ms 持续 1 分钟
login_limit_degraded_total降级触发次数大于 0立即告警
login_limit_noscript_totalNOSCRIPT 触发次数频繁触发说明脚本缓存丢失5 分钟内超过 10 次

其中login_ban_unique_ip是最有信息量的一个。如果短时间内大量不同 IP 被封,说明攻击者在用 IP 池;如果只有少数几个 IP 被封但失败数极高,说明是集中式攻击或者你的阈值配得太紧了。这两种情况的处置完全不同——前者要上设备指纹和账号维度,后者要检查是否误伤。

还有一个指标容易被忽略:封禁命中的用户里,有多少在解封后 5 分钟内再次成功登录。这个比例如果很低(比如低于 30%),说明被封的可能是真实用户而不是攻击者,那就该回过头去调阈值了。

6. 常见问题排查速查表

6.1 现象与根因对照

这套东西跑了两年多,遇到的问题基本集中在下面这些。按现象排查能省不少时间:

现象最可能的根因排查方法修复
所有用户第一次登录就被拒TTL判断写成了ttl ~= -1,把 -2 当成已封禁手动TTL一个不存在的 key 看返回值改判断为ttl > 0
脚本报attempt to compare number with nilARGV 被 JSON 序列化,带了引号;或 key/ARGV 顺序传错在脚本开头return {ARGV[1]}看实际值换StringRedisTemplate或统一StringRedisSerializer
集群上报CROSSSLOTkey 没加 hash tag,或脚本内部拼了新的 key打印所有 key,检查{}内内容是否一致统一加 hash tag,禁止脚本内造 key
计数永远不涨成功路径也清了 key,或者计数写在了校验之前被拦截的请求上加日志看recordFailure调用量调整调用顺序,只在密码校验失败后计数
封禁到期了还在封封禁 key 的过期没设上,或者用了SET没带EXTTL banKey看是否返回 -1脚本里改用SET ... EX
内存持续增长计数 key 没设过期,或滑动窗口 ZSET 没清理redis-cli --bigkeys,SCAN匹配login:fail:*补EXPIRE,ZSET 加ZREMRANGEBYSCORE
攻击时防护没生效取到的是伪造的X-Forwarded-For在入口打日志比对getRemoteAddr()和头部值改用 Nginx 覆写的X-Real-IP
封禁时间比配的短多个实例用不同配置,或者降级期间放行检查配置中心是否全量下发统一配置源,加版本号校验

6.2 几个只有在生产上才会遇到的坑

坑一:INCR返回负数。这个听起来离谱,但确实有团队遇到过。原因是某个 key 被手工改成了非数字值(比如运维调试时SET了一个字符串),INCR会直接报ERR value is not an integer or out of range。我们的处理是在脚本里对INCR做pcall保护:

local ok, count = pcall(redis.call, 'INCR', KEYS[1]) if not ok then redis.call('DEL', KEYS[1]) count = redis.call('INCR', KEYS[1]) end

这段会多一次DEL的开销,只在异常路径触发,正常情况几乎无感。

坑二:脚本执行超时不等于脚本没执行。我们把脚本的timeout配成 100ms,超时后 Java 侧抛异常,但 Redis 那边可能已经把INCR做完了。如果代码里在 catch 分支里做了补偿逻辑(比如“超时了就当作没失败”),就会导致计数和实际不一致。结论是:脚本超时后不要做补偿写操作,只做日志和告警,让计数自然过期。

坑三:预热阶段的全量误伤。上线当天,历史失败记录都是空的,但网关层因为某些原因把大量请求的 IP 归到了同一个(比如容器网络的 SNAT 出口),导致首批用户涌进来就把这个 IP 打到阈值。我们在灰度阶段的做法是先跑“影子模式”——只计数不封禁,观察 24 小时内的 IP 分布和失败分布,确认阈值合理再切到真封禁。这个步骤后来成了所有风控类需求的标准流程。

坑四:SET带NX的误用。有人在封禁标记上用SET key 1 EX 1800 NX,想实现“只在没有封禁标记时设置”,避免重复刷新。逻辑上没错,但如果前面的SET成功了而脚本后面的逻辑报错,这个标记就会一直挂到过期。更麻烦的是,NX模式下返回 nil 时,Lua 里的redis.call会把 nil 转成 false,如果不判断返回值就往回传,会导致 Redis 报错。用SET就别用NX,用NX就必须处理 false 的返回值。

坑五:多环境共用 Redis。测试环境连了生产的 Redis(这种事在中小团队真的常见),测试用例里反复失败把某个 IP 封了,导致生产上某个共享出口的用户登不上。最低成本的防御手段是在 key 里加上环境前缀,并且在不同环境的配置里把database索引也区分开,双保险。

7. 把这套东西用在自己项目上的几点建议

如果现在让我重新做一遍,我会在原始版本上改三处。

第一处是把“阈值配置”和“脚本”彻底解耦。最初的版本把 10 次、900 秒、1800 秒这三组数字硬编码在 Java 常量里,改一次要发版。后来的做法是把整套参数放进配置中心的一个 JSON 里,按维度(ip / uid / 支付密码)分别配置,脚本只负责执行,参数全部通过 ARGV 传入。这样调整阈值不需要动代码,运营侧也能自己改。

第二处是给每个封禁动作留一条可追溯的审计记录。我们后来加了一个异步的LIST写入,每次触发封禁就把 IP、维度、失败次数、时间戳、User-Agent 摘要推一条进去,T+1 归档到数仓。这份数据在事后复盘攻击特征、调整阈值、以及跟客服解释“为什么这个用户被锁了”的时候,价值极高。

第三处是最重要的——不要试图用一层防护解决所有问题。IP 计数只是成本最低的那一层,真正让攻击成本上升到不可接受的水平,靠的是 IP 计数、账号计数、设备指纹、行为序列、验证码升级这一整套组合。每一层的实现都不复杂,难的是把它们串起来,并且保证其中任何一层挂掉都不会导致整体失效。我个人的做法是从最低成本的那层开始做,每加一层都单独做灰度、单独看指标,跑稳了再叠下一层。一次全上,出了问题根本不知道是哪层导致的。

至于那段 Lua 脚本本身,两年来大改过两次,都是因为参数调整和返回值扩展,核心逻辑几乎没动过。这也印证了一件事:限流的复杂度从来不在脚本,而在你对自己业务流量的理解程度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询