
做后端这些年登录接口的安全问题几乎天天有人在群里问。其中最高频的需求之一就是同一个用户1分钟内登录失败超过3次要锁定。听起来是一句话的活儿真正落地的时候坑却不少——要不要把IP也算上锁多久过期时间设置多少合理用户被误锁了怎么解除分布式多台机器怎么保证计数不歪今天不聊虚的直接把这套逻辑从设计到代码再到排查完整拆开讲清楚。适合正在做账号系统后端的同学尤其是那些系统上线后天天被爆破请求骚扰的项目可以照着这篇文章自己撸一套防爆破机制。1. 先搞清楚需求本质这句“1分钟3次”在防什么1.1 暴力破解的攻击路径登录接口本质上是一个“验证密码对不对”的入口。如果接口没有任何限制攻击者就可以拿用户名列表逐个用户、逐个常用密码去试速度能达到每秒几十次甚至上百次几分钟就能把一套常见弱密码库跑完。像“123456”“password”“qwerty123”这类弱密码碰撞成功率相当可观。这就是典型的暴力破解也是“1分钟3次锁定”这类需求要解决的核心问题。所以这个需求的核心目的是让暴力破解的效率急剧下降。正常用户输错一两次密码是正常的但如果一分钟内尝试4次、5次、10次基本可以判定是自动化脚本在跑。把这个通道切断攻击者每一次尝试的成本就从“一次HTTP请求”变成“等待解锁时间”破解效率立刻降到不划算的水平。这个思路在任何带账号体系的产品里都适用不管是后台管理系统、电商前台还是App开放平台。1.2 需求拆解四个关键决策点这句话看着明白但每个词背后都藏着选择。我通常会把需求拆成四个问题这四个问题不敲定代码写起来容易返工。锁的对象是谁账号维度最简单IP维度能防同一个IP扫多个账号设备指纹维度最精准但实现成本高。一般建议先做账号维度再叠加IP维度。锁多长时间有的系统锁5分钟有的锁15分钟有的干脆锁到第二天取决于业务风险等级。金融类系统会要求更严格普通社区站点临时锁15分钟相对合理。计算什么行为是只算密码错误还是验证码错误也算我的建议是两种都算因为自动化脚本很少去正确识别验证码一旦被验证码挡住它们的请求同样会打到登录接口。锁定后的响应是什么是直接拒绝登录还是要求输入验证码或者发送一条短信验证码进行解封不同方案对用户体验影响很大。这四个问题定了技术实现才有明确依据。很多人一上来就写代码结果产品临时说“锁10分钟吧”虽然改起来不难但Key的设计、过期策略最好从一开始就要能灵活调整这是所有方案里最容易忽略的一点。2. 三种常见实现方案内存、数据库、Redis怎么选2.1 单机内存方案简单但适用范围有限在早期单体应用里直接在进程里维护一个Map或本地缓存记录登录失败次数是很多人第一反应的做法。比如用并发安全的HashMapkey是用户名value是失败次数和第一次失败时间写一个定时清理或者随请求清理过期记录。这个方案的优点是真的简单几行代码就能跑起来。但缺点同样明显应用一重启内存就清零多台服务器负载均衡时A机器记录了3次失败用户下一次请求被转发到B机器B机器不知道前面已经失败了3次锁定直接失效。所以这个方案只适合个人项目、单机部署或安全要求很低的内部系统。但凡系统要上集群或者有对外价值这套代码就得推翻重来。2.2 数据库方案适合低并发的简单系统把失败次数落库建一张登录失败记录表字段包含用户名、IP、失败时间、是否已锁定等。每次登录失败插一条记录查询时统计最近60秒内记录数量超过阈值就写入一条锁定记录。这个方案的好处是数据可以持久化多实例访问同一份数据库天然一致。坏处也很明显登录接口是极高频率的接口如果每次失败都插库而且查锁定时需要聚合统计数据库压力会非常大。尤其是遭遇攻击时攻击本身就变成了对数据库的写入放大攻击数据库很容易被打慢。数据库方案可以接受但我更推荐做成“失败记录落库 Redis计数前置”的混合方案数据库只用来审计真正的拦截判断走缓存。2.3 Redis方案分布式环境下的主流选择Redis做这类计数几乎是标配原因有这么几点INCR、EXPIRE这些命令天然适合做计数器。所有服务实例共享同一个Redis不存在多机数据不一致的问题。每次登录就一两次Redis交互性能很好。数据可以设置过期时间不用自己写清理任务。所以除非你们的系统连Redis都没上那这篇文章的方案可以先存着备用否则我强烈建议直接上Redis。接下来的完整落地也以此为主线。3. Redis方案的核心设计与完整落地3.1 Key设计决定你后续能不能快速排查问题设计Key要注意可读性、可分区性和可过期性。我常用的Key格式是login:fail:user:{username}记录某个用户连续失败次数login:fail:ip:{ip}记录某个IP连续失败次数login:lock:user:{username}记录用户锁定状态login:lock:ip:{ip}记录IP锁定状态为什么要把失败次数和锁定状态分开因为次数只是一个中间态锁定才是最终判决。分开设置可以让计数的过期时间和锁定的过期时间互相独立。比如计数窗口设置60秒锁定时间设置900秒15分钟互不干扰。用户名和IP如果包含特殊字符要注意统一编码避免生僻Key。更重要的是在代码里固定规则用户名统一转小写再拼接IP地址统一去掉端口这些细节很多人忽略等到线上数据不统一想排查就麻烦了。另外每个Key都要设置合理的过期时间不要留永久Key否则Redis里会堆积大量无用的登录记录。3.2 原子计数别用“先读再写”那套最容易写错的地方是有人在代码里先GET次数然后再INCR。这种做法在并发下百分百出问题两个请求同时GET到2又同时INCR最后计数只变成3该锁的没锁上。正确做法是用Redis的INCR命令它是原子自增。但INCR和EXPIRE两个命令分开执行也存在一个窗口问题第一次INCR成功之后如果进程挂了或者Redis响应超时EXPIRE没执行成功这个Key就变成一个永不过期的计数器用户失败次数会一直累计之后哪怕过了一个小时用户一登录就直接被判为“该锁定了”。解决这个问题最干净的办法是把计数、设置过期时间、判断阈值、设置锁定全部塞进一个Lua脚本里。Redis对Lua脚本是单线程执行的天然具备原子性。脚本大概长这样-- KEYS[1]: 失败计数Key -- KEYS[2]: 锁定Key -- ARGV[1]: 时间窗口单位秒 -- ARGV[2]: 锁定时间单位秒 -- ARGV[3]: 锁定阈值失败次数达到该值即锁定 local count redis.call(INCR, KEYS[1]) if count 1 then redis.call(EXPIRE, KEYS[1], ARGV[1]) end if count tonumber(ARGV[3]) then redis.call(SET, KEYS[2], 1, EX, ARGV[2]) redis.call(DEL, KEYS[1]) return 1 end return 0这里有个语义细节要说明需求里写“超过3次则锁定”有的人理解为第4次失败才算“超过”有的人则直接把“3次”当成锁定阈值。一般来说产品同学口头说的“3次”落库里往往就是“失败次数达到3就锁”。为了避免歧义在开工前和产品对齐一下口径。上面脚本里ARGV[3]传3就是失败3次即锁如果要严格“超过3次”把阈值传4就行。有人会问锁定之后为什么要DEL删除计数Key因为锁定状态已经用单独的Lock Key表达了计数留着只会让解锁逻辑混乱。把计数清零锁定到期后用户重新尝试计数从0开始语义更干净。3.3 登录流程主链路改造在真正校验密码之前先查询锁定状态。不管是用户维度还是IP维度只要锁了直接拒绝放行返回业务错误码。示例逻辑大概是这样public LoginResult login(String username, String password, String ip) { // 第一步查锁定 if (isUserLocked(username)) { return Result.error(账号已锁定请15分钟后再试); } if (isIpLocked(ip)) { return Result.error(当前网络环境已被临时限制); } // 第二步校验账号密码 User user userService.queryByUsername(username); if (user null || !passwordEncoder.matches(password, user.getPassword())) { // 失败计数 recordFailure(username, ip); return Result.error(账号或密码错误); } // 第三步登录成功清除失败记录 clearFailureRecord(username, ip); return Result.success(user); }查锁这一步用Redis的EXISTS就行开销很低。失败计数调用前面那段Lua脚本入参就是两个Key和三个参数。在Java里配合Spring Data Redis执行脚本大概这样private Long executeLua(ListString keys, String window, String lockTime, String threshold) { DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(LUA_SCRIPT); script.setResultType(Long.class); return redisTemplate.execute(script, keys, window, lockTime, threshold); }这里有个非常常见的坑查询锁定状态是放在密码校验前但很多人忘了在密码错误时也先判断一下当前是否已经处于锁定状态。也就是说如果用户已经锁了后续请求还在继续尝试应该直接返回锁定提示而不是继续走失败计数流程。否则计数Key会被反复重置过期时间语义就乱了。3.4 时间窗口口径固定窗口和滑动窗口的区别“1分钟内3次”实现上其实有两个口径。固定窗口每过1分钟计数清零。用上面的Lua脚本INCR第一次时设置EXPIRE 60秒只要60秒一到Key就自动删除。实现最简单但存在临界问题用户在59.5秒失败了3次又在60.5秒失败了3次虽然横跨两个窗口但实际间隔只有2秒攻击者可以对窗口边界做偏移绕开连续密集尝试的限制。滑动窗口真正统计“任意连续60秒内”的失败次数。Redis没有自带滑动窗口结构常见有两种实现方式用ZSET记录每次失败的时间戳查询时删除60秒前的记录再统计集合内数量把60秒拆成多个固定小窗口比如6个10秒计数器查询时聚合求和。对于登录防爆破这个场景说实话固定窗口往往够用攻击脚本并不会刻意卡在窗口边界上。但如果你要给客户交付一个“严谨的风控方案”建议直接上滑动窗口。用ZSET实现也不复杂ZADD login:fail:window:user:{username} 当前毫秒时间戳 唯一串 ZREMRANGEBYSCORE login:fail:window:user:{username} 0 当前毫秒时间戳-60000 ZCARD login:fail:window:user:{username}每次失败插入一个带时间戳的成员先清理60秒以前的旧记录再数一数剩余数量超过阈值就锁。ZSET成员需要一个唯一串可以用时间戳加随机数避免同一毫秒内多个请求覆盖同一成员。这个方案的时间复杂度取决于集合长度在登录失败量级下完全没有问题。3.5 参数配置给一组可直接上线的默认值参数设置没法一步到位但得有合理起点。我做过的项目里用过一组通用配置给大家参考参数建议值说明时间窗口60秒对应需求里的“1分钟”锁定阈值3次连续失败3次触发锁定锁定时长900秒15分钟防空转又不过度惩罚IP维度阈值20次/小时防止同一出口IP下多个账号被同时误伤白名单用户不限制管理员、客服账号等可配置豁免阈值这块有个亲身感受如果系统经常出现用户误锁不一定是因为阈值定低了而是因为没有做失败次数的前端提示升级。比如第一次到第二次失败时提示“还剩最后一次机会”第3次失败后前端立刻引导走“找回密码/验证码解锁”。用户体验是从这些细节里省出来的不是靠把阈值放宽到10次来省。4. 上线后最常见的坑问题与排查实录4.1 并发下计数不准结果是该锁的没锁真实项目里遇到过这个现象压测时模拟同一个用户同时发5个登录请求理论上第3个失败后就应该锁定但实际查Redis计数只到2或者4完全不按剧本走。排查后发现原因是代码里用的不是原子操作。业务方在代码里先查询当前失败次数再决定是否自增自增用单独一次INCR查询用GET中间没有任何锁保护。多个请求同时进入时GET返回的都是同一个旧值看起来“当前只有2次”于是每个请求都放行并自增最终计数和判断逻辑全部乱套。修复方式就是前面提到的Lua脚本一步到位以后不要再有“先GET再判断再INCR”的写法。4.2 锁IP还是锁账号误伤怎么处理IP维度是一把双刃剑。办公室、学校、运营商出口共享IP的环境下一个人输错3次密码同网段的其他人全部跟着锁这是最常见的误伤案例。我处理过的一个现场办公网出口IP被某个爆破脚本扫了几下当天全公司员工都登录不了系统排查半天才定位到IP维度锁得太狠。我的建议是账号锁定要严格IP锁定可以放宽。比如账号维度3次/60秒锁15分钟IP维度做成“同一个IP对多个不同账号失败合计超过一定次数才触发限制”这样既挡住了横扫全站用户名列表的脚本又不至于因为一个手滑的用户连累所有人。还有一种方案是IP维度只封常见云主机机房IP段普通住宅/办公IP不做强限制这对国内企业用户的体验友好很多。4.3 锁定后解不了锁解锁流程缺失“只锁不解”是新手团队里出现概率极高的问题。需求提的是“锁定用户”没人提“怎么解锁”于是用户被锁之后只能干等15分钟。可有的人等不了管理员账号自己锁了电话打到运维那边运维也不知道怎么解。比较好的做法是在后台管理端提供“解除锁定”的入口有权限的管理员可以查用户锁定状态并一键解锁。同时给被锁用户一个自助解锁通道比如发送短信验证码验证通过即可立即解除锁定。这个通道本质上是做了一次更严格的身份核验但比单纯等待好得多。4.4 常见问题速查表现象可能原因排查思路解决建议用户频繁被锁阈值过低或没做提示查看Redis里计数Key的剩余TTL调整阈值增加前端剩余次数提示锁定了还能继续登录查询锁时Key没查或锁的Key类型不对看登录日志是否走到了校验密码那一步登录校验前统一查锁状态锁了1分钟后自动解锁锁定Key的EXPIRE参数配了60秒检查锁定Key的TTL锁定Key与计数Key用不同过期时间多台机器行为不一致用了内存计数方案看架构图确认是否共享Redis切换到Redis方案Redis被刷爆攻击打满计数Key写入看监控大Key和写入量给Redis加防护限制失败计数请求本身5. 从锁定到风控再往前走半步5.1 分级响应机制“1分钟3次锁定”本质是一个二值策略要么不锁要么锁。更完善的做法是分级响应第一次到第二次失败只返回提示第三次失败要求输入验证码第四次失败锁定15分钟如果同一个账号一周内被锁定多次升级为需要短信验证才能登录。分级响应能让正常用户觉得系统并不粗暴也让攻击者的尝试空间被进一步压缩。实现上就是在锁定之前多几个判断节点成本不高但对整体体验的影响是量级的。尤其是做C端产品不能因为一个防爆破需求把用户活活气走。5.2 监控与告警锁了用户之后不能只闷头拦截。我建议至少记录三类日志锁定事件、解锁事件、IP维度命中事件。每天跑一个简单统计看看哪些用户/IP触发了大量失败这往往是撞库攻击的前兆。告警层面当某个IP的失败次数在单位时间内猛增或者某个用户名被尝试的失败次数异常高时要能通知到人。你永远不会后悔提前发现攻击者。5.3 别把安全都压在登录接口上最后说一句经验之谈登录接口限流是必须的但真正的安全不能只靠登录锁定。密码泄露、撞库成功之后攻击者直接就进去了你再怎么锁登录接口也拦不住。所以条件允许的话上多因素认证敏感操作再走一次二次校验。这个需求做完只能算把第一道门装好后面还有很多门要装。我自己做这类需求最大的体会是技术实现真的不难难的是想清楚要保护谁、惩罚谁、怎么不误伤正常用户。先从一个简单的Redis计数器开始跑跑通后再叠IP维度、滑动窗口、分级响应这套东西会越来越稳。等你把“1分钟3次锁定”这个需求吃透了下一回再遇到“同一IP每小时超50次登录”“设备指纹关联异常”这类风控需求思路完全是一样的直接套用这套框架就行。