关于redis分布式锁的逐步升级完善
一、redis分布式锁简介
是什么?
redis分布式锁:可以分为两点:1.分布式 2.加锁
主要作用是,在多副本部署服务的情况下(或者高并发时),相同时间点内,对业务代码进行加锁,业务代码只能被一个线程执行
用了分布式锁,相当于强制将多副本(或者单副本高并发时)并行改成串行执行,其他副本直接返回或者阻塞等待(排队执行)
由于是多副本部署服务, JVM锁某些情况下不能用,诸如synchronized或ReentrantLock只能是锁定当前副本, 分布式锁就能解决锁定全部副本服务
缺点:并行改成串行后,对高并发不友好,处理能力降低
二、分布式锁的作用
为什么要用?解决了什么问题
为了保证一个方法在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLcok或synchronized)进行互斥控制。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题
三、使用场景
什么时候用?
1.DB操作扣减/增加商品库存数量,DB操作扣减/增加财务金额;按顺序记录变动前,变动后,变动值
使用mysql时,如果想知道扣减/增加商品库存数量,mysql不能通过一句sql知道数量变动前, 变动后,变动值,所以可使用加锁后,先查询,在更新的方式。
2.接口防重; 创建订单、支付订单,可以使用用户id进行加锁,防止重复提交; 同一时刻用户只能创建一笔订单或支付一次
3.防止机器高频刷接口;可以使用颁发给前端的token进行加锁
四、实现基本原理
单线程执行命令
不考虑redis集群时, 单体redis服务是单线程执行命令的(get、set、delete等等命令), 命令会排队执行,并不存在多个命令同时执行.
redis服务其实也是多线程,但在执行命令时候是单线程的,所以我们经常说它是单线程。
redis在6.0的版本中引入了多线程, 多线程处理了网络I/O、多线程处理了持久化(RDB, AOF),用来提高性能, 但是执行命令还是保留单线程.
加锁原理:
redis提供底层setnx命令;setnx是一个原子性操作;进行加锁
若key不存在时,才会set值且填充过期时间,返回 1 。
若key已存在时,不做任何动作,返回 0。
解锁原理:
redis提供底层del命令;进行释放锁
执行成功返回 1 ; 否则返回 0。
五、加锁实现方式分析
怎么用?
1、不加锁扣减库存
public String deductStockOriginal() {
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";
}
问题说明:此时查询和扣减库存不是原子性操作,并发场景会出现超卖现象。此时需要增加分布式锁,减少超卖现象发生。
2、本地锁实现加锁
@RequestMapping("/deduct_stocksych")
public String deductStockSych() {
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";
}
3、redis使用stringRedisTemplate增加分布式锁
1、第一种情况redis分布式锁未设置超时时间
public String deductStockIfNoTime() {
String lockKey = "lock:product_101";
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "lockValue"); //jedis.setnx(k,v)
if (!result) {
return "error_code";
}
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(lockKey);
}
return "end";
}
问题描述:如果没有设置超时时间,则出现宕机问题时,会出现死锁,这个锁将一直不能释放,影响其他请求获取锁。
2、将设置缓存和超时时间分开未做到原子性
问题描述:如果将设置锁和设置超时时间分开,则存在原子性问题,如果设置锁后,过期时间还没设置好,此时宕机,则锁没有超时时间将一直存在
3、设置了超时时间,但是没有续期逻辑,会存在逻辑没执行完锁就过期的情况
/* *
* @description: 使用stringRedisTemplate的IfAbsent实现分布式锁
* 此时如果1号线程10s未执行完,锁超时过期,2号线程可以获取到锁,从而执行扣减库存操作
*
* @author: quwuju
* @date: 2023/12/22 14:27
* @param 【null】
* @return: null
*/
@RequestMapping("/deduct_stock1")
public String deductStockIfAbsent() {
String lockKey = "lock:product_101";
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "lockValue", 10, TimeUnit.SECONDS); //jedis.setnx(k,v)
if (!result) {
return "error_code";
}
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(lockKey);
}
return "end";
}
问题说明:此时存在一个问题是如果当前1号线程执行时间较长,过了redis缓存的时间了,那新的2号线程进来就能加锁成功,此时1号线程业务逻辑结束之后,删除锁,删除的是2号线程的锁,很有可能因为某种原因2号线程的业务逻辑还没结束。那3号线程再来加锁也可以加锁成功,线程2结束时删除的是线程3的锁。。。。。。
4、redis使用stringRedisTemplate继续改进升级分布式锁
设置一个clientId作为标识,每个线程只能删除自己的锁
/* *
* @description: 使用stringRedisTemplate实现分布式锁 setnx
* 升级,设置一个clientId作为标识,每个线程只能删除自己的锁
*
* @author: quwuju
* @date: 2023/12/22 14:27
* @param 【null】
* @return: null
*/
@RequestMapping("/deduct_stock")
public String deductStock() {
String lockKey = "lock:product_101";
// 产生一个uuid来区分
String clientId = UUID.randomUUID().toString();
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS); //jedis.setnx(k,v)
if (!result) {
return "error_code";
}
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 (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
stringRedisTemplate.delete(lockKey);
}
}
return "end";
}
问题描述:升级,设置一个clientId作为标识,每个线程只能删除自己的锁。但是超时时间不易确定是第一个问题,第二个问题是假设判断cliedntId的时候刚判断完,卡顿了1s,锁过期了,新的线程又加锁成功,此时执行到删除的逻辑,会将新线程的锁删除掉,此时又不是删除了自己的锁,所以此时就引入redisson来做看门狗锁续期。
5、redis使用redisson实现看门狗机制
1、线程加锁续命逻辑分析
public String deductStockRedisson(String a, int b) {
String lockKey = "lock:product_101";
//获取锁对象
RLock redissonLock = redisson.getLock(lockKey);
//加分布式锁
redissonLock.lock(); // .setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
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 {
//解锁
redissonLock.unlock();
}
return "end";
}
redisson锁流程图
使用lua脚本保证原子性,使用脚本加锁设置超时时间,设置完成之后会回调续期代码逻辑
加锁完成后执行续期,递归续期
2、线程抢夺锁自旋逻辑分析
- 加锁过程中如果成功返回一个空值,直接执行下面逻辑,如果加锁失败会得到当前锁剩余过期时间。
- 此时如果加锁失败,则开始返回值不为null,开始死循环(自旋)
此时会阻塞(当前锁还剩余的过期时间),等等完了剩余的超时时间后开始执行逻辑
getLatch方法里面是获取一个信号量,如果未获取到锁,会等待上一个线程还剩余的超时时间,到时间后继续循环获取锁 信号量
在redisson的解锁逻辑中会通过redis的发布订阅功能。在抢锁过程中会订阅一个channel,解锁的时候会发布消息给订阅的队列,通知等待的线程继续抢锁。
6、redis红锁使用方式
红锁针对redis cluster内置集群,设置主从的时候,当从节点同步主节点数据的时候还没同步,此时主节点宕机了,但是客户端认为这个锁已经设置成功了,此时从节点选举为主节点了,但是从节点没有这把锁就会导致锁失效。为了解决这种问题引入了红锁。
红锁的实现方式是整多个节点(非主从节点,各自独立),每次加锁必须半数以上节点返回成功才算加锁成功。一般也会给每个节点配置从节点。
@RequestMapping("/redlock")
public String redlock() {
String lockKey = "product_001";
//这里需要自己实例化不同redis实例的redisson客户端连接,这里只是伪代码用一个redisson客户端简化了
RLock lock1 = redisson.getLock(lockKey);
RLock lock2 = redisson.getLock(lockKey);
RLock lock3 = redisson.getLock(lockKey);
/**
* 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)
*/
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
/**
* waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
* leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)
*/
boolean res = redLock.tryLock(10, 30, TimeUnit.SECONDS);
if (res) {
//成功获得锁,在这里处理业务
}
} catch (Exception e) {
throw new RuntimeException("lock fail");
} finally {
//无论如何, 最后都要解锁
redLock.unlock();
}
return "end";
}
如果宕机的slave节点重启之后也不存在当前这个key,那2号节点和3号节点都可以加锁成功,此时就是半数以上节点成功,所以红锁失效。
红锁存在的问题可参考文章:红锁存在的问题
红锁相关分析及问题,暂不做赘述:引用别人的文章 红锁相关分析及问题
六、缓存数据库双写不一致(锁的应用)
先读到数据的线程操作数据时慢了,导致其他线程后读先改,导致数据不一致
解决方案在查询数据库和更线缓存这段逻辑加上锁,使整个过程具有原子性,保证不被其他线程打断,从而避免双写不一致问题。代码实现如下代码块中。
@Transactional
public Product update(Product product) {
Product productResult = null;
//RLock updateProductLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
RReadWriteLock readWriteLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
RLock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
productResult = productDao.update(product);
redisUtil.set(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(), JSON.toJSONString(productResult),
genProductCacheTimeout(), TimeUnit.SECONDS);
productMap.put(RedisKeyPrefixConst.PRODUCT_CACHE + productResult.getId(), product);
} finally {
writeLock.unlock();
}
return productResult;
}
七、redis锁的优化使用
1.锁的粒度一定要小
2.可参考concurrentHashMap分段锁实现,比如有1000个商品要加锁,可以分10个锁,每100个商品一个锁。