先来引入一个问题,复习复习以前多线程和数据库的知识。
如下所示,有一个Product表,带有库存:
在这里提供一个简单的扣减库存的接口:
@Override
@Transactional
public boolean buy(Integer id) {
boolean b = false;
//1)从数据库获取库存
int stock = mapper.getStock(id);
//2)判断库存是否充足
if (stock > 0) {
System.out.println("库存为:" + stock);
//3)扣减库存
b = mapper.updateStock(id, stock - 1);
}
return b;
}
使用jmeter测试,开启n多个线程同时去扣减id为1的数据的库存(每次减一):
最终的结果显示库存为负数,明显超卖了(有可能是正数),这是在单线程下的并发安全问题。问题的原因是多个线程同时查询,都查询到库存充足,进入if块了,但库存被扣完了,此时就会超卖。
解决这种并发问题有多种:
①在应用层面加锁,也就是加synchronized同步代码块:
public boolean director(Integer id) {
synchronized (this) {
return ((ProductService)AopContext.currentProxy()).buy(id);
}
}
@Override
@Transactional(isolation = Isolation.REPEATABLE_READ, propagation = Propagation.REQUIRED)
public boolean buy(Integer id) {
boolean b = false;
int stock = mapper.getStock(id);
if (stock > 0) {
System.out.println("库存为:" + stock);
b = mapper.updateStock(id, stock - 1);
}
return b;
}
为什么这么写?是因为直接加锁有一点问题——>传送门。
②在数据库层面加悲观锁:在第一条查询语句时就加上排他锁,保证了只有一个线程能够同时查询和更新
③数据库层面加乐观锁。但乐观锁在高并发场景下失败率会急剧增加,严重影响效率
update product set stock=#{count},version=version+1 where id=#{id} and version=#{version}
集群服务的情况:
上面的问题解决方案是在单机服务的基础上,如果我们的服务是分布式的,用户访问的是集群系统中的多个节点,此时加synchronized的方式就不行了,但是在数据库层面加锁还可以解决的。
因为synchronized的锁由本地jvm管控,它是在一个进程的基础上实现的;而分布式系统它包含多个jvm,也就是多个进程,我们代码中的锁对象在这多个jvm中都是独立的,锁的逻辑也是独立的,允许多个线程在不同的节点中同时进入synchronized代码块。
而在数据库层面上锁就不一样了,我们这的数据库是一个全局性的,所有集群中的节点都能感知到它,所以说它可以控制所有节点的加锁情况。但这种锁不是很通用。
分布式锁用于在分布式系统中的多个进程之间保证互斥性。单机锁是在一个进程上有用,而分布式系统中一个服务可能是一个集群,分布在多个进程上,此时单机锁就控制不了了,需要使用一个全局的锁,让集群中的所有节点都能感知到它,需要加锁时都去请求该全部锁。
实现分布式锁的方案有哪些?
分布式锁的特性应该是:多进程可见、互斥、高可用、高性能
前面说的redis通过setnx可以模拟分布式锁这一个过程。如果某线程通过setnx获取到锁,那么后面的线程再执行setnx就会返回0,也就是加锁失败,因为redis单个命令必须是串行执行的,所以可以保证多个线程执行的一致性。
具体实现方式:
private static final String LOCK_NAME = "service:product:lock";
//1.加锁操作
private boolean lock() {
Boolean res = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_NAME, "1");
return res == null ? false : res;
}
//2.解锁操作
private void unlock() {
stringRedisTemplate.delete(LOCK_NAME);
}
public boolean director(Integer id) {
try {
boolean locked = lock();
//如果未获取到锁就等50ms
while (!locked) {
ThreadUtils.sleep(50);
locked = lock();
}
return ((ProductService) AopContext.currentProxy()).buy(id);
} finally {
unlock();
}
}
@Override
@Transactional(isolation = Isolation.REPEATABLE_READ, propagation = Propagation.REQUIRED)
public boolean buy(Integer id) {
boolean b = false;
int stock = mapper.getStock(id);
if (stock > 0) {
System.out.println("库存为:" + stock);
b = mapper.updateStock(id, stock - 1);
}
return b;
}
这是一个简单的redis实现的分布式锁,还比较简陋,存在一些问题:
问题:假如节点A获取到锁,如果节点A突然宕机了,此时锁还没有被释放(DEL key操作),所以会出现类似死锁的现象,导致其它节点永远获取不到锁。
解决方案:在申请锁时设定一个过期时间,超过该时间锁自动释放。假设操作共享资源的时间不超过5s,那么过期时间设置为5s即可:
SETNX lock 1 //添加锁
EXPIRE lock 5 //5s自动过期
但是还有个问题:这是两条语句,存在并发问题,如果某个客户都执行了第一条语句获取到了锁,但是还没来得及设置过期时间就宕机了,那么死锁还是会出现。
还好在Redis 2.6.12之后,Redis 扩展了SET命令的参数,将SETNX和EXPIRE融合为一个命令:
SET lock 1 EX 5 NX
命令解释:EX表示设置过期时间,设置为5s,最后的NX表示SETNX中的NX,不存在key才能设置成功。
另外,过期时间设的不好也会出现问题:
超时时间提前过期,会导致:
1.其他线程也能进入临界区,导致业务出错,出现超卖问题。
2.释放其他线程的锁,导致连锁反应,每次都有两个线程可以同时进入临界区。
这里主要是讲解如何避免释放别人的锁。
解决办法:客户端在加锁时,给锁打上自己的标志,解锁时再校验一下是否为当初的标志,如果是才能解锁,否则啥也不干。
可以用线程ID或UUID作为该标志,如下:
//加锁流程
SET lock $threadId EX 5 NX
解锁时进行校验,类似:
if( threadId == redis.get("lock") ){
redis.del("lock");
}
但是该逻辑也是两条语句组成的,不是原子的,而且redis没有单独的语句和这两条语句的作用相同。在极端情况下,就会出现一个问题:
当线程A判断该锁的id是自己的时候,还没释放锁之前,可能就停了,如果这时候锁突然过期了,而且又冲过来一个线程B趁机抢到了redis的锁,此时线程A继续执行,结果把线程B的锁给释放了。
那怎么办? 这就需要融合Lua脚本来玩了。
在Redis中可以执行Lua脚本,且是单线程的执行,执行时其他请求必须等待,直到Lua脚本执行完毕。这样就保证了原子性。redis提供的调用函数:
#以另一种方式执行redis命令
redis.call('命令名称','key','参数',...)
#执行LUA脚本(可以传参)。 格式:eval 脚本 参数个数 参数.. 其他参数..
eval script numkeys key [key ...] arg [arg ...]
例如:
eval "return redis.call('set','name','jack')" 0
它就是执行set name jack操作。
我们的解锁Lua脚本可以如下:
if redis.call('GET',KEYS[1]) == ARGV[1] then
return redis.call('DEL',KEYS[1])
else
return 0
end
StringRedisTemplate通过如下方法调用lua脚本:
所以改造后的解锁流程应该如下:
public static final String LOCK_NAME = "service:product:lock";
//脚本对象
private static final DefaultRedisScript UNLOCK_SCRIPT;
//初始化解锁的lua脚本对象
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
//设置脚本,可以从外部文件获取,也可以硬编码
UNLOCK_SCRIPT.setScriptText("if redis.call('GET',KEYS[1]) == ARGV[1] then\n" +
" return redis.call('DEL',KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end");
//UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
private void unlock() {
//执行脚本对象
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(LOCK_NAME),
Thread.currentThread().getId()+"");
}
解决方法:
1.加锁时,先设置一个相对较短的过期时间,然后启动一个守护线程,它定时的去检测这个锁的过期时间,如果锁快要过期了,但是锁的持有者还没有完成操作共享资源,那就自动对锁进行续命,重新设置过期时间。
该守护线程称为看门狗线程,是在Redisson框架中的一个实现
2.超时回滚:当我们解锁时发现锁已经被其他线程获取了,说明此时我们执行的操作已经是“不安全”的了,此时需要进行回滚,并返回失败。该方法是乐观锁的一个思想。
使用看门狗这种方式较可靠,但是在极端情况下还是会发生问题:
解决方法:上述问题的根本原因主要是由于 redis 异步复制带来的数据不一致问题导致的,因此解决的方向就是保证数据的一致。
当前比较主流的解法和思路有两种:
因为这种情况是很少遇见的,也比较难,所以就不做过多讲解。
如果想让分布式锁有更多功能,可以看看Redssion。
整体的流程是这样的,一共分为5步:
可以看出,该方案为了解决数据不一致的问题,直接舍弃了异步复制,只使用 master 节点,同时由于舍弃了slave,为了保证可用性,引入了N个节点,官方建议是5。
该方案看着挺美好的,但是实际上我所了解到的在实际生产上应用的不多,主要有两个原因:
1)该方案的成本似乎有点高,需要使用5个实例;
2)该方案一样存在问题。
该方案主要存以下问题:
1)严重依赖系统时钟。如果线程1从3个实例获取到了锁,但是这3个实例中的某个实例的系统时间走的稍微快一点,则它持有的锁会提前过期被释放,当他释放后,此时又有3个实例是空闲的,则线程2也可以获取到锁,则可能出现两个线程同时持有锁了。
2)如果线程1从3个实例获取到了锁,但是万一其中有1台重启了,则此时又有3个实例是空闲的,则线程2也可以获取到锁,此时又出现两个线程同时持有锁了。
针对以上问题其实后续也有人给出一些相应的解法,但是整体上来看还是不够完美,所以目前实际应用得不是那么多。