Redis分布式锁:从理论到实践的全方位解析
1. 为什么需要分布式锁?
在单机应用中,我们可以使用Java内置的synchronized或ReentrantLock来解决并发问题。但在分布式环境下,多个服务实例运行在不同的JVM中,这些本地锁就失去了作用。
典型应用场景:
-
订单系统中的库存扣减
-
优惠券发放防止超发
-
定时任务的分布式调度
-
重要操作的幂等性控制
2. 分布式锁的核心要求
一个合格的分布式锁应该满足以下基本要求:
| 要求 | 说明 |
|---|---|
| 互斥性 | 同一时刻只有一个客户端能持有锁 |
| 防死锁 | 即使客户端崩溃,锁也能在一定时间后自动释放 |
| 容错性 | Redis节点宕机时,锁机制仍然可用 |
| 可重入性 | 同一线程可多次获取同一把锁 |
3. Redis分布式锁的演进之路
3.1 第一代:简单的SETNX实现
/*** 第一代Redis分布式锁 - 基于SETNX命令* 这是最基础的实现,存在明显缺陷,仅用于理解原理*/
public class SimpleRedisLock {private Jedis jedis; // Redis客户端private String lockKey; // 锁的键名/*** 尝试获取分布式锁* @param value 锁的值,用于标识锁的持有者* @param expireSeconds 锁的过期时间(秒)* @return 是否成功获取锁*/public boolean lock(String value, long expireSeconds) {// 使用SET命令的NX和EX参数实现原子性的加锁操作// NX: 仅当key不存在时才设置,保证互斥性// EX: 设置过期时间,防止死锁String result = jedis.set(lockKey, value, "NX", "EX", expireSeconds);return "OK".equals(result); // 返回"OK"表示获取锁成功}/*** 释放分布式锁(避免服务宕机,但是锁又没有释放,所以引起死锁)* @param value 锁的值,用于验证锁的持有者* 问题:直接删除,可能误删其他客户端持有的锁*/public void unlock(String value) {// 直接删除锁,存在严重问题:// 1. 可能误删其他客户端获取的锁// 2. 非原子操作,在旧版Redis中需要先GET再DELjedis.del(lockKey);}
}
问题分析:
-
非原子操作:在旧版Redis中,SETNX和EXPIRE是两个独立命令,可能设置成功但过期时间设置失败
-
可能误删其他客户端的锁:解锁时没有验证锁的持有者
-
没有锁续期机制:如果业务执行时间超过锁的过期时间,锁会自动释放,导致并发问题
3.2 第二代:Lua脚本保证原子性
/*** 第二代Redis分布式锁 - 使用Lua脚本保证原子性* 解决了第一代的主要问题,但仍有改进空间*/
public class AdvancedRedisLock {// 加锁的Lua脚本:原子性地执行SETNX和EXPIREprivate static final String LOCK_SCRIPT = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then\n" + // 如果key不存在,设置value" return redis.call('expire', KEYS[1], ARGV[2])\n" + // 设置过期时间,返回1表示成功"else\n" +" return 0\n" + // key已存在,返回0表示失败"end";// 解锁的Lua脚本:验证锁持有者后再删除private static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then\n" + // 验证当前值是否等于传入的value" return redis.call('del', KEYS[1])\n" + // 相等则删除,返回1表示成功"else\n" +" return 0\n" + // 不相等,返回0表示失败"end";/*** 获取分布式锁 - 原子操作版本*/public boolean lock(String lockKey, String value, int expireSeconds) {// 执行Lua脚本:保证SETNX和EXPIRE的原子性// KEYS[1] = lockKey, ARGV[1] = value, ARGV[2] = expireSecondsObject result = jedis.eval(LOCK_SCRIPT, Collections.singletonList(lockKey), // 键列表Arrays.asList(value, String.valueOf(expireSeconds))); // 参数列表return "1".equals(result.toString()); // 返回"1"表示成功}/*** 释放分布式锁 - 安全的解锁操作*/public boolean unlock(String lockKey, String value) {// 执行Lua脚本:验证锁持有者后再删除,防止误删Object result = jedis.eval(UNLOCK_SCRIPT,Collections.singletonList(lockKey), // 键列表Collections.singletonList(value)); // 参数列表return "1".equals(result.toString()); // 返回"1"表示解锁成功}
}
3.3 第三代:Redisson生产级实现
Redisson是一个在Redis基础上实现的Java驻内存数据网格框架。它提供了很多分布式Java对象和服务,让开发者能够像操作本地对象一样操作分布式环境下的数据,使得操作操作更简单。
集成spring生态
@Autowired private RedissonClient redissonClient;即可操作对象
/*** Redis配置类 - 配置Redisson客户端* Redisson是Redis的Java客户端,提供了丰富的分布式对象和服务*/
@Configuration
public class RedisConfig {/*** 创建Redisson客户端单例* @return RedissonClient实例*/@Beanpublic RedissonClient redissonClient() {Config config = new Config();// 使用单节点Redis服务器配置config.useSingleServer().setAddress("redis://127.0.0.1:6379") // Redis服务器地址.setDatabase(0); // Redis数据库编号return Redisson.create(config); // 创建Redisson客户端}
}/*** 订单服务 - 使用Redisson实现分布式锁的业务示例* Redisson提供了生产级别的分布式锁实现*/
@Service
public class OrderService {@Autowiredprivate RedissonClient redissonClient; // 注入Redisson客户端/*** 扣减库存 - 使用分布式锁保证并发安全* @param productId 商品ID* @param quantity 扣减数量*/public void deductStock(Long productId, Integer quantity) {// 生成锁的键:按商品ID区分,不同商品使用不同的锁String lockKey = "stock_lock:" + productId;// 获取分布式锁对象RLock lock = redissonClient.getLock(lockKey);try {// 尝试获取锁:// - waitTime=10: 最多等待10秒获取锁// - leaseTime=30: 锁的持有时间为30秒// - TimeUnit.SECONDS: 时间单位boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);if (locked) {// 成功获取锁,执行库存扣减业务逻辑processStockDeduction(productId, quantity);} else {// 获取锁失败,抛出异常或进行重试throw new RuntimeException("获取锁失败,请重试");}} catch (InterruptedException e) {// 线程在等待锁时被中断,恢复中断状态Thread.currentThread().interrupt();throw new RuntimeException("锁等待被中断", e);} finally {// 在finally块中确保锁被释放if (lock.isHeldByCurrentThread()) {// 检查当前线程是否还持有锁,避免重复释放lock.unlock();}}}/*** 具体的库存扣减逻辑*/private void processStockDeduction(Long productId, Integer quantity) {// 这里实现具体的库存扣减逻辑// 例如:查询库存、校验数量、更新库存等// ...}
}
4. Redis分布式锁的核心原理
4.1 锁的获取机制
/*** Redisson锁获取的核心逻辑* 使用Lua脚本保证所有操作的原子性*/
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,// Lua脚本开始"if (redis.call('exists', KEYS[1]) == 0) then " + // 检查锁是否存在"redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 不存在:设置哈希字段,值为1(支持可重入)"redis.call('pexpire', KEYS[1], ARGV[1]); " + // 设置过期时间(毫秒)"return nil; " + // 返回nil表示成功"end; " +"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + // 检查当前线程是否已持有锁"redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 已持有:重入计数+1"redis.call('pexpire', KEYS[1], ARGV[1]); " + // 刷新过期时间"return nil; " + // 返回nil表示成功"end; " +"return redis.call('pttl', KEYS[1]);", // 锁被其他线程持有:返回剩余过期时间// Lua脚本结束Collections.singletonList(getRawName()), // KEYS[1] = 锁的键名unit.toMillis(leaseTime), getLockName(threadId)); // ARGV[1] = 过期时间, ARGV[2] = 线程标识
}
关键特性:
-
可重入性:同一线程可以多次获取同一把锁
-
原子性:所有操作在Lua脚本中原子执行
-
过期时间:自动设置锁的过期时间,防止死锁
-
4.2 看门狗机制(锁续期)
/*** 看门狗机制 - 锁自动续期* 防止业务执行时间超过锁的过期时间*/
private void scheduleExpirationRenewal(long threadId) {// 创建一个定时任务,在锁过期时间的1/3时执行续期Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {@Overridepublic void run(Timeout timeout) throws Exception {// 异步执行锁续期操作RFuture<Boolean> future = renewExpirationAsync(threadId);future.onComplete((res, e) -> {if (e != null) {// 续期失败,记录错误日志log.error("Can't update lock " + getRawName() + " expiration", e);return;}if (res) {// 续期成功,递归调用继续安排下一次续期scheduleExpirationRenewal(threadId);}// 如果res为false,说明锁已释放,不再续期});}}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); // 在锁过期时间的1/3时触发// 将续期任务保存到Map中,用于后续取消expirationRenewalMap.put(getEntryName(), task);
}
看门狗机制的作用:
-
自动续期:在业务执行期间自动延长锁的过期时间
-
防止过早释放:避免业务未完成时锁自动过期
-
异常处理:客户端崩溃时,锁最终还是会过期,不会永久死锁
