XXL-Job
订单超时取消,为什么我最后会想到 Flink 这类状态流方案
让我一直别扭的,不是“订单超时取消”这件事怎么做,而是美团这类大规模履约系统底层可能怎么表达它。顺着公开资料往下看,再回头对照我们自己用 XXL-Job 的场景,我才慢慢把两类系统真正的分界线想清楚。
前段时间有天晚上,我肚子不太舒服,临时在外卖平台上买了点药。下完单以后,我一边盯着配送页等单,一边脑子里突然冒出来一个问题:美团这类履约系统,底层到底会怎么表达这种“到时间了,系统自动帮你做一件事”的需求?
像这类场景其实到处都是:
- 订单 30 分钟未支付,自动取消。
- 超过预计送达时间,状态自动变更。
- 一段时间内无人接单,自动流转。
- 优惠券到点失效。
- 某个流程超时自动关闭。
表面上看,这些需求都很像一句话:到了某个时间点,自动触发一个动作。
但我后来慢慢觉得,让我别扭的点不在于“后端一般会怎么做”——像美团这种量级、这种履约链路,底层大概率不会只是起个定时任务扫一遍。更值得拆的是另一件事:系统到底把这个未来动作记在哪里。它是被数据库扫出来的,被消息系统约出来的,还是被状态机自己等出来的?这三种表达,背后其实是三种完全不同的系统模型。
后来我顺着一些公开资料继续往下看,越来越怀疑这类大规模履约场景更接近 Flink 这种状态化流处理的表达。再回头看我们自己做过的订单 30 分钟超时取消,我才慢慢把 XXL-Job、延迟消息和 Flink Timer 之间那层差别想清楚。
我们当时用 XXL-Job 扫数据库,并不丢人
先把这一层边界说清楚: 我们的工单量没那么大,业务也不要求绝对实时。分钟级发现、分钟级取消,对当时的场景已经够用了。放在这种前提下,XXL-Job 轮询数据库是一个合理方案。
我们的做法很朴素:
- 订单创建时,把订单写进数据库,状态是
UNPAID。 - 起一个 XXL-Job 定时任务,比如每分钟执行一次。
- 扫描订单表里已经超过 30 分钟、而且仍然没支付的订单。
- 批量更新成取消状态。
大概会成这样:
SELECT id
FROM orders
WHERE status = 'UNPAID'
AND created_at <= NOW() - INTERVAL 30 MINUTE
LIMIT 1000;
再配合条件更新:
UPDATE orders
SET status = 'CANCELLED'
WHERE id = ?
AND status = 'UNPAID';
这里 AND status = 'UNPAID' 很关键。因为它实际上承担了并发兜底:如果用户刚好支付成功,这条取消 SQL 就不会生效。
这类方案在工程上为什么常见,我后来想得很明白,原因其实不复杂。
第一,它简单。数据库、定时任务框架、业务服务,基本都是团队手里现成的东西,不需要额外引入一整套基础设施。
第二,它可控。链路都在自己熟悉的技术栈里,出问题时排查路径很直接。这一点在很多团队里很重要,因为系统能不能 hold 住,不只看架构图,还看出事时谁能扛。
第三,它对很多业务场景来说确实够用。不是所有“超时取消”都要求毫秒级精确,很多时候分钟级准实时已经足够。只要业务不要求特别强的时效一致性,数据量也还没大到把数据库顶成瓶颈,这种方案完全能落地。
所以如果一个团队没有成熟的实时平台,也没有必要为了一个需求先把平台复杂度抬起来,那 XXL-Job 这种做法一点都不寒碜。至少对我们当时那个量级,它是更合适的。
但它表达超时的方式,本质上是“扫出来”
这套方案老不老不重要,关键在于它到底在怎么表达“30 分钟后取消”这件事。
XXL-Job 这类方案,说白了是一个“拉”模型。系统不会被订单事件自然唤醒,而是在不断主动问数据库:
- 有没有超时订单?
- 这一分钟有没有?
- 这一轮要取消哪些?
这意味着哪怕这一轮一条都没有,它还是得扫一次。所以它天然会有空轮询。
再往下一层看,它其实把数据库顺带用成了一个时间判定中心。数据库本来应该主要存订单状态,但在这套模型里,它还得承担另一层职责:告诉调度器“哪些东西已经到点了”。
量小时这没问题,量一大,瓶颈就会开始往数据库上压:
- 某个时间段订单创建量特别大。
- 30 分钟后恰好有一大批订单集中到期。
- 定时任务开始密集扫表、更新、重试。
这时候数据库扛的不只是业务存储压力,还得扛调度压力。
还有一个更细的点:如果作业每分钟跑一次,那订单理论上就会在超时后 0 到 60 秒之间的某个时刻才被取消。很多业务场景下这完全可以接受,不过严格说,这更像“下一轮扫描时被发现”,算不上“到点触发”。
也就是说,这套方案的语义其实不是“等 30 分钟后取消”,而是“不断扫,扫到已经超过 30 分钟的订单就取消”。
延迟消息往前走了一步,但未来动作和当前状态还是分开的
如果比数据库轮询再往前走一步,大家很容易想到延迟消息。
思路通常是这样:
- 订单创建时,发一条 30 分钟后投递的延迟消息。
- 消息到期以后,由消费者来处理。
- 消费者查数据库,如果订单还是
UNPAID,就执行取消;如果已经支付,就直接忽略。
这比扫表更接近一种“约定未来动作”的表达:
- 订单来了,就约一个 30 分钟后的动作。
- 到时间了,再处理。
它确实比 XXL-Job 更进一步,因为它不再需要定时任务去全局扫描。至少在表达上,系统已经从“不断问有没有到期”变成“先约一个未来动作”了。
但它还不是终点。因为延迟消息到了之后,消费者通常并不知道这笔订单中途发生了什么。它还是得去数据库再查一次:
- 还没支付?那就取消。
- 已经支付?那就忽略。
于是问题换了一种形式,从“全表轮询”变成了另一种压力:
- 大量订单集中创建。
- 30 分钟后消息集中到期。
- 消费者同时去数据库反查状态。
你会发现,数据库虽然不用被周期性扫了,但在到期洪峰面前,仍然可能被反查流量顶住。
压力换了个姿势,又回到了老地方。
还有一个后来让我很在意的问题:很多延迟消息方案只能让“未来动作到点后自证失效”,却没法真正撤销。
订单创建时,30 分钟后的消息已经发出去了。用户 5 分钟后支付成功,这条消息很多时候还是会在 25 分钟后按时到达。系统只能在那一刻再查数据库,然后发现:
这单已经支付了,这次取消不用执行。
工程上这当然可行,但语义上总还是别扭了一点。因为你其实并没有把那个未来动作撤掉,你只是等它自己到了,再告诉它“你已经没用了”。
如果把 Redis ZSet、JVM DelayQueue 这类方案也放进来一起看,本质上也还是这一类:未来动作和当前状态分开放,到了时间再去对齐。
Flink Timer 真正打动我的,不只是“它也能定时”
后来我继续看这类问题,让我觉得味道变了的,是 Flink 这种状态化流处理方式。这里我更关心的问题是:如果真是美团这种量级、这种履约复杂度,系统很可能已经不再把它当普通定时任务了。
如果用它来表达“订单 30 分钟超时取消”,思路会更接近下面这样:
- 订阅订单创建事件流。
- 订阅支付成功事件流。
- 按
orderId做keyBy。 - 对每个订单维护自己的状态和自己的 Timer。
流程会自然很多。
订单创建时
- 把订单状态写进
ValueState。 - 注册一个 30 分钟后的 Timer。
支付成功时
- 更新状态。
- 删除刚才注册的 Timer。
Timer 真到点时
- 说明直到 30 分钟为止,支付事件都没有到来。
- 这时候再执行取消逻辑。
如果写成伪代码,大概就是:
onOrderCreated(order):
state.put(order.status = UNPAID)
timer.register(order.createdAt + 30min)
onOrderPaid(order):
state.update(order.status = PAID)
timer.delete(order.createdAt + 30min)
onTimer(order):
if state.status == UNPAID:
cancel(order)
这套表达最顺的地方在于,它的逻辑是:
- 订单来了,记住它,并约一个未来动作。
- 支付来了,就把未来动作撤掉。
- 时间到了还没人撤,那就执行取消。
Flink 的强点,在于它把状态、等待和触发放回了一个上下文
很多人会把 Flink Timer 理解成一句话:它也支持定时。
但我后来越看越觉得,定时只是表面,它强在把下面几件事放回了同一个计算上下文里:
- 当前状态是什么。
- 未来动作是什么。
- 什么时候触发。
- 中途能不能撤销。
1. Timer 是按 Key 管的,不是中心化大闹钟
keyBy(orderId) 之后,同一个订单相关的创建、支付、取消,都会进入同一个 key 的处理逻辑。
这意味着:
- 每个订单有自己的状态。
- 每个订单也有自己的 Timer。
- Timer 和这笔订单的状态天然绑在一起。
所以它不像传统调度器那样,有一个全局中心在盯着所有任务。更像是数据怎么分片,等待和触发能力就跟着怎么分片。
2. 如果状态已经进入流里,就不必每次回头反查数据库
这点我觉得特别有工程价值。
在延迟消息方案里,消息到了以后要不要取消,通常还得查一次数据库。因为消费者自己不知道这笔订单现在到底是什么状态。
但如果订单创建流和支付流都已经进入同一个状态机,那系统本身就知道这笔订单当前处在哪个状态里。很多判断就可以直接在状态里完成,而不是每次都回外部存储再对齐一次。
当然,这里有一个明确前提:创建、支付、取消这些关键状态变更,真的都已经进入了这条状态链路。如果支付结果最终还是只写在外部数据库里,流里没有看到,那你最终还是得回查。Flink 不是魔法,它只是把本来应该放在同一上下文里的东西收回来了。
3. Timer 不是随手记在某个线程里,它是状态体系的一部分
很多人一听“海量 Timer”,第一反应都是:这 JVM 不得炸掉?
但 Flink 这种状态后端的思路,本来就不是纯靠 JVM 堆裸扛一切。状态和 Timer 都会跟着 checkpoint、恢复、迁移一起被托管。
从工程理解上看,你可以把它理解成:
这些未来动作已经作为状态体系的一部分被管理起来了,不再是临时记在某个线程的内存里。
这对超时类业务很关键,因为这类业务最怕的不是慢一点,而是该触发的时候结果丢了。
4. 扩展的不只是数据,还有“谁来等它到点”
如果有几亿个待支付订单,不代表某一台机器要记几亿个闹钟。因为在 keyBy 之后,这些订单会被打散到不同的并行实例上。
也就是说:
- 数据被分散。
- 状态被分散。
- Timer 也被分散。
- 扩容时,这些能力跟着一起扩。
5. 它为什么真的能扛住亿级别定时
我觉得这里还得再补一句。很多人听到“一个订单一个 Timer”,第一反应都会是:那上亿订单不就等于上亿个定时器?这东西怎么可能扛得住?
真拆开看,Flink 靠的不是一个多神奇的“大闹钟”——它从一开始就没按中心化调度器那套思路在做这件事。
第一,订单会先按 keyBy(orderId) 打散。上亿个 Timer 跟着数据分区落到不同并行实例上,不会堆在一个调度中心里。对系统来说,这更像是“很多实例各自管理自己那一份状态和等待”。
第二,这些 Timer 不是裸挂在 JVM 堆上的临时对象。它们和状态一起被状态后端托管,也会跟着 checkpoint 和恢复一起走。也就是说,系统关心的问题已经从“内存里塞了多少个 Java 定时器”变成了“状态体系里托管了多少个未来触发点”。
第三,它的扩展方式是跟着并行度一起扩的。数据量上来、等待中的订单上来,要问的问题就变了:从“单机能不能再记更多闹钟”变成“这批 key 能不能继续被分摊到更多实例上”。只要状态后端、checkpoint 和整体资源还扛得住,它就不是那种一上量就先被中心化调度器卡死的模型。
所以“支持亿级别定时”这件事,成立的前提在于这类系统把分片、状态托管、恢复和定时触发放进了一套统一模型里,单靠某个 Timer API 特别强是做不到的。到这个量级以后,你已经不能把它理解成“开了很多个定时任务”,而更该理解成“系统在替很多个 key 持续维护未来动作”。
但这不等于应该反过来否定 XXL-Job
这点我特别想说清楚。
如果写成“Flink 才是正确答案,XXL-Job 太落后”,这肯定是不对的。真实工程从来不是这么选的。
如果一个团队:
- 没有成熟的 Flink 平台。
- 没有经验去维护 checkpoint、状态恢复和扩缩容。
- 平台 SLA 掌握在别的团队手里。
- 业务又没有强到必须做成事件驱动状态机。
那你就算知道 Flink 的系统表达更顺,也未必应该真的上。
因为方案好不好,不只取决于语义上是不是更优雅,还取决于:
- 团队能不能 hold 住。
- 平台是不是稳定。
- 出问题时自己能不能兜。
- 业务值不值得这个复杂度。
所以如果回到我们当时那个场景,我依然会说,XXL-Job 是合理、务实、可落地的方案。它和 Flink 不是高低之分,而是问题规模不一样、实时要求不一样、系统愿不愿意为这种表达方式支付复杂度也不一样。
我后来看美团这类场景时更倾向往 Flink 这类方案上想,倒不是觉得 Flink 更高级——那种量级和那种履约复杂度,已经更像一套持续流动的状态机,靠偶尔扫一下是兜不住的。
我后来真正学到的,不是选 Flink,而是先问系统把未来动作记在哪里
以前我会觉得,“订单超时取消”就是一道标准后端题。无非就是:
- 定时扫表。
- 延迟消息。
- 再往前走就是流处理。
但这次顺着它往下拆以后,我反而更在意另一组问题:
- 这个“30 分钟后”到底是谁记住的?
- 中途状态变了,未来动作能不能真正撤销?
- 系统是在“扫出来”这个超时,还是在“等到”这个超时?
- 调度和状态到底是分离的,还是一体的?
- 规模上来以后,瓶颈会落在数据库、消息系统,还是状态管理本身?
到这时候我才觉得,定时任务看起来很普通,但它背后分开的,其实是几种完全不同的系统表达,远不止实现手段的差异。更具体一点说,是不同量级、不同实时要求、不同业务复杂度,最后会把系统推向完全不同的表达方式。
如果只从今天回看,我最喜欢的一种表达依然是:
- 订单来了,注册一个 30 分钟后的 Timer。
- 支付来了,删掉这个 Timer。
- Timer 真响了,说明支付没来,于是执行取消。
它让我第一次很明确地感觉到,原来“定时”不只是调度问题。对某些业务来说,它其实是在问:系统有没有能力把等待、状态变化和未来动作放进同一个模型里。