一、背景
在项目开发过程中出现了一个新的需求。用户在连续输错3次密码后跳出验证码,继续输错2次后按照时间5min、10min、15min梯度等待重新登录。
需求关键点解析
- 失败计数器:需要跟踪用户连续输错密码的次数。
- 输错3次时,显示验证码。
- 输错5次时,触发梯度等待(等待时间基于梯度计数器)。
- 梯度等待机制:
- 梯度计数器记录用户触发梯度等待的次数(初始为0)。
- 第一次触发(输错5次)时,等待5分钟。
- 第二次触发(输错6次)时,等待10分钟。
- 第三次触发(输错7次)时,等待15分钟。
- 重置逻辑:当用户成功登录时,所有计数器(失败计数器和梯度计数器)应重置为0。
- 验证码处理:输错3次后,登录接口需验证验证码(验证码生成和验证逻辑需单独实现)。
- 等待期处理:在等待期内,用户无法尝试登录,系统需提示剩余时间。
二、实现考虑
考虑到四种实现方法:
- 使用内存处理,但需额外关注并发问题。
- 加入重量级锁(可能指Java中的synchronized或ReentrantLock),但存在速度问题。
- 使用Redis分布式锁(悲观锁)。
- 使用Redis乐观锁(WATCH, MULTI, EXEC)。
关键考虑因素:
- 后期延展性:单体应用可能在未来需要扩展为分布式系统,因此使用Redis这样的外部存储可以更好地支持分布式环境。
- 数据一致性:在并发环境下,确保多个请求修改共享数据时不会出现不一致。
- 并发问题:高并发场景下,需要高效处理。
为什么选择Redis乐观锁?
乐观锁假设冲突发生概率较低,因此在操作数据时不会加锁,而是在提交时检查数据是否被修改。这通常比悲观锁性能更好,尤其是在读多写少的场景。
在Redis中,乐观锁通过WATCH
、MULTI
、EXEC
实现:
WATCH key
:监视一个或多个键,如果在事务执行前这些键被修改,则事务会失败。MULTI
:开始一个事务。- 执行多个命令。
EXEC
:执行事务。如果被监视的键在WATCH
后到EXEC
前被修改,则事务不会执行。
与其他方法对比
- 内存处理:在单体应用中,使用Java锁(如synchronized)可以处理并发,但无法扩展到分布式环境。
- 重量级锁:如Java中的锁,在单机内有效,但在分布式环境下无效,且可能引起性能瓶颈。
- Redis分布式锁(悲观锁):使用如Redisson的分布式锁,可以跨多个实例,但获取和释放锁有开销,且可能发生死锁。
- Redis乐观锁:无锁设计,性能较高,但需要处理事务失败的情况。
方案 | 适用场景 | 缺点 | 延展性 |
---|---|---|---|
内存处理 | 单机低并发 | 无法扩展分布式 | ❌ |
重量级锁(如synchronized ) |
单机简单业务 | 性能瓶颈明显 | ❌ |
Redis悲观锁 | 强一致性场景 | 网络开销大,死锁风险 | ⭐⭐ |
Redis乐观锁 | 高并发读写 | 需处理重试逻辑 | ⭐⭐⭐⭐ |
选择理由:
- 延展性:Redis作为独立中间件,天然支持分布式扩展
- 数据一致性:通过
WATCH
实现CAS(Compare-and-Swap)原子操作 - 并发性能:无锁竞争,吞吐量高于悲观锁(理论值提升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();
}
}
}
});