资讯动态

Redis+Lua实现登录失败次数限制与IP防暴力破解实战

发布时间:2026/10/26 11:36:54 来源:尧图企业网站定制
登录接口被人拿字典跑密码是很多系统上线后迟早要面对的一件事。我在几个业务系统上都做过登录失败次数限制从最早的 JVM 内存计数到后来用 Redis 加 Lua 脚本做原子计数中间踩过的坑比想象中多。Redis 加 Lua 脚本限制 IP 多次输入错误密码这个方案本身不复杂核心就是计数 过期 原子操作三件事但真正决定它上线后是安静干活还是天天被投诉的是键怎么命名、窗口怎么切、阈值怎么定、Redis 挂了怎么办这些细节。这篇内容偏实战会把脚本逐行拆开讲也会把我在线上遇到的几个真实问题完整复盘一遍。适合已经能写 CRUD、正准备给登录接口加防护的后端同学也适合已经上了这套方案但发现误封率高、想优化策略的人。读完之后你应该能直接抄出一份能跑的 Lua 脚本和配套的调用封装并且知道哪些参数是可以放心改的哪些地方改了会出事。1. 从一次登录接口的异常曲线说起这套方案到底在防什么先说清楚这个场景。绝大多数系统的登录接口长这样接收账号密码查库比对哈希对上了发 token对不上返回用户名或密码错误。逻辑没问题问题在于它对错误这件事毫无记忆——同一个人连续错一百次和错一次服务端的处理完全一样。这意味着一台普通机器跑个字典一晚上能把一个六位纯数字密码的空间扫完。我第一次认真对待这件事是因为监控上看登录接口的 QPS 曲线在凌晨三点出现了一个非常规整的方波每分钟固定几百次请求间隔均匀失败率 99% 以上。不是业务流量是脚本在跑。当时第一反应是加个限流但普通的接口限流比如每秒 100 次拦不住——攻击者可以放慢到每秒 2 次慢到你根本看不出异常但一晚上照样能试几万次。所以限制 IP 多次输入错误密码这件事的本质不是流量控制而是针对失败结果的累积计数。它和普通限流有三个关键差别只统计失败成功要清零计数要跟一个时间窗口绑定。这三个差别决定了它不能简单套用现成的限流中间件得自己写一段带状态的逻辑。1.1 判定入口应该放在密码校验之前还是之后这个顺序很多人没想清楚。正确做法是请求进来先查计数超阈值直接拒绝根本不走密码比对。原因有两个。一是性能锁定期间的请求应该以最小的代价被挡回去不该再去查一次数据库、算一次 BCrypt二是安全BCrypt 这类哈希算法本身是 CPU 密集型的如果锁定期间还让它跑攻击者只要持续打就能用极低的成本把你的 CPU 打满这本身就是一种资源耗尽攻击。我见过一个实现是把计数逻辑放在密码校验失败之后逻辑上更准确——只有真的错了才计数。看起来合理但配合上 BCrypt 的 cost10单次校验大概 50~100ms攻击者并发 200 就能把一台 4 核机器的 CPU 吃干净。所以顺序必须是读计数 → 判断是否锁定 → 未锁定才做密码校验 → 校验失败则累加计数。1.2 为什么只封 IP是不够的但必须从 IP 开始严格来说只按 IP 计数是有漏洞的攻击者换 IP 就绕过了。但反过来只按账号计数同样有漏洞攻击者拿一个字典对所有账号各试两次每个账号都没超阈值但整体已经扫了很多。所以成熟的做法是双维度IP 和账号各记一份任意一个超限就拦。那为什么还从 IP 开始做因为 IP 维度的实现成本最低不需要用户先提交一个合法的账号格式天然能覆盖连账号都不存在的探测请求而且它对暴力破解的拦截效果最直接。先把 IP 这层做好再去叠加账号维度是性价比最高的推进路径。这篇主要讲 IP 维度的实现账号维度的交叉判定放到后面章节说。2. 把计数器从 JVM 内存搬到 Redis 的完整理由最早我用的方案特别朴素一个ConcurrentHashMapString, AtomicIntegerkey 是 IPvalue 是失败次数配一个定时任务每分钟扫一遍把过期的清掉。在自己机器上测毫无问题上线也挺好直到做了水平扩容。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 处于锁定状态都做不到。维度拆分上至少要有三种 keykey 模式维度用途建议窗口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 RedisScriptList 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 dont 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 绕过只按账号会被撒网绕过。双维度的判定逻辑我一般写成这样一个优先级链判定顺序条件处置1IP 维度超阈值如 20/10min直接拒绝要求验证码2账号维度超阈值如 5/30min拒绝提示稍后再试3IP 在该账号上的失败次数超阈值如 3拒绝要求验证码4IP 尝试过的不同账号数超阈值如 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 次返回错误计数为 1TTL 正常阈值边界连续输错等于阈值次数第 N 次仍走密码校验第 N1 次被拦触发锁定超阈值后再请求直接返回限流不查库等待恢复等窗口过期后再请求恢复正常计数从 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% 大概率本来就有问题。最后再补充一个我觉得很值的小改动把限流返回的错误码和密码错误的错误码做成同一个前端不做任何区分。这样攻击者拿不到我是不是被锁了这个反馈只能靠盲猜而正常用户看到的是统一的账号或密码错误体验上也不会有违和感。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑