先讲 Spring 定时任务的简洁实现思路,再结合苍穹外卖项目代码。
零基础入门
- 定时任务是什么:到了指定时间,系统自动执行一段代码,不需要用户点击按钮。
- 为什么需要定时任务:很多业务会“随时间自然变化”,比如超时未支付关单、长时间派送自动完成。
@Scheduled是什么:告诉 Spring 这个方法要按固定规则执行(如每分钟、每天凌晨)。- cron 表达式是什么:用一串时间规则描述“什么时候执行”。
一个最小例子
@Component
public class DemoTask {
// cron表达式:每分钟的第0秒执行一次
@Scheduled(cron = "0 * * * * ?")
public void runEveryMinute() {
System.out.println("每分钟执行一次");
}
}
@SpringBootApplication
@EnableScheduling // 开启Spring定时任务调度功能,让@Scheduled注解生效
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
业务代码负责定义时间到了要做什么,调度框架负责按时触发执行。
一、Spring 定时任务的简洁实现思路
目标:让时间规则有统一执行入口
我们不希望在 Controller 里写“人工触发”的管理接口,而是把“时间到了自动收口”的逻辑放在 Task 层集中处理。
最小实现只需要 3 个点:
- 开启调度能力(
@EnableScheduling) - 定义任务类(
@Component) - 在方法上声明触发规则(
@Scheduled(cron = "..."))
sequenceDiagram
title 任务触发流程
participant Scheduler as SpringScheduler
participant Task as OrderTask
participant Service as 业务逻辑
participant DB as 数据库
Scheduler->>Task: 到达 cron 时间点
Task->>Service: 执行任务方法
Service->>DB: 查询符合条件数据
Service->>DB: 更新目标状态
Task-->>Scheduler: 本轮执行结束
最小代码骨架
@Component
public class OrderTimeoutTask {
@Scheduled(cron = "0 * * * * ?")
public void closeTimeoutOrders() {
// 1) 查询“待支付且已超时”的订单
// 2) 逐条改为“已取消”
// 3) 记录取消时间和原因
}
}
核心思想:定时任务是“时间维度的业务入口”,应集中治理,而不是散落在 Controller 或手工脚本里。
二、苍穹外卖项目的实现
业务场景
| 场景 | 触发条件 | 自动动作 |
|---|---|---|
| 超时未支付订单 | 待付款且超过 15 分钟 | 改为已取消,写取消原因和取消时间 |
| 长时间派送中订单 | 派送中且超过阈值 | 改为已完成 |
这些规则本质都是:按“状态 + 时间阈值”筛选订单,再做自动状态推进。
找到任务入口(启动类)
@SpringBootApplication
@EnableTransactionManagement //开启注解方式的事务管理
@Slf4j
@EnableCaching//开发缓存注解功能
@EnableScheduling //开启任务调度
public class SkyApplication {
public static void main(String[] args) {
SpringApplication.run(SkyApplication.class, args);
log.info("server started");
}
}
如果没有 @EnableScheduling,@Scheduled 方法不会被调度执行。
核心任务代码(OrderTask)
@Scheduled(cron = "0 * * * * ? ") // 每分钟扫描超时未支付订单
public void processTimeoutOrder(){
log.info("定时处理超时订单:{}", LocalDateTime.now());
LocalDateTime time = LocalDateTime.now().plusMinutes(-15);
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT, time);
if(ordersList != null && ordersList.size() > 0){
for (Orders orders : ordersList) {
orders.setStatus(Orders.CANCELLED);
orders.setCancelReason("订单超时,自动取消");
orders.setCancelTime(LocalDateTime.now());
orderMapper.update(orders);
}
}
}
@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨 1 点扫描派送中订单
public void processDeliveryOrder(){
log.info("定时处理处于派送中的订单:{}",LocalDateTime.now());
LocalDateTime time = LocalDateTime.now().plusMinutes(-60);
List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS, time);
if(ordersList != null && ordersList.size() > 0){
for (Orders orders : ordersList) {
orders.setStatus(Orders.COMPLETED);
orderMapper.update(orders);
}
}
}
这段代码体现了 5 个关键点:
- 任务粒度清晰:一个方法只负责一种规则(超时取消 / 超时完成)。
- 筛选条件明确:按状态和时间阈值查库,避免误处理。
- 状态推进可追踪:取消单补齐取消原因和时间,便于排查。
- 可重复执行:同一轮任务多执行几次,最终结果仍收敛到正确状态。
- 日志可观测:每轮执行打点,便于追踪任务是否按时触发。
数据访问条件(Mapper)
Task应遵循幂等原则,即重复执行n次操作,结果是相同的。还要避免定时操作与用户操作互相覆盖。所以需要先判断,再访问数据。OrderTask 依赖 OrderMapper.getByStatusAndOrderTimeLT(status, orderTime),语义是:
- 订单状态等于目标状态
- 下单时间早于阈值时间
这就是时间任务里最常见的查询模型:状态机条件 + 时间窗口条件。
三、定时任务与业务异常的边界(容易混淆)
| 类型 | 典型来源 | 表现形式 |
|---|---|---|
| 业务接口异常 | Controller / Service | 返回 Result.error(...) 给前端 |
| 定时任务执行异常 | Task 方法内部 | 打日志、警告,之后重试或下轮继续处理 |
不要把定时任务当成“用户请求接口”去设计。它更像“后台守护线程”:重点不是前端提示,而是可恢复性和可观测性。
四、注意事项
- 异常处理:任务里要记录完整异常堆栈,避免静默失败。
- 幂等意识:更新语句尽量带状态条件,防止和人工操作互相覆盖。
- 多实例防重:线上多节点部署时,要考虑分布式锁或统一调度平台。
- 执行时长监控:关注任务耗时、每轮处理数量,及时发现积压。
- 业务补偿联动:关单后若涉及库存、优惠券等,应同步补偿回滚。
总结
@EnableScheduling+@Scheduled把“时间到了要发生什么”集中到 Task 层治理。- 在苍穹外卖里,
OrderTask用“状态 + 时间阈值”实现了超时关单和自动完成,让订单生命周期具备自动收口能力。
附录:相关源码路径
sky-server/src/main/java/com/sky/SkyApplication.java
sky-server/src/main/java/com/sky/task/OrderTask.java
sky-server/src/main/java/com/sky/mapper/OrderMapper.java
参考
苍穹外卖www.bilibili.com/video/BV1TP…
deekseek-v4