实现分布式锁最简单的一种方式:基于Redis
不论是本地锁还是分布式锁,核心都在于“互斥”。
在 Redis 中, SETNX
命令是可以帮助我们实现互斥。SETNX
即 set if not exists (对应 Java 中的 setIfAbsent
方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX
啥也不做。
SETNX lockKey uniqueValue
(integer) 1
SETNX lockKey uniqueValue
(integer) 0
释放锁的话,直接通过 DEL
命令删除对应的 key 即可。
DEL lockKey
(integer) 1
为了防止误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。
选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end
这是一种最简易的 Redis 分布式锁实现,实现方式比较简单,性能也很高效。不过,这种方式实现分布式锁存在一些问题。就比如应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。
为了避免锁无法被释放,我们可以想到的一个解决办法就是:给这个 key(也就是锁) 设置一个过期时间 。
127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK
- lockKey:加锁的锁名;
- uniqueValue:能够唯一标识锁的随机字符串;
- NX:只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功;
- EX:过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。
这样确实可以解决问题,不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。
你或许在想:如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!
好 它来了
Redission+lua脚本实现互斥锁
Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。
Redisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单。在Redisson中需要手动加锁,并且可以控制锁的失效时间和等待时间。当锁住的一个业务还没有执行完成时,Redisson会引入一个看门狗机制,每隔一段时间检查当前业务是否还持有锁。如果持有,就增加加锁的持有时间。
实践一下:优惠券秒杀一人一单防止超卖实现步骤
1 引入依赖
<dependencies><!-- Redisson --><dependency><groupId>org.redisson</groupId><artifactId>redisson</artifactId><version>3.16.1</version></dependency><!-- Spring Boot Data Redis --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- Spring Web --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
</dependencies>
2. Redisson 配置
@Configuration
public class RedissonConfig {@Value("${spring.redis.host}")private String host;@Value("${spring.redis.port}")private String port;@Value("${spring.redis.password}")private String password;@Beanpublic RedissonClient redissonClient() {Config config = new Config();config.useSingleServer().setAddress("redis://" + host + ":" + port).setPassword(password == null || password.isEmpty() ? null : password).setDatabase(0);return Redisson.create(config);}
}
3. 常量类
public interface RedisConstants {String SECKILL_STOCK_KEY = "seckill:stock:";String SECKILL_ORDER_KEY = "seckill:order:";String LOCK_COUPON_KEY = "lock:coupon:";long LOCK_TIMEOUT = 30; // 锁超时时间(秒)
}
4. service
@Service
public class CouponService {@Autowiredprivate RedissonClient redissonClient;@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Autowiredprivate CouponMapper couponMapper;@Autowiredprivate OrderMapper orderMapper;// 秒杀优惠券public Result seckillCoupon(Long couponId, Long userId) {// 1. 生成锁keyString lockKey = RedisConstants.LOCK_COUPON_KEY + couponId;// 2. 获取Redisson锁RLock lock = redissonClient.getLock(lockKey);// 3. 尝试获取锁,等待10秒,自动释放时间30秒 这里没有启用看门狗 因为设置了自动30s超时释放 boolean isLocked = false;try {isLocked = lock.tryLock(10, RedisConstants.LOCK_TIMEOUT, TimeUnit.SECONDS);if (!isLocked) {return Result.fail("抢购失败,请稍后再试");}// 4. 执行Lua脚本校验库存和用户订单String script = buildSeckillScript();List<String> keys = Arrays.asList(couponId.toString());List<String> args = Arrays.asList(userId.toString());Long result = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class),keys, args);// 5. 处理脚本返回结果if (result == null) {return Result.fail("系统异常");}if (result == 0) {return Result.fail("库存不足");}if (result == -1) {return Result.fail("每个用户限购一次");}// 6. 创建订单(这里简化处理,实际项目可能需要更复杂的订单创建逻辑)createOrder(couponId, userId);return Result.ok("抢购成功");} catch (InterruptedException e) {Thread.currentThread().interrupt();return Result.fail("系统异常");} finally {// 7. 释放锁if (isLocked && lock.isHeldByCurrentThread()) {lock.unlock();}}}// 构建秒杀Lua脚本private String buildSeckillScript() {return "local stockKey = 'seckill:stock:' .. KEYS[1] " +"local orderKey = 'seckill:order:' .. KEYS[1] " +"local userId = ARGV[1] " +"local stock = tonumber(redis.call('get', stockKey) or 0) " +"if stock <= 0 then return 0 end " +"if redis.call('sismember', orderKey, userId) == 1 then return -1 end " +"redis.call('decr', stockKey) " +"redis.call('sadd', orderKey, userId) " +"return 1";}// 创建订单private void createOrder(Long couponId, Long userId) {// 查询优惠券信息Coupon coupon = couponMapper.selectById(couponId);// 创建订单Order order = new Order();order.setUserId(userId);order.setCouponId(couponId);order.setPayAmount(coupon.getPrice());// 设置其他订单字段...// 保存订单orderMapper.insert(order);}
}
5. controller
@RestController
@RequestMapping("/api/coupon")
public class CouponController {@Autowiredprivate CouponService couponService;@PostMapping("/seckill/{couponId}")public Result seckillCoupon(@PathVariable Long couponId, @RequestHeader("userId") Long userId) {return couponService.seckillCoupon(couponId, userId);}
}
6. 初始化库存和优惠券
@Service
public class InitService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Autowiredprivate CouponMapper couponMapper;// 初始化优惠券库存到Redis@PostConstructpublic void initCouponStock() {// 查询所有可用优惠券List<Coupon> coupons = couponMapper.selectList(new QueryWrapper<Coupon>().eq("status", 1).gt("stock", 0).lt("start_time", LocalDateTime.now()).gt("end_time", LocalDateTime.now()));// 将优惠券库存加载到Redisfor (Coupon coupon : coupons) {stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY + coupon.getId(), coupon.getStock().toString());}}
}
lua脚本详解
-- 获取库存键和订单键
local stockKey = 'seckill:stock:' .. KEYS[1]
local orderKey = 'seckill:order:' .. KEYS[1] -- 获取用户ID参数
local userId = ARGV[1] -- 获取当前库存(如果不存在则为0)
local stock = tonumber(redis.call('get', stockKey) or 0) -- 检查库存是否不足
if stock <= 0 then return 0 end -- 检查用户是否已购买过
if redis.call('sismember', orderKey, userId) == 1 then return -1 end -- 扣减库存
redis.call('decr', stockKey) -- 记录用户已购买
redis.call('sadd', orderKey, userId) -- 返回成功标识
return 1
看门狗机制在哪体现捏?
当你调用tryLock()
方法没有显式指定锁的持有时间(即只传等待时间,不传释放时间)时,看门狗机制会自动生效。例如:
// 启用看门狗:不指定leaseTime,使用默认续期时间(默认30秒)
lock.tryLock(10, null, TimeUnit.SECONDS);// 禁用看门狗:显式指定leaseTime,锁到期后不会续期
lock.tryLock(10, 30, TimeUnit.SECONDS); // 你提供的代码使用这种方式
如果启用dog 建议增加配置来调整看门狗的默认续期时间:
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379").setLockWatchdogTimeout(60 * 1000); // 设置看门狗续期时间为60秒
Redisson实现的分布式锁是可重入的吗?它是怎么实现的?
是的,Redisson 实现的分布式锁是可重入的。可重入锁允许同一个线程多次获取同一把锁而不会被阻塞,这可以有效避免死锁问题,同时让代码逻辑更清晰。
Redisson 如何实现可重入锁
Redisson 是基于 Redis 的哈希结构来存储锁信息的。打个比方,我们有个叫 “myLock” 的锁,这就是锁的唯一标识,相当于哈希结构里的 Key。而每个尝试获取锁的线程都有自己唯一的标识,像线程 ID 或者 UUID,这就是哈希结构里的 Field。线程获取锁的次数,也就是重入次数,就是哈希结构里的 Value。
加锁过程
当一个线程想去获取锁的时候,Redisson 首先会检查这个锁对应的 Key 存不存在。要是不存在,那就说明当前没有线程持有这把锁,Redisson 就会创建这个锁,把 Field 设成当前线程的标识,Value 设为 1,同时给锁设置一个过期时间。要是锁已经存在,Redisson 就会去检查 Field 是不是和当前线程的标识一样。如果一样,那就意味着当前线程已经持有这把锁了,Redisson 就把 Value 加 1,并且刷新锁的过期时间。要是不一样,那就表示锁被其他线程占着,当前线程就得等着锁被释放。
释放锁过程
释放锁的时候,Redisson 会先看看锁的 Field 和当前线程标识是不是一致。如果一致,就把 Value 减 1。要是减完之后 Value 变成 0 了,那就说明当前线程已经完全释放了这把锁,Redisson 就把锁对应的 Key 删除。要是 Value 还大于 0,说明当前线程还有重入的情况,还持有锁,Redisson 就刷新一下锁的过期时间。
防止死锁
Redisson 在防止死锁方面也有很实用的机制。一方面,加锁的时候会给锁设置过期时间,就算某个线程出问题了,一直不释放锁,到时间了锁也会自动被删除。另一方面,它的可重入机制也能避免因为线程嵌套调用导致的死锁。
可重入锁
Redisson 的可重入锁优势也很明显。从线程安全角度看,只有持有锁的线程才能释放锁,这就保证了不会出现线程安全问题。在性能上,它通过 Lua 脚本确保加锁和释放锁的操作是原子性的,避免了竞态条件,效率很高。