不得不说,黑马点评是一个非常不错的课程,对于线程安全方面的讲解十分详细且明朗,故写下这篇笔记方便复习及帮助后人()
目标
我们的目标是对于大量对于优惠劵的访问时,要防止超卖问题以及一人多单问题。
单JVM(非集群)
非集群的话解决方式很简单:
1.超卖问题
问题出在我们每次操作完查询优惠劵数量时,准备将优惠劵数量减一时,这个间隔出现了大量的其他线程进行同样操作,把优惠劵减到了0以下:
解决方式也很简单:
在减操作时同时检测当时的数量是否大于0即可:
boolean success = seckillVoucherService
.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock",0)
.update();
if(!success){
return Result.fail("已抢光!");
}
如果大于0,说明当前的确有剩下,反正说明没有了,就返回报错。
这里利用的是数据库操作的原子性,我们之所以会出错,就是因为我们的查询和操作之间有间隔,但是数据库操作时会加锁,数据库的查询与操作是具有原子性的!
这样就能利用数据库的原子性来帮助我们变相“加锁”
这样的话仍然是并行,效率较高,属于乐观锁
如果采用悲观锁的话,只能像之前用互斥锁那样来手动变成串行,效率较低,不建议使用。
2.一人一单
这里的思路很明确:在购买之前手动查数据库看看是不是已经买过了。
但是同样存在线程问题:你的查询和购买存在间隙,可能你查的时候你还没买,正在买时另一个线程已经买完了提交了,那么你就买了两张
怎么解决呢?手动在查询和购买之间加锁:
@Transactional
public Result getResult(Long voucherId) {
synchronized (userId.toString().intern()) {
long userId = UserHolder.getUser().getId();
int cnt = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (cnt > 0) {
return Result.fail("已经购买过!");
}
boolean success = seckillVoucherService
.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("已抢光!");
}
long orderId = redisIdGenerator.nextId("order");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
save(voucherOrder);
return Result.ok(orderId);
}
}
这样子看上去的确防止了这种问题
但是我们对这个方法开启了事务处理:
在执行完这个方法后,
锁被释放,但是事务还没有提交!!!
那么数据库的操作还没有被提交!!!
这个间隙就可能会被其他线程进入,继续操作!!!
那么我们的锁就要包括整个方法,让这个方法从头到尾完全执行完之后才能释放锁!
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.getResult(voucherId);
}
改成这样即可
这里为什么要手动获取代理对象呢?
原因在于,只有 getResult是被@Transactional代理的,但你直接
return getResult(voucherId)实际上是 returbn this.getResult(voucherId),返回的是直接调用而不是通过代理对象导入(我写过一篇关于代理的文章)
所以要手动从代理处调用
这样即可解决一人一单问题。
集群的问题
1.误删问题
误删是指当线程A堵塞,导致锁超时自动释放时,线程B开始获取锁进行工作,就在工作中,堵塞的线程A苏醒并完成了工作释放了锁,线程C就会进入与正在工作的线程B竞争,引发安全问题。
解决方案非常简单:对于每一把锁赋予创建线程的唯一标识,只有具有该标识的线程才能释放该锁:
@Component
public class SimpleRedisLock {
@Autowired
private RedisTemplate redisTemplate;
static final String uuid = UUID.randomUUID().toString() + "-";
public boolean tryLock(String name, Long expireTime) {
long threadId = Thread.currentThread().getId();
//threadId区分线程,uuid区分JVM
String key = "lock:" + name ;
Boolean b = redisTemplate.opsForValue().setIfAbsent(key, uuid + threadId, expireTime, TimeUnit.SECONDS);
return Boolean.TRUE.equals(b);
}
public void unlock(String name) {
String key = "lock:" + name;
long threadId = Thread.currentThread().getId();
String id = (String) redisTemplate.opsForValue().get(key);
if (id == uuid + threadId) {
redisTemplate.delete(key);
}
}
}
UUID是static,这样的话同一个JVM的uuid就相同了,uuid用来区分不同JVM,而threadId用来区分不同的线程。
这样就貌似保证了在集群环境下每把锁只能解锁自己获取的锁。
2.原子性问题
但是这样还可能有一个问题:
假如一个线程判断完了线程属于自己,正准备释放锁时,被阻塞了(GC等),时间长到了触发了锁的自动释放,就会有另外一个线程获得锁并进入。
这时假如阻塞线程苏醒,那它就会立刻释放锁:因为之前判断过了:就会放进其他线程造成安全问题。
但实际上这种情况并不会发生(黑马点评例子没举好):
因为我们的锁标识是threadId+uuid,阻塞线程想要释放锁执行的是:
public void unlock(String name) {
String key = "lock:" + name;
long threadId = Thread.currentThread().getId();
String id = (String) redisTemplate.opsForValue().get(key);
if (id == uuid + threadId) {
redisTemplate.delete(key);
}
}
就算是删也是根据这个锁的唯一标识(threadId + uuid)来删,根本不会误删其它线程的锁.
但是,如果无法保证查询和操作的原子性,就存在安全性问题,还是需要解决,思路也很简单,让数据库帮我们查询和删除,因为数据库操作是有原子性的!
这里我们就需要Redis执行Lua脚本来实现同时查询和删除!
if(redis.call('get',KEYS[1]) == ARGV[1]) then
return redis.call('del',KEYS[1])
end
return 0
Lua的语法建议学一下
将脚本存储到resources中
修改锁代码:
@Component
public class SimpleRedisLock {
@Autowired
private RedisTemplate redisTemplate;
static final String uuid = UUID.randomUUID().toString() + "-";
static final DefaultRedisScript<Long> script;
static {
script = new DefaultRedisScript<>();
script.setResultType(Long.class);
script.setLocation(new ClassPathResource("unlock.lua"));
}
public boolean tryLock(String name, Long expireTime) {
long threadId = Thread.currentThread().getId();
//threadId区分线程,uuid区分JVM
String key = "lock:" + name ;
Boolean b = redisTemplate.opsForValue().setIfAbsent(key, uuid + threadId, expireTime, TimeUnit.SECONDS);
return Boolean.TRUE.equals(b);
}
public void unlock(String name) {
String key = "unlock:" + name ;
long threadId = Thread.currentThread().getId();
redisTemplate.execute(script, Collections.singletonList(key),uuid + threadId);
}
}
即可完美解决原子性问题.
终极解决方案
有些人可能会问了:
作者作者,你说的Lua脚本实现原子性,什么悲观锁乐观锁确实高大上,但是有没有更简单的方案啊
有的兄弟有的,这样简单又好用的我们还有 Redisson! 各种锁一键傻瓜式使用,轻松帮你实现各种高大上复杂的锁!