电商场景中的问题向来很受面试官的青睐,因为业务场景大家都相对更熟悉,相关的问题也很有深度,也有代表性,能更方便地考察候选人的技术水平。
比如商品购买下单支付的流程,在买家购买商品后会先生成订单,之后有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<String, String> 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<String, String> zSetOps = redisTemplate.opsForZSet();
Set<String> allOrderKeys = zSetOps.range("orders_to_close", 0, -1);
Set<String> 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<Boolean> create(@PathVariable int time) throws SimpleException {
delayOrderService.createOrder(System.currentTimeMillis() + "", time);
return Response.ok();
}
}
补充:
ZSet代表Redis中的有序集合(Sorted Set)数据结构。它是一个集合,每个成员元素都会关联一个分数(score),Redis会根据这个分数对所有成员进行排序。因此,有序集合同时具备了集合和排序列表的特性:
- 唯一性:集合中的每个成员都是唯一的,不允许重复。
- 排序性:集合中的元素按照其分数进行排序,可以是升序或降序。有序集合支持的操作包括但不限于:
- 添加元素并指定分数。
- 根据分数范围或者成员排名来获取集合的子集。
- 计算成员数量。
- 增减成员的分数。
- 获取指定成员的分数。
- 删除指定成员等。
在Java中操作Redis有序集合时,通常通过如ZSetOperations这样的接口来进行。