HashedWheelTimer延时队列
参考文章:
https://www.javadoop.com/post/HashedWheelTimer
1 前言
在项目开发中,会有一些延时任务的需求,比如订单在30分钟之内未成功支付,则自动取消订单。此类需求可通过延迟队列实现,本文记录一下netty延时队列HashedWheelTimer的实现方式。
2 HashedWheelTimer介绍
(该图及以下介绍内容来自https://www.javadoop.com/post/HashedWheelTimer,想看HashedWheelTimer详细介绍和源码分析的也可以去看)
默认地,时钟每 100ms 滴答一下(tick),往前走一格,共 512 格,走完一圈以后继续下一圈。把它想象成生活中的钟表就可以了。
内部使用一个长度为 512 的数组存储,数组元素(bucket)的数据结构是链表,链表每个元素代表一个任务(Timeout 的实例)。
提交任务的线程,只要把任务往虚线上面的任务队列中存放即可返回。工作线程是单线程,一旦开启,不停地在时钟上绕圈圈。
仔细看下面的介绍:
-
工作线程到达每个时间整点的时候,开始工作。在 HashedWheelTimer 中,时间都是相对时间,工作线程的启动时间,定义为时间的 0值。因为一次 tick 是 100ms(默认值),所以 100ms、200ms、300ms… 就是我说的这些整点。
-
如上图,当时间到 200ms 的时候,发现任务队列有任务,取出所有的任务。
-
按照任务指定的执行时间,将其分配到相应的 bucket 中。如上图中,任务2 和任务6指定的时间为 100ms~200ms这个区间,就被分配到第二个 bucket 中,形成链表,其他任务同理。
这里还有轮次的概念,不过不用着急,比如任务 6 指定的时间可能是 150ms + (512*100ms),它也会落在这个 bucket 中,但是它是下一个轮次才能被执行的。
-
任务分配到 bucket 完成后,执行该次 tick 的真正的任务,也就是落在第二个 bucket 中的任务 2 和任务 6。
-
假设执行这两个任务共消耗了 50ms,到达 250ms 的时间点,那么工作线程会休眠 50ms,等待进入到 300ms 这个整点
3 示例
以下为使用延迟队列HashedWheelTimer的示例:
3.1 pom依赖
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.24.Final</version>
</dependency>
3.2 创建任务
import io.netty.util.Timeout;
import io.netty.util.TimerTask;
import lombok.extern.slf4j.Slf4j;
/**
* <pre>
* 订单取消任务
* </pre>
*
* @author loopy_y
* @since 2022/6/14
*/
@Slf4j
public class OrderCancelTimerTask implements TimerTask {
private String orderId;
CancelOrderTimerTask(String orderId) {
this.orderId = orderId;
}
@Override
public void run(Timeout timeout) throws Exception {
log.info("==== 订单【{}】取消操作执行中 ====", orderId);
// TODO 以下省略具体业务实现
}
}
3.3 创建延迟队列实例
import io.netty.util.HashedWheelTimer;
import io.netty.util.Timeout;
import io.netty.util.TimerTask;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* <pre>
* 时间管理队列工具
* </pre>
*
* @author yjj
* @since 2022/6/14
*/
@Slf4j
public class OrderCancelTimer {
private static HashedWheelTimer timer;
/**
* 延迟执行时间,可配置在配置文件中,这里为了方便测试设置为20s
*/
private static long delay = 20L;
/**
* 存储Timeout对象,建立订单id与Timeout关系,用于通过订单id找到Timeout从队列中移除
*/
private static Map<String, Timeout> timeoutMap = new HashMap<>();
static {
// 创建延迟队列实例,可以设置时间轮长度及刻度等,这里直接使用默认的
timer = new HashedWheelTimer();
}
/**
* 将任务添加进队列
* @param timerTask 任务
* @param orderId 订单id
*/
public static void addNewTimeout(TimerTask timerTask, String orderId) {
log.info("订单号【{}】准备添加进延迟队列", orderId);
Timeout timeout = timer.newTimeout(timerTask, delay, TimeUnit.SECONDS);
timeoutMap.put(orderId, timeout);
log.info("订单号【{}】添加进延迟队列成功", orderId);
}
/**
* 将任务从队列中移除
* @param orderId 订单id (本示例中是通过订单id关联的Timeout对象,所以移除时需要根据此字段查询到Timeout对象)
* @return 是/否成功移除
*/
public static boolean delTimeout(String orderId) {
log.info("订单号【{}】准备从延迟队列中移除", orderId);
Timeout timeout = timeoutMap.get(orderId);
boolean cancel = timeout.cancel();
log.info("订单号【{}】从延迟队列中移除结果为:【{}】", orderId, cancel);
if (cancel) {
// 任务从队列中移除成功后,移除订单id和Timeout的关联关系
timeoutMap.remove(orderId);
}
return cancel;
}
}
3.4 编写测试Controller测试
import com.sinosoft.springbootplus.common.api.ApiResult;
import io.swagger.annotations.Api;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* <pre>
* 延迟队列测试 API
* </pre>
*
* @author loopy_y
* @since 2022/6/14
*/
@Slf4j
@RestController
@RequestMapping("/timer")
@Api(tags = "延迟队列API")
public class TimerTaskController {
@GetMapping("/add")
public ApiResult<Boolean> add(@RequestParam("orderId") String orderId) {
OrderCancelTimer.addNewTimeout(new OrderCancelTimerTask(orderId), orderId);
return ApiResult.ok();
}
@GetMapping("/delete")
public ApiResult<Boolean> delete(@RequestParam("orderId") String orderId) {
boolean delStatus = OrderCancelTimer.delTimeout(orderId);
return ApiResult.ok(delStatus);
}
}
3.5 测试结果
测试添加订单111、222、333、444,并把333订单从队列中移除,执行结果如下:
2022-06-14 17:54:59.357 [http-nio-8082-exec-18] INFO [com.springbootplus.netty.HashedWheelTimerUtil:] [,] - 订单号【111】准备添加进延迟队列
2022-06-14 17:54:59.357 [http-nio-8082-exec-18] INFO [com.springbootplus.netty.HashedWheelTimerUtil:] [,] - 订单号【111】添加进延迟队列成功
2022-06-14 17:55:01.115 [http-nio-8082-exec-19] INFO [com.springbootplus.netty.HashedWheelTimerUtil:] [,] - 订单号【222】准备添加进延迟队列
2022-06-14 17:55:01.115 [http-nio-8082-exec-19] INFO [com.springbootplus.netty.HashedWheelTimerUtil:] [,] - 订单号【222】添加进延迟队列成功
2022-06-14 17:55:03.050 [http-nio-8082-exec-20] INFO [com.springbootplus.netty.HashedWheelTimerUtil:] [,] - 订单号【333】准备添加进延迟队列
2022-06-14 17:55:03.050 [http-nio-8082-exec-20] INFO [com.springbootplus.netty.HashedWheelTimerUtil:] [,] - 订单号【333】添加进延迟队列成功
2022-06-14 17:55:05.349 [http-nio-8082-exec-21] INFO [com.springbootplus.netty.HashedWheelTimerUtil:] [,] - 订单号【444】准备添加进延迟队列
2022-06-14 17:55:05.350 [http-nio-8082-exec-21] INFO [com.springbootplus.netty.HashedWheelTimerUtil:] [,] - 订单号【444】添加进延迟队列成功
2022-06-14 17:55:07.536 [http-nio-8082-exec-22] INFO [com.springbootplus.netty.HashedWheelTimerUtil:] [,] - 订单号【333】准备从延迟队列中移除
2022-06-14 17:55:07.536 [http-nio-8082-exec-22] INFO [com.springbootplus.netty.HashedWheelTimerUtil:] [,] - 订单号【333】从延迟队列中移除结果为:【true】
2022-06-14 17:55:19.458 [pool-6-thread-1] INFO [com.springbootplus.netty.CancelOrderTimerTask:] [,] - ==== 【111】订单取消操作执行中 ====
2022-06-14 17:55:21.163 [pool-6-thread-1] INFO [com.springbootplus.netty.CancelOrderTimerTask:] [,] - ==== 【222】订单取消操作执行中 ====
2022-06-14 17:55:25.358 [pool-6-thread-1] INFO [com.springbootplus.netty.CancelOrderTimerTask:] [,] - ==== 【444】订单取消操作执行中 ====
通过结果日志可以看出,订单号111、222、444成功添加进延迟队列中,并在20s后成功执行了超时后的业务操作。订单号333从队列中移除后,在20s后没有执行超时后的业务操作。测试结果完全符合预期。