分布式锁:满足分布式系统或集群模式下多个进程可见并且互斥的锁。分布式锁的核心思想就是多线程都使用同一把锁,实现程序串行执行。
分布式锁需要具备的条件:
特性 | 含义 |
---|---|
可见性 | 多个线程都能感知到变化 |
互斥性 | 分布式锁的最基本的特性,让程序串行执行 |
高可用 | 程序不易崩溃,时刻保证较高的可用性 |
高性能 | 要求分布式锁具备较高的加锁和释放锁性能 |
安全性 | 要求分布式锁具备一定的安全性 |
常见的分布式锁有三种:
Mysql: mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见
Redis: redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
Zookeeper: zookeeper也是企业级开发中较好的一个实现分布式锁的方案,这里不过多阐述。
实现分布式锁需要实现的两个基本方法:
核心思路:利用redis的setnx特性实现锁的互斥。当第一个线程setnx返回1,代表它获取锁成功,可以执行业务,然后释放锁;其他线程则等待一段时间后进行重试。
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;
}
@Override
public boolean tryLock(long timeoutSec) {
// value建议设置当前线程的id
long threadId = Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
// 不要直接返回success,自充拆箱可能会出现空指针异常
return BooleanUtil.isTrue(success);
}
@Override
public void unlock() {
stringRedisTemplate.delete(LOCK_PREFIX + name);
}
}
核心代码:
// 使用分布式锁实现一人一单
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);
}
可以发现,集群模式下,两个线程同时争抢锁,只有一个线程成功获取到锁,实现了分布式锁的互斥!
现考虑一种在分布式锁情况下仍会导致线程安全问题的极端情况:
这种情况下,线程2和线程3存在线程安全问题。
导致该问题出现的本质原因在于线程在去释放锁的时候,不加判断,都不看这锁是不是自己的就给人家释放了。
分布式锁会被误删的关键是redis再去删除数据的时候,没有做判断,当前线程没有判断在redis中存储的锁是不是自己的那把锁就直接给删掉了。
解决方案:给锁添加唯一标识(UUID),删除前做一次查询,判断是不是自己的那把锁,如果是,再做删除操作。
获取锁
删除锁
准备两个线程
线程1成功获取锁
通过手动删除锁,模拟线程1因业务阻塞导致锁过期被删除
线程2成功获取锁
线程1执行完业务,删除锁
线程2执行完业务,删除锁
至此,就避免了分布式锁误删的问题!
目前仍存在一种更为极端的情况会导致分布式锁误删问题
由此造成了分布式锁的误删问题
造成该问题出现的本质原因是:释放锁的查询判断和删除操作不具备原子性
Lua 是一种轻量级的编程语言,具有简洁的语法和强大的功能。它是一种动态类型的语言,支持函数式编程和面向对象编程。Lua 是一种嵌入式脚本语言,可以轻松地集成到其他应用程序中。
Redis提供了对Lua的支持实现
Spring提供了调用Lua脚本的API
基于这些特性,保证分布式锁删除操作原子性的实现思路:
由于在Java中只有调用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;
}
@Override
public boolean tryLock(long timeoutSec) {
// value建议设置当前线程的id
long threadId = Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + name, uniqueStr + threadId, timeoutSec, TimeUnit.SECONDS);
// 不要直接返回success,自充拆箱可能会出现空指针异常
return BooleanUtil.isTrue(success);
}
/**
* 通过Lua脚本释放锁,保证操作的原子性
*/
@Override
public 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);
// }
// }
}
至此,解决了因操作原子性而造成的分布式锁误删问题