登录接口被人拿字典跑密码,是很多系统上线后迟早要面对的一件事。我在几个业务系统上都做过登录失败次数限制,从最早的 JVM 内存计数,到后来用 Redis 加 Lua 脚本做原子计数,中间踩过的坑比想象中多。Redis 加 Lua 脚本限制 IP 多次输入错误密码这个方案本身不复杂,核心就是"计数 + 过期 + 原子操作"三件事,但真正决定它上线后是安静干活还是天天被投诉的,是键怎么命名、窗口怎么切、阈值怎么定、Redis 挂了怎么办这些细节。这篇内容偏实战,会把脚本逐行拆开讲,也会把我在线上遇到的几个真实问题完整复盘一遍。适合已经能写 CRUD、正准备给登录接口加防护的后端同学,也适合已经上了这套方案但发现误封率高、想优化策略的人。读完之后你应该能直接抄出一份能跑的 Lua 脚本和配套的调用封装,并且知道哪些参数是可以放心改的,哪些地方改了会出事。
1. 从一次登录接口的异常曲线说起:这套方案到底在防什么
先说清楚这个场景。绝大多数系统的登录接口长这样:接收账号密码,查库比对哈希,对上了发 token,对不上返回"用户名或密码错误"。逻辑没问题,问题在于它对"错误"这件事毫无记忆——同一个人连续错一百次,和错一次,服务端的处理完全一样。这意味着一台普通机器跑个字典,一晚上能把一个六位纯数字密码的空间扫完。
我第一次认真对待这件事,是因为监控上看登录接口的 QPS 曲线在凌晨三点出现了一个非常规整的方波:每分钟固定几百次请求,间隔均匀,失败率 99% 以上。不是业务流量,是脚本在跑。当时第一反应是加个限流,但普通的接口限流(比如每秒 100 次)拦不住——攻击者可以放慢到每秒 2 次,慢到你根本看不出异常,但一晚上照样能试几万次。
所以"限制 IP 多次输入错误密码"这件事的本质,不是流量控制,而是针对失败结果的累积计数。它和普通限流有三个关键差别:只统计失败,成功要清零,计数要跟一个时间窗口绑定。这三个差别决定了它不能简单套用现成的限流中间件,得自己写一段带状态的逻辑。
1.1 判定入口应该放在密码校验之前还是之后
这个顺序很多人没想清楚。正确做法是:请求进来先查计数,超阈值直接拒绝,根本不走密码比对。原因有两个。一是性能,锁定期间的请求应该以最小的代价被挡回去,不该再去查一次数据库、算一次 BCrypt;二是安全,BCrypt 这类哈希算法本身是 CPU 密集型的,如果锁定期间还让它跑,攻击者只要持续打,就能用极低的成本把你的 CPU 打满,这本身就是一种资源耗尽攻击。
我见过一个实现是把计数逻辑放在密码校验失败之后,逻辑上"更准确"——只有真的错了才计数。看起来合理,但配合上 BCrypt 的 cost=10,单次校验大概 50~100ms,攻击者并发 200 就能把一台 4 核机器的 CPU 吃干净。所以顺序必须是:读计数 → 判断是否锁定 → 未锁定才做密码校验 → 校验失败则累加计数。
1.2 为什么"只封 IP"是不够的,但必须从 IP 开始
严格来说,只按 IP 计数是有漏洞的:攻击者换 IP 就绕过了。但反过来,只按账号计数同样有漏洞:攻击者拿一个字典对所有账号各试两次,每个账号都没超阈值,但整体已经扫了很多。所以成熟的做法是双维度,IP 和账号各记一份,任意一个超限就拦。
那为什么还从 IP 开始做?因为 IP 维度的实现成本最低,不需要用户先提交一个合法的账号格式,天然能覆盖"连账号都不存在"的探测请求,而且它对暴力破解的拦截效果最直接。先把 IP 这层做好,再去叠加账号维度,是性价比最高的推进路径。这篇主要讲 IP 维度的实现,账号维度的交叉判定放到后面章节说。
2. 把计数器从 JVM 内存搬到 Redis 的完整理由
最早我用的方案特别朴素:一个ConcurrentHashMap<String, AtomicInteger>,key 是 IP,value 是失败次数,配一个定时任务每分钟扫一遍把过期的清掉。在自己机器上测毫无问题,上线也挺好,直到做了水平扩容。
2.1 单机计数在多实例部署下的必然失效
服务的实例数从 1 变成 3,负载均衡默认轮询。攻击者每个请求落到不同实例上,每个实例自己记自己的,阈值是 5 的话,实际允许的失败次数就变成了 15。实例扩到 10 个,就是 50 次。这个漏洞不是线性的,是随扩容直接放大的,而且非常隐蔽——你在测试环境单实例跑,永远发现不了。
有人会想到用会话粘滞(把同一 IP 的请求固定到一个实例)。这个思路在网关层能做,但引入两个新问题:一是粘滞本身会破坏负载均衡,热点 IP 会把单个实例打爆;二是实例重启或者扩容缩容时,计数会丢,攻击者只要踩准滚动发布的窗口就能重置计数。所以计数状态必须是外置的、共享的、有持久化的。
2.2 用数据库表记失败次数的代价
外置存储第一反应是数据库:一张login_fail_log表,每次失败 insert 一条,查询时 count 一下最近十分钟的记录。这个方案能用,但代价不小。
首先是写入压力。登录失败本身可能是正常用户手滑,也可能是攻击,后者一天几十万条记录很常见,这些垃圾数据既要占存储又要占 IO。其次是查询成本,count(*) where ip = ? and create_time > ?这种查询如果没有合适的联合索引会全表扫,有了索引也要走一次范围扫描,而且这个查询在每次登录请求上都要执行一次。最后是并发问题:如果你用"先 select 再 update"的方式维护一张计数表,在高并发下必然出现更新丢失,得上行锁或者乐观重试,复杂度立刻上去了。
我算过一笔账:假设一天 50 万次失败登录,数据库方案每次失败至少一次 insert 加一次查询,Redis 方案是两次 O(1) 的内存操作。差距不在一个量级上。而且这类数据天生就是"短命"的,十分钟后就没人关心了,放数据库里纯属浪费。
2.3 Lua 脚本真正解决的是读和写之间的那道缝
那直接用 Redis 的INCR不就行了?INCR key本身是原子的,配合EXPIRE也不难。问题出在"先判断再累加"这个组合上。
假设不用脚本,客户端这样写:先GET key拿到当前次数,判断是否超过阈值,没超过就INCR。这中间有一段窗口期。阈值是 5,当前计数是 4,一百个并发请求同时GET到 4,全部判断"没超",然后全部INCR,计数直接冲到 104。这一百个请求全都进了密码校验环节。这就是典型的检查后使用(TOCTOU)问题,单条命令原子不等于多条命令的组合原子。
Lua 脚本的价值就在这里:Redis 执行脚本期间是单线程的,脚本内的所有命令作为一个整体执行,中间不会插入其他客户端的命令。所以"读计数 → 比较阈值 → 决定是否递增 → 设置过期"这一整串逻辑,在脚本里就是原子的。这也是为什么几乎所有正经的限流实现最终都会落到 Lua 上。
3. 键的设计与时间窗口:决定误杀率的地方
脚本写起来十分钟,键设计和窗口选型才是真正需要想半天的地方。这两件事一旦上线再改,涉及数据迁移和历史计数失效,成本很高。
3.1 key 的维度拆分与命名约定
我习惯的命名是login:fail:ip:{ip}这种分段式,前缀标明业务域,中间标明维度,最后是具体值。分段的目的是方便运维用SCAN按前缀统计,也方便在监控面板上按前缀聚合。千万不要用ip_1.2.3.4这种无结构命名,后期想统计"当前有多少 IP 处于锁定状态"都做不到。
维度拆分上,至少要有三种 key:
| key 模式 | 维度 | 用途 | 建议窗口 |
|---|---|---|---|
login:fail:ip:{ip} | 单 IP | 拦截单机暴力破解 | 10~30 分钟 |
login:fail:acct:{account} | 单账号 | 拦截撞库、针对特定账号的爆破 | 30~60 分钟 |
login:fail:ipacct:{ip}:{account} | IP + 账号组合 | 精细判定,减少误伤 | 15 分钟 |
第三种的用途后面会讲,它的作用是区分"这个 IP 在打很多账号"和"这个 IP 只是在反复试自己的账号"。前者是攻击特征,后者大概率是用户忘了密码。
还有一个容易忽略的点:如果账号是用户可控的输入,直接拼进 key 会有风险。攻击者提交一个超长的账号名,或者带特殊字符的账号名,会把 key 撑得很大,Redis 的 key 内存占用直接上去。所以拼 key 之前一定要先做长度截断(比如 64 字符)和哈希处理(比如对账号做一次 MD5 取前 16 位)。IP 相对安全,但 IPv6 有 39 字符,也要留意。
3.2 固定窗口的漏洞和滑动窗口的近似实现
最简单的做法是固定窗口:第一次失败时设一个 10 分钟的 TTL,窗口内计数累加,到期自动清零。这个方案实现最简单,但有个明显缺陷——边界效应。
假设窗口 10 分钟、阈值 5。攻击者在第 9 分 50 秒连试 5 次,全部失败,计数到 5 被锁。等到第 10 分 01 秒,key 过期了,计数归零,他立刻又能试 5 次。也就是说在跨窗口的这 20 秒内,他实际试了 10 次。窗口越长、阈值越高,这个放大效应越明显。
更麻烦的是慢速爆破。如果攻击者知道你的窗口是 10 分钟、阈值 5,他完全可以每 10 分钟试 4 次,永远不触发锁定。按这个速率,一天能试 576 次,一个月的量足以覆盖大部分弱口令。
所以正经的实现应该用滑动窗口。Redis 里最常用的近似实现是 ZSET:每个失败记录一个 member(用时间戳加随机后缀保证唯一),score 用时间戳。每次判定时先ZREMRANGEBYSCORE把窗口外的记录删掉,再ZCARD看当前数量。这样任意时刻统计的都是"最近 N 分钟"的真实数量,没有边界效应。
代价是内存。每次失败要存一个 ZSET 元素,一个元素大概几十字节。如果一天 50 万次失败,窗口 10 分钟,同一时刻最多也就几千个元素,完全扛得住。真遇到大规模攻击时,单个 IP 的 ZSET 会因为计数超阈值而被快速拦截,元素数量反而涨不上去——因为超限之后我们直接拒绝,不再写入新记录。
我自己在大部分业务里用的是折中方案:IP 维度用滑动窗口(因为它是对外暴露最直接的),账号维度用固定窗口(因为账号维度本身有业务侧的其他保护)。这样实现复杂度可控,防护效果也够。
3.3 阈值和锁定时长的量化估算
阈值定多少,不能拍脑袋。我的做法是先算清楚"安全边界"。
假设密码策略要求最少 8 位、包含字母数字,有效组合数大约 2.8 万亿。如果攻击者能做到每秒 10 次尝试(已经很快了,受网络和服务端响应限制),要穷举完需要 8900 年,这个空间是安全的。真正的风险在于用户实际使用的弱口令——生日、手机尾号、常见单词,这类密码的真实熵可能只有 20~30 bit,也就是一百万到十亿种组合。按每秒 10 次算,一百万种组合只需要 27 小时。
所以阈值的设定目标不是"让穷举不可能",而是"把单 IP 的尝试速率压到一个足够低的水平"。经验值是:10 分钟窗口内允许 5 次失败,触发后锁定 30 分钟。这样单个 IP 一天的尝试上限大概在 240 次左右,对于一个 20 bit 熵的弱口令来说,需要 4000 多天,基本可以认为安全了。
但阈值不能只考虑安全,还要考虑误伤。真实的用户行为里,连续输错 5 次密码是相当常见的——尤其是手机上密码管理器自动填充了旧密码,用户点五次登录就是五次失败。所以我一般会把阈值定在 5,但对已登录过的设备(比如带有效 refresh token 的请求)放宽到 10 次。这个细节能显著降低客服工单量。
锁定时长也要分层。第一次触发锁 10 分钟,第二次触发(24 小时内)锁 30 分钟,第三次锁 2 小时,后面按指数退避。这个策略专门针对慢速爆破——因为固定窗口方案的漏洞就在于"等窗口过去再试",加了递增惩罚之后,攻击者的成本会指数上升。
4. 把 Lua 脚本写对:从能跑到线上稳
前面铺垫了这么多,现在上代码。下面这个脚本是我现在项目里在用的版本,做了几轮修改,每一处改动都能对应到一个具体问题。
4.1 校验并递增脚本的完整实现
-- KEYS[1] = 计数 key,例如 login:fail:ip:1.2.3.4 -- ARGV[1] = 阈值,例如 5 -- ARGV[2] = 窗口秒数,例如 600 -- ARGV[3] = 当前时间戳(毫秒,由客户端传入) -- 返回: {是否放行(1放行/0拒绝), 当前计数, 剩余秒数} local threshold = tonumber(ARGV[1]) local windowSec = tonumber(ARGV[2]) local nowMs = tonumber(ARGV[3]) local windowMs = windowSec * 1000 -- 滑动窗口:清掉窗口范围外的历史记录 redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, nowMs - windowMs) -- 统计窗口内的失败次数 local cnt = redis.call('ZCARD', KEYS[1]) if cnt >= threshold then -- 已超限,直接拒绝,并返回剩余锁定时间 local ttl = redis.call('PTTL', KEYS[1]) if ttl < 0 then ttl = windowMs end return {0, cnt, math.ceil(ttl / 1000)} end -- 未超限,记录这次失败 -- member 用时间戳+随机后缀,避免同一毫秒的并发写入互相覆盖 local member = tostring(nowMs) .. ':' .. tostring(math.random(100000, 999999)) redis.call('ZADD', KEYS[1], nowMs, member) -- 给整个 key 设置过期时间,防止冷数据长期占内存 redis.call('PEXPIRE', KEYS[1], windowMs) return {1, cnt + 1, windowSec}逐段说一下几个关键点。
ZREMRANGEBYSCORE那行是滑动窗口的核心。它把 score 小于"当前时间减窗口长度"的成员全部删掉。注意这里用的是毫秒时间戳,因为秒级精度在低延迟场景下会有明显误差——一个窗口 600 秒的场景,秒级精度误差可以接受,但如果你把窗口调到 60 秒以下,秒级精度就不够用了。
ZCARD拿到的是清理后的真实数量。这里没有再单独维护一个计数器,因为 ZSET 的ZCARD是 O(1) 的,而且天然和 ZSET 内容保持一致,不会出现"计数器和实际记录对不上"的情况。
math.random那段是踩过坑才加的。一开始 member 直接用了时间戳,结果在压测时发现计数偏低——同一毫秒内的多个并发请求,member 完全相同,ZADD是幂等的,后写的会覆盖先写的,导致实际记了 10 次失败,ZSET 里只有 3 个成员。加上随机后缀之后,冲突概率降到可以忽略。
最后那个PEXPIRE是每次写入都刷新的。有人会问:如果一直有新失败写入,key 的过期时间一直续,那 ZSET 岂不是永远不过期?不会,因为 ZSET 里窗口外的成员每次都被清掉,key 的大小始终受窗口内失败次数限制,最多就是阈值多个。而过期时间在这里的作用是"兜底清理"——当某个 IP 再也不会失败时,这个 key 会在最后一次失败后的一个窗口期自动消失,不会长期占内存。
4.2 为什么时间戳必须由客户端传,而不是在脚本里取
这是最容易被忽略、也最容易出事的细节。很多人会在脚本里写redis.call('TIME')来获取当前时间,觉得"这样更准确、少传一个参数"。
问题出在主从复制上。Redis 的复制模型是"把写命令原样传给从节点",从节点执行命令时用的是自己的时钟。如果脚本里用了TIME或者RANDOM、math.random(没有指定种子的话)这类非确定性函数,主节点算出来的结果和从节点重放时算出来的结果可能不一样,数据就开始漂移。稍新一些的 Redis 版本会直接拒绝执行包含这类函数的脚本,报"Write commands not allowed after non deterministic commands"或者要求开启redis.replicate_commands()。
解决办法很简单:所有跟时间有关的东西,全部由客户端算好,通过 ARGV 传进来。这样脚本的执行结果只取决于输入参数,是确定性的,主从重放完全一致。
顺带说一句math.random。在新版本的 Redis 里,脚本内的math.random每次执行都会用新的种子,所以是"看起来随机但每次不同",这在主从环境下同样有问题。稳妥做法是在脚本开头先math.randomseed(ARGV[4]),用客户端传进来的一个种子值来初始化,保证主从一致。或者更干脆,随机后缀也在客户端生成好传进来。
4.3 登录成功之后的善后动作
计数清零这件事,看起来就是一句DEL,但实际要考虑的东西不少。
首先是清零的时机。不能在校验密码之前清,那就等于攻击者只要试对一次就把计数清了,前面的记录白记。正确的顺序是:密码校验通过 → 检查该 IP 和该账号的计数 → 如果计数超过某个较低的阈值(比如 3),记录一条风控日志 → 然后DEL掉两者的 key。
为什么要在清零前记日志?因为"失败多次后终于成功"这个模式,在某些情况下是攻击成功的信号——比如撞库攻击中,攻击者试了几百个密码终于撞对一个。这时候虽然登录成功,但应该触发额外的验证,比如要求短信验证码,或者标记该会话为"待观察"。
其次是清零的粒度。清 IP 维度还是账号维度?我的做法是账号维度的 key 必须清,IP 维度的 key 视情况清。原因是一个公共出口 IP 后面可能有几百个用户,某个用户成功登录不应该把整个 IP 的失败记录清掉。所以 IP 维度的 key 我一般不动,让它自然过期;账号维度的 key 在成功登录后DEL。
这个设计有个副作用:如果某个 IP 确实是攻击者,他在疯狂尝试之后偶然试对了一个账号,账号 key 被清了,但 IP key 还在,后续攻击仍然会被拦。这个效果正好是我们想要的。
4.4 Spring 侧怎么把这套东西封装成一个可用的组件
Java 里用 Spring Data Redis 调用 Lua 脚本,核心是DefaultRedisScript。要注意三点:脚本要缓存(RedisScript对象是线程安全的,不要每次调用都重新加载),返回类型要声明对(这里返回的是 List),连接要用StringRedisTemplate而不是RedisTemplate(避免序列化器把数字变成奇怪的字节)。
@Component public class LoginFailLimiter { private static final RedisScript<List> SCRIPT = new DefaultRedisScript<>( new ClassPathResource("lua/login_fail_limit.lua") .getContentAsString(StandardCharsets.UTF_8), List.class); private final StringRedisTemplate redis; public LoginFailLimiter(StringRedisTemplate redis) { this.redis = redis; } public LimitResult check(String ip, int threshold, int windowSec) { String key = "login:fail:ip:" + normalize(ip); long nowMs = System.currentTimeMillis(); List<?> raw = redis.execute(SCRIPT, Collections.singletonList(key), String.valueOf(threshold), String.valueOf(windowSec), String.valueOf(nowMs)); if (raw == null || raw.size() < 3) { // 脚本异常时走本地兜底,见 6.2 节 return LimitResult.fallbackAllow(); } boolean allowed = ((Long) raw.get(0)) == 1L; long count = (Long) raw.get(1); long remain = (Long) raw.get(2); return new LimitResult(allowed, count, remain); } private String normalize(String ip) { if (ip == null || ip.isEmpty()) return "unknown"; return ip.length() > 64 ? DigestUtils.md5Hex(ip) : ip; } }调用方拿到LimitResult之后,allowed == false就直接返回一个统一的错误提示,不要告诉用户"你被锁定了还有 8 分 32 秒"——这个信息对正常用户没什么用,对攻击者却是有用的反馈信号。统一返回"账号或密码错误,请稍后再试"就够了,最多加一个"尝试过于频繁"的模糊提示。
5. 线上真实踩过的四个坑
下面这四个问题,每一个都在生产环境出过事,而且都不是"代码写得不对",而是"代码写对了但环境不配合"。
5.1 集群模式下的 CROSSSLOT 报错
第一次上 Redis 集群,脚本一跑就报错:CROSSSLOT Keys in request don't hash to the same slot。
原因是脚本里操作了多个 key——login:fail:ip:1.2.3.4和login:fail:acct:zhangsan,这两个 key 经过 CRC16 哈希后落在不同的槽位上。Redis 集群不允许一个脚本跨槽操作,因为跨槽意味着要在多个节点上执行,做不到原子性。
解决办法是 hash tag。把 key 的命名改成login:fail:{1.2.3.4}:ip和login:fail:{1.2.3.4}:acct,用花括号包住的部分会被用来计算槽位,只要花括号里的内容相同,两个 key 就落在同一个槽。但这样又带来一个新问题:槽位的分布变得不均匀,因为同一个 IP 的所有 key 都挤在一个节点上。
所以我在集群环境下的实际做法是不让一个脚本操作多个 key。IP 维度的判定一个脚本,账号维度的判定另一个脚本,两次调用分别判断,任意一个拒绝就拦。这样牺牲了一点原子性(两次调用之间可能状态变了),但换来的是槽位分布均匀、单脚本性能稳定。考虑到两次调用之间的时间差在毫秒级,而且限流场景本身对"绝对精确"的要求没那么高,这个取舍是划算的。
5.2 TTL 为 -1 导致的黑名单永久生效
有个下午接到反馈,说某一片地区的用户全部无法登录,持续了两个多小时。查 Redis 发现一个 IP 的计数 key 的 TTL 是 -1,也就是永不过期,计数停在 6,一直超阈值,一直拒绝。
追下来是这么回事:脚本里用的是INCR加EXPIRE的组合,逻辑上第一次INCR返回 1 时才设过期时间。但当时有个分支是"如果计数已经存在就直接INCR不设过期",用于避免每次请求都刷新 TTL。结果有一次运维手动清了 Redis 数据,key 没了,但某个节点的本地缓存放着一个"这个 key 曾经存在"的标记,直接走了不设过期的分支,INCR创建了一个没有 TTL 的新 key。从那以后这个 key 就永远存在了。
这个 bug 的教训是:任何写计数的地方,都要确保 TTL 一定被设置。我现在的脚本里,PEXPIRE是无条件执行的,每次写入都刷,不做任何分支判断。多一次 O(1) 的命令开销,换的是"绝不会出现永久 key"的确定性,非常值。
另外加了一条运维规范:所有带login:fail:前缀的 key,监控上要定期采样检查 TTL,发现 -1 就告警。这类"数据状态异常"的问题靠代码 review 是防不住的,必须靠监控兜。
5.3 出口 IP 复用造成的误封
这个是最难处理的。某次大范围投诉,用户在同一个办公区、同一个小区、同一个运营商 NAT 出口下,一个人输错密码触发了 IP 锁定,整个区域的人都登不上。
这个问题在 IPv4 资源紧张的背景下只会越来越常见。移动网络的出口 IP 复用率极高,一个 IP 后面可能是几千个用户。这种情况下按 IP 做严格锁定,误伤是必然的。
我试过几个缓解手段,效果从高到低:
第一个是提高 IP 维度的阈值,降低账号维度的阈值。IP 维度放到 20 次/10 分钟,账号维度放到 5 次/30 分钟。攻击者想爆破单个账号,会被账号维度拦住;想批量撞库,会被 IP 维度拦住。而普通用户手滑几次,离 20 次还远得很。
第二个是引入"IP + 账号"的组合维度做精细判定。如果同一个 IP 只在一个账号上失败,说明是用户自己忘了密码,宽松处理;如果同一个 IP 在多个不同账号上失败,那就是撞库特征,严格处理。实现上就是维护一个login:fail:ip-accts:{ip}的 HyperLogLog 或者小集合,记录这个 IP 最近试过哪些账号,基数超过 3 就升级为攻击。
第三个是对已知的运营商 NAT 网段做白名单放宽。这个需要维护一份网段列表,成本不低,而且列表会变化,我一般只在大客户或者特定场景下做。
还有个更简单的办法:不要返回"你被锁了"这个明确信号。统一返回"账号或密码错误",同时在服务端对该 IP 的后续请求强制要求验证码。用户看到验证码,正常用户会去解,攻击脚本解不了,实际效果比硬锁好,投诉也少。
5.4 脚本执行超时引发的连接池耗尽
这个问题出现在一次大促期间。监控上看到 Redis 客户端连接数飙升,应用线程大量卡在redis.execute()上,最后 Tomcat 线程池打满,整个服务不可用。
根因是脚本里的ZREMRANGEBYSCORE在极端情况下变慢。正常情况下这个命令删除的元素很少,但有一次某个 IP 的 ZSET 里堆了几十万个成员——原因是当时有个 bug,超限之后仍然在写入新记录(判断逻辑写反了),ZSET 无限膨胀,每次清理都要遍历大量元素,单次脚本执行从 0.2ms 涨到了 40ms。Redis 是单线程的,40ms 的脚本会阻塞所有其他命令,然后雪崩。
从这个事故之后我立了几条规矩:
一是脚本执行时间必须监控。Spring Data Redis 没有直接暴露脚本耗时,我用的是在脚本前后打点,或者在 Redis 侧用SLOWLOG捞超过 10ms 的命令,单独看EVALSHA。线上告警阈值设在 P99 超过 5ms。
二是给集合类 key 加硬上限。在脚本里判断ZCARD,如果超过阈值就直接DEL重建,绝不允许 ZSET 无限增长。这个判断本身是 O(1),成本可以忽略。
三是给 Redis 调用加超时。客户端超时设 200ms,超时之后走降级逻辑(下一节讲)。宁可短暂放行,也不能让线程卡死。
6. 让这套策略更耐打的几层加固
单靠一个 Redis 脚本,防护是够用的,但离"稳"还有距离。下面三层加固是我在几个项目里逐步加上去的。
6.1 IP 与账号双维度交叉判定
前面提过,只按 IP 会被换 IP 绕过,只按账号会被撒网绕过。双维度的判定逻辑我一般写成这样一个优先级链:
| 判定顺序 | 条件 | 处置 |
|---|---|---|
| 1 | IP 维度超阈值(如 20/10min) | 直接拒绝,要求验证码 |
| 2 | 账号维度超阈值(如 5/30min) | 拒绝,提示稍后再试 |
| 3 | IP 在该账号上的失败次数超阈值(如 3) | 拒绝,要求验证码 |
| 4 | IP 尝试过的不同账号数超阈值(如 5) | 拒绝,标记为可疑 |
| 5 | 以上都不满足 | 放行,走正常校验 |
这个链子的设计意图是层层递进:前两条拦截最明显的暴力特征,第三条处理"单个用户忘了密码",第四条专门抓撞库。实际跑下来,第 4 条抓到的才是真正有威胁的攻击流量,前三条大多数时候拦的是普通用户的失误。
第 4 条的实现要小心内存。用一个SADD login:fail:ip-accts:{ip} account加SCARD判断,每个成员要存一个账号名,如果攻击者用随机账号打,这个集合会涨得很快。所以必须给它设一个硬上限,超过 100 个成员就直接判定为攻击,不再继续记录。
6.2 本地缓存前置与 Redis 故障时的降级
登录接口是核心链路,不能因为 Redis 抖动就整个服务不可用。我的做法是在应用侧加一层轻量本地缓存(Caffeine 就够了),缓存"已锁定 IP 列表",TTL 设得很短,比如 5 秒。
判断流程变成:先查本地缓存,命中"已锁定"就直接拒绝,这一步完全不碰 Redis,能把绝大部分攻击流量挡在应用进程内;本地没命中再去查 Redis 做精确判定。这样 Redis 的 QPS 能降一个数量级,而且 Redis 挂了的时候,本地缓存里已有的锁定状态还能继续生效。
如果 Redis 完全不可用怎么办?两种策略,取决于业务的安全等级。安全要求高的(比如涉及资金的),选择快速失败:Redis 不可用就拒绝所有疑似异常的登录,宁可误伤。普通业务选择降级放行:记录一条告警,放行本次请求,但仍然做密码校验,只是不做频率限制。我所在的几个项目都是后者,因为登录不可用的业务影响远大于被爆破几次。
降级逻辑一定要有开关,而且开关要能实时生效(用配置中心推),这样真出问题时可以一键切换策略,而不用发版。
6.3 验证码降级:比硬锁更友好的处置方式
前面提过,硬锁会让正常用户很恼火。我现在更倾向于"分级处置":第一次超阈值不是直接拒绝,而是要求输入图形验证码或滑块。这个动作对正常用户只是多花三秒,对脚本是致命的。
具体实现是在返回体里带一个标记,前端收到之后弹出验证码组件。服务端校验验证码通过后,允许本次登录请求继续,但计数不清零——计数还是要靠成功登录来清。
只有连续超过第二阈值(比如 10 次)才升级为硬锁定。这套分级策略上线之后,我观察到两个变化:客服工单量下降了大概 70%,因为大部分用户只是被要求输一次验证码;同时攻击流量的拦截率反而上升了,因为脚本处理不了验证码,会主动放弃。
7. 怎么验证这套逻辑真的生效了
写完代码不验证,等于没写。这部分说说我平时怎么测。
7.1 手工用例清单
上线前我至少要过一遍下面这些用例,每一条都手动打一次:
| 用例 | 操作 | 预期结果 |
|---|---|---|
| 正常失败 | 输错密码 1 次 | 返回错误,计数为 1,TTL 正常 |
| 阈值边界 | 连续输错等于阈值次数 | 第 N 次仍走密码校验,第 N+1 次被拦 |
| 触发锁定 | 超阈值后再请求 | 直接返回限流,不查库 |
| 等待恢复 | 等窗口过期后再请求 | 恢复正常,计数从 1 开始 |
| 成功清零 | 输错 3 次后输对 | 账号维度计数清空,IP 维度保留 |
| 滑动窗口 | 第 9 分钟试 4 次,第 11 分钟再试 | 计数不归零(因为第 9 分钟的还在窗口内) |
| TTL 检查 | 随机取 10 个 key 查 TTL | 全部大于 0,无 -1 |
最后一条尤其重要,TTL = -1意味着永久 key,是前面踩过的坑。测试环境里跑一轮之后,一定要拿SCAN配合TTL抽样检查一遍。
7.2 并发压测验证原子性
原子性这件事,靠单线程测试是永远测不出来的。必须做并发验证。
我常用的方法是用一段简单的脚本,对同一个 key 并发打 100 个请求,阈值设 5,然后统计结果。正确的实现应该返回恰好 5 个"放行"和 95 个"拒绝",且最终的ZCARD是 5。如果返回的放行数量是 6 或者更多,说明脚本存在竞态;如果少于 5,说明有请求被漏记。
# 用 ab 打并发,观察服务端日志里的放行计数 ab -n 200 -c 100 -p payload.json -T application/json \ http://localhost:8080/api/login压测完之后直接连 Redis 看数据:
> ZCARD login:fail:ip:127.0.0.1 (integer) 5 > PTTL login:fail:ip:127.0.0.1 (integer) 598732两个值都对得上,才算过关。另外要测一下 Redis 主从切换时的表现——手动DEBUG SLEEP或者用哨兵切一次,观察切换期间是否有计数丢失。这个测试能验证出前面说的"脚本里不能用 TIME"的问题。
7.3 监控指标与告警
这套东西上线之后,必须有几个指标盯着,否则出问题只能靠用户投诉发现。
| 指标 | 采集方式 | 告警阈值 |
|---|---|---|
| 脚本执行耗时 P99 | 应用侧埋点 | > 5ms 持续 5 分钟 |
| 限流触发次数 | 每次拒绝打点 | 环比突增 5 倍 |
| 被锁定 IP 数量 | 定时SCAN统计 | > 历史 P99 的 2 倍 |
| Redis 调用错误率 | 客户端埋点 | > 0.1% |
| 降级开关状态 | 配置中心 | 处于降级状态超过 10 分钟 |
"被锁定 IP 数量突增"这个指标特别有用。正常情况下,处于锁定状态的 IP 数量是平稳的,一天内波动很小。如果突然涨了十倍,要么是有人在跑批量攻击,要么是我们自己的阈值配错了。这两种情况都需要立刻看,而不是等用户投诉。
还有一个小技巧:把每次触发限流的 IP 和账号写一条日志到独立的 appender,保留 7 天。真出了误封投诉,能快速回溯"这个 IP 当时到底失败了多少次、试了哪些账号",比凭猜测排查快得多。
我个人在这套方案上最大的体会是:脚本本身只占了整个工作量的一小部分,剩下八成的精力都花在键设计、阈值调参、误伤处理和降级方案上。见过太多实现是把 Lua 脚本写得漂漂亮亮,结果阈值定成"3 次就锁一天",上线第二天客服被打爆。所以如果你的系统正在准备上这个功能,建议先拿灰度环境跑两周,把真实的失败登录日志捞出来,看看失败次数的分布长什么样——大部分系统的 P99 也就 3 到 4 次,你把阈值定在 5,其实已经覆盖了绝大多数正常用户,剩下那 1% 大概率本来就有问题。
最后再补充一个我觉得很值的小改动:把限流返回的错误码和"密码错误"的错误码做成同一个,前端不做任何区分。这样攻击者拿不到"我是不是被锁了"这个反馈,只能靠盲猜,而正常用户看到的是统一的"账号或密码错误",体验上也不会有违和感。