资讯详情

资讯详情

Redis+Lua实现IP锁定:高并发场景下的原子计数防刷方案

1. 先想清楚IP锁定的本质做后端几年IP锁定是我见过最容易“看着简单、上线出事”的功能之一。登录失败要锁IP、短信接口要防刷、活动页面要拦爬虫拆到底都是同一件事在一个时间窗口内统计某个IP的访问次数超过阈值就拒绝服务过一段时间再自动放行。之前在一个高并发项目里我们把整个流程拆成Java编排业务、Lua在Redis里做原子计数最终这套方案稳定扛过了好几次活动峰值。这篇就把完整思路和落地代码拆给大家适合正在做登录防护、接口限流和基础风控的同学参考。1.1 拆开来看业务到底在问什么IP锁定这个概念看着简单实际落地的时候会发现它是一组相近但又不完全一样的需求登录失败超过5次锁账号还是锁IP接口1分钟最多打100次超了是提示稍后再试还是直接拒绝某个IP明显在扫描是要封10分钟还是封24小时这些场景对“计数窗口”“阈值大小”“锁定后的表现”要求都不相同但底层要解决的问题只有三个——统计、判断、恢复。统计是指在一个时间窗口内累计次数判断是超过阈值后进入锁定状态恢复是等锁定时间到了自动解封或者管理员手工放行。如果纯在Java内存里做单机重启就清零多节点各自计数一个IP在A节点已经超了切到B节点又能继续尝试等于白做。所以这类状态必须放到一个所有节点都能访问的地方Redis是最自然的选择。1.2 为什么是Java配合Lua而不是纯Java有人会问直接Java调Redis的INCR和EXPIRE不就行了问题出在多条命令不是原子执行的。取当前计数、判断是否超阈值、设置过期时间这三个动作如果拆成三次Redis调用并发情况下就会有漏洞两个请求同时读到计数是9阈值是10判断都没超然后都放行实际变成了11次。这种漏洞在登录接口这种高并发场景里尤其致命攻击者只要稍微制造并发就能绕过限制。Lua脚本能把这个过程变成一个整体。Redis在执行Lua脚本时不会穿插执行其他客户端的命令整个脚本从第一条到最后一条是原子的中间不存在“读到旧值又被别人改掉”的情况。Java侧只需要把业务参数传进去拿到结果做编排不需要自己处理分布式锁、并发补偿这些事情。1.3 这套方案解决不了什么不是所有限流场景都适合用IP维度也不能指望一个计数脚本搞定所有风控。IP锁定本质是一个轻量级计数器它能防住无脑刷、暴力枚举这种明显特征但对分布式攻击、代理池轮换、慢速低频攻击基本无能为力。另外Redis是单线程执行脚本如果脚本里写了慢循环、大key遍历会把整个Redis实例拖垮。所以设计脚本时必须保持在微秒级所有数据通过参数传入不要在脚本里查大集合、做复杂的模糊匹配。这套方案最适合的形态是“短时间窗口里的高频动作保护”。常见一句话总结就是把一个IP在单位时间内的行为次数变成Redis里的一个会自然过期的计数器Java侧只做参数计算和结果放行。2. Lua脚本的核心让计数和过期变成一个原子动作2.1 Redis里跑Lua是怎么回事Redis从早期版本开始内置了一个轻量级Lua解释器客户端可以通过EVAL命令把一段Lua代码发给Redis执行。Redis服务器会在自己的进程里跑这段脚本脚本里可以通过redis.call去调用Redis的原生命令。整个脚本执行期间Redis不处理其他任何命令所以脚本里所有的读和写都是连续的、不被打断的。可以把它理解成一个数据库事务只不过是一个只能在单线程模型下执行的“短事务”。正因为Redis是单线程原子性变得很简单脚本里第1行的INCR结果到第5行判断时一定是最新的不存在脏读或者并发覆盖。这就是Java侧无论起多少个线程、部署多少个节点最终计数都准确的根基。2.2 核心脚本逐行拆解我最常用的是一个通用性很强的脚本做登录失败锁和接口防刷都能复用。先看完整代码local key KEYS[1] local limit tonumber(ARGV[1]) local ttl tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, ttl) end if current limit then redis.call(EXPIRE, key, ttl) return 0 end return 1这个脚本的输入是三个参数要计数的Redis key、阈值limit、锁定时长ttl。执行流程是先把key对应的值加1得到当前计数。如果计数等于1说明这是窗口内第一次请求此时给key设置一个过期时间保证整个计数器只存活ttl秒。如果计数没有超过阈值返回1代表放行如果超过了阈值返回0代表拒绝。为什么要判断current 1才设置过期因为如果每次INCR都重新EXPIRE会不断把过期时间往后推导致一个频繁访问的IP永远不会有窗口重置计数每分钟都在涨最后可能把正常用户也误伤了。只在首次创建计数时设置过期才能保证窗口是固定长度。2.3 边界行为和两个容易理解错的点注意脚本里超过阈值后我又调用了一次EXPIRE。这个行为在一些场景里是有意为之只要锁定期间还有人不断触发锁定期就自动滚动延长防止攻击者用低频试探的方式熬到锁定期结束。如果产品要求锁定窗口必须固定超过后不能再刷新过期时间那就把脚本里超阈值分支的那行EXPIRE去掉只返回0锁到期后自然解封。另一个容易理解错的点是被锁定的IP对应的key不需要主动删除。Redis的key过期机制会在TTL耗尽后自动清理解封完全不需要Java侧再做一次DEL。而且这种“用过期代表解封”的设计天然适合分布式环境每个节点看到的是同一个Redis状态不存在某个节点提前解封的问题。还有一点脚本返回值我用1和0来表示放行和拒绝实际用Long接收不要用Integer。Redis的Lua脚本返回值如果是数字客户端经常拿到的类型不固定用Long最稳。后续在Java代码里如果发现结果值不对先检查返回类型。3. Java工程侧的接入姿势3.1 环境准备和脚本加载Java侧我用的是Spring Data Redis底层连接工厂可以选Lettuce或者Jedis这两种我都试过对于这种短脚本没有本质差别。重点是把Lua脚本在项目启动时加载成可复用的对象而不是每次请求都重新解析脚本文件。Configuration public class IpLockConfig { Bean public DefaultRedisScriptLong ipLockScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(lua/ip_lock.lua)); script.setResultType(Long.class); return script; } }这里有个细节DefaultRedisScript的泛型结果类型一定要写对。如果脚本返回的是整数就填Long.class。填成Integer.class虽然不一定会报错但在某些客户端版本下会把结果解析成null排查起来非常迷惑。脚本文件放在resources/lua/ip_lock.lua下打包之后会进classpath部署不会丢。3.2 写一个通用的tryAcquire入口有了脚本对象Java侧的核心调用就非常薄了只需要组装key和参数。Service public class IpLockService { private final StringRedisTemplate redisTemplate; private final DefaultRedisScriptLong ipLockScript; public IpLockService(StringRedisTemplate redisTemplate, DefaultRedisScriptLong ipLockScript) { this.redisTemplate redisTemplate; this.ipLockScript ipLockScript; } public boolean tryAcquire(String scene, String ip, int limit, long ttlSeconds) { String key ip:lock: scene : ip; Long result redisTemplate.execute( ipLockScript, Collections.singletonList(key), String.valueOf(limit), String.valueOf(ttlSeconds) ); return result ! null result 1L; } }key的命名我建议统一用业务前缀:场景:维度这种三段式比如ip:lock:login:192.168.1.10。加上scene字段是为了不同业务之间互不影响登录失败锁定不会和接口防刷互相干扰。维度这里写的是IP但后续如果扩展到用户ID、设备ID只需要把这个方法泛化成dimension即可脚本完全不用改。为什么用StringRedisTemplate而不是RedisTemplate因为RedisTemplate默认带JDK序列化器会把String序列化成一堆二进制前缀手工写出来的key格式很乱在Redis客户端里看到的是带转义符的乱码排障的时候特别头疼。StringRedisTemplate直接存明文字符串一眼能看清。3.3 在拦截器里串联业务工具方法有了接下来就是怎么接入业务。最省事的方式是做一层拦截器按URL前缀匹配要保护的接口。以Spring MVC为例注册一个HandlerInterceptor在preHandle里做校验public class IpLockInterceptor implements HandlerInterceptor { private final IpLockService ipLockService; public IpLockInterceptor(IpLockService ipLockService) { this.ipLockService ipLockService; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String ip resolveClientIp(request); if (!ipLockService.tryAcquire(api, ip, 100, 60)) { response.setStatus(429); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:429,\message\:\request too frequent\}); return false; } return true; } private String resolveClientIp(HttpServletRequest request) { String ip request.getHeader(X-Forwarded-For); if (ip ! null !ip.isBlank()) { return ip.split(,)[0].trim(); } return request.getRemoteAddr(); } }这里需要特别提醒如果服务部署在反向代理后remoteAddr拿到的是代理的IP所有用户都会被锁成同一个。必须通过约定的转发头字段取真实IP而且一定不要盲目信任客户端传的X-Forwarded-For否则攻击者自己伪造一个IP头就能绕过锁定。生产环境我一般会在网关层统一把真实IP写入一个内部专用头业务代码只认这个头。拦截器层面只做统一的接口级防刷。像登录失败这种需要“先验证明失败再计数”的逻辑拦截器做不了必须在登录Service里显式调用先查用户账号密码错误后再执行tryAcquire计数。下面在具体场景里细说。4. 三种典型场景的落地参数设计4.1 登录失败锁定登录失败锁定的经典需求是同一IP一小时之内失败5次锁定15分钟。但这里有个设计细节值得认真想一想。如果只用一个key从第一次失败开始计数5次之后一直拒绝那么即使在某个连续的15分钟窗口里锁定了只要窗口还没过去用户就一直不能重新登录。这其实是合理的因为锁定本身就是为了阻断暴力尝试。参数上我建议把“失败计数”和“锁定状态”拆成两个key。失败计数key负责记录窗口内的失败次数设计成1小时过期锁定状态key在失败次数达到阈值那一刻写入设计成15分钟过期。这样有两个好处第一计数窗口和锁定期不必相等例如可以“10分钟内失败5次锁定2小时”第二不会因为频繁尝试不停刷新计数窗口导致用户被锁好几个小时。如果只想用前面的通用脚本那其实就是把ttl当成失败窗口牺牲掉“短窗口累计、长时段锁定”这个能力。区别在于通用脚本是从0开始计数每一个失败都算一次拆分方案里只有失败次数达到阈值才创建锁定key。实际做登录保持的时候我强烈推荐拆成两个key。登录Service里的大致流程是校验账号密码如果账号密码正确直接放行如果失败先INCR失败计数当失败计数恰好等于阈值时额外SET一个锁定key并设置过期时间。后续请求进来先检查锁定key是否存在存在就直接拒绝登录不再查数据库。4.2 接口级防刷接口防刷的典型需求是普通查询接口1分钟最多100次写接口和下单接口更加严格1分钟最多30次。这种用通用脚本就非常顺手把scene设计成接口名或URL路径即可。key的格式是ip:lock:api:/order/submit:192.168.1.10限制参数从配置中心读取一时半会调整阈值不用发版。读接口和写接口的阈值必须分开而且写接口要严格很多。原因很直观读接口打多了最多占带宽写接口打多了可能产生垃圾订单、重复请求造成资金损失。另外活动页和普通页也要分开活动期间流量短时间暴涨如果共用一套防刷参数容易把真用户拦住。接入方式很简单在WebMvcConfigurer里给需要限流的URL路径注册拦截器即可。要注意拦截器的匹配规则/api/**这种匹配会覆盖所有子路径如果某个接口想放开优先用更具体的规则排除不要在一个拦截器里去判断所有URL。4.3 恶意IP周期封禁第三种场景是运维和风控最喜欢的某个IP在短时间内频繁触发404、频繁请求不存在的路径、或者大量尝试无权限接口直接封禁一个较长的时间周期。这种不用按用户维度去猜行为特征已经很明显了直接用IP维度封禁。参数设计上我给过两档短时计数窗口设10分钟30次一旦触发写入一个24小时的封禁key。封禁到期后key自动消失IP自动解封。这种场景里强制要求锁定窗口固定不要滚动续期。因为恶意IP往往是用脚本高频打如果每次触发都刷新TTL可能永远解封不了还会一直占用Redis内存。我自己写过一个更严格的变体封禁key用ZSET来存value是过期时间戳Java侧定时任务扫描解封这样可以在封禁到期前做人工审核。但多数情况下用EXPIRE自动过期就够没必要为了“看起来可控”引入额外的定时任务复杂度。5. 上线之后必看的坑5.1 慢脚本会把整个Redis拖住Redis的Lua脚本执行期间会阻塞整个实例所以脚本里绝对不能有大key遍历、KEYS命令、SMEMBERS这类操作。比如要在脚本里判断某个IP是否在封禁名单里正确的做法是在Java侧用SISMEMBER查完把结果作为参数传进脚本而不是在脚本里执行redis.call(SISMEMBER, block_list, ip)。虽然SISMEMBER不算慢但这个原则很重要脚本只做最关键的计数和判断其余全在外部处理好。我之前亲眼见过一个线上事故运营在Lua脚本里做了模糊匹配把几百万个key遍历了一遍整个Redis阻塞了几十秒所有读写请求全部超时。从那以后我对进脚本的代码立了一条规矩不许有循环依赖集合、不许有KEYS命令、单脚本执行时间必须小于1毫秒否则必须拆分。5.2 集群模式下的CROSSSLOT问题如果Redis是Cluster集群部署Lua脚本涉及到的所有key必须落在同一个哈希槽上。Redis计算哈希槽的逻辑是找key里花括号包含的部分所以多key场景可以在key里加一个相同的哈希标签比如ip:lock:{login}:{ip}。但这个方案有个副作用所有登录相关的key会集中在一个节点上数据分布不均匀。我的项目里通用脚本始终只操作一个key所以不存在这个问题。如果你要扩展脚本让它同时读写两个key就一定要考虑哈希槽。与其在集群模式下处理这种复杂问题不如保持脚本只操作单key把多key逻辑拆到Java侧或者干脆用非集群模式对于几十个节点以内的规模性能也够。5.3 EVALSHA的NOSCRIPT问题为了节省网络开销客户端一般先用EVALSHA传脚本的SHA1摘要让Redis执行已经缓存过的脚本。但如果Redis发生重启或者主从切换脚本缓存可能没了这时再发EVALSHA会返回NOSCRIPT错误。Spring Data Redis的默认脚本执行器在某些版本下遇到这个错误不一定能自动重试导致请求直接失败。我的处理方式很朴素在调用处捕获这个异常然后降级成EVAL重新发完整脚本。或者更简单一点把脚本对象用Bean管理每次执行时让底层客户端自动处理。如果你用的是Jedis可以在捕获到异常后重试一次重试时客户端会带上完整脚本Redis会重新加载。排查这个问题的快速方法Redis启动后第一次调用如果报NOSCRIPT多半是脚本没有被初始化检查脚本文件路径是否在classpath里以及DefaultRedisScript是否被Spring正确实例化。5.4 别把TTL续期写成永不失效我在2.3节提到过脚本里超阈值后又EXPIRE了一次目的是滚动锁定。但如果你在写自己的脚本时把EXPIRE放到了每次INCR之后而不只是首次计数和超阈值时那么一个高频访问的IP的key会被不断续期永远不会过期。这在接口防刷场景里会误伤正常用户因为只要他一直被拒绝、一直刷新TTL他就永远不可能解封。正确做法是明确区分两种策略。固定窗口只在当前计数为1时设置TTL滚动锁定首次和超阈值时都设置TTL。具体用哪个取决于业务诉求不要在两种策略之间摇摆。我的通用脚本默认是滚动锁定登录场景则用拆分的两个key实现固定窗口这样既灵活又清晰。另外还要处理好limit等于0或负数的情况。如果配置中心推送错误limit传了0第一个请求INCR后current1立刻超过0所有用户全部被锁。我后来在Java入口做了参数校验limit和ttl都必须大于0且小于某个合理上限配置异常直接抛异常而不是静默执行。5.5 排查速查表现象可能原因排查方法锁定一直没有生效Java侧key前缀和Redis里看到的不一致或者取IP取到了代理IP打开Redis的MONITOR命令观察实际写入的key所有IP都被锁定limit参数被错误配置成了0或负数或者脚本加载了旧版本检查配置中心确认脚本内容和发布版本锁定时间不准TTL设置分支不对每次INCR都刷新了过期时间复查脚本里的EXPIRE调用次数和位置集群环境报CROSSSLOT脚本操作多个key且没有哈希标签给key加相同花括号内容或拆成单key脚本偶尔出现NOSCRIPT异常Redis重启丢脚本缓存捕获异常后重试EVAL或检查客户端自动回退逻辑6. 往生产环境走的一些扩展6.1 短窗口计数加长窗口封禁单层限流在真实场景里容易被绕过。比如限制1分钟100次攻击者控制频率变成1分钟90次持续打一个小时虽然单看每个窗口都没超但累计量很大。解决办法是加一个长窗口封禁短窗口负责实时拦截长窗口负责识别持续性风险。比如某IP在5分钟内累计次数超过300就触发2小时封禁。这种多级设计在风控里更常见Java侧只需要多调一次接口底层脚本不变。6.2 白名单和维度组合IP一直是风控里最基础、也最容易误判的维度。很多宽带出口IP是多用户共用的一个用户触发锁定整个楼栋的出口IP全被锁体验很糟糕。上线前一定要梳理出可信IP段、办公网段、合作方回调IP段放入白名单。白名单判断放在Java侧比放进Lua脚本更灵活因为白名单本身是变化的放Redis里用SET维护配合定时刷新。维度也不一定永远是IP。同一时间同一个IP可能代表成千上万个用户组合用户ID更精准。可以把维度从IP换成userId或者组合成userIdip、deviceIdip脚本仍然复用只是传入的维度参数变了。这里要注意key的长度不要过长太长会占内存如果维度是IP拼接UA这种建议先做哈希再拼进去。6.3 管理后台与人工干预自动锁定难免误伤强烈建议加一层管理能力能在后台查看当前被锁定的IP、剩余锁定时长、在当前窗口内的请求次数能手动解封一个IP能针对特定IP临时提高阈值或者加入白名单。我见过不少团队功能上线后没有解封入口一个误判就要等锁定期结束用户投诉一大堆。管理后台本身不复杂封装一层查询和更新的Service就可以但要在第一版就带上。监控也别忘了。至少要监控三个指标被锁定请求的比例、IP锁定key数量、脚本平均执行耗时。如果锁定key数量突然暴涨可能是攻击也可能是参数配错了如果脚本执行耗时上涨要排查是不是有人往脚本里加了耗时操作。报警阈值按业务平时水平加20%左右就可以不用太敏感。6.4 我的一些体会做IP锁定这类基础防护最怕的不是技术复杂而是想太多。我见过有人一上来就上个分布式限流框架甚至引入独立的网关组件最后发现业务量根本用不上。先用一个Lua脚本加一个Service就能解决的问题拖到上线后再优化也来得及。核心是先把“计数准确、过期可靠、扩展参数可配置”这三件事做扎实。还有一点锁定期限和阈值一定来自实际日志分析不要拍脑袋。登录失败锁定可以先看正常用户一天的失败次数分布把阈值设到正常值的3到5倍才能在拦住暴力尝试的同时不误伤手滑输错密码的用户。接口防刷也是先上线统计一段时间的请求分布再确定阈值比一上来就定一个保守数字靠谱得多。最后分享一个我养成的习惯每次调整IP锁定参数都把改动记录成一条配置变更说明包括修改人、时间、背景和影响面。看起来多了一步但当线上出现“不知道哪个版本把阈值调成了0”这种事故时你会发现这条记录比什么架构设计都值钱。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →