摘要:目前主流的技术方案有如下四种:基于 RocketMQ 的延时队列方案、基于 Redis ZSet 的延时队列方案、基于 Redis 的过期监听方案,以及基于 XXL-JOB 的定时轮询方案。
,立个写 1024 篇原创技术面试文章的 flag,欢迎过来视察监督~
最近很多同学面试都被问到了这个问题,但他们给出的技术方案并不能让面试官满意,我在本文中给出一个无法辩驳的最优解。
目前主流的技术方案有如下四种:基于 RocketMQ 的延时队列方案、基于 Redis ZSet 的延时队列方案、基于 Redis 的过期监听方案,以及基于 XXL-JOB 的定时轮询方案。
RocketMQ 是直接支持延时队列的,在其4.X 版本:支持18个固定延迟级别
(1s/5s/10s/30s/1m/.../30m/1h/2h),需根据业务需求选择合适的级别。
生产者端代码如下:
Message msg = new Message("ORDER_DELAY_ #技术分享TOPIC", "订单已创建,等待支付".getBytes);msg.setDelayTimeLevel(16); SendResult sendResult = producer.send(msg);消费者端代码如下:
consumer.subscribe("ORDER_DELAY_TOPIC", "*", new MessageListener { public Action consume(Message message, ConsumeContext context) { String orderId = parseOrderId(message); if (checkOrderUnpaid(orderId)) { cancelOrder(orderId); } return Action.CommitMessage; }});而到了 RocketMQ 5.X 版本,就支持任意时刻的延迟消息了,可通过客户端 API 直接指定延迟时间,灵活性更高。
Redis Zset(SortedSet),是 Set 的可排序版,是通过增加一个排序属性 score 来实现的,适用于排行榜和时间线之类的业务场景。
如下图所示,这里的 score 属性对应的是销售额:
我们可以通过 Redis Zset 来实现延时队列的功能,具体思路是:在生产者端向 Zset 中添加元素,并设置 score 值为元素过期的时间。
在消费端对 Zset 进行轮询,将元素的 score 值与当前时间进行比对,将小于当前时间的过期 key 进行移除。
生产者端代码如下:
long cancelTime = System.currentTimeMillis +redisTemplate.opsForZSet.add("order:delay:queue", orderId, cancelTime);消费者端代码如下:
Set expiredOrders = redisTemplate.opsForZSet.rangeByScore( "order:delay:queue", 0, now);if (expiredOrders != null && !expiredOrders.isEmpty) { Long removedCount = redisTemplate.opsForZSet.remove( "order:delay:queue", expiredOrders.toArray(new String[0])); if (removedCount > 0) { batchCancelOrders(expiredOrders); } } Thread.sleep(1000); }基于 Redis 的过期监听实现取消订单的方案,核心是利用 Redis 的 键过期事件 ,在订单的取消时间到达时自动触发取消逻辑。
修改 redis.conf 文件:
notify-keyspace-events Ex订单创建代码:
public void createOrder(Order order) { orderRepository.save(order);String key = "order:unpaid:" + order.getId; redisTemplate.opsForValue.set(key, order.getId); redisTemplate.expire(key, 30, TimeUnit.MINUTES); }订单过期事件监听:
@Componentpublic class RedisKeyExpirationListener { @Autowired private OrderService orderService;@EventListener public void handleKeyExpiration(RedisKeyExpiredEvent event) { String orderId = new String(event.getId); if (orderId.startsWith("order:unpaid:")) { orderService.cancelOrder(orderId.replace("order:unpaid:", "")); } } }基于 XXL-JOB 的定时轮询实现取消订单的方案,是一种通过分布式任务调度平台定时扫描数据库,取消超时未支付订单的方式。
XXL-JOB 调度中心:作为核心控制平台,负责任务的配置、触发和状态记录,支持集群化部署,保障任务调度的高可用性。
业务系统执行器:集成 XXL-JOB 客户端,接收调度中心指令,执行订单取消逻辑,包括订单状态更新、库存释放等操作。
执行器代码如下:
@XxlJob("cancelExpiredOrdersJob")public void cancelExpiredOrders { List expiredOrders = orderService.findExpiredOrders; for (Order order : expiredOrders) { try { orderService.cancelOrder(order.getId); log.info("成功取消订单: {}", order.getId); } catch (Exception e) { log.error("取消订单失败,订单ID: {}", order.getId, e); } }}我们在本文开头中提到过,最近很多同学面试都被问到了这个问题,但他们给出的技术方案并不能让面试官满意,当然也包括上述四种技术方案。
因为面试官通常会问如下两个问题:
1、你通过 RocketMQ/Redis/XXL-JOB 来实现的取消超时未支付的订单,那如果 RocketMQ/Redis/XXL-JOB 挂了怎么办?
2、RocketMQ/Redis/XXL-JOB 可能会存在处理延迟的问题,不能在30分钟精准地取消过期未支付订单,那你要如何解决?
而这两个问题通常会把面试经验不足的同学难住。
对于第一个问题,确实在 RocketMQ 和 Redis 挂了且没做 Plan B 的情况下,那只能寄希望于 RocketMQ 和 Redis 集群快点儿恢复了。
这还不算完,还需要对故障时间内的过期未关单数据进行手动处理。
但 XXL-JOB 就不一样了,就算它的调度中心集群宕机了,我们仍然可以直接向执行器发送请求即可触发任务,或者通过一个操作系统任务定时发送请求。
格式如下:
POST http://执行器IP:端口/runContent-Type: application/json{ "jobId": 任务 ID, "executorHandler": "任务处理器名称", "executorParams": "任务参数", "logId": 日志 ID(可随机生成), "broadcastIndex": 0, "logDateTime": 当前时间戳 }对于第二个问题,其实根本就 TMD 不是问题。
我们试想一下,如果用户在第30分01秒对未支付订单进行支付了,那到底是给公司带来损失了,还是带来收入了?
显然是后者!
所以做技术不能死做技术,应该去结合业务场景去制定技术方案,取消超时未支付的订单本质上是为了让订单进入终态,以解决财务结算的问题而已。
当然,从技术方案上也是有解的,我们想想在 Redis 底层是如何清理过期 Key 的?
Redis 用的是定期清理和惰性清理相结合的方式。
定期清理
Redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定期遍历这个字典来删除到期的 key。
Redis 默认会每秒进行十次过期扫描,过期扫描不会遍历过期字典中所有的 key,而是采用了一种简单的贪心策略。
(1)从过期字典中随机取出20个 key。
(2)清理这20个 key 中已经过期的 key。
(3)如果过期的 key 比率超过25%,那就再重复执行一次步骤(1)(2)。
同时,Redis 为保证定期扫描不会出现“贪心”过度,从而导致线程卡死现象,在算法上还增加了扫描时间的上限,默认不会超过25ms。
惰性删除
在客户端访问某个 key 的时候,Redis 对该 key 的过期时间进行检查,如果过期了就立即删除。
回到原题上,如果真的需要精准地取消超时未支付的订单,那我们参考 Redis 的惰性删除策略,在订单支付的时候进行二次校验就好了。
所以,解决取消超时未支付的订单的终极方案,就是基于 XXL-JOB 的定时轮询 + 订单支付时二次校验兜底。
这样可以在功能可用性、开发复杂性和处理时延性上达到最优解。
来源:墨码行者