订单付完了,后面那一串我没上 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 入队 lPush | 37 处,10 个类 |
Redis List 出队 rightPop | 11 处 |
队列长度探测 getListSize | 9 处 |
@Scheduled | 2 个 |
| 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 的收益只在流量高峰时才显现。
换句话说:你为大促那三天的峰值,买了一个三百六十五天都要照顾的东西。
大促那三天它还特别容易出事,因为那是它唯一一次真的满负荷跑。
我的三样替代方案
一、进程内异步:只用在「失败也无所谓」的事上
@Async
public void notifyPaid(Long orderId) {
// 发个通知,失败就失败了
}用它的唯一前提是:这件事失败了不影响业务正确性。
发通知、记日志、更新一个展示用的统计——这些丢了不心疼。
绝不能用在钱和库存上。 进程内异步在服务重启时会丢任务,没有任何持久化保证。看到有人用 @Async 扣库存,那是在赌服务不重启。
二、Redis List 当轻量队列
需要削峰、且丢一条能靠对账补回来的场景,用 Redis 的 List:
// 生产:任务入队
redis.lPush("task:paid", "" + orderId);
// 消费:定时或循环拉取
String id = redis.rightPop("task:paid");
if (id != null) {
handle(id);
}它买到的是削峰这一条,成本却低一个数量级——因为 Redis 你本来就有,不用多运维一个中间件。
但它的局限要清楚:
- 没有 ACK 机制。
rightPop之后消息就没了,处理到一半挂了就丢了。要补的话得用rightPopAndLeftPush搬到一个「处理中」队列,处理完再删——这就是在手工实现 ACK - 没有消费组、没有重试、没有死信
- 积压了只能靠
getListSize自己盯着
所以它适合:任务量可预期、单条处理快、偶尔丢一条能靠对账补回来的场景。
三、定时任务扫表:我用得最多的
这是三样里用得最多的,也是最被低估的。
真实系统里跑着一大批:优惠券过期、佣金解冻、积分解冻、进件状态轮询、订单超时取消、自动确认收货、分账重试。
它的写法朴素到有点土:
@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 放进了主链路 | 主链路只写库,异步动作失败不阻塞下单 |
这条链路上的其他几篇
- 《支付回调一定会重复,你的幂等挡不挡得住》
- 《订单状态机是怎么烂掉的,以及怎么不烂》
