1、基本原理和实现方式对比
分布式锁:满足分布式系统或集群模式下多个进程可见并且互斥的锁。分布式锁的核心思想就是多线程都使用同一把锁,实现程序串行执行。
分布式锁需要具备的条件:
特性 | 含义 |
---|---|
可见性 | 多个线程都能感知到变化 |
互斥性 | 分布式锁的最基本的特性,让程序串行执行 |
高可用 | 程序不易崩溃,时刻保证较高的可用性 |
高性能 | 要求分布式锁具备较高的加锁和释放锁性能 |
安全性 | 要求分布式锁具备一定的安全性 |
常见的分布式锁有三种:
Mysql: mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见
Redis: redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
Zookeeper: zookeeper也是企业级开发中较好的一个实现分布式锁的方案,这里不过多阐述。
2、Redis分布式锁实现的核心思路
实现分布式锁需要实现的两个基本方法:
- 获取锁
- 互斥:只能有一个线程成功获取到锁
- 非阻塞:尝试获取一次,成功返回true,失败返回false
- 释放锁
- 手动释放
- 超时释放:避免服务宕机导致出现死锁
核心思路:利用redis的setnx特性实现锁的互斥。当第一个线程setnx返回1,代表它获取锁成功,可以执行业务,然后释放锁;其他线程则等待一段时间后进行重试。
3、实现分布式锁 V1.0
- 锁对象接口
public interface ILock {/*** 尝试获取锁* @param timeoutSec 超时时间(秒)* @return*/boolean tryLock(long timeoutSec);/*** 释放锁*/void unlock();
}
- 锁对象实现类
public class SimpleRedisLock implements ILock {private StringRedisTemplate stringRedisTemplate;// 锁的名字(一般与当前业务模块相关)private String name;private String LOCK_PREFIX = "lock:";public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {this.stringRedisTemplate = stringRedisTemplate;this.name = name;}@Overridepublic boolean tryLock(long timeoutSec) {// value建议设置当前线程的idlong threadId = Thread.currentThread().getId();Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);// 不要直接返回success,自充拆箱可能会出现空指针异常return BooleanUtil.isTrue(success);}@Overridepublic void unlock() {stringRedisTemplate.delete(LOCK_PREFIX + name);}
}
- 业务类-VoucherOrderServiceImpl
核心代码:
// 使用分布式锁实现一人一单
SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
// 尝试获取锁
boolean isLock = lock.tryLock(1200);
if (!isLock) {return Result.fail("不允许重复下单");
}
try {return oneUserAndOrder(voucherId);
} finally {lock.unlock();
}
/*** 一人一单** @param voucherId* @return*/
@Transactional
/*1、将锁放在方法体上,那么这个方法就是一个同步方法,只有一个线程能够进入,会导致性能问题*/
public /*synchronized */Result oneUserAndOrder(Long voucherId) {Long userId = UserHolder.getUser().getId();/*2、将锁放在方法体内存在的问题:方法执行完毕后,锁会被释放,但事务是由Spring管理的此时,事务还未提交,锁就被释放了,下一个进程进来,仍会出现线程安全问题*/
// synchronized (userId.toString().intern()){// 保证一人一单Integer count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();if (count > 0) {return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");}// 扣减库存,添加乐观锁boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId)// 这种方式反而会增加下单的失败率
// .eq("stock", voucher.getStock())// 只要我库存还大于0,就允许用户继续下单.gt("stock", 0).update();if (!success) {return Result.fail("秒杀券已售罄");}// 生成订单VoucherOrder order = new VoucherOrder();long orderID = redisIdWorker.nextId("order");order.setId(orderID);order.setVoucherId(voucherId);order.setUserId(UserHolder.getUser().getId());save(order);return Result.ok(orderID);
}
- 单元测试
可以发现,集群模式下,两个线程同时争抢锁,只有一个线程成功获取到锁,实现了分布式锁的互斥!
4、分布式锁误删问题
4.1、误删问题
现考虑一种在分布式锁情况下仍会导致线程安全问题的极端情况:
- 线程1获取锁,获取成功,但因业务阻塞问题,导致分布式锁的TTL过期,锁失效
- 线程2获取锁,获取成功。
- 线程1执行完业务,释放锁,也就是把线程2的锁给释放掉了。
- 线程3获取锁,获取成功。
- 线程2执行完业务,释放锁,也就是释放了线程3的锁
- 线程3执行完业务,执行释放锁。
这种情况下,线程2和线程3存在线程安全问题。
导致该问题出现的本质原因在于线程在去释放锁的时候,不加判断,都不看这锁是不是自己的就给人家释放了。
4.2、解决方案
分布式锁会被误删的关键是redis再去删除数据的时候,没有做判断,当前线程没有判断在redis中存储的锁是不是自己的那把锁就直接给删掉了。
解决方案:给锁添加唯一标识(UUID),删除前做一次查询,判断是不是自己的那把锁,如果是,再做删除操作。
- 核心代码更新
获取锁
删除锁
- 测试
准备两个线程
线程1成功获取锁
通过手动删除锁,模拟线程1因业务阻塞导致锁过期被删除
线程2成功获取锁
线程1执行完业务,删除锁
线程2执行完业务,删除锁
至此,就避免了分布式锁误删的问题!
5、分布式锁的原子性问题
5.1、原子性问题
目前仍存在一种更为极端的情况会导致分布式锁误删问题
- 线程1正常获取锁,执行业务逻辑,执行完毕准备删除锁
- 经过判断的确是自己的锁,此事发生线程阻塞等意外导致分布式锁TTL到期
- 线程2进入,获取到锁
- 切回到线程1,由于之前已经判断过是自己的锁了,直接执行释放锁操作
由此造成了分布式锁的误删问题
造成该问题出现的本质原因是:释放锁的查询判断和删除操作不具备原子性
5.2、通过Lua脚本解决原子性问题
Lua 是一种轻量级的编程语言,具有简洁的语法和强大的功能。它是一种动态类型的语言,支持函数式编程和面向对象编程。Lua 是一种嵌入式脚本语言,可以轻松地集成到其他应用程序中。
Redis提供了对Lua的支持实现
Spring提供了调用Lua脚本的API
基于这些特性,保证分布式锁删除操作原子性的实现思路:
- 将锁查询及删除操作写入到Lua脚本;
- 通过Spring调用编写好的Lua脚本
由于在Java中只有调用Lua脚本这一行操作语句,从而保证了原子性
- unlock.lua
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
- 释放锁核心代码
public class SimpleRedisLock implements ILock {private StringRedisTemplate stringRedisTemplate;// 锁的名字(一般与当前业务模块相关)private String name;private String LOCK_PREFIX = "lock:";final String uniqueStr = UUID.randomUUID().toString(true) + "-";private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;static {UNLOCK_SCRIPT = new DefaultRedisScript<>();UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));UNLOCK_SCRIPT.setResultType(Long.class);}public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {this.stringRedisTemplate = stringRedisTemplate;this.name = name;}@Overridepublic boolean tryLock(long timeoutSec) {// value建议设置当前线程的idlong threadId = Thread.currentThread().getId();Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + name, uniqueStr + threadId, timeoutSec, TimeUnit.SECONDS);// 不要直接返回success,自充拆箱可能会出现空指针异常return BooleanUtil.isTrue(success);}/*** 通过Lua脚本释放锁,保证操作的原子性*/@Overridepublic void unlock() {stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(LOCK_PREFIX + name), uniqueStr + Thread.currentThread().getId());}// @Override
// public void unlock() {
// // 查询当前线程的锁
// String lock = stringRedisTemplate.opsForValue().get(LOCK_PREFIX + name);
// // 如果当前线程的锁是自己的,才能删除
// if (lock != null && lock.equals(uniqueStr + Thread.currentThread().getId())){
// stringRedisTemplate.delete(LOCK_PREFIX + name);
// }
// }
}
至此,解决了因操作原子性而造成的分布式锁误删问题