
首先一人一单已经在单机模式下实现,原理就是利用悲观锁(syn锁)将对象锁起来,但这种方法在集群模式下是有严重问题的,因为不同的服务器内部的jvm是不同的,你同一台机器子所以能够达到这种效果完全是因为一个jvm内部使用一个锁监视器,不同线程去运行监视同一个锁监视器,但在集群模式下,不共用一个锁监视器,这样我们做了nginx负载均衡后就会出问题,继续使用syn锁是达不到这种效果的,这时就需要使用分布式锁,
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。 分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路而redis就是一个天然的工具,它可以在多个集群之间共享。
1.分布式锁

那么分布式锁他应该满足条件:
可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
高可用:程序不易崩溃,时时刻刻都保证较高的可用性
高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
安全性:安全也是程序中必不可少的一环
常见的分布式锁有三种
Mysql:mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见
Redis:redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
Zookeeper:zookeeper也是企业级开发中较好的一个实现分布式锁的方案,后续可以学一下。
2.redis实现分布式锁
实现分布式锁时需要实现的两个基本方法:
* 获取锁:
* 互斥:确保只能有一个线程获取锁
* 非阻塞(就是失败的时候直接返回false,不做什么休眠呀,然后重试这种东西):尝试一次,成功返回true,失败返回false
* 释放锁:
* 手动释放
* 超时释放:获取锁时添加一个超时时间
这是基本的流程

2.1实现分布式锁基本代码
这是实现锁的工具类,有几个细节:
java
public class SimpleRedisLock {
/**
* 锁名称,针对每个业务名字不同, 锁名称也不同,因为不可能每个业务的锁都是同一把
*/
private String name;
private StringRedisTemplate stringRedisTemplate;
private long userId;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate, long userId) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
this.userId = userId;
}
/*
* 统一前缀
* */
public static final String KEY_PREFIX = "lock:";
public boolean tryLock(long timeoutSec) {
String name1 = Thread.currentThread().getName();
String key = KEY_PREFIX + name + userId;
Boolean b = stringRedisTemplate.opsForValue().setIfAbsent(key, name1, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(b);
}
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name + userId);
}
}
首先 Boolean b = stringRedisTemplate.opsForValue().setIfAbsent(key, name1, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(b);其实以前讲过,因为函数要求返回boolean,但是我们得到的Boolean是执行那个redis命令返回的,可能是True,False,Null,自动拆包的时候就会产生问题,所以使用Boolean.TRUE.equals(b),只有当b是True的时候才返回true,不然才是false。
其次要给业务一个名字,因为不同业务是不同的锁,你不可能所有操作都是一个锁乱套了,所以要用name去限制业务名字,然后就是因为要实现一人一单,应该把一个人锁住,所以就要加入userid这个,一起合成key,然后就是要存入当前的线程,方便后续操作。
这是修改的业务逻辑,以前是单机的。

改进一:误删他人锁情况:
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明,而要解决这种情况的发生,应该给每一个线程一个标识(可以用UUID表示),存在锁里,删的时候看一下是不是自己的锁,不是就不能删。

我们之前存入的是线程名,在不同集群下是完全又可能一样的,我们如果使用uuid拼接这个,每次初始化的uuid都不一样,这样就可以区分了。

于是有了改进代码,就是加上了uuid,释放锁的逻辑哪里判断了一下。
java
public class SimpleRedisLock {
/**
* 锁名称,针对每个业务名字不同, 锁名称也不同,因为不可能每个业务的锁都是同一把
*/
private String name;
private StringRedisTemplate stringRedisTemplate;
private long userId;
private static final String ID_PREFIX = UUID.randomUUID().toString() + "-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate, long userId) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
this.userId = userId;
}
/*
* 统一前缀
* */
public static final String KEY_PREFIX = "lock:";
public boolean tryLock(long timeoutSec) {
String name1 = Thread.currentThread().getName();
String key = KEY_PREFIX + name + ':'+ userId;
Boolean b = stringRedisTemplate.opsForValue().setIfAbsent(key, ID_PREFIX+name1, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(b);
}
public void unlock() {
//获取锁中标识
String s = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name + ':' + userId);
//判断是否一致
if (s.equals(ID_PREFIX+Thread.currentThread().getName())) {
//一致则释放即可。
stringRedisTemplate.delete(KEY_PREFIX + name + ':'+ userId);
}
}
改进二(这是自己发现的问题,课上没讲):不是自己的锁失败情况补充,其实这样还是不优化,比如说还是上面的情况,线程一发现释放的时候锁不是自己的,就不会删除了,但是此时它还是执行了自己的逻辑,这时当然线程二恰好也执行完了自己的逻辑,这不就发生了一人多单了吗,所以我的理解是应该判断锁不是自己的就让这个业务集体回滚,
所以改进释放锁的代码:
java
public void unlock() {
//获取锁中标识
String s = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name + ':' + userId);
//判断是否一致
if (!s.equals(ID_PREFIX+Thread.currentThread().getName())) {
//如果不一致,需要回滚,即以前做的操作都不行
throw new IllegalStateException("锁不属于当前线程,触发回滚");
}
//一致则释放即可。
stringRedisTemplate.delete(KEY_PREFIX + name + ':'+ userId);
}
当 unlock() 方法检测到锁不属于当前线程时,会抛出 IllegalStateException。这个异常会从 finally 块中抛出,被spring层捕获,由于 createVoucherOrder 方法上有 @Transactional 注解,Spring 会检测到这个 RuntimeException 子类,并自动触发事务回滚,撤销在 createVoucherOrder 中执行的所有数据库操作(如扣库存、创建订单)。
注意因为我想让spring捕获,所以就不能被try-catch的catch层捕获,正因为没有 catch,unlock() 抛出的异常才能向上传递,触发事务回滚,所以一定不能让catch层捕获这个异常,索性我们就不要catch层了,当然如果先写catch层,一定不能包含这个异常
java
//获取成功执行下面的函数
try {
IVoucherOrderService o = (IVoucherOrderService)AopContext.currentProxy();
Result voucherOrder = o.createVoucherOrder(voucherId);
return voucherOrder;
}finally {
simpleRedisLock.unlock();
}
为什么不需要写 catch?
- 异常的作用是触发回滚 :
unlock()抛出的IllegalStateException是RuntimeException子类,只要这个异常能传递到 Spring 事务管理器,就会自动触发回滚。 - catch 会吞掉异常 :如果在
finally外层加catch,并且没有重新抛出异常,事务管理器就感知不到异常,回滚会失效。
改进三,锁的原子性问题:
更为极端的误删逻辑说明:线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的(释放锁的逻辑不是原子性的),我们要防止刚才的情况发生,
lua脚本解决一致性问题:
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html,这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁和删锁是一个原子性动作了
这里重点介绍Redis提供的调用函数,基本语法:redis.call('命令名称', 'key', '其它参数', ...) 例如,我们要执行set name jack,则脚本是这样: # 执行 set name jack redis.call('set', 'name', 'jack') 例如,我们要先执行set name Rose,再执行get name,则脚本如下: # 先执行 set name jack redis.call('set', 'name', 'Rose') # 再执行 get name local name = redis.call('get', 'name') # 返回 return name 这是一个整体,写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
我们要判断当前锁标识和我们现在的一样不一样,这里的 KEYS[1] 就是锁的key(用来查锁标识),这里的ARGV[1] 就是当前线程标示 -- 获取锁中的标示,判断是否与当前线程标示一致,利用ai让它生成我们原来业务逻辑的lua脚本:
Lua
-- KEYS[1]: 锁的 key,即 KEY_PREFIX + name + ':' + userId
-- ARGV[1]: 预期的锁 value,即 ID_PREFIX + Thread.currentThread().getName()
-- 1. 获取当前锁的 value
local currentValue = redis.call('GET', KEYS[1])
-- 2. 判断锁是否存在且归属一致
if currentValue == ARGV[1] then
-- 一致则删除锁
return redis.call('DEL', KEYS[1])
else
-- 不一致或锁不存在,返回 0
return 0
end
然后利用Java在unlock逻辑中调用lua脚本
java
public void unlock() {
// Lua 脚本字符串
String script = """
local currentValue = redis.call('GET', KEYS[1])
if currentValue == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""";
// 执行脚本
Long result = stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(KEY_PREFIX + name + ':'+ userId),
ID_PREFIX + Thread.currentThread().getName()
);
// 脚本返回 0,说明锁不属于当前线程,抛出异常触发回滚
if (result == null || result == 0) {
throw new IllegalStateException("锁不属于当前线程,触发回滚");
}
}
关于execute的第一个参数解释:
DefaultRedisScript 是 Spring Data Redis 提供的Lua 脚本执行器,它的两个构造参数作用是:
- 第一个参数
script:你要执行的 Lua 脚本字符串。 - 第二个参数
Long.class:指定 Lua 脚本返回值的类型。(默认Object)。
至此完成redis实现分布式锁,我们摒弃了单机模式下的syn锁用户id,然后利用事务进行的业务实现一人一单。使用redis实现分布式锁,key是业务名+用户id,存储的是当前线程标识+线程名字,并且利用Lua脚本实现了拿锁,比锁,删锁的原子性。并且进行了如果锁不是自己的就抛出异常实现事务回滚操作。自此大概已经完成一人一单集群模式的代码,大部分情况下可用
