电商场景中的问题向来很受面试官的青睐,因为业务场景大家都相对更熟悉,相关的问题也很有深度,也有代表性,能更方便地考察候选人的技术水平。

比如商品购买下单支付的流程,在买家购买商品后会先生成订单,之后有15或者30分钟的支付时间,如果超时未支付就会自动取消这个订单。

面试官:订单超时未支付自动取消,这个你用什么方案实现?

这里就不得不提延迟队列了,延迟队列是一种特殊的消息队列,它允许消息在特定时间点或延迟一段时间后才被消费者处理。这一特性使得系统能够更加灵活地控制任务的执行时机。延迟队列作为一种重要的消息队列模式,广泛应用于订单超时处理、定时任务处理、邮件延迟发送等场景。

延迟队列的实现方式多样,比如RocketMQ、RabbitMQ等消息队列本身就支持延迟队列的功能,如果公司正在用这些MQ组件,那可以直接使用。如果公司没有使用这些MQ组件,而在使用Redis,那么我们就可以考虑使用Redis实现延迟队列了。

Redis的Sorted Set数据结构天然适合实现延迟队列。可以将任务ID作为成员(member),任务的执行时间戳作为分数(score)。这样,通过ZADD命令可以轻松地按照执行时间将任务插入到集合中。而ZRangeByScore或ZRemRangeByScore命令则可以在合适的时机取出或删除已到期的任务。如下图:

实现步骤主要包括:

1、任务入队:将任务详情序列化后存储,并以其执行时间戳作为score,通过ZADD命令加入到Sorted Set中。

2、任务出队:使用ZRANGEBYSCORE命令,配合WITHSCORES选项,获取当前时间戳之前的所有任务,并通过分数(score)判断哪些任务已经到期,然后进行处理。

3、周期性检查:通过启动一个额外的定时任务周期性检查并处理已到期的任务。

可能你会说,通过额外的定时任务检查还是挺麻烦的,是否可以使用Redis的Keyspace Notifications,订阅key过期事件来做?

答案是不可以。因为Redis的key过期事件并不能保证key过期的时刻能够及时发出通知事件,甚至不能保证key过期能发出事件。原因是,Redis删除过期key的时机是:客户端访问该key时Redis服务端发现过期或者Redis后台任务检测到这个key过期。如果一直不访问这个key,那有可能长期不能发现key过期,也就不会产生key过期的事件了。设置的key过期精确度如此不可控,这对于大部分使用延迟队列的业务场景应该是不可接受的。

实现生产可用的延迟队列还需要关注什么

按照上述的思路去具体实现一个延迟队列的话,还需要关注以下几点,这样才能打造出一个生产环境可用的好方案。

1、首先是性能。如果底层只采用一个Sorted Set,数据量大的时候,比如同时有几百万人下单,这些数据被存储到同一个Sorted Set,就容易引发性能瓶颈。可以采用指定数量的Sorted Set来解决此问题,这样生产和消费延迟消息的并发处理效率会提升。

2、其次是原子操作。在消费消息的时候可能涉及查询和删除的两步操作,有可能还涉及数据库等其他操作,如果部分处理失败,可能会造成消息丢失或者重复处理的问题。需要采用重试机制和幂等处理机制来应对。

3、最后是简单易用的封装。要实现好延迟队列,不是一件轻松的事儿。可设计上报延迟消息、到期回调处理两个接口,简化延迟队列的接入成本。可以参考Redission等封装实现,使用Sorted Set、消息Pub/Sub、Stream等结合实现完善的延迟队列。

具体实现Demo:

@Service

public class DelayOrderService {

@Resource

private RedisTemplate redisTemplate;

public void createOrder(String orderId, int timeoutSeconds) {

long timeoutTimestamp = System.currentTimeMillis() + (timeoutSeconds * 1000);

String orderKey = "order:" + orderId;

redisTemplate.opsForZSet().add("orders_to_close", orderKey, timeoutTimestamp);

System.out.println("Order " + orderId + " created with " + timeoutSeconds + " seconds timeout.");

}

@Scheduled(fixedDelay = 1000)

public void checkAndCloseExpiredOrders() {

long currentTime = System.currentTimeMillis();

System.out.println("Checking for expired orders...Current time:" + currentTime);

ZSetOperations zSetOps = redisTemplate.opsForZSet();

Set allOrderKeys = zSetOps.range("orders_to_close", 0, -1);

Set filteredOrderKeys = allOrderKeys.stream()

.filter(key -> key.startsWith("order:"))

.collect(Collectors.toSet());

for (String i : filteredOrderKeys) {

double score = zSetOps.score("orders_to_close", i);

System.out.println(i + ":" + score);

//超时关闭

if (score <= currentTime) {

String orderId = i.substring("order:".length());

System.out.println("Closing expired order: " + orderId);

// 在这里处理关闭订单的业务逻辑,例如更新数据库状态

// ...

// 从Sorted Set中移除订单

zSetOps.remove("orders_to_close", i);

} else {

System.out.println("Order " + i + " is still active.");

}

}

}

}

@EnableAutoConfiguration

@RestController

@RequestMapping("/api/delay-order")

public class DelayOrder {

@javax.annotation.Resource

private DelayOrderService delayOrderService;

@ApiOperation("新增")

@RequestMapping(value = "/create/{time}", method = RequestMethod.GET)

public Response create(@PathVariable int time) throws SimpleException {

delayOrderService.createOrder(System.currentTimeMillis() + "", time);

return Response.ok();

}

}

补充:

ZSet代表Redis中的有序集合(Sorted Set)数据结构。它是一个集合,每个成员元素都会关联一个分数(score),Redis会根据这个分数对所有成员进行排序。因此,有序集合同时具备了集合和排序列表的特性:

唯一性:集合中的每个成员都是唯一的,不允许重复。排序性:集合中的元素按照其分数进行排序,可以是升序或降序。有序集合支持的操作包括但不限于:添加元素并指定分数。根据分数范围或者成员排名来获取集合的子集。计算成员数量。增减成员的分数。获取指定成员的分数。删除指定成员等。

在Java中操作Redis有序集合时,通常通过如ZSetOperations这样的接口来进行。

文章链接

评论可见,请评论后查看内容,谢谢!!!评论后请刷新页面。