mmall_v2.0 分布式锁实现定时关单

在电商项目中,用户购买商品在生成订单后,一定时间内如果没有付款,应该将订单关闭。这里,主要用 Spring Schedule和分布式锁来实现,而分布式锁也分别用 Redis 命令原生实现和 Redisson 框架两种方式。

Spring Schedule 介绍

Spring Schedule 是一个任务调度框架,用于定时任务调度等。主要通过 @Scheduled 注解来创建定时任务,可通过 cron 表达式来指定任务特定的执行时间。

Cron 表达式

Cron 表达式是一个字符串,由 67 个字段组成,对应为 秒、分、时、日、月、周、年(可选)。

允许的值和特殊字符

字段名 允许的值 允许的特殊字符
0-59 , - * /
0-59 , - * /
0-23 , - * /
月内第几天 1-31 , - * / ? L W C
1-12 或 JAN-DEC , - * /
周内第几天 1-7 或 SUN-SAT , - * / ? L C #
年(可选) 留空,1970-2099 , - * /

特殊字符的含义

  • *:匹配任意值,例如秒域为 * 表示每秒都会触发事件;
  • ?: 只能在月内第几天和周内第几天两个域使用,用于执行不明确的值。当两个域之一被指定值后,为避免冲突,需要将另一个的值设为 ?
  • -: 指定一个范围,例如分域为 3-6,表示从 3 分到 6 分钟每分钟触发一次;
  • / : 指定增量,表示起始时间开始触发,然后每隔固定时间触发一次,例如分域为 5/15,则意味着 5 分、20 分、35 分、50 分,分别触发一次;
  • ,:指定几个可选值。例如在分域使用 5,15,则意味着在 520 分各触发一次;
  • L : 表示最后,只能出现在周内第几天和月内第几天域,表示一月的最后一天,或一周的最后一天。如果在周内第几天域前加上数字,表示一月的最后一个第几天。例如 5L 表示一个月的最后一个周五;
  • W : 指定有效工作日(周一到周五),只能在月内第几天域使用,系统将在离指定日期的最近的有效工作日触发。注意一点,W 的最近寻找不会跨过月份;
  • LW : 两个字符可以连用,表示一月的最后一个工作日,即最后一个星期五。
  • # : 指定一月的周内第几天,只能出现在月内第几天域。例如在 2#3,表示一月的第三个星期一(2 表示周一,3 表示第三周)。
  • C:可以在月内第几天和周内第几天使用。

举例

"0 1 * * * *"               表示每小时10秒执行一次

"*/20 * * * * *"            表示每20秒执行一次

"0 0 9-12 * * *"            表示每天9101112点执行一次

"0 0/20 9-12 * * *"         表示每天9点到12点,每20分钟执行一次

"0 0 9-12 * * 2-6"          表示每周一至周五,9点到12点的00秒执行一次

"0 0 0 1 4 ?"               表示41000秒执行一次
复制代码

实现定时任务

首先,在 applicationContext.xml 文件中配置:

<task:annotation-driven/>
复制代码

开启定时任务。注意,导入约束时导入的是 http://www.springframework.org/schema/task

然后,创建定时关闭订单的 task

@Component
@Slf4j
public class CloseOrderTask {

    @Autowired
    private IOrderService iOrderService;

    @Scheduled(cron="0 */1 * * * ?")
    public void closeOrderTaskV1() {
        log.info("关闭订单定时任务启动");
        int hour = Integer.parseInt(PropertiesUtil.getProperty("close.order.task.time.hour", "1"));
        iOrderService.closeOrder(hour);
        log.info("关闭订单定时任务结束");
    }
}
复制代码

表示每隔一分钟就查看是否有超过一个小时的订单未付款,如果有则进行关闭。IOrderServiceImplcloseOrder 方法如下:

@Override
public void closeOrder(int hour) {
    Date closeDateTime = DateUtils.addHours(new Date(), -hour);
    List<Order> orderList = orderMapper.selectOrderStatusByCreateTime(Const.OrderStatusEnum.NO_PAY.getCode(), DateTimeUtil.dateToStr(closeDateTime));
    for (Order order : orderList) {
        List<OrderItem> orderItemList = orderItemMapper.getByOrderNo(order.getOrderNo());
        for (OrderItem orderItem : orderItemList) {
            Integer stock = productMapper.selectStockByProductId(orderItem.getId());
            if (stock == null) {
                continue;
            }
            Product product = new Product();
            product.setId(orderItem.getProductId());
            product.setStock(stock + orderItem.getQuantity());
            productMapper.updateByPrimaryKeySelective(product);
        }
        orderMapper.closeOrderByOrderId(order.getId());
        log.info("关闭订单OrderNo:{}", order.getOrderNo());
    }
}
复制代码

主要逻辑是,首先查询超过一个小时的订单列表,然后对列表中的每一条订单,根据订单号查询商品列表,对每一件商品的库存进行更新,最后,对订单的状态进行修改,即意味着删除。

如此,定时关闭一定时间内未付款的订单的 v1 版本就完成了。但是在 tomcat 集群环境下,每次只需要一台机器执行即可,不用每台机器都执行;而且,多台机器同时执行也容易造成数据错乱。所以,这就需要使用分布式锁来进行保证。

Redis 命令实现分布式锁

Redis 命令

下面是其中会用到的一下 Redis 命令:

1).setnx key value

SET if Not eXists 的简称。如果键不存在,则将键 key 的值设置为 value。否则如果键已经存在,则不做任何操作。

设置成功时返回 1,设置失败时返回 0

2).getset key value

将键 key 的值设为 value,并返回键 key 在被设置之前的旧值。

如果键 key 存在旧值,则会返回。否则如果不存在旧值,也就是键 key 在设置之前并不存在,则返回 nil

3).expire key seconds

为给定的键 key 设置生存时间,当 key 的生存时间为 0(过期) 时,它会被自动删除。

4).del key [key...]

删除给定的一个或多个 key

Redis 分布式锁

Redis 分布式锁原理

Redis 分布式锁的流程图如下:

它的主要原理是:首先,通过 setnx 存入一个 lockkey,如果设置成功,也就是获取锁成功,就为锁设置一个有效期,然后执行业务,之后将 lockkey 删除,最后将锁释放。如果设置失败,也就是获取锁失败,则直接结束。

这里使用 setnx 命令,开始时 Redis 中不存在 lockkeysetnx(lockkey) 就会返回 1,表示本台机器获取到了锁,可以定时执行业务。而其他机器在有效期内获取锁时,lockkey 已经存在,就会返回 0,表示没有获取到锁,其他机器正在执行业务。

构建分布式任务调度

利用 Spring Schedule + Redis 分布式锁构建分布式任务调度,方法 closeOrderTaskV2 版本如下:

@Scheduled(cron = "0 */1 * * * ?")
public void closeOrderTaskV2() {
    log.info("关闭订单定时任务启动");
    long lockTimeout = Long.parseLong(PropertiesUtil.getProperty("lock.timeout", "5000"));
    // 获取锁
    Long setnxResult = RedisShardedPoolUtil.setnx(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, String.valueOf(System.currentTimeMillis() + lockTimeout));
    if (setnxResult != null && setnxResult.intValue() == 1) {
        // 如果返回值是 1,代表设置成功,获取锁
        closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
    } else {
        log.info("没有获取到分布式锁:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
    }
    log.info("关闭订单定时任务结束");
}

private void closeOrder(String lockName) {
    // 修改存活时间
    RedisShardedPoolUtil.expire(lockName, 5);
    log.info("获取{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
    int hour = Integer.parseInt(PropertiesUtil.getProperty("close.order.task.time.hour", "2"));
    iOrderService.closeOrder(hour);
    // 删除 key
    RedisShardedPoolUtil.del(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
    log.info("释放{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
}
复制代码

缺点

如果某台 tomcat 机器成功获取到锁,但在为锁设置有效期之前,tomcat 机器意外关闭,这时就会产生死锁。

可以在 CloseOrderTask 中添加一个 delLock 方法,在销毁之前删除分布式锁:

@PreDestroy
public void delLock() {
    RedisShardedPoolUtil.del(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
}
复制代码

但如果直接 killtomcat 进程,仍然不会调用这个方法,从而产生死锁。

Redis 分布式锁双重防死锁

Redis 分布式锁优化原理

Redis 分布式锁优化后的流程图如下:

它的原理是:同样首先通过 setnx 存入一个 lockkey,如果设置成功,同之前一样。否则,通过 get 获得之前设置的 currentTime + timeout,判断 lockValeA 是否不为 null,并且 currentTime 大于 lockValueA,即分布式锁是否过期。

如果过期,通过 getsetlockkey 对应的 value 设置为 currentTime + timeout,并得到之前的旧值 lockValueB,判断 lockValueB 是否为 null,即 lockkey 是否还存在,或者 lockValueA 是否等于 lockValueB,即在这个过程中锁没有改变。如果条件满足,则表示获取锁成功,同 setnx 获取锁成功一样。

如果锁没有过期,则表示获取锁失败,直接结束。在 getset 后,如果 lockValueB 不为空,即 lockkey 仍然存在,或者锁被改变了,也表示获取锁失败,直接结束。

构建分布式任务调度

利用 Spring Schedule + Redis 分布式锁构建分布式任务调度,方法 closeOrderTaskV3 版本如下:

@Scheduled(cron = "0 */1 * * * ?")
public void closeOrderTaskV3() {
    log.info("关闭订单定时任务启动");
    long lockTimeout = Long.parseLong(PropertiesUtil.getProperty("lock.timeout", "5000"));
    // 获取锁
    Long setnxResult = RedisShardedPoolUtil.setnx(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, String.valueOf(System.currentTimeMillis() + lockTimeout));
    if (setnxResult != null && setnxResult.intValue() == 1) {
        // 如果返回值是 1,代表设置成功,获取锁
        closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
    } else {
        // 未获取到锁,继续判断时间戳,看锁是否过期
        String lockValueStr = RedisShardedPoolUtil.get(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
        if (lockValueStr != null && System.currentTimeMillis() > Long.parseLong(lockValueStr)) {
            // 锁过期,重置并获取锁
            String getSetResult = RedisShardedPoolUtil.getSet(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, String.valueOf(System.currentTimeMillis() + lockTimeout));
            if (getSetResult == null || (getSetResult != null && StringUtils.equals(lockValueStr, getSetResult))) {
                closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
            } else {
                log.info("没有获取到分布式锁:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
            }
        } else {
            log.info("没有获取到分布式锁:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
        }
    }

    log.info("关闭订单定时任务结束");
}
复制代码

Redisson 实现分布式锁

Redisson 是架设在 Redis 上的一个 Java 驻内存数据网格,它在基于 NIONetty 框架上,充分地利用了 Redis 键值数据库提供的一系列优势。

Redisson 集成到项目中,只需要在 pom.xml 文件需要添加 redissonjackson-dataformat-avro 的依赖。

Redisson 初始化类

public class RedissonManager {

    private Config config = new Config();
    private Redisson redisson;

    private static String redis1IP = PropertiesUtil.getProperty("redis1.ip", "192.168.23.130");
    private static Integer redis1Port = Integer.parseInt(PropertiesUtil.getProperty("redis1.port", "6379"));

    @PostConstruct
    public void init() {
        try {
            config.useSingleServer().setAddress(redis1IP + ":" + redis1Port);
            redisson = (Redisson) Redisson.create(config);
            log.info("初始化 Redisson 结束");
        } catch (Exception e) {
            log.error("初始化 Redisson 失败", e);
        }
    }

    public Redisson getRedisson() {
        return redisson;
    }
}
复制代码

这里使用单服务器模式,在传入地址时采用 ip:port 格式。

任务调度 v4 版本

tryLock 方法在获取锁时,三个参数分别为:尝试获取锁最多等待的时间、获取锁后自动释放的时间、时间单元。

@Scheduled(cron = "0 */1 * * * ?")
public void closeOrderTaskV4() {
    RLock lock = redissonManager.getRedisson().getLock(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
    boolean locked = false;
    try {
        if(locked = lock.tryLock(0, 5, TimeUnit.SECONDS)) {
            log.info("Redisson 获取到分布式锁:{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
            int hour = Integer.parseInt(PropertiesUtil.getProperty("close.order.task.time.hour", "2"));
            iOrderService.closeOrder(hour);
        } else {
            log.info("Redisson 没有获取到分布式锁:{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());

        }
    } catch (InterruptedException e) {
        log.error("Redisson 获取分布式锁异常", e);
    } finally {
        if (!locked) {
            return;
        }
        lock.unlock();
        log.info("Redisson 释放分布式锁");
    }
}
复制代码

这里 tryLock 方法在获取锁之后,如果后续的执行业务时间小于 1 秒,而另外的 tomcat 在等待 1 秒后,又能重新获取锁,就会出现两个进程都获得锁的情况。

所以,应该将 waitTime 设置为 0waitTime 时间小于业务执行时间)。

猜你喜欢

转载自juejin.im/post/5c64dbbde51d45012d0661f4