面试场景题2:订单未支付过期如何实现自动关单

138 阅读12分钟

零、前言

面试的场景题目,很多是搜集的。相应的解决方案,其实大同小异。因为面试的短暂时间,只能够展示我们的技能,并不足以开启实际解决问题。

有些解决方案是存在类似的。内容存在借鉴部分,但是保证每篇文章都是经过本人的思考的。

一、场景

1.1 场景描述

在日常的开发工作中,我们频繁遭遇诸如外卖订单超过30分钟未支付则自动取消,或用户注册成功后15分钟发送短信通知等业务场景。这些,正是延时任务处理的典型应用。

在电商、支付等系统中,常见的做法是首先创建订单(或支付单),然后给予用户一定的支付期限。若用户未能在规定时间内完成支付,系统则需自动取消先前的订单(或支付单)。类似场景不胜枚举,如到期自动确认收货、超时自动发起退款、下单后即刻发送短信通知等,均是此类业务问题的具体体现。

1.2 回答要点

在回答此类问题时,易陷入话题扩散的陷阱,比如从定时任务扩展到整个交易系统的设计,再延伸至电商领域的相关内容。因此,在前期沟通中,务必锁定问题的核心点,围绕核心进行深入探讨。

场景题本质上属于半结构化面试,面试官会基于自身经验,挖掘其中的难点,通过直接提问或引导的方式,测试你的回答是否切中要害。若你的回答未能准确命中要点,可能会被视为业务不匹配。因此,在对比多种方案时,应选择你最熟悉且技术含量较高的答案进行阐述。

展现思维全面性的关键在于,你是否全面对比了不同方案的优劣,是否预见了每种方案可能遭遇的问题,以及是否提出了相应的解决方案。

至于沟通能力,则体现在你对问题的表述是否清晰、思维是否有条不紊,以及是否能够让他人轻松理解你的观点。同时,若能结合能力模型进行阐述,将更具说服力。

本人更倾向于采用以下回答策略:

1、首先,我会概述多种解决方案,仅从理论层面进行探讨,而不深入细节。例如,提及基于定时任务的解决方案、基于延迟队列的解决方案,以及基于过期监听的解决方案等。此时,时间大约已过去两分钟,我会密切观察面试官的反应,以判断其对这些方案的接受程度及后续可能关注的重点。若面试官询问我们采用了哪种方案,我便会从中挑选一个自认为最优的方案进行推荐。

2、经过第一步的试探后,我会针对所选方案进行深入剖析,详细阐述其实现细节、数据流转过程以及动作触发机制。随后,我会做好准备,迎接面试官的深入提问,如为何选择此方案、其优缺点,以及在实施过程中是否遇到过任何问题。

3、若面试官真的对方案中的问题提出质疑,我会进一步展开讨论,并主动提供解决方案。在此过程中,我不会进行无谓的争辩,而是坦诚面对问题,并展示我们解决问题的能力。我深知,理论上存在的问题在实际操作中同样可能发生,因此提供切实可行的解决方案至关重要。同时,我会避免讨论极端情况,以展现我们的专业素养和水平。

4、当话题深入讨论约十分钟后,我预计面试即将接近尾声。此时,我会主动询问面试官是否还有其他补充或想探讨的内容,以确保对话的完整性和深入性。

二、解决方案

2.1 定时任务

定时任务是一种简洁直观的解决方案,涵盖了基于Spring框架的定时任务以及基于分布式任务调度器的定时任务。尽管我们通常更倾向于讨论分布式任务调度器,但以下示例将基于Spring的任务调度配置进行阐述。

利用定时任务关闭订单,不仅成本低廉,而且实现起来也相当简便。只需编写几行代码,设置一个定时任务,定期扫描数据库中的订单记录。一旦发现订单已过期,便将其状态更新为关闭状态。

优点:

  • 实现便捷,成本较低,几乎不依赖其他外部组件。

缺点:

  • 时间精度不足。由于定时任务的扫描间隔是固定的,因此可能存在订单已过期一段时间后才被扫描到的情况,导致订单关闭时间晚于预期。
  • 增大数据库压力。随着订单数量的不断攀升,扫描成本也随之增加,执行时间延长,可能导致部分应被关闭的订单未能及时关闭。

总结:

采用定时任务方案较为适合那些对时间要求不严格且数据量适中的业务场景。然而,定时任务方案存在时间精度不足且无法充分利用集群效应的缺点(任务只能由单一机器触发)。为此,我们提出了一种相对的优化策略:

  1. 将任务触发分为扫描层与业务处理层。当任务被触发时,首先进行业务扫描,随后将每个子任务进行分布式调度,以发挥分布式系统的优势。

  2. 在业务层面也设置定时任务处理机制。即为业务数据设置一个超时字段,在单个查询时,进行二次时间判断,以作为状态展示的补充。同时,采取时间补偿策略,以应对时间晚的问题。

基于Spring定时任务实现的代码示例:

图片

2.2 JDK 延迟队列 DelayQueue

DelayQueue 是 JDK 自带的一个无界阻塞队列,它要求队列中的元素必须实现 Delayed 接口,该接口仅包含一个方法,即获取元素的过期时间。

当用户订单生成后,我们可以为其设置过期时间(例如 30 分钟),然后将订单放入预先定义好的 DelayQueue 中。接着,创建一个线程,该线程通过 while(true) 循环不断从 DelayQueue 中获取已过期的数据。

优点:无需依赖任何第三方组件,甚至无需数据库支持,实现起来相对简便快捷。

缺点:

  • 由于 DelayQueue 是一个无界队列,若放入的订单数量过多,可能会导致 JVM 内存溢出(OOM)。
  • DelayQueue 基于 JVM 内存运行,一旦 JVM 发生重启,所有数据将丢失殆尽。

总结:

DelayQueue 更适用于数据量较小且数据丢失对主业务影响不大的场景,例如内部系统中的一些非关键性通知。即便这些通知丢失,也不会造成太大影响。

此方案与消息队列的思想颇为相似,但在实际生产中鲜有应用。因为这类不重要的场景确实较为罕见。

基于JDK的延迟队列实现方案示例:

图片

图片

2.3 Redis 过期监听

Redis,作为一个高性能的 KV 数据库,除了卓越的缓存功能外,还巧妙地内置了过期监听机制。只需在 redis.conf 文件中简单配置 notify-keyspace-events Ex,即可激活这一功能。随后,在代码中通过继承 KeyspaceEventMessageListener 并实现 onMessage 方法,便能轻松监听到过期的数据动态。

深入探究其源码,不难发现,其核心在于注册一个监听器,巧妙利用 Redis 的发布/订阅机制。当某个 key 过期时,Redis 会立即发布一个过期消息(即该 key)至特定的 Channel:keyevent@*:expired

在业务实践中,我们可以为订单设置 30 分钟的过期时间,并将其存入 Redis。30 分钟后,一旦该 key 过期,我们便能即时消费它,并触发一系列后续业务动作,例如检查用户是否已完成支付。

优点显著:

  • 凭借 Redis 的高性能优势,无论是设置 key 还是消费 key,都能确保极高的处理速度。

然而,缺点亦不容忽视:

  • 受限于 Redis 的 key 过期策略,当 key 过期时,Redis 并不能保证立即删除它,因此我们的监听事件也无法做到实时消费该 key,存在一定的延迟。
  • 在 Redis 5.0 版本之前,发布/订阅中的消息并未实现持久化,也缺乏确认机制。这意味着,若消费消息时客户端发生宕机,该消息将永久丢失。

总结而言,Redis 的过期订阅功能相较于其他方案并无显著优势,在实际生产环境中应用较少。

Redis消息过期监听的示例

图片

2.4 Redisson 分布式延迟队列

Redisson,作为 Redis 的官方推荐 JAVA 客户端,是一个基于 Redis 实现的驻内存数据网格。它不仅提供了一系列分布式 Java 常用对象,还集成了众多分布式服务。

其中,Redisson 提供的分布式延迟队列 RDelayedQueue 尤为引人注目。该队列基于 Redis 的 zset 结构实现,具体由 RedissonDelayedQueue 类承载。其使用简便,且在实现过程中大量运用 lua 脚本确保操作的原子性,有效避免了并发重复问题。

优点突出:

  • 使用便捷,且通过 lua 脚本保障原子性操作,确保并发环境下的数据安全。

当然,也存在一定的局限性:

  • 依赖于 Redis(尽管这往往是使用 Redisson 的前提)。

总结而言,Redisson 作为 Redis 的官方 JAVA 客户端,功能全面、使用简便且高效。它提供的分布式延迟队列等特性,值得我们在实际项目中尝试与运用。

Redisson的延迟队列实现:

图片

2.5 RocketMQ 延迟消息

延迟消息,即在消息被写入到 Broker 后,并不会立刻被消费者所消费,而是需要等待一段指定的时长后,方可被消费处理的消息类型。这类消息被形象地称为延时消息。

在订单创建流程中,我们可以将订单信息封装成一条消息,投递至 RocketMQ,并为其设置 30 分钟的延迟时间。这样,30 分钟后,我们预设的消费者便能接收到这条消息,进而检查用户是否已完成订单的支付。借助延迟消息,我们能够轻松实现业务逻辑的解耦,极大地简化代码结构。

其优点显而易见:它让代码逻辑变得清晰明了,系统间实现了完全的解耦,开发者只需专注于消息的生产和消费即可。此外,RocketMQ 拥有极高的吞吐量,能够轻松支撑万亿级的数据处理需求。

然而,缺点也同样明显:MQ 作为重量级的组件,其引入无疑会增加系统的复杂性。消息丢失、幂等性问题等挑战也随之而来,需要开发者投入更多的精力去应对。

总结而言,利用 MQ 实现系统业务的解耦,以及通过其性能优势来削峰填谷,已成为当前高性能系统的标配。

2.6 RabbitMQ 死信队列

除了 RocketMQ 的延迟队列外,RabbitMQ 的死信队列同样具备实现消息延迟的功能。在 RabbitMQ 中,当一条正常消息因存活时间到期(TTL 过期)、队列长度超限或被消费者拒绝等原因而无法被正常消费时,它会被视为死信消息,并被投递到死信队列中。

基于这一机制,我们可以为消息设置一个 TTL,并故意不消费它。等待消息过期后,它便会自动进入死信队列,此时我们再对死信队列中的消息进行消费即可。通过这种方式,我们同样能够达到与 RocketMQ 延迟消息相似的效果。

其优点在于:与 RocketMQ 类似,RabbitMQ 也能实现业务的解耦,并且凭借其集群的扩展性,同样可以实现高可用、高性能的目标。

但缺点也同样突出:死信队列本质上仍是一个队列,遵循先进先出的原则。如果队头的消息过期时间较长,那么后续过期的消息可能会因此无法得到及时处理,从而引发消息阻塞的问题。

总结而言,除了增加系统复杂度外,死信队列的阻塞问题同样值得我们重点关注。

在分布式系统中,基于分布式任务调度器和基于延迟消息的解决方案都是常见的选择。然而,如果团队的消息中间件是基于 Kafka,且 Kafka 不支持延迟消息功能,那么分布式任务调度器便成为了一个可行的替代方案。但如何解决时间延迟的问题,确实需要我们深入思考,以给面试官一个满意的答复。