假如说目前有这样一个场景,多个客户同时去下单抢购同一件商品,为了出现防止商品超卖的情况,常规的做法是会进行上锁,锁住这个库存,同一时刻只能有一个扣减库存的操作,防止出现超卖的现象。
在只有一个服务实例的情况下可以使用synchronized来进行上锁控制多线程的并发。但是由于目前大部分的项目都是分布式开发的,所以很多服务会部署多个节点进行抗压保证服务的可靠性,所以synchronized的话就无法保证多个jvm之间的并发操作。此时我们就需要借助第三方工具将原本多个服务之间的并行操作变为串行操作(多服务之间的原子性)。比如mysql的主键冲突(性能不高,不推荐),zookeeper的watch监控,Redission的分布式锁,Redis的setnx。刚开始考虑过引入Redission,它主要还是针对于Redis的集群,提供了很多的API,同样后台线程资源消耗也很高,我们本身的服务就用不到那么多API,所以觉得没必要为了一个分布式锁引入。我们这里就是对Redis的setnx命令进行封装出我们自定义的分布式锁工具类。
代码:
package com.lopu.yxddyjc.yxdd.util;
import com.lopu.yxddyjc.yxdd.function.RedisLockFunction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.concurrent.*;
/**
* redis分布式锁+续期
*
* <p>0.0.1 Version
*
* @author jyd
* @date 2022/11/22 10:45
*/
@Component
@Slf4j
public class RedisLockUtil implements ApplicationContextAware {
private static StringRedisTemplate redisTemplate;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
redisTemplate = applicationContext.getBean(StringRedisTemplate.class);
}
private static final Long EXPIRATION_TIME = 30 * 1000L;
/** 间隔扫描时间 */
private static final Long INTERVAL_CHECK_TIME = 5 * 1000L;
/** 延迟队列,存放扫描的key */
private static final BlockingQueue<Delayed> QUEUE = new DelayQueue();
/** 此线程池用来读取队列中的数据,固定大小为1 队列长度为1,不会从当前队列获取任务,从延迟队列拉取任务 */
private static final ThreadPoolExecutor EXECUTOR =
new ThreadPoolExecutor(
1,
1,
0,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
r -> new Thread(r, "RedisUtilThread"));
@PostConstruct
public void init() {
EXECUTOR.execute(this::startThreadPoll);
log.info("RedisUtil-Thread-Start.....");
}
/** 开启线程池进行扫描 */
private void startThreadPoll() {
for (; ; ) {
try {
Delayed delayed = QUEUE.take();
String key = ((CusmoDelayed) delayed).getData();
log.info("当前线程:{}获取到任务key:{}", Thread.currentThread().getName(), key);
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
log.info("key:{}不存在", key);
continue;
}
if (redisTemplate.getExpire(key, TimeUnit.SECONDS) >= 10L) {
QUEUE.add(new CusmoDelayed(INTERVAL_CHECK_TIME, key));
log.info("key:{}未到阈值,不进行续期", key);
continue;
}
redisTemplate.expire(key, 30, TimeUnit.SECONDS);
QUEUE.add(new CusmoDelayed(INTERVAL_CHECK_TIME, key));
log.info("key续期:{}", key);
} catch (Exception ex) {
log.info("获取队列任务异常,异常原因:{}", ex.getMessage());
// todo 是否需要重新放入到队列执行?
}
}
}
/**
* 分布式锁
*
* <p>抢锁失败直接返回,抢锁成功则设置key过期时长,默认30s
*
* <p>如果代码执行时间过长,则后台自动续期,当过期时间<=10s,则续期到30s,间隔10s检查
*
* <p>注: 间隔时间,过期时间目前固定,后期可能会更换为用户动态指定
*
* @param key key
* @param runFunction 执行方法
* @param failFunction 抢锁失败失败调用方法
*/
public static void lock(
String key, RedisLockFunction runFunction, RedisLockFunction failFunction) {
// 抢锁
if (!snatchLock(key)) {
failFunction.run();
return;
}
// 不进行初始化 防止抢锁失败 创建对象浪费内存空间
CusmoDelayed delay = new CusmoDelayed(INTERVAL_CHECK_TIME, key);
try {
QUEUE.add(delay);
// 讲当前key 添加到线程池进行监听 定时获取key 进行超时时间判断 续期
runFunction.run();
} finally {
// 删除redis的key
// 删除队列的数据
// todo 此处可能会有异常 几率很小,暂时不解决,最坏结果是此闸室30s不能生成闸次计划
releaseLock(key, delay);
}
}
/**
* 分布式锁+续期+尝试等待时间
*
* @param key key
* @param success 成功执行逻辑
* @param fail 失败执行逻辑
* @param tryTimeSecond 尝试等待时间
*/
public static void lock(
String key, RedisLockFunction success, RedisLockFunction fail, Long tryTimeSecond) {
long thresholdTime = System.currentTimeMillis() + tryTimeSecond * 1000;
boolean lockAttemptSucceeded = Boolean.FALSE;
do {
// 保持抢锁
if (!snatchLock(key)) {
// 失败继续尝试抢锁
continue;
}
lockAttemptSucceeded = Boolean.TRUE;
// 不进行初始化 防止抢锁失败 创建对象浪费内存空间
CusmoDelayed delay = new CusmoDelayed(INTERVAL_CHECK_TIME, key);
try {
QUEUE.add(delay);
// 讲当前key 添加到线程池进行监听 定时获取key 进行超时时间判断 续期
success.run();
} finally {
// 删除redis的key
// 删除队列的数据
// todo 此处可能会有异常 几率很小,暂时不解决,最坏结果是此闸室30s不能生成闸次计划
releaseLock(key, delay);
}
break;
} while (System.currentTimeMillis() <= thresholdTime);
// 判断过程抢锁是否成功
if (!lockAttemptSucceeded) {
fail.run();
}
}
/**
* 释放锁
*
* @param key key
* @param delay 队列对象
*/
private static void releaseLock(String key, CusmoDelayed delay) {
redisTemplate.delete(key);
QUEUE.remove(delay);
}
/**
* 上锁
*
* @param key key
* @return 返回
*/
private static boolean snatchLock(String key) {
// 包装类比较 防止拆卸NPE
return Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(key, "1", 30, TimeUnit.SECONDS));
}
}
使用方式
//枪锁失败直接执行fail函数的逻辑然后返回
RedisLockUtil.lock(key, () -> //执行你的业务操作, () -> System.out.println("抢锁失败了!"));
// 枪锁失败尝试等待多少秒,超过时间段执行fail函数的逻辑
RedisLockUtil.lock(key, () -> //执行你的业务操作, () -> System.out.println("抢锁失败了!"), tryTime);
上锁的原理主要是setnx命令,续期的原理是后台启动一个单线程的线程池(也可以设置守护线程),创建一个阻塞延迟队列,抢锁成功后会向延迟队列添加一条延迟10s的数据,线程不断从队列中获取存在redis中缓存的key,从redis中判断是否已经释放,如果释放则返回,如果没有紧接着判断剩余过期时间是否>=10s(根据自己需求来配置),如果小于则进行续期到30s,如果没到时间点则重新加入到队列,等待下一个时间点重新判断。尝试等待的逻辑则是先将当前时间+预期等待时间得到的预期结束时间放在循环条件中,拿当前的时间点去判断有没有超时,如果中途抢锁成功可以执行业务逻辑进行推出,如果没有则会将剩余的fail函数的业务逻辑进行处理返回。
注:这是自定义的第一版分布式锁,可能有一些不足或者可以优化的地方欢迎提出。尝试抢锁的逻辑其实可以修改为:将当前线程wait住放入到队列中去,让出cpu执行的时间片,等待上一个线程释放了锁之后唤醒队列中的线程继续执行业务逻辑,有点类似于zookeeper的watch监听,避免系统资源浪费的问题。