分布式环境下高并发访问衍生的问题
针对单机环境下的并发访问,可以通过锁机制(Syschronized或独占锁等)来进行控制,使得一个资源在一段时间内只能被一个线程访问;但在多服务器的分布式环境下,并发访问同一个资源,可能会导致被同时修改或更新,原因在于juc包下的并发控制机制,都是基于JVM层面的,而分布式环境下的多服务器场景,每一个部署了应用的Tomcat服务器都有一个自己的JVM,属于JVM层的锁是无法跨JVM进行资源独占保护的。
以商品减少库存为例,假设库存是100
在redis上设置初始库存
背景
正常单机环境下,库存会按顺序减少,且不同的线程在库存足够的情况下都可以获得不同的商品。但在集群的环境下,为了减轻服务器的压力,都会有一个负载均衡,或是nginx或是ribbon或F5等其他的方式,根据不同的负载均衡策略,将请求分发到集群的各个服务器上,此时可能会有多个请求同时下单成功,且库存只减少了一次
解决方法
为了解决该问题,可以在减库存的操作发生前,加一把锁,只有获取这把锁的请求,才能下单成功;当一个请求获取到了锁,在它执行完减库存释放锁之前,其他请求只能等待。
通过Redis的SetNx命令,其特点为,当key不存在才能设置成功;否则不做任何操作返回false
@RequestMapping("/deduct_stock")
public String deductStock() {
String countKey = "商品id";
boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, "zhangsan");
if(!lock){
return "没获取到锁";
}
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
//下单成功 释放锁
stringRedisTemplate.delete(countKey);
return "end";
}
衍生问题1
针对无异常的情况,上述方案可以解决同一商品被下单多次的问题,但若是下单过程中出现异常,锁未释放就中断,会导致后续的请求都无法下单这一类的商品,因此要有异常处理
@RequestMapping("/deduct_stock")
public String deductStock() {
String countKey = "商品id";
boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, "zhangsan");
if(!lock){
return "没获取到锁";
}
try{
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
}finally {
//下单成功 释放锁
stringRedisTemplate.delete(countKey);
}
return "end";
}
衍生问题2
即使是出现异常的情况,该锁也会释放,不会影响到其他的请求访问,但也有可能出现应用宕机或投产关停在释放锁之前的情况,因此,最合适的方法是设置过期时间
@RequestMapping("/deduct_stock")
public String deductStock() {
String countKey = "商品id";
//设置值和key的过期时间,最好是合并成一个原子操作,保证上了锁不会因程序中断而无法释放
// boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, "zhangsan");
// stringRedisTemplate.expire(countKey, 10, TimeUnit.SECONDS);
boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, "zhangsan",10,TimeUnit.SECONDS);
if(!lock){
return "没获取到锁";
}
try{
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
}finally {
//下单成功 释放锁
stringRedisTemplate.delete(countKey);
}
return "end";
}
衍生问题3
这种处理可以在并发不大的场景下进行使用,但在高并发的大量请求下,还有可能衍生一种新的问题,锁失效:指的是因为并发高了或者其他原因,导致业务逻辑执行的时间超过了锁的过期时间,在请求1未执行完时锁就过期了,此时库存的数量可能还未减少;但另一个请求2因为锁过期,可以进行下单操作,在该请求2还未执行结束时,请求1执行结束了,此时它会执行delete锁的操作,这个时候会导致请求2的锁被请求1释放,以此类推,redis中设置的这个锁的存在时间可能会越来越短,甚至会刚设置好就被上一个或其他请求给清理。
针对这种情况,在释放锁的时候,应该添加一个唯一标识,只有是当前请求进来设置的锁,才能释放
@RequestMapping("/deduct_stock")
public String deductStock() {
String countKey = "商品id";
//
// boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, "zhangsan");
// stringRedisTemplate.expire(countKey, 10, TimeUnit.SECONDS);
//增加唯一标识
String primaryKey = UUID.randomUUID().toString();
boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, primaryKey,10,TimeUnit.SECONDS);
if(!lock){
return "没获取到锁";
}
try{
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
}finally {
//下单成功 有当前请求设置的锁,才能删除
if(primaryKey.equals(stringRedisTemplate.opsForValue().get(countKey))){
stringRedisTemplate.delete(countKey);
}
}
return "end";
}
但即使这种情况,由于判断+释放是两步操作,也不是一个原子性的,可以通过锁续命来处理,其实现机制为,启动一个子线程来判断执行业务逻辑的线程是否执行完,若没有,重置其过期时间,以避免,判断后卡顿导致锁未释放。
RedisSon
是一个基于Redis的扩展类库,针对以上的过期时间早于程序的释放锁问题,其提供了良好的解决方案,即通过类似redis中的setNx的操作,加上lua脚本来达到控制分布式系统下并发请求的安全性。
Lua脚本
在Redis中,它的好处在于,可以将多个操作写到一起,组成一个原子性的操作,例如:下面的操作是要先获取key的值进行判断,然后才能进行delete这是两步的操作;但若是使用Lua脚本,就可以将这两个步骤整合到一个Lua脚本中去执行,而Lua脚本的执行是具有原子性的。
@RequestMapping("/deduct_stock")
public String deductStock() {
String countKey = "商品id";
//获取锁对象
RLock rLock = redisson.getLock(countKey);
//加锁
rLock.lock();
try{
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
} else {
System.out.println("扣减失败,库存不足");
}
}
//解锁
rLock.unlock();
return "end";
}