【Redis实践】多次登录的差异化处理策略

发布于:2025-08-01 ⋅ 阅读:(17) ⋅ 点赞:(0)

一、背景

       在项目开发过程中出现了一个新的需求。用户在连续输错3次密码后跳出验证码,继续输错2次后按照时间5min、10min、15min梯度等待重新登录。

需求关键点解析
  1. 失败计数器:需要跟踪用户连续输错密码的次数。
    • 输错3次时,显示验证码。
    • 输错5次时,触发梯度等待(等待时间基于梯度计数器)。
  2. 梯度等待机制
    • 梯度计数器记录用户触发梯度等待的次数(初始为0)。
    • 第一次触发(输错5次)时,等待5分钟。
    • 第二次触发(输错6次)时,等待10分钟。
    • 第三次触发(输错7次)时,等待15分钟。
  3. 重置逻辑:当用户成功登录时,所有计数器(失败计数器和梯度计数器)应重置为0。
  4. 验证码处理:输错3次后,登录接口需验证验证码(验证码生成和验证逻辑需单独实现)。
  5. 等待期处理:在等待期内,用户无法尝试登录,系统需提示剩余时间。

二、实现考虑

考虑到四种实现方法:

  1. 使用内存处理,但需额外关注并发问题。
  2. 加入重量级锁(可能指Java中的synchronized或ReentrantLock),但存在速度问题。
  3. 使用Redis分布式锁(悲观锁)。
  4. 使用Redis乐观锁(WATCH, MULTI, EXEC)。

关键考虑因素:

  • 后期延展性:单体应用可能在未来需要扩展为分布式系统,因此使用Redis这样的外部存储可以更好地支持分布式环境。
  • 数据一致性:在并发环境下,确保多个请求修改共享数据时不会出现不一致。
  • 并发问题:高并发场景下,需要高效处理。

为什么选择Redis乐观锁?

乐观锁假设冲突发生概率较低,因此在操作数据时不会加锁,而是在提交时检查数据是否被修改。这通常比悲观锁性能更好,尤其是在读多写少的场景。

在Redis中,乐观锁通过WATCHMULTIEXEC实现:

  • WATCH key:监视一个或多个键,如果在事务执行前这些键被修改,则事务会失败。
  • MULTI:开始一个事务。
  • 执行多个命令。
  • EXEC:执行事务。如果被监视的键在WATCH后到EXEC前被修改,则事务不会执行。

与其他方法对比

  • 内存处理:在单体应用中,使用Java锁(如synchronized)可以处理并发,但无法扩展到分布式环境。
  • 重量级锁:如Java中的锁,在单机内有效,但在分布式环境下无效,且可能引起性能瓶颈。
  • Redis分布式锁(悲观锁):使用如Redisson的分布式锁,可以跨多个实例,但获取和释放锁有开销,且可能发生死锁。
  • Redis乐观锁:无锁设计,性能较高,但需要处理事务失败的情况。
方案 适用场景 缺点 延展性
内存处理 单机低并发 无法扩展分布式
重量级锁(如synchronized 单机简单业务 性能瓶颈明显
Redis悲观锁 强一致性场景 网络开销大,死锁风险 ⭐⭐
Redis乐观锁 高并发读写 需处理重试逻辑 ⭐⭐⭐⭐

选择理由:

  1. 延展性:Redis作为独立中间件,天然支持分布式扩展
  2. 数据一致性:通过WATCH实现CAS(Compare-and-Swap)原子操作
  3. 并发性能:无锁竞争,吞吐量高于悲观锁(理论值提升30%-50%)

三、代码示例

存储结构

字段名 类型 说明
attempt_count 整数 验证尝试次数,达到阈值触发锁定
is_locked 布尔值 账户锁定状态 (True/False)
captcha_required 布尔值 下次操作是否需要验证码
lock_timestamp 整数 锁定开始时间 (Unix时间戳

冲突处理

  • exec()返回null时表示事务失败(版本冲突)

代码实例

    public final static String LOGIN_USER_KEY = "auth:loginuser:%s";
    public final static int CAPTCHA_THRESHOLD = 3;         // 触发验证码的尝试次数
    public final static int LOCK_THRESHOLD = 5;            // 触发锁定的额外尝试次数
    public final static int[] LOCK_DURATIONS = {300, 600, 900}; // 梯度锁定时间(秒)
    public final static int MAX_LOCK_COUNT = 8;             // 最大锁定次数(超过则永久锁定)
    public final static int ATTEMP_TEXPIRY = 5;          // 尝试次数过期时间(秒)
    public final static int MAX_ATAMPT_TIME = 24 * 60 * 60;          // 最大锁定时间(秒)
 
    String userKey = getUserKey(username);

        // 使用SessionCallback保证所有操作在同一连接中执行
        return bladeRedis.getRedisTemplate().execute(new SessionCallback<Object>() {
                 @Override
                 public Object execute(RedisOperations redisOperations) throws DataAccessException {
                    // 使用事务保证原子性
                     while (true) {
                         try {
                             // 1. 监控用户键,防止并发修改
                             // 注意:这里的 session 是 RedisOperations<String, String> 类型
                             redisOperations.watch(userKey);

                             Kv resultKv = Kv.create();

                             // 2. 获取当前状态
                             Map<Object, Object> userData = redisOperations.opsForHash().entries(userKey);
                             int attempts = userData.containsKey("attempts") ? Integer.parseInt(userData.get("attempts").toString()) : 0;
                             boolean isLocked = false;
                             long remainingLockTime = 0;


                             // 检查是否已锁定
                             if (userData.containsKey("lockedUntil")) {
                                 long lockedUntil = Long.parseLong(userData.get("lockedUntil").toString());
                                 long now = Instant.now().getEpochSecond();
                                 if (lockedUntil > now) {
                                     isLocked = true;
                                     remainingLockTime = lockedUntil - now;
                                 }
                             }

                             // 如果已锁定,直接返回
                             if (isLocked) {
                                 redisOperations.unwatch();
                                 long timeMessage = 1;
                                 if (remainingLockTime / 60 != 0) {
                                     timeMessage = remainingLockTime / 60;
                                 }

                                 return resultKv.set("success","false").set("login_count",attempts).set("message","账号已锁定,请" + timeMessage + "分钟后再试");
                             }

                             // 4. 登录失败处理
                             int newAttempts = attempts + 1;

                             // 检查是否需要验证码(3次错误后)
                             if (newAttempts > CAPTCHA_THRESHOLD) {
                                 if (!validateCaptcha(captchaId, captchaInput)) {
                                     redisOperations.unwatch();
                                     return resultKv.set("success","false").set("login_count",attempts).set("message","验证码错误");
                                 }
                             }

                             // 3. 登录成功处理
                             if (success) {
                                 // 清除登录状态
                                 redisOperations.delete(userKey);
                                 redisOperations.unwatch();
                                 return resultKv.set("success","true").set("login_count",attempts).set("message","登录成功");
                             }



                             // 5. 开启事务
                             redisOperations.multi();

                             // 6. 更新尝试次数
                             redisOperations.opsForHash().put(userKey, "attempts", String.valueOf(newAttempts));

                             // 7. 判断是否需要锁定(5次错误后)
                             if (newAttempts >= SecurityPolicyUtils.LOCK_THRESHOLD) {
                                 // 计算锁定次数(用于梯度)
                                 int lockCount = userData.containsKey("lockCount") ? Integer.parseInt(userData.get("lockCount").toString()) : 0;
                                 int lockLevel = Math.min(lockCount, LOCK_DURATIONS.length - 1);
                                 int lockDuration = LOCK_DURATIONS[lockLevel];
                                 long lockedUntil = Instant.now().getEpochSecond() + lockDuration;

                                 // 更新锁定信息
                                 redisOperations.opsForHash().put(userKey, "lockedUntil", String.valueOf(lockedUntil));
                                 redisOperations.opsForHash().put(userKey, "lockCount", String.valueOf(lockCount + 1));
                                 // 设置键过期时间(与锁定时间一致)
                                 redisOperations.expire(userKey, MAX_ATAMPT_TIME, TimeUnit.SECONDS);

                                 // 执行事务
                                 List<Object> execResult = redisOperations.exec();
                                 if (execResult == null) {
                                     // 事务被回滚(并发修改),重试
                                     continue;
                                 }
                                 return resultKv.set("success","false").set("login_count",attempts).set("message","登录失败次数过多,已锁定" + lockDuration / 60 + "分钟");
                             } else {
                                 // 未达锁定阈值,设置尝试记录过期时间
                                 redisOperations.expire(userKey, MAX_ATAMPT_TIME, TimeUnit.SECONDS);
                                 // 执行事务
                                 List<Object>  execResult = redisOperations.exec();
                                 if (execResult == null) {
                                     // 事务被回滚(并发修改),重试
                                     continue;
                                 }

                                 // 返回剩余尝试次数
                                 int remaining = SecurityPolicyUtils.LOCK_THRESHOLD - newAttempts;
                                 String msg = "密码错误,剩余" + remaining + "次尝试机会";
                                 if (newAttempts == CAPTCHA_THRESHOLD) {
                                     msg += ",下次登录需要验证码";
                                 }
                                 redisOperations.unwatch();
                                 return resultKv.set("success","false").set("login_count",attempts).set("message",msg);
                             }

                         } catch (Exception e) {
                             // 发生并发修改,重试
                             continue;
                         } finally {
                             redisOperations.unwatch();
                         }
                     }
                 }
        });


网站公告

今日签到

点亮在社区的每一天
去签到