pom.xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>2.7.0</version>
</dependency>
</dependencies>
我们使用的redis客户端库是springboot的。
application.yml
spring:
redis:
database: 0
host: 192.168.8.125
port: 6379
jedis:
pool:
max-active: 8
我们肯定要讲多个线程同时购买某件商品的例子。
多个线程同时进入我们的order()
方法,一定会有并发问题,这个问题的结果就是:超卖。
@RestController
public class ShopCartController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping(value = "/order")
public String order() {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
stock -= 1;
stringRedisTemplate.opsForValue().set("stock", stock + "");
System.out.println("succeed in deduction! stock: " + stock);
} else {
System.out.println("no more stock");
}
return "operation ends!";
}
}
因为有自动装配,所以StringRedisTemplate
可以直接用。
我们可以用synchronized
来解决并发问题。
但是实际情况是,服务部署在多个tomcat上,也就是说,有多个jvm,这时synchronized
是不起作用的。
所以我们要在redis上面做文章。
在redis命令中,有
setnx
的含义是set if not exists。
如果key不存在,则可以set;否则不能set。
我们可以用这个特性来做一把锁。
public static final String product = "computer";
@RequestMapping(value = "/order")
public String orderClothes() {
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(product, "ocean");
if(!lock){
return "locked by others";
}
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
stock -= 1;
stringRedisTemplate.opsForValue().set("stock", stock + "");
System.out.println("succeed in deduction! stock: " + stock);
} else {
System.out.println("no more stock");
}
stringRedisTemplate.delete(product);
return "operation ends!";
}
比如A操作成功了,那么他就会执行业务代码。其他人就会得到locked by others
这样的反馈。
最后我们一定要将用来做锁的key给删掉。这样子其他人操作setIfAbsent
就会成功了。
如果在执行业务是抛出了异常:
if (stock > 0) {
stock -= 1;
//exception fired when executing service
stringRedisTemplate.opsForValue().set("stock", stock + "");
System.out.println("succeed in deduction! stock: " + stock);
这时stringRedisTemplate.delete(product);
就不会执行,锁永远没有释放,出现了死锁。
我们的解决办法是try catch。
try {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
stock -= 1;
//exception fired when executing service
stringRedisTemplate.opsForValue().set("stock", stock + "");
System.out.println("succeed in deduction! stock: " + stock);
} else {
System.out.println("no more stock");
}
}catch (Exception e){
}finally {
stringRedisTemplate.delete(product);
}
有了finally
,jvm就会保证给你把锁释放了。
如果宕机了,jvm直接就挂了,即使写了finally
也无济于事。
那么,我们就只能指望redis了。
redis的超时机制会帮我们把锁删掉。
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(product, "ocean",30, TimeUnit.SECONDS);
在设置锁的时候就给它30秒的过期时间,这样就不用担心宕机了。
基于上面的讨论,我们将方法抽取出来继续研究。
接口:
public interface RedisLock {
boolean tryLock(String key, long timeout, TimeUnit timeUnit);
void releaseLock(String key);
}
实现类:
@Component
@Scope("singleton")
public class RedisLockImpl implements RedisLock {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
@Override
public boolean tryLock(String key, long timeout, TimeUnit timeUnit) {
String uuid = UUID.randomUUID().toString();
threadLocal.set(uuid);
boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, timeout, timeUnit);
return lock;
}
@Override
public void releaseLock(String key) {
if (threadLocal.get().equals(stringRedisTemplate.opsForValue().get(key))) {
stringRedisTemplate.delete(key);
}
}
}
这就是前面讨论的逻辑的抽取。
那么为什么要用threadLocal
呢?就是为了让生成的uuid可以在同一个线程中传递,并且保证线程之间的隔离。
为什么要传递呢?
因为我们在releaseLock
的时候,需要从threadLocal
中取出在tryLock
中存的uuid。然后和redis中存的比较。如果是一样的,那就释放锁。
为什么要这么做呢?这是为了保证先tryLock
后releaseLock
的顺序。如果在releaseLock
时没有这个比较的限制,有的程序员可能就不小心先调用了releaseLock
,出现锁的误删。
如果我还有其他业务:
public class ServiceA {
private ServiceB serviceB;
public void methodA(){
serviceB.methodB();
}
}
public class ServiceB {
private RedisLock redisLock;
public void methodB() {
String key = "TV";
boolean lock = redisLock.tryLock(key, 20, TimeUnit.SECONDS);
if(!lock){
return;
}
try{
//TODO
//service code
}finally {
redisLock.releaseLock(key);
}
}
}
我在加锁之后又执行了methodB()
方法会怎样?
原有的order()
方法变为:
if (stock > 0) {
stock -= 1;
//exception fired when executing service
stringRedisTemplate.opsForValue().set("stock", stock + "");
System.out.println("succeed in deduction! stock: " + stock);
//other services
new ServiceA().methodA();
......
现在的问题是,methodB()
里面能够拿到锁吗?
显然是不行的,里面的boolean lock = redisLock.tryLock(key, 20, TimeUnit.SECONDS);
会返回false
。
这就是不可重入锁。
我们希望锁是能够重入的,也就是说,同一条线程能够两次获得锁。
@Override
public boolean tryLock(String key, long timeout, TimeUnit timeUnit) {
boolean lock;
//if it's the first time to obtain the lock
if(threadLocal.get()==null) {
String uuid = UUID.randomUUID().toString();
threadLocal.set(uuid);
lock = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, timeout, timeUnit);
}else{
//if the lock has been obtained
lock = true;
}
return lock;
}
想一下,到了methodB()
,它会直接走else
,也能够拿到锁。
但是还有问题。
想像一下:
if (stock > 0) {
stock -= 1;
//exception fired when executing service
stringRedisTemplate.opsForValue().set("stock", stock + "");
System.out.println("succeed in deduction! stock: " + stock);
//other services
new ServiceA().methodA();
} else {
System.out.println("no more stock");
}
other services走完之后,methodB()
会redisLock.releaseLock(key);
,这时候就把锁的那个key给清掉了。如果此时有一个线程过来拿锁,当然是能够成功获得的。
也就是在ShopCartController
里:
public String order() {
Boolean lock = redisLock.tryLock(product,30,TimeUnit.SECONDS);
........
能返回true
。
这当然是不对的,因为我第一个线程还没完事呢。
所以我们要在RedisLockImpl
中加入记录获取锁次数的属性:
private static int lockCount = 0;
@Override
public boolean tryLock(String key, long timeout, TimeUnit timeUnit) {
boolean lock;
//if it's the first time to obtain the lock
if(threadLocal.get()==null) {
String uuid = UUID.randomUUID().toString();
threadLocal.set(uuid);
lock = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, timeout, timeUnit);
}else{
//if the lock has been obtained
lock = true;
}
if(lock){
lockCount++;
}
return lock;
}
@Override
public void releaseLock(String key) {
lockCount--;
if(lockCount==-1) {
if (threadLocal.get().equals(stringRedisTemplate.opsForValue().get(key))) {
stringRedisTemplate.delete(key);
}
}
}
每次获取锁,lockCount
加一。
只有当lockCount
为-1时才删掉key。这就保证了只能在order()
方法里才能释放锁。
没有获取到锁的线程只是单纯的
if(!lock){
return "locked by others";
}
这是阻塞锁。
我们要非阻塞锁,或者说,要乐观锁。
这时没获取锁的线程就不断去获取,这是自旋:
if(lock){
lockCount++;
}else {
while (true){
lock = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, timeout, timeUnit);
if(lock){
break;
}
}
}
没有获取锁就一直去拿,知道lock为true
。
这些知识点,和ReentrantLock
一模一样。
还有一个问题,如果第一个拿到锁的线程的执行时间超过了30秒怎么办?
30秒后key就失效了,其他线程就能够拿到锁了,这是不对的。
所以,我们要定时去续这个key的命:
在tryLock
代码中添加一个异步逻辑:
new Thread(() -> {
stringRedisTemplate.expire(key,30, TimeUnit.SECONDS);
try {
TimeUnit.SECONDS.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
每隔10秒,我去把这个key的时间重新设置成30秒。