Skip to content

订单付完了,后面那一串我没上 MQ ​

订单付完了,后面还有一串:改状态、扣库存、加积分、送优惠券、发通知、触发分账、写流水。

同步做完,接口慢得用户想骂人。于是很自然地想到:上个 MQ 吧。 我这套没上。pom.xml 里 RocketMQ、RabbitMQ、Kafka 一个依赖都没有。

这些异步的事全落在一个类上:1113 行,18 个方法,被调用 37 处。
它没有主题、没有消费组、没有投递保证 —— 它就是 18 个方法名。

听起来很土。但这篇想说的是:这笔账我算过,算完觉得土的那个更划算。

先说结论 ​

MQ 的活被拆给了三样东西:

方案用在哪大概占比
进程内异步(@Async)支付成功后的通知、日志这类「失败也无所谓」的事少数
Redis List 当轻量队列(lPush / rightPop)需要削峰、允许短暂延迟的任务中等
定时任务扫表延迟执行、批处理、状态兜底绝大多数

不是没考虑过 MQ,是算完账觉得不划算。 这篇讲这笔账怎么算的。

先把这三样数出来 ​

「没上 MQ」是个否定句,容易说。说清楚替代品有多大,这篇才有内容。

数了一遍:

东西数量
MQ 依赖(Rocket / Rabbit / Kafka)0
带 @Async 的类3
承载全部异步业务的那一个类1113 行 / 18 个方法
它被调用的地方37 处
Redis List 入队 lPush37 处,10 个类
Redis List 出队 rightPop11 处
队列长度探测 getListSize9 处
@Scheduled2 个
Quartz 相关类3

三行值得单独看。

一、18 个方法的那一个类,就是这套系统的「消息队列」。

1113 行。方法名从「支付成功触发分账」「支付后冻结佣金」「订单完成解冻」,
一路排到「商品详情浏览统计」「社区笔记点赞」「用户等级升级」。
支付成功之后该发生的事,一大半在这一个文件里。

它没有主题、没有消费组、没有投递保证。它是 18 个方法名。

二、37 个入队点,11 个出队点。

入队分布在订单、支付、支付回调、退款、秒杀、拼团、次卡这些类里;
出队集中在少数几处。这个 3:1 说明多个生产者共用少数几条队列——
拓扑上就是队列的形状,只不过这条队列是 Redis 的一个 List。

三、@Scheduled 只有 2 个,而且都在分账模块。

一个每 5 分钟轮询进件状态,一个按配置间隔重试分账(默认 10 分钟)。
这两个恰好都是「必须一直重试到成功」的资金场景。
其余的定时任务走 Quartz——触发器落在数据库里,重启不丢。

所以「没上 MQ」准确的说法是:MQ 的四个能力被拆开了,各用一样更省事的东西补上各自需要的那一块。 下面这笔账就是这么算的。

MQ 解决什么,代价是什么 ​

MQ 真正解决四个问题:

解耦 —— 生产者不用知道谁在消费
削峰 —— 突发流量先进队列,消费端按自己节奏处理
异步 —— 主流程不等下游
可靠投递 —— 消息不丢,失败能重试

这四条都是真的。但它同时带来的东西,讨论的时候经常被跳过:

多一个必须运维的中间件。 部署、监控、扩容、升级、磁盘满了怎么办。它挂了,你的订单流程也就挂了——你没有减少故障点,你换了一个故障点。

消息顺序问题。 「支付成功」和「订单取消」两条消息乱序到达,你的订单状态就错了。要保证顺序就要用顺序队列,而顺序队列意味着并发度下降。

重复消费。 MQ 保证的是至少一次,不是恰好一次。所以消费端必须幂等——这跟不上 MQ 时的要求是一样的,MQ 没帮你省掉这件事。

死信和积压。 消费失败的消息去哪?积压了几十万条怎么办?这些都要有预案,而预案要写代码。

排查链路变长。 出问题要看:生产端发了吗、MQ 里有吗、消费端收到了吗、消费成功了吗。四段,每段都可能是断点。

这些成本是天天都在的,而 MQ 的收益只在流量高峰时才显现。

换句话说:你为大促那三天的峰值,买了一个三百六十五天都要照顾的东西。
大促那三天它还特别容易出事,因为那是它唯一一次真的满负荷跑。

我的三样替代方案 ​

一、进程内异步:只用在「失败也无所谓」的事上 ​

java
@Async
public void notifyPaid(Long orderId) {
    // 发个通知,失败就失败了
}

用它的唯一前提是:这件事失败了不影响业务正确性。

发通知、记日志、更新一个展示用的统计——这些丢了不心疼。

绝不能用在钱和库存上。 进程内异步在服务重启时会丢任务,没有任何持久化保证。看到有人用 @Async 扣库存,那是在赌服务不重启。

二、Redis List 当轻量队列 ​

需要削峰、且丢一条能靠对账补回来的场景,用 Redis 的 List:

java
// 生产:任务入队
redis.lPush("task:paid", "" + orderId);

// 消费:定时或循环拉取
String id = redis.rightPop("task:paid");
if (id != null) {
    handle(id);
}

它买到的是削峰这一条,成本却低一个数量级——因为 Redis 你本来就有,不用多运维一个中间件。

但它的局限要清楚:

  • 没有 ACK 机制。 rightPop 之后消息就没了,处理到一半挂了就丢了。要补的话得用 rightPopAndLeftPush 搬到一个「处理中」队列,处理完再删——这就是在手工实现 ACK
  • 没有消费组、没有重试、没有死信
  • 积压了只能靠 getListSize 自己盯着

所以它适合:任务量可预期、单条处理快、偶尔丢一条能靠对账补回来的场景。

三、定时任务扫表:我用得最多的 ​

这是三样里用得最多的,也是最被低估的。

真实系统里跑着一大批:优惠券过期、佣金解冻、积分解冻、进件状态轮询、订单超时取消、自动确认收货、分账重试。

它的写法朴素到有点土:

java
@Scheduled(cron = "0 */5 * * * ?")
public void handlePending() {
    // 每次取一批,别一次捞光
    List<Order> list =
            mapper.selectPending(200);
    list.forEach(this::processQuietly);
}

// 单条失败不中断整批,下一轮自然重试
private void processQuietly(Order o) {
    try {
        process(o);
    } catch (Exception e) {
        log.error("跳过 {}", o.getId(), e);
    }
}

但它有三个 MQ 给不了的好处:

一、天然可重试。 这轮没处理成功,状态没变,下轮自动再来。你不需要写重试逻辑,扫表本身就是重试。

二、天然幂等的入口。 扫的是「状态还没变的记录」,处理完状态变了就扫不到了。状态本身就是幂等标记。

三、排查极其简单。 出问题就一句 SQL:SELECT * FROM order WHERE status = ? AND create_time < ?。你能立刻看到有多少积压、积压了多久、具体是哪些单。 MQ 里积压的消息,你得连上控制台翻。

它的代价是延迟。 五分钟跑一次,最坏就是五分钟延迟。对绝大多数电商场景,这个延迟完全可以接受——用户不会在支付成功后一秒内去查积分到没到。

什么时候我会上 MQ ​

不是永远不上。这三个信号出现任意一个,我就会重新评估:

一、需要跨系统解耦。 订单数据要给另一个团队维护的系统(数据中台、风控、ERP),这时候 MQ 是接口契约,不只是异步工具。让对方来查你的库,或者你去调对方的接口,都比走 MQ 差。

二、削峰是硬需求。 大促瞬时下单量是平时的几十倍,数据库扛不住。这时候需要真正的队列来缓冲,Redis List 撑不住那个量级。

三、定时任务的延迟成为业务问题。 如果业务要求秒级到账(比如充值即时到账),扫表那五分钟就不能接受了。

这三条都不成立的时候,上 MQ 就是给自己增加一个要照顾的东西。

我可能错的地方 ​

诚实说两条:

一、我的量级还没到。 如果订单量涨十倍,扫表这套可能就撑不住了——每次扫的范围变大、锁竞争变多。我的结论有量级前提,不是普适真理。

二、Redis List 那套确实糙。 没有 ACK,丢消息是真会发生的。我靠对账兜底,但如果业务对实时一致性要求高,这套不够。换成 Redis Stream 会好一些(有消费组和 ACK),这个我还没深入用过。

如果你的场景跟我不同,别照抄结论,照抄算账的方法就行。

一句话交代调度器 ​

上面示例用的是 @Scheduled,因为它最短、说明问题最快。

但真到量上来的时候,调度这一层通常不是它。 我这套里 @Scheduled 只在两个
文件里出现,主力是 Quartz —— 要的是持久化触发器、多实例不重复执行、
以及任务停了能查出来。这些 @Scheduled 都没有。

选哪个的分界线:任务停了你能不能立刻知道。
单实例、停了无所谓的,@Scheduled 够用;
多实例部署、或者这个任务停一天会出资金问题的,上 Quartz。

这条其实跟前面那笔 MQ 的账是同一个问题:
定时任务和消息队列都不难写,难的是它们停了没人知道。
选型的时候大家比的是吞吐和特性,上线之后天天操心的是「它还活着吗」。

常见问题对照表 ​

现象真正的原因解法
支付成功接口很慢后续动作全同步串行拆出非核心动作异步化,先别急着上 MQ
@Async 的任务在重启后丢了进程内异步没有持久化只用在失败无所谓的事上;涉及钱和库存必须落库
Redis 队列偶尔丢任务rightPop 之后崩溃,没有 ACK用 rightPopAndLeftPush 搬到处理中队列,或改用扫表
定时任务处理不过来,越积越多单次批量太小或执行间隔太长加大批量、缩短间隔;仍不够才考虑真队列
上了 MQ 之后订单状态错乱消息乱序,取消和支付两条消息顺序颠倒顺序队列,或消费端用状态机拒绝非法迁移
上了 MQ 还是重复处理以为 MQ 保证恰好一次MQ 只保证至少一次,消费端必须自己幂等
MQ 挂了整个下单流程瘫痪把 MQ 放进了主链路主链路只写库,异步动作失败不阻塞下单

这条链路上的其他几篇

  • 《支付回调一定会重复,你的幂等挡不挡得住》
  • 《订单状态机是怎么烂掉的,以及怎么不烂》

签名-C

大粽子