前段时间有天晚上,我肚子不太舒服,临时在外卖平台上买了点药。下完单以后,我一边盯着配送页等单,一边脑子里突然冒出来一个问题:美团这类履约系统,底层到底会怎么表达这种“到时间了,系统自动帮你做一件事”的需求?

像这类场景其实到处都是:

  • 订单 30 分钟未支付,自动取消。
  • 超过预计送达时间,状态自动变更。
  • 一段时间内无人接单,自动流转。
  • 优惠券到点失效。
  • 某个流程超时自动关闭。

表面上看,这些需求都很像一句话:到了某个时间点,自动触发一个动作。

但我后来慢慢觉得,让我别扭的点不在于“后端一般会怎么做”——像美团这种量级、这种履约链路,底层大概率不会只是起个定时任务扫一遍。更值得拆的是另一件事:系统到底把这个未来动作记在哪里。它是被数据库扫出来的,被消息系统约出来的,还是被状态机自己等出来的?这三种表达,背后其实是三种完全不同的系统模型。

后来我顺着一些公开资料继续往下看,越来越怀疑这类大规模履约场景更接近 Flink 这种状态化流处理的表达。再回头看我们自己做过的订单 30 分钟超时取消,我才慢慢把 XXL-Job、延迟消息和 Flink Timer 之间那层差别想清楚。

我们当时用 XXL-Job 扫数据库,并不丢人

先把这一层边界说清楚: 我们的工单量没那么大,业务也不要求绝对实时。分钟级发现、分钟级取消,对当时的场景已经够用了。放在这种前提下,XXL-Job 轮询数据库是一个合理方案。

我们的做法很朴素:

  1. 订单创建时,把订单写进数据库,状态是 UNPAID
  2. 起一个 XXL-Job 定时任务,比如每分钟执行一次。
  3. 扫描订单表里已经超过 30 分钟、而且仍然没支付的订单。
  4. 批量更新成取消状态。

大概会成这样:

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 分钟后恰好有一大批订单集中到期。
  • 定时任务开始密集扫表、更新、重试。

这时候数据库扛的不只是业务存储压力,还得扛调度压力。

还有一个更细的点:如果作业每分钟跑一次,那订单理论上就会在超时后 060 秒之间的某个时刻才被取消。很多业务场景下这完全可以接受,不过严格说,这更像“下一轮扫描时被发现”,算不上“到点触发”。

也就是说,这套方案的语义其实不是“等 30 分钟后取消”,而是“不断扫,扫到已经超过 30 分钟的订单就取消”。

延迟消息往前走了一步,但未来动作和当前状态还是分开的

如果比数据库轮询再往前走一步,大家很容易想到延迟消息。

思路通常是这样:

  1. 订单创建时,发一条 30 分钟后投递的延迟消息。
  2. 消息到期以后,由消费者来处理。
  3. 消费者查数据库,如果订单还是 UNPAID,就执行取消;如果已经支付,就直接忽略。

这比扫表更接近一种“约定未来动作”的表达:

  • 订单来了,就约一个 30 分钟后的动作。
  • 到时间了,再处理。

它确实比 XXL-Job 更进一步,因为它不再需要定时任务去全局扫描。至少在表达上,系统已经从“不断问有没有到期”变成“先约一个未来动作”了。

但它还不是终点。因为延迟消息到了之后,消费者通常并不知道这笔订单中途发生了什么。它还是得去数据库再查一次:

  • 还没支付?那就取消。
  • 已经支付?那就忽略。

于是问题换了一种形式,从“全表轮询”变成了另一种压力:

  • 大量订单集中创建。
  • 30 分钟后消息集中到期。
  • 消费者同时去数据库反查状态。

你会发现,数据库虽然不用被周期性扫了,但在到期洪峰面前,仍然可能被反查流量顶住。

压力换了个姿势,又回到了老地方。

还有一个后来让我很在意的问题:很多延迟消息方案只能让“未来动作到点后自证失效”,却没法真正撤销。

订单创建时,30 分钟后的消息已经发出去了。用户 5 分钟后支付成功,这条消息很多时候还是会在 25 分钟后按时到达。系统只能在那一刻再查数据库,然后发现:

这单已经支付了,这次取消不用执行。

工程上这当然可行,但语义上总还是别扭了一点。因为你其实并没有把那个未来动作撤掉,你只是等它自己到了,再告诉它“你已经没用了”。

如果把 Redis ZSet、JVM DelayQueue 这类方案也放进来一起看,本质上也还是这一类:未来动作和当前状态分开放,到了时间再去对齐。

后来我继续看这类问题,让我觉得味道变了的,是 Flink 这种状态化流处理方式。这里我更关心的问题是:如果真是美团这种量级、这种履约复杂度,系统很可能已经不再把它当普通定时任务了。

如果用它来表达“订单 30 分钟超时取消”,思路会更接近下面这样:

  1. 订阅订单创建事件流。
  2. 订阅支付成功事件流。
  3. orderIdkeyBy
  4. 对每个订单维护自己的状态和自己的 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 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 真响了,说明支付没来,于是执行取消。

它让我第一次很明确地感觉到,原来“定时”不只是调度问题。对某些业务来说,它其实是在问:系统有没有能力把等待、状态变化和未来动作放进同一个模型里。