CRUD补全计划
首页
  • Java-集合框架

    • Java集合-概述
    • Java集合-源码解析
  • Java-并发相关

    • Java并发-概述
    • Java并发-线程池
    • Java并发-锁详解
  • Java-JVM相关

    • Java-类加载机制
    • Java-垃圾回收机制
  • SQL 数据库

    • MySQL详解
    • MySQL-索引
    • MySQL-事务
  • NoSQL 数据库

    • Redis-概述
    • Redis-Zset实现原理
    • Redis-AOF与RDB
  • Spring知识体系

    • Spring-IOC概述
    • Spring-IOC源码分析
    • Spring-AOP原理详解
  • ORM框架

    • Mybatis架构
    • Mybatis执行流程
    • Mybatis缓存原理
  • RPC框架

    • Dubbo详解
  • 限流框架

    • 限流框架详解
  • Web容器

    • Tomcat详解
  • 架构基础

    • 高并发-缓存
    • 高并发-限流
  • 场景实现

    • 场景概述
    • 订单过期关闭
    • 库存扣减
  • 概述

    • 机器学习概述
    • 网站roadmap
    • 关于我
    • 友链
首页
  • Java-集合框架

    • Java集合-概述
    • Java集合-源码解析
  • Java-并发相关

    • Java并发-概述
    • Java并发-线程池
    • Java并发-锁详解
  • Java-JVM相关

    • Java-类加载机制
    • Java-垃圾回收机制
  • SQL 数据库

    • MySQL详解
    • MySQL-索引
    • MySQL-事务
  • NoSQL 数据库

    • Redis-概述
    • Redis-Zset实现原理
    • Redis-AOF与RDB
  • Spring知识体系

    • Spring-IOC概述
    • Spring-IOC源码分析
    • Spring-AOP原理详解
  • ORM框架

    • Mybatis架构
    • Mybatis执行流程
    • Mybatis缓存原理
  • RPC框架

    • Dubbo详解
  • 限流框架

    • 限流框架详解
  • Web容器

    • Tomcat详解
  • 架构基础

    • 高并发-缓存
    • 高并发-限流
  • 场景实现

    • 场景概述
    • 订单过期关闭
    • 库存扣减
  • 概述

    • 机器学习概述
    • 网站roadmap
    • 关于我
    • 友链
  • 场景实现

    • 场景实现概述
    • 订单超时关闭
      • 库存的超卖与少卖
    • Scene
    • 场景实现
    zfd
    2024-08-11
    目录

    订单超时关闭

    # 前言

    在电商、支付等系统中,一般都是先创建订单(支付单),再给用户一定的时间进行支付,如果没有按时支付的话,就需要把之前的订单(支付单)取消掉。这种类似的场景有很多,还有比如到期自动收货、超时自动退款、下单后自动发送短信等等都是类似的业务问题。

    订单的到期关闭的实现有很多种方式,总结如下:

    1. 被动关闭
    2. 定时任务
    3. DelayQueue
    4. 时间轮
    5. Kafka
    6. RocketMQ延迟消息
    7. RabbitMQ死信队列
    8. Redis过期监听
    9. Redis的ZSet
    10. Redisson

    扩展知识

    # 被动关闭

    在解决这类问题的时候,有一种比较简单的方式,那就是通过业务上的被动方式来进行关单操作。

    简单来说,就是订单创建好了之后。系统上不做主动关单,什么时候用户来访问这个订单了,再去判断时间是不是超过了过期时间,如果过了时间那就进行关单操作,然后再提示用户。

    优点

    1. 无需开发定时关闭功能

    缺点

    1. 用户长期不看的话,一直存在脏数据
    2. 查询订单的时候会有额外的耦合逻辑,耗时更长,处理更复杂

    建议

    不建议使用

    # 定时任务

    定时任务关闭订单,这是很容易想到的一种方案。具体实现细节就是我们通过一些调度平台来实现定时执行任务,任务就是去扫描所有到期的订单,然后执行关单动作。

    优点

    1. 实现简单,基于Timer Quartz 或者 xxl-job类似的调度框架都可以简单实现

    缺点

    1. 时间不精准
    2. 处理大量的订单会耗时比较久(单线程轮询)
    3. 轮询的时候 数据库会处于尖刺压力中
    4. 分库分表会导致 订单轮询代码异常复杂

    建议

    大型订单系统不推荐,少量订单可以简单高效实现

    # DelayQueue

    有这样一种方案,直接基于应用自身就能实现,那就是基于JDK自带的DelayQueue来实现。

    DelayQueue是一个无界的BlockingQueue,用于放置实现了Delayed接口的对象,其中的对象只能在其到期时才能从队列中取走。

    基于延迟队列,是可以实现订单的延迟关闭的,首先,在用户创建订单的时候,把订单加入到DelayQueue中,然后,还需要一个常驻任务不断的从队列中取出那些到了超时时间的订单,然后在把他们进行关单,之后再从队列中删除掉。

    这个方案需要有一个线程,不断的从队列中取出需要关单的订单。一般在这个线程中需要加一个while(true)循环,这样才能确保任务不断的执行并且能够及时的取出超时订单。

    优点

    1. 实现简单,编码简单,无外部依赖

    缺点

    1. DQ基于JVM内存的,机器重启会导致数据丢失
    2. 仅能处理单机订单
    3. 单机大量订单会导致 OOM

    建议

    1. 只使用单机系统,数据量不大的场景,分布式场景不建议使用

    # 时间轮

    JDK自带的DelayQueue类似,基于时间轮实现。

    为什么要有时间轮呢?主要是因为DelayQueue插入和删除操作的平均时间复杂度——O(nlog(n)),虽然已经挺好的了,但是时间轮的方案可以将插入和删除操作的时间复杂度都降为O(1)。

    时间轮可以理解为一种环形结构,像钟表一样被分为多个 slot。每个 slot 代表一个时间段,每个 slot 中可以存放多个任务,使用的是链表结构保存该时间段到期的所有任务。时间轮通过一个时针随着时间一个个 slot 转动,并执行 slot 中的所有到期任务。

    基于Netty的HashedWheelTimer可以帮助我们快速的实现一个时间轮,这种方式和DelayQueue类似,缺点都是基于内存、集群扩展麻烦、内存有限制等等。

    但是他相比DelayQueue的话,效率更高一些,任务触发的延迟更低。代码实现上面也更加精简。

    所以,基于Netty的时间轮方案比基于JDK的DelayQueue效率更高,实现起来更简单,但是同样的,只适合在单机场景、并且数据量不大的场景中使用,如果涉及到分布式场景,那还是不建议使用。

    优点

    O(1)的时间复杂度

    缺点

    类似DelayQueue

    建议

    同DelayQueue

    # Kafka的时间轮

    既然基于Netty的时间轮存在一些问题,那么有没有其他的时间轮的实现呢?

    还真有的,那就是Kafka的时间轮,Kafka内部有很多延时性的操作,如延时生产,延时拉取,延时数据删除等,这些延时功能由内部的延时操作管理器来做专门的处理,其底层是采用时间轮实现的。

    而且,为了解决有一些时间跨度大的延时任务,Kafka 还引入了层级时间轮,能更好控制时间粒度,可以应对更加复杂的定时任务处理场景

    Kafka 中的时间轮的实现是 TimingWheel 类,位于 kafka.utils.timer 包中。基于Kafka的时间轮同样可以得到O(1)时间复杂度,性能上还是不错的。

    基于Kafka的时间轮的实现方式,在实现方式上有点复杂,需要依赖kafka,但是他的稳定性和性能都要更高一些,而且适合用在分布式场景中。

    优点

    1. 实现简单,有现成的工具包、性能也比较高效
    2. 适用于分布式场景中

    缺点

    1. 依赖kafka实现

    建议

    内部如果使用kafka建议可以使用

    # RocketMQ延迟消息

    相比于Kafka来说,RocketMQ中有一个强大的功能,那就是支持延迟消息。

    延迟消息,当消息写入到Broker后,不会立刻被消费者消费,需要等待指定的时长后才可被消费处理的消息,称为延时消息。

    有了延迟消息,我们就可以在订单创建好之后,发送一个延迟消息,比如20分钟取消订单,那就发一个延迟20分钟的延迟消息,然后在20分钟之后,消息就会被消费者消费,消费者在接收到消息之后,去关单就行了。

    但是,RocketMQ的延迟消息并不是支持任意时长的延迟的,它只支持:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h这几个时长。(商业版支持任意时长)

    可以看到,有了RocketMQ延迟消息之后,我们处理上就简单很多,只需要发消息,和接收消息就行了,系统之间完全解耦了。但是因为延迟消息的时长受到了限制,所以并不是很灵活。

    如果我们的业务上,关单时长刚好和RocketMQ延迟消息支持的时长匹配的话,那么是可以基于RocketMQ延迟消息来实现的。否则,这种方式并不是最佳的。(但是在RocketMQ 5.0中新增了基于时间轮实现的定时消息,可以解决这个问题!)

    优点

    1. 与系统解耦,编码简单
    2. 支持分布式

    缺点

    1. 延迟消息的时间是固定的时间,不一定match业务

    建议

    如果延迟消息的时间match业务,建议使用

    # RabbitMQ死信队列

    延迟消息不仅在RocketMQ中支持,其实在RabbitMQ中也是可以实现的,只不过其底层是基于死信队列实现的。

    当RabbitMQ中的一条正常的消息,因为过了存活时间(TTL过期)、队列长度超限、被消费者拒绝等原因无法被消费时,就会变成Dead Message,即死信。

    当一个消息变成死信之后,他就能被重新发送到死信队列中(其实是交换机-exchange)。

    那么基于这样的机制,就可以实现延迟消息了。那就是我们给一个消息设定TTL,然但是并不消费这个消息,等他过期,过期后就会进入到死信队列,然后我们再监听死信队列的消息消费就行了。

    而且,RabbitMQ中的这个TTL是可以设置任意时长的,这就解决了RocketMQ的不灵活的问题。

    但是,死信队列的实现方式存在一个问题,那就是可能造成队头阻塞,因为队列是先进先出的,而且每次只会判断队头的消息是否过期,那么,如果队头的消息时间很长,一直都不过期,那么就会阻塞整个队列,这时候即使排在他后面的消息过期了,那么也会被一直阻塞。

    基于RabbitMQ的死信队列,可以实现延迟消息,非常灵活的实现定时关单,并且借助RabbitMQ的集群扩展性,可以实现高可用,以及处理大并发量。他的缺点第一是可能存在消息阻塞的问题,还有就是方案比较复杂,不仅要依赖RabbitMQ,而且还需要声明很多队列(exchange)出来,增加系统的复杂度

    优点

    1. 支持分布式,实现灵活,编码简单
    2. 时间支持自定义

    缺点

    1. 死信队列队头阻塞
    2. 需要声明很多exchange,增加复杂度

    建议

    建议使用

    # Redis过期监听

    很多用过Redis的人都知道,Redis有一个过期监听的功能,

    在 redis.conf 中,加入一条配置notify-keyspace-events Ex开启过期监听,然后再代码中实现一个KeyExpirationEventMessageListener,就可以监听key的过期消息了。

    这样就可以在接收到过期消息的时候,进行订单的关单操作。

    这个方案不建议大家使用,是因为Redis官网上明确的说过,Redis并不保证Key在过期的时候就能被立即删除,更不保证这个消息能被立即发出。所以,消息延迟是必然存在的,随着数据量越大延迟越长,延迟个几分钟都是常事儿。

    而且,在Redis 5.0之前,这个消息是通过PUB/SUB模式发出的,他不会做持久化,至于你有没有接到,有没有消费成功,他不管。也就是说,如果发消息的时候,你的客户端挂了,之后再恢复的话,这个消息你就彻底丢失了。(在Redis 5.0之后,因为引入了Stream,是可以用来做延迟消息队列的。)

    优点

    1. 支持分布式,解耦系统

    缺点

    1. 没有时效性保障

    建议

    不建议使用

    # Redis的zset

    虽然基于Redis过期监听的方案并不完美,但是并不是Redis实现关单功能就不完美了,还有其他的方案。

    我们可以借助Redis中的有序集合——zset来实现这个功能。

    zset是一个有序集合,每一个元素(member)都关联了一个 score,可以通过 score 排序来取集合中的值。

    我们将订单超时时间的时间戳(下单时间+超时时长)与订单号分别设置为 score 和 member。这样redis会对zset按照score延时时间进行排序。然后我们再开启redis扫描任务,获取"当前时间 > score"的延时任务,扫描到之后取出订单号,然后查询到订单进行关单操作即可。

    使用redis zset来实现订单关闭的功能的优点是可以借助redis的持久化、高可用机制。避免数据丢失。但是这个方案也有缺点,那就是在高并发场景中,有可能有多个消费者同时获取到同一个订单号,一般采用加分布式锁解决,但是这样做也会降低吞吐型。

    但是,在大多数业务场景下,如果幂等性做得好的,多个消费者取到同一个订单号也无妨。

    优点

    1. 支持分布式、持久化、高可用

    缺点

    1. 实现逻辑较为复杂,需要大量业务耦合代码
    2. 高并发场景下会有重复消费的问题,需要部分代码实现幂等

    建议

    1. 业务量不大,可以使用

    # Redisson + Redis

    上面这种方案看上去还不错,但是需要我们自己基于zset这种数据结构编写代码,那么有没有什么更加友好的方式?

    有的,那就是基于Redisson。

    Redisson是一个在Redis的基础上实现的框架,它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。

    Redisson中定义了分布式延迟队列RDelayedQueue,这是一种基于我们前面介绍过的zset结构实现的延时队列,它允许以指定的延迟时长将元素放到目标队列中。

    其实就是在zset的基础上增加了一个基于内存的延迟队列。当我们要添加一个数据到延迟队列的时候,redisson会把数据+超时时间放到zset中,并且起一个延时任务,当任务到期的时候,再去zset中把数据取出来,返回给客户端使用。

    基于Redisson的实现方式,是可以解决基于zset方案中的并发重复问题的,而且还能实现方式也比较简单,稳定性、性能都比较高。

    优点

    1. 支持分布式、高可用
    2. 代码编写简单

    缺点

    1. 暂无

    建议

    大量订单可以使用

    # 总结

    本文介绍了多种实现订单定时关闭的方案,其中不同的方案各自都有优缺点,也各自适用于不同的场景中。

    # 实现的复杂度上(包含用到的框架的依赖及部署):

    Redisson > RabbitMQ死信队列 > RocketMQ延迟消息 ≈ Redis的zset > Redis过期监听 ≈ kafka时间轮 > 定时任务 > Netty的时间轮 > JDK自带的DelayQueue > 被动关闭

    # 方案的完整性:

    Redisson ≈ RabbitMQ插件 > kafka时间轮 > Redis的zset ≈ RocketMQ延迟消息 ≈ RabbitMQ死信队列 > Redis过期监听 > 定时任务 > Netty的时间轮 > JDK自带的DelayQueue > 被动关闭

    # 不同的场景中也适合不同的方案:

    ● 不建议使用:被动关闭

    ● 单体应用,业务量不大:Netty的时间轮、JDK自带的DelayQueue、定时任务

    ● 分布式应用,业务量不大:Redis过期监听、RabbitMQ死信队列、Redis的zset、定时任务

    ● 分布式应用,业务量大、并发高:Redisson、RabbitMQ插件、kafka时间轮、RocketMQ延迟消息

    总体考虑的话,考虑到成本,方案完整性、以及方案的复杂度,还有用到的第三方框架的流行度来说,个人比较建议优先考虑Redisson+Redis、RabbitMQ插件、Redis的zset、RocketMQ延迟消息等方案。

    上次更新: 2024/11/06, 02:51:54

    ← 场景实现概述 库存的超卖与少卖→

    Theme by Vdoing | Copyright © 2013-2025 zfd 苏ICP备2023039568号
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式