一、案例引入
简单的一个库存减少案例
@RestController
public class testController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/test_stock")
public String testStock() {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足!");
}
return "end";
}
}
二、初步优化
那么这样一段代码,当多线程来访问的时候是很有可能出现多个线程同时获取到同一个库存值,这样就一定会出现超卖的情况,所以我们首先想到的是加锁,最简单的我们使用synchronized的同步锁代码块可以确保只有一个线程去获取到代码块内部的方法。如下:
@RequestMapping("/test_stock")
public String testStock() {
synchronized (this) {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足!");
}
}
return "end";
}
三、问题分析
按照上述操作在一般的简单的单体架构的项目中已经够用了,都是我们发现如果部署在集群中,这个锁会失效,因为这个是基于jdk的锁,生效范围只是一个jvm中。这样我们就想到要用分布式锁来对集群进行操作。我们可以联想到redis中的一个很简单的命令就能实现分布式锁,那就是setnx命令。
setnx命令:当一个线程通过setnx去存一个值的时候,当这个键里面没有值的时候会直接存进去,并返回true,否则会返回false。不同于普通的set命令可以覆盖。
四、分布式集群入门操作
这里的setIfAbsent其实就是我们在redis中的setnx命令,所以当这个设置的key存在了,新的线程操作就无法进入方法内部,直到这个key过期或者弃用删除了,新的线程才能进入方法。
@RequestMapping("/test_stock")
public String testStock() {
//这个setIfAbsent其实就是我们在redis中的setnx命令
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent("lockKey", "lockValue");
if (!result) {
return "error";
}
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足!");
}
//删除key
stringRedisTemplate.delete("lockKey");
return "end";
}
五、进一步分析
结果分析我们发现:
1、假如代码在执行的时候出现宕机获取其他异常导致该线程迟迟不能执行完成,这时候其他线程也只能干等着,从而造成大量的资源和时间浪费。所以我们分析,必须要对其加上过期时间,而且这个添加过期时间和添加key和value最好要保持原子性,都写在setIfAbsent方法中,因为不保持原子性还是会出现设置的过期时间因为宕机而无法被执行到导致其他线程的一直等待。如设置为:stringRedisTemplate.opsForValue().setIfAbsent("lockKey", "lockValue",10); 这里我们暂时使用10s作为过期时间
2、如果出现代码异常而导致线程无法执行完成,会直接跳过后续代码,导致我们的key没有被删除,其他线程检测到key的存在就会一直等待。所以我们想到了 try-finally,将删除key的代码发到finally中去,确保一定会执行。代码如下:
@RequestMapping("/test_stock")
public String testStock() {
try {
//这个setIfAbsent其实就是我们在redis中的setnx命令,设置过期时间和时间单位
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent("lockKey", "lockValue",10, TimeUnit.SECONDS);
if (!result) {
return "error";
}
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足!");
}
//确保执行出现异常也能释放key
} finally {
//删除key
stringRedisTemplate.delete("lockKey");
}
return "end";
}
六、深入分析
这样一步步我们已经解决了很多问题,我们继续深入分析,我们会发现当过期时间达到后,加入线程还没有执行完成,那么其他线程就会进入执行,就会导致一系列的错误,设置时这个锁起不到作用,那么我们能不能引入UUID来唯一确定一个线程呢,只有当finally中判断的UUID也相同才能将key删除,这样就可以避免有线程执行超时导致其他线程乱入执行。代码如下:
@RequestMapping("/test_stock")
public String testStock() {
String uuid = UUID.randomUUID().toString();
try {
//将UUID存到redis中,后面依据uuid判断是否是同一个线程,即可解决大多数因为线程超时的问题
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent("lockKey", uuid,10, TimeUnit.SECONDS);
if (!result) {
return "error";
}
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足!");
}
} finally {
//判断UUID是否相等
boolean b = uuid.equals(stringRedisTemplate.opsForValue().get("lockKey"));
if(b){
//删除key
stringRedisTemplate.delete("lockKey");
}
}
return "end";
}
七、最终优化
至此,以上的代码对锁的控制,足以满足大多数中小型项目的需求,偶尔的几次超卖的问题还是存在的,但是相比于正常业务的需求 ,这些都在接受范围之内。但是遇到大型的抢购或秒杀是无法承受如此大量的冲击。
所以我们继续深入分析,我们会发现,这个设置的过期时间难道就是十秒吗,答案是否定的,如果这个过期时间设置的错误就会导致一系列的错误,设置时这个锁起不到作用,那么这个过期时间到底是多少呢,不好说,不同的业务会有不同的情况,那我们该怎么解决呢?
1、这个时候我们就可以引入看门狗的机制,这里就需要使用redisson框架,他会每隔一段时间(这个时间一般是你设置的锁持续时间的三分之一)就去检查一次当前业务是否还持有锁,如果有则增加锁的持续时间,当业务执行完成后就直接释放锁。
2、另外这个还有一个好处就是在高并发下,一个业务有可能很快执行完成,先客户1持有锁的时候,客户2来的时候不会直接拒绝,而是自旋不断的尝试获取锁,当客户1释放锁后,客户2就马上持有锁,性能可以得到大大提高。
其中我们使用redisson是需要引入配置的:
@Bean
public Redisson redisson(){
//此为单机模式
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379").setDatabase(0);
return (Redisson) Redisson.create(config);
}
业务代码如下:
@RequestMapping("/test_stock")
public String testStock() {
//拿到一个锁对象
RLock redissonLock = redisson.getLock("lockKey");
try {
//加锁
redissonLock.lock();
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + "");
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足!");
}
} finally {
//解锁
redissonLock.unlock();
}
return "end";
}
八、补充说明
redisson实现的分布式锁能否解决主从一致性的问题?
答案是不能的,比如线程1加锁成功后,master节点数据会异步复制信息到slave节点,此时如果持有锁的master节点宕机了,slave节点就会被提升为新的节点,假如这个时候线程2来了,就会再次加锁,会在新的master节点加锁成功,此时就会出现两个锁同时存在的问题。
这个时候我们可以使用redisson提供的RedLock(红锁)来解决这个问题,他的主要作用是:不能只在一个redis实例创建锁,应该在多个redis实例都创建锁,并且红锁要求redis创建锁的数量要过半,这样才能避免线程1加锁成功后master节点宕机导致线程2成功获取锁到新的master节点。
但是,如果用红锁,需要同时在多个节点都添加锁,性能就会变得很差,并且维护成本也会非常高,所以一般不使用红锁,官方也暂时放弃了红锁。
总结:对于非要保持数据的强一致性,就建议使用zookeeper实现分布式锁。redis本身是支持高可用的,要做到强一致性就会非常影响性能。