上文我们提到了基于Redis的秒杀券抢购业务的实现,在单体系统下式完全能够满足的,但是随着业务的扩展,需要进行集群部署,因此仍然会出现一人多单现象。
本文基于Redis实现分布式锁,解决在集群部署下出现的一人多单现象。
【分析】
为什么在集群部署下会出现一人多单问题呢?
因为在集群部署下,每一个项目都有自己的JVM,那么就都有字节锁监视器,因此在访问时仍然会出现一人多单,解决方法,我们设置全局唯一的锁监视器,那么任何项目都要访问这个全局唯一的锁监视器,因此就可以解决一人多单问题。
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
实现分布式锁时需要实现的两个基本方法:
获取锁:
互斥:确保只能有一个线程获取锁
非阻塞:尝试一次,成功返回true,失败返回false
释放锁:
手动释放
超时释放:获取锁时添加一个超时时间
核心思路:
我们利用redis 的setNx 方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的哥们,等待一定时间后重试即可(或者立即退出)
定义接口
SimpleRedisLock
利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性
- private static final String KEY_PREFIX="lock:"
- @Override
- public boolean tryLock(long timeoutSec) {
- // 获取线程标示
- String threadId = Thread.currentThread().getId()
- // 获取锁
- Boolean success = stringRedisTemplate.opsForValue()
- .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
- return Boolean.TRUE.equals(success);
- }
释放锁逻辑
SimpleRedisLock
释放锁,防止删除别人的锁
- public void unlock() {
- //通过del删除锁
- stringRedisTemplate.delete(KEY_PREFIX + name);
- }
- @Override
- public Result seckillVoucher(Long voucherId) {
- // 1.查询优惠券
- SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
- // 2.判断秒杀是否开始
- if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
- // 尚未开始
- return Result.fail("秒杀尚未开始!");
- }
- // 3.判断秒杀是否已经结束
- if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
- // 尚未开始
- return Result.fail("秒杀已经结束!");
- }
- // 4.判断库存是否充足
- if (voucher.getStock() < 1) {
- // 库存不足
- return Result.fail("库存不足!");
- }
- Long userId = UserHolder.getUser().getId();
- //创建锁对象(新增代码)
- SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
- //获取锁对象
- boolean isLock = lock.tryLock(1200);
- //加锁失败
- if (!isLock) {
- return Result.fail("不允许重复下单");
- }
- try {
- //获取代理对象(事务)
- IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
- return proxy.createVoucherOrder(voucherId);
- } finally {
- //释放锁
- lock.unlock();
- }
- }
【分析代码】可能会出现的问题-锁误删现象,当线程1获得锁对象,此时业务被阻塞了,锁超时释放了,然后线程2看到锁释放了,因此就获得了新锁,然后线程1又完成任务,直接把锁释放了,此时其他线程就又获得了锁,可能会出现多线程安全问题。
解决方法:判断锁是否是自己的,如果是自己的再释放。
【业务逻辑】
【代码实现】
- private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
- @Override
- public boolean tryLock(long timeoutSec) {
- // 获取线程标示
- String threadId = ID_PREFIX + Thread.currentThread().getId();
- // 获取锁
- Boolean success = stringRedisTemplate.opsForValue()
- .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
- return Boolean.TRUE.equals(success);
- }
-
- public void unlock() {
- // 获取线程标示
- String threadId = ID_PREFIX + Thread.currentThread().getId();
- // 获取锁中的标示
- String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
- // 判断标示是否一致
- if(threadId.equals(id)) {
- // 释放锁
- stringRedisTemplate.delete(KEY_PREFIX + name);
- }
- }
【分析代码】出现问题,判断锁和释放锁不是原子性的,因此在极端情况下仍然会出现多线程问题
解决方法:采用lua脚本进行改写。
接下来我们来回一下我们释放锁的逻辑:
释放锁的业务流程是这样的
如果用Lua脚本来表示则是这样的:
最终我们操作redis的拿锁比锁删锁的lua脚本就会变成这样
- -- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
- -- 获取锁中的标示,判断是否与当前线程标示一致
- if (redis.call('GET', KEYS[1]) == ARGV[1]) then
- -- 一致,则删除锁
- return redis.call('DEL', KEYS[1])
- end
- -- 不一致,则直接返回
- return 0
最终加锁和释放锁的逻辑如下:
- private static final DefaultRedisScript
UNLOCK_SCRIPT; - static {
- UNLOCK_SCRIPT = new DefaultRedisScript<>();
- UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
- UNLOCK_SCRIPT.setResultType(Long.class);
- }
-
- public void unlock() {
- // 调用lua脚本
- stringRedisTemplate.execute(
- UNLOCK_SCRIPT,
- Collections.singletonList(KEY_PREFIX + name),
- ID_PREFIX + Thread.currentThread().getId());
- }