简介
现在很多互联网公司的网站、应用至少都会部署2台以上的机器,形成一个分布式服务部署架构,用以解决单机服务部署架构下的很多问题,比如提升QPS、TPS。但是同时也带来了其他问题,比如事务处理、超卖,当然这些问题都是有解决方案的,本篇文章探讨下用分布式锁来解决我们常说的超卖问题。分布式锁的实现方案也有很多,借助zookeeper、dubbo、redis等都可以实现。本篇用redis实现分布式锁,并逐步分析实现过程中存在的bug,并针对发现的bug逐步完善代码,写一个大型互联网公司,比如类似某东、某宝这些会产生非常高并发的网站常用的实现方案。
初始方案
用redis的setnx原子操作设值命令。
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/test")
public String test() throws InterruptedException{
synchronized (this) {
//假如开始我们的库存stock在redis里面初始值是30
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); //相当于redis.get("stock")
if (stock > 0){
//每访问一次库存就减去1
int realStock = stock - 1;
//把剩余库存重新设置到redis
stringRedisTemplate.opsForValue().set("stock", realStock + ""); //相当于iedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败, 库存不足");
}
}
return "处理结束";
}
以上这种写法是常规的库存业务处理,单机部署访问,这种写法是没问题的,但多台机器部署,并且产生并发访问,就会出现库存超买,因为synchronized是单机锁,只能在同一个JVM进程下生效。
部署多台机器的方式,启动多个服务端口,springboot应用就很方便,改下端口号直接启动就好,用nginx配置多台机器负责均衡,用JMeter模拟同时发送几百个请求
怎么判断出现超买,假如部署了两台机器,如果两台机器都打印了日志 “扣减成功,剩余库存:15”,也就是出现多台机器打印相同的剩余库存,就是超买了。
代码优化 1
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/test")
public String test() throws InterruptedException{
String lockKey = "商品ID";
try {
Boolean setStatus = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"lock", 10L, TimeUnit.SECONDS);
if (!setStatus) {
return "系统繁忙,请稍等!";
}
//假如开始我们的库存stock在redis里面初始值是30
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); //相当于redis.get("stock")
if (stock > 0){
//每访问一次库存就减去1
int realStock = stock - 1;
//把剩余库存重新设置到redis
stringRedisTemplate.opsForValue().set("stock", realStock + ""); //相当于iedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败, 库存不足");
}
} finally {
stringRedisTemplate.delete(lockKey);
}
return "处理结束";
}
这种代码的思路分析:try-finally捕获异常防止代码处理过程中出现的各种bug导致redis锁没有释放,设置redis过期时间防止redis服务停了或者其他意外错误导致没有释放锁,要注意redis的过期时间一定要和值同时设置,不能分开两个命令单独设置值和过期时间,防止设置了值之后没来得及设置过期时间redis服务停了,保证原子操作。(setIfAbsent底层是redis的sexnx和expire命令,因为redis是天生的单例模式即单线程模式,所以无论多少个请求到来,始终都会进行先后顺序排队,一个个执行,所以redis能对同一个资源加锁)。这种写法可以解决一般的问题。
但这种写法还会有bug,而且很难排查。看下面的图就会明白这个bug出现的场景。
假如线程1处理完要15秒,锁过期是10秒,线程1未处理完,锁就过期了,同时线程2来了并加了一把锁,到线程1处理完,同时也会释放锁,而此时释放的也是线程2的锁,这时线程3可以直接进来加锁等处理,依次类推到第4个线程第5个线程 …,这种情况下就导致了锁永久失效。
代码优化 2
这种写法避免了锁永久失效
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/test")
public String test() throws InterruptedException{
String lockKey = "商品ID";
String markId = UUID.randomUUID().toString();
try {
Boolean setStatus = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,markId, 10L, TimeUnit.SECONDS);
if (!setStatus) {
return "系统繁忙,请稍等!";
}
//假如开始我们的库存stock在redis里面初始值是30
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); //相当于redis.get("stock")
if (stock > 0){
//每访问一次库存就减去1
int realStock = stock - 1;
//把剩余库存重新设置到redis
stringRedisTemplate.opsForValue().set("stock", realStock + ""); //相当于iedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败, 库存不足");
}
} finally {
//加一个标记值,如果是当前线程的值则可以处理释放线程,防止释放掉不属于当前线程的锁,导致锁永久失效
if (markId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
stringRedisTemplate.delete(lockKey);
}
}
return "处理结束";
}
还可以对这种写法继续进行优化,代码如下:
这种写法是用 Redisson做分布式锁,解决了锁永久失效,它的底层原理:为当前线程单独开一个子线程,在子线程里面做一个定时即是循环,不断重复检测当前线程的锁是否还未失效,如果是则重新设置过期时间,不断重新设置,为锁续命,这种实现方式在Redissson中叫WatchDog,这样就避免了线程未处理完锁就失效的问题。
/**
* 初始化redisson客户端
* @return
*/
public Redisson redisson () {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);
return (Redisson) Redisson.create(config);
}
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private Redisson redisson;
@RequestMapping("/test")
public String test() throws InterruptedException{
String lockKey = "商品ID";
RLock redissonLock = redisson.getLock(lockKey);
try {
redissonLock.lock(30, TimeUnit.SECONDS);
//假如开始我们的库存stock在redis里面初始值是30
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); //相当于redis.get("stock")
if (stock > 0){
//每访问一次库存就减去1
int realStock = stock - 1;
//把剩余库存重新设置到redis
stringRedisTemplate.opsForValue().set("stock", realStock + ""); //相当于iedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock);
}else {
System.out.println("扣减失败, 库存不足");
}
} finally {
redissonLock.unlock();
}
return "处理结束";
}
====================End ====================
好了,码字留作学习记录,期待各位斧正。