抛砖引玉
假设有个秒杀活动,秒杀手机50台。现在把手机台数放在redis缓存里,然后秒杀成功一次,库存减一,库存没了就告诉用户秒杀失败
看下面这段伪代码
String productName = "iphone11";
Integer num = Integer.parseInt( stringRedisTemplate.opsForValue().get(productName));
if(num > 0){
int realNum = num - 1;
stringRedisTemplate.opsForValue().set(productName,String.valueOf(realNum));
System.out.println("秒杀成功,库存为" + realNum);
}else {
System.out.println("库存不足,秒杀失败");
}
这段代码有个很严重的问题,就是线程不安全。
线程不安全
当多个用户同时访问,同时获取到当前库存,同时然后将库存减1,再写回缓存里。这样必定会发生超卖现象。
线程不安全,有的同学会说了,这个我熟啊,只需加个锁呗。
synchronized (this){
String productName = "iphone11";
Integer num = Integer.parseInt( stringRedisTemplate.opsForValue().get(productName));
if(num > 0){
int realNum = num - 1;
stringRedisTemplate.opsForValue().set(productName,String.valueOf(realNum));
System.out.println("秒杀成功,库存为" + realNum);
}else {
System.out.println("库存不足,秒杀失败");
}
}
如果这个系统只是单体架构,就是一台服务器在跑,只有一个JVM,那其实是可以的啦。但是如果这个一个分布式系统呢,synchronized是JVM层面的锁,同个JVM下对多个线程有效果。但是如果是不同JVM下的两个线程在竞争呢,那就没有屌用了…分布式的系统呢,当然用的是分布式锁嘛
分布式锁
我听过redis有个操作,就setnx。什么意思呢。只有缓存里没有相同的Key,才会set进去,然后返回true。否则返回false。咦~~,这个可以有耶。我们可以让线程进来前,都setnx相同的key值,只有返回true的那个线程才能继续往下走。等它做完它想做的事,再删掉这个key值。
但是会不会有多个线程就在那么一瞬间同时进行setNx,同时设置成功呢?这个不会的。原因是redis是单线程的,不过再怎么同时,你进入redis这边,一定一定是有个先后顺序的。所以这个放心嘛~~。
先来看看代码怎么改
String lockKey = "lockKey";
String lockValue = "lockValue";
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue);
if(!result){
return "It is busy now";
}
String productName = "iphone11";
Integer num = Integer.parseInt( stringRedisTemplate.opsForValue().get(productName));
if(num > 0){
int realNum = num - 1;
stringRedisTemplate.opsForValue().set(productName,String.valueOf(realNum));
System.out.println("秒杀成功,库存为" + realNum);
}else {
System.out.println("库存不足,秒杀失败");
}
stringRedisTemplate.delete(lockKey);
但是这个会有很多问题,假如我加上锁,但是在后面的业务逻辑上抛异常了怎么办?
业务逻辑上抛了异常怎么办
因为抛了异常,程序结束,执行不了删除锁那行代码,造成死锁,后面的线程都进不来了。
有同学会说这个我也熟啊,只要抛了异常还能够执行删除锁那句话,可以用try-finally。代码如下
String lockKey = "lockKey";
String lockValue = "lockValue";
try {
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue);
if(!result){
return "It is busy now";
}
String productName = "iphone11";
Integer num = Integer.parseInt( stringRedisTemplate.opsForValue().get(productName));
if(num > 0){
int realNum = num - 1;
stringRedisTemplate.opsForValue().set(productName,String.valueOf(realNum));
System.out.println("秒杀成功,库存为" + realNum);
}else {
System.out.println("库存不足,秒杀失败");
}
}finally {
stringRedisTemplate.delete(lockKey);
}
嗯~~。但是如果不是抛异常,而是程序跑到中途服务器直接挂机呢。这个用finally也无济于事。
服务器中途挂机了
反正目标是不要造成死锁,我在看看redis还有什么好用的命令。嗯~,我发现有个命令,叫expire,设置过期时间。对,就是这个,可以这样,将这把锁设置为一个过期时间,假如是10秒。那么就算服务挂了,过期时间一到,锁也会失效了。代码如下:
String lockKey = "lockKey";
String lockValue = "lockValue";
try {
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue);
stringRedisTemplate.expire(lockKey,10, TimeUnit.SECONDS);
if(!result){
return "It is busy now";
}
String productName = "iphone11";
Integer num = Integer.parseInt( stringRedisTemplate.opsForValue().get(productName));
if(num > 0){
int realNum = num - 1;
stringRedisTemplate.opsForValue().set(productName,String.valueOf(realNum));
System.out.println("秒杀成功,库存为" + realNum);
}else {
System.out.println("库存不足,秒杀失败");
}
}finally {
stringRedisTemplate.delete(lockKey);
}
嗯~。但是如果好巧不巧,加上锁后,还没到设过期时间之前,服务就挂了呢。
没事,咱还有另外一个API,在set的时候,直接就设置好过期时间了,代码如下:
String lockKey = "lockKey";
String lockValue = "lockValue";
try {
Boolean result =
stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue,10,TimeUnit.SECONDS);
if(!result){
return "It is busy now";
}
String productName = "iphone11";
Integer num = Integer.parseInt( stringRedisTemplate.opsForValue().get(productName));
if(num > 0){
int realNum = num - 1;
stringRedisTemplate.opsForValue().set(productName,String.valueOf(realNum));
System.out.println("秒杀成功,库存为" + realNum);
}else {
System.out.println("库存不足,秒杀失败");
}
}finally {
stringRedisTemplate.delete(lockKey);
}
过期时间太短了怎么办
万一过期时间太短了呢。我设置过期时间为10秒,但是我这个线程在10秒还没走完,锁就过期失效了。那肯定不行的。那设个20秒?30秒?咋不设个一年呢。那还不是一样,问题没解决。咋办呢。可以当锁被加上去以后,再开一个子线程去做一个定时器任务。每隔一段时间,一般是过期时间的1/3,去判断下锁是不是过期了,没过期的话,再续回来,直到主线程结束任务。
那这代码咋写呢,要再开一个子线程,还要考虑其他的细节。
这里已经有个很好的解决方案了,可以用框架redisson,原理是都一样的。这个问题,包括上面的那些问题,redisson已经都考虑到了(当然了)
代码如下
String lockKey = "lockKey";
RLock lock = redisson.getLock(lockKey);
try {
lock.lock();
String productName = "iphone11";
Integer num = Integer.parseInt( stringRedisTemplate.opsForValue().get(productName));
if(num > 0){
int realNum = num - 1;
stringRedisTemplate.opsForValue().set(productName,String.valueOf(realNum));
System.out.println("秒杀成功,库存为" + realNum);
}else {
System.out.println("库存不足,秒杀失败");
}
}finally {
lock.unlock();
}
redisson的底层原理
原理
用到是lua脚本,那么lua脚本能够保证脚本里的业务逻辑的原子性
代码如下
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);
加锁机制
先判断加锁的key存不存在,不存在就加锁,用的是hset命令
后面进来的线程进来会获取到这个锁的剩余生存时间。然后它会进入循环,不断的尝试加锁
可重入锁
可重入用到的是hincrby命令,也是在上面那段lua脚本,锁的次数加上1。
释放锁
逻辑很简单,就是加锁次减1,到0后用del命令删除锁的key值
redisson问题
当然,这个还是有个问题,就是当把key写入redis Master时,此时会异步复制到redis Slave。但是这时要是master挂了的话,还没来得及同步到slave。然后主备切换,slave变成master。但是这时候这台新的master并没有这个key值的数据。然后另外一个线程也过来,咦~,发现没有锁,自己也加上锁。这样就导致两个线程同时都认为自己有锁。