高并发系统里,异步是一个很像"办公室甩锅"的技术。
区别是,普通甩锅会让同事想拉黑你;设计得好的异步,会让系统少加班、用户少等待、老板少拍桌子。
很多人一提到异步,第一反应就是:
这还不简单?丢 MQ,完事。
这句话听起来很潇洒,但真到线上,MQ 可不是垃圾桶。你随手一丢,后面可能就是消息堆积、重复消费、状态不一致、补偿任务满天飞。
所以异步不是为了把问题"挪到后面",而是为了把一次请求里不同优先级的事情拆开:必须马上干的先干,不着急的排队干,失败能补的慢慢补。
如果用一句话概括异步:
异步的本质,是把主链路从"全家桶套餐"改造成"主菜先上,配菜后厨慢慢做"。
这篇文章继续按照金字塔思想,聊聊高并发场景下异步到底怎么做,哪些地方容易翻车,以及怎么把异步从"看起来很高级"落到"线上真能扛"。
一、为什么需要异步
先看一个很常见的下单流程。
用户点一下"提交订单",系统如果很实诚,可能会在一个接口里干完这些事:
- 创建订单;
- 扣减库存;
- 创建支付单;
- 发短信;
- 发优惠券;
- 加积分;
- 更新用户画像;
- 同步搜索;
- 推送站内信;
- 写操作日志;
- 通知数据仓库。
这就像用户只是点了一份盖饭,后厨非要当场完成采购、切菜、炒菜、结算、开发票、打扫卫生、写经营分析报告。
用户等得住,线程池也等不住。
这些动作从业务上看都和下单有关,但从系统设计上看,完全不是一个级别:
- 订单创建失败,用户确实不能继续;
- 库存没锁住,可能会超卖;
- 支付单没创建,后面没法付款;
- 短信晚 3 秒,用户一般不会报警;
- 积分晚 1 分钟,大多数人也不会半夜坐起来追问;
- 用户画像晚点同步,对当前下单没有直接影响。
所以高并发系统里,最怕的不是逻辑多,而是所有逻辑都挤在同步链路里。
同步链路太长,会带来几个很现实的问题:
- 接口 RT 被拉长,用户感觉系统慢;
- 线程长期被占用,吞吐量上不去;
- 一个非核心下游抖动,拖慢整个主流程;
- 高峰期请求堆积,服务开始排队;
- 系统扩容只能一锅端,成本很高。
异步要解决的,就是把主链路从"大巴车模式"改成"地铁换乘模式":核心乘客先到站,其他乘客按线路继续走。
二、用金字塔思想看异步
异步不是只引入一个 MQ 就算完成。真正成熟的异步设计,应该是分层的。
越靠近底层,越偏基础能力和削峰;越靠近上层,越靠近业务流程和一致性。
可以把它看成一个金字塔:
text
业务异步:流程拆分、最终一致性、补偿闭环
应用异步:线程池、任务队列、事件监听、回调
消息异步:MQ、延迟消息、顺序消息、事务消息
基础异步:日志、埋点、IO、批处理、缓存刷新
1. 基础层:先把"顺手但不值钱"的活拆出去
有些操作很常见,但没有必要拖着用户一起等。
比如:
- 访问日志;
- 埋点上报;
- 操作流水;
- 图片压缩;
- 文件解析;
- 缓存刷新;
- 报表生成;
- 非核心通知。
这些活不是不重要,而是不值得堵在用户面前。
用户提交一个表单,核心是数据保存成功;至于埋点、统计、日志、画像更新,可以慢一点。系统不要像一个过分热情的服务员,非要把后厨清洁记录也念给用户听完再放人走。
基础层异步的目标很简单:
不让低价值、耗时长、可延后的操作占住主请求线程。
这一步做好了,接口 RT 往往能立刻降一截。
2. 消息层:MQ 不是垃圾桶,是中转站
MQ 是异步里最常见的工具,但它经常被误用。
正确姿势是:
text
订单服务 -> 订单创建事件 -> 库存、积分、优惠券、通知、数据同步
订单服务只负责把"订单已经创建"这件事可靠地发出去,后面的消费者各干各的。
MQ 的核心价值有两个:
- 解耦:生产者不需要认识所有消费者;
- 削峰:流量先进入队列,消费者按自己的能力处理。
但 MQ 也不是魔法棒。
你把 10 万个请求扔进队列,不代表这 10 万个请求消失了,只是它们换了个地方排队。消费者处理不过来,队列照样堆积;幂等没做好,重复消费照样把数据写乱;补偿没设计,消息丢了你可能半个月后才发现。
所以 MQ 的正确定位是"中转站",不是"垃圾桶"。中转站要有路由、秩序、监控和兜底。
3. 应用层:线程池别搞成公共澡堂
不是所有异步都需要 MQ。
有些场景在应用内部用线程池、事件监听、任务队列就够了:
- 查询接口并行调用多个下游;
- 批量任务拆分执行;
- 非核心逻辑后台处理;
- 本地事件通知多个监听器;
- 定时任务扫描待处理数据。
这里最容易踩的坑是:所有异步任务共用一个线程池。
结果就是,报表导出这种慢任务占满线程池,短信通知进不来;某个下游超时,所有异步任务一起陪葬;队列越堆越长,内存也开始冒汗。
线程池要隔离,至少要按任务类型拆:
- 通知线程池;
- 报表线程池;
- 下游调用线程池;
- 数据同步线程池;
- 高优先级任务线程池。
每个线程池都要有自己的边界:
- 核心线程数;
- 最大线程数;
- 队列长度;
- 超时时间;
- 拒绝策略;
- 线程命名;
- 监控指标。
线程池不是越大越好。线程太多,CPU 会忙着上下文切换;队列太长,问题会被藏起来,等你发现时已经堆成一座小山。
4. 业务层:异步最后拼的是最终一致性
异步做到业务层,真正难的不是"怎么执行",而是"怎么算成功"。
还是以下单为例。用户看到"下单成功"的那一刻,系统到底必须保证什么?
- 订单必须创建成功;
- 库存必须锁定或扣减成功;
- 支付单必须可用;
- 短信可以晚一点;
- 积分可以晚一点;
- 画像可以晚一点;
- 搜索和数仓可以晚一点。
这就是业务异步最核心的判断:
哪些事情要强一致,哪些事情可以最终一致。
强一致的部分放主链路里,不能装作看不见;最终一致的部分放异步链路里,用消息、任务表、重试、补偿慢慢收敛。
异步不是逃避一致性,而是承认一致性有不同等级。
三、哪些场景适合异步
1. 削峰填谷:别让洪水直接冲进客厅
大促、秒杀、抢券、报名,这些场景流量来得很不讲武德。
平时 1000 QPS,活动一开可能直接 10 万 QPS。你要是让所有请求直奔订单和库存系统,那基本就是让数据库现场表演极限运动。
常见设计是:
text
用户请求 -> 资格校验 -> 排队/令牌 -> MQ -> 后台消费 -> 查询结果
前台做轻量校验和排队,后台按系统承载能力慢慢创建订单。
注意,这里一定要给用户明确反馈:
- 请求已受理;
- 正在排队;
- 预计稍后返回结果;
- 可以稍后刷新查看。
不要让用户面对一个一直转圈的页面。用户不知道你在削峰,只会觉得你在"装死"。
2. 非核心链路后置:主菜先上,餐巾纸可以晚点来
很多动作是必要的,但不必同步完成。
比如:
- 下单后的短信通知;
- 注册后的欢迎消息;
- 支付后的积分发放;
- 内容发布后的分发;
- 商品更新后的搜索同步;
- 用户行为后的推荐画像更新。
这些任务适合异步化。
判断标准很简单:
用户当前操作是否依赖这个结果?
依赖,就同步;不依赖,就异步。
用户付完款,最关心的是订单状态;积分晚几秒到账可以接受。用户发布内容,最关心的是提交成功;推荐系统晚点拿到数据也可以接受。
3. 慢任务后台化:别让用户陪你跑批
报表导出、文件解析、批量导入、视频处理、数据清洗,这些任务天生不适合同步接口。
好的交互方式应该是:
text
提交任务 -> 返回任务 ID -> 后台执行 -> 查询进度 -> 下载结果
接口只负责创建任务,真正耗时的处理放到后台。
这样有几个好处:
- 用户不用一直等;
- 系统可以控制后台并发;
- 任务失败可以重试;
- 任务进度可以展示;
- 历史结果可以追踪。
不要把一个 3 分钟的导出任务塞进 HTTP 请求里。Nginx、网关、浏览器、用户耐心,总有一个会先扛不住。
4. 下游隔离:别让一个慢服务拖全家下水
高并发系统里,下游服务不稳定是常态。
如果主链路强依赖一个慢下游,上游线程会被长期占住。这个时候异步可以把部分下游调用拆出去,降低抖动传播。
比如:
- 支付成功后异步通知 CRM;
- 订单完成后异步同步 ERP;
- 用户行为异步写推荐系统;
- 商品变更异步刷新搜索索引。
但也要注意:不是所有下游都能异步。
如果下游结果决定当前请求是否成功,就不能简单丢到异步里。比如支付确认、库存扣减、风控校验,这些关键动作该同步还是得同步,只是要配合超时、熔断、隔离来控制风险。
四、异步最容易翻车的地方
1. 异步边界切错
异步边界切得不好,就会变成两种极端。
一种是切得太少,主链路还是很胖,异步只是摆设。
另一种是切得太碎,一个简单流程被拆成十几个消息,排查问题像翻案卷:消息 A 发了吗?B 消费了吗?C 重试了吗?D 补偿了吗?最后人也异步了,精神状态延迟一致。
我的判断方式是三个问题:
- 这个动作是否影响主流程成功;
- 用户是否必须立刻看到结果;
- 失败后是否可以补偿。
如果不影响主流程、用户不急、失败能补,那就适合异步。
如果影响主流程、用户马上依赖、失败不可补,那就别硬异步。为了异步而异步,最后通常会得到一套很复杂但不可靠的系统。
2. 消息可靠性没兜住
异步最怕的不是慢,而是悄悄失败。
同步接口失败,至少用户能看到报错;异步消息丢了,可能系统表面风平浪静,业务结果却永远停在半路。
常见做法有:
- 本地事务表:业务数据和消息记录同事务提交;
- Outbox 模式:先写消息表,再由后台投递 MQ;
- 事务消息:利用 MQ 自身能力保证半消息和确认;
- 消费确认:处理成功后再 ack;
- 失败重试:短期失败自动重试;
- 死信队列:长期失败转入人工或补偿流程;
- 补偿扫描:定时找异常状态重新推进。
核心原则就一句:
关键异步任务不能只赌一次网络调用。
网络这东西,平时像朋友,关键时刻可能像面试官,突然沉默。
3. 幂等没做好
异步系统里,重复消费不是异常,是日常。
消费者重启、ack 失败、网络抖动、MQ 重投、业务重试,都可能让同一条消息被处理多次。
所以消费者必须幂等。
常见方式包括:
- 使用业务唯一键,比如订单号、支付单号、消息 ID;
- 数据库唯一索引防重复写;
- 状态机控制,只允许合法状态流转;
- Redis setnx 做短期去重;
- 幂等表记录处理结果;
- 下游接口提供幂等请求号。
比如发积分,不能简单写:
sql
update user_point set point = point + 100 where user_id = ?
否则重复消费两次,用户积分喜提翻倍,财务同事可能就不太喜悦。
更稳的方式是用订单号做唯一约束,保证同一笔订单只能发一次积分。
4. 顺序性没想清楚
很多异步问题不是消息没到,而是消息乱序。
比如订单状态应该是:
text
已创建 -> 已支付 -> 已发货 -> 已完成
如果"已完成"消息先到了,"已支付"消息后到了,消费者不校验状态,订单状态可能被写回去。
常见处理方式:
- 同一个业务 key 发到同一个分区;
- 消费端按状态机校验;
- 加版本号,旧版本消息直接丢弃;
- 数据库乐观锁;
- 乱序消息暂存后重试;
- 尽量减少对全局顺序的依赖。
不要一上来就追求全局有序。全局有序听起来很美,落地时经常很贵。大多数业务只需要保证单个订单、单个用户、单个商品内有序。
5. 队列堆积没人看
异步削峰不是无限削峰。
生产速度长期大于消费速度,队列一定会堆积。堆积后不是"慢一点"这么简单,可能会出现:
- 用户结果迟迟不返回;
- 消息过期;
- 重试流量放大;
- 消费者扩容滞后;
- 下游被集中打爆;
- 业务状态长时间不一致。
所以队列必须有监控:
- 队列长度;
- 消费延迟;
- 生产速率;
- 消费速率;
- 失败次数;
- 重试次数;
- 死信数量;
- 单条消息处理耗时;
- 消费者线程池水位。
队列堆积时,先别急着加消费者。先判断是生产突增、消费变慢、下游故障,还是消息本身处理异常。原因不同,解法完全不同。
五、落地实践建议
1. 先给主链路减肥
异步改造不要一上来就全系统铺开。
先画出核心链路:
text
用户请求 -> 参数校验 -> 业务校验 -> 核心写入 -> 返回结果 -> 后置处理
然后逐个动作问:
- 是否必须同步;
- 是否影响用户当前结果;
- 是否可以失败重试;
- 是否需要强一致;
- 是否有补偿入口;
- 是否有明确监控。
优先拆掉那些耗时长、失败可补偿、和主结果弱相关的动作。
这一步做好了,比盲目上 MQ 更有价值。
2. 任务一定要有状态
重要的异步任务,不要只活在内存里。
最好有任务表或状态字段:
text
INIT -> PROCESSING -> SUCCESS / FAILED -> COMPENSATED
这样系统至少能回答:
- 任务有没有创建;
- 消息有没有发出去;
- 消费有没有开始;
- 现在卡在哪一步;
- 失败原因是什么;
- 是否还能重试;
- 是否需要人工处理。
没有状态的异步任务,排查起来就像在黑屋子里找键盘,还不能开灯。
3. 重试要有节制
失败重试是必要的,但无限重试就是系统版"死缠烂打"。
比较合理的策略是:
- 短暂抖动:快速重试几次;
- 下游故障:指数退避;
- 参数错误:不要重试,直接失败;
- 长期失败:进入死信队列;
- 关键任务:补偿扫描兜底;
- 重试次数、间隔、最大耗时都要可配置。
尤其要避免失败任务疯狂重试,把已经不舒服的下游继续按在地上摩擦。
4. 线程池和 MQ 都要限流
异步不是没有成本。
线程池满了会拒绝,队列满了会堆积,MQ 慢了会拖延,下游扛不住会失败。
所以异步链路也要限流:
- 生产端控制发送速度;
- 消费端控制并发数;
- 线程池控制队列长度;
- 下游调用设置超时;
- 消费失败设置退避;
- 核心任务和非核心任务隔离。
不要让异步链路变成另一个没有边界的同步链路。
5. 监控要看业务是否收敛
异步监控不能只看 MQ 是否活着。
更关键的是业务结果有没有收敛:
- 下单后库存是否最终扣减;
- 支付后积分是否最终发放;
- 发布后内容是否最终审核;
- 商品变更后搜索是否最终同步;
- 失败任务是否最终补偿成功。
技术指标说明系统在跑,业务指标说明系统跑对了。
只看"消息消费成功",不看"业务结果正确",很容易出现一种尴尬:机器很忙,业务很慌。
六、一个简单的异步伪代码
下面是一个本地事务表加后台投递的简化例子:
java
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = orderRepository.save(command);
OutboxMessage message = OutboxMessage.builder()
.bizId(order.getOrderNo())
.topic("order.created")
.payload(toJson(order))
.status("INIT")
.build();
outboxRepository.save(message);
}
public void publishOutboxMessage() {
List<OutboxMessage> messages = outboxRepository.findInitMessages();
for (OutboxMessage message : messages) {
try {
mqProducer.send(message.getTopic(), message.getPayload());
message.markSuccess();
} catch (Exception ex) {
message.markFailed(ex.getMessage());
}
}
}
public void consumeOrderCreated(OrderCreatedEvent event) {
if (pointRepository.existsByOrderNo(event.getOrderNo())) {
return;
}
pointRepository.addPoint(event.getUserId(), event.getOrderNo(), 100);
}
这个例子想表达的不是代码有多高级,而是几个关键点:
- 订单和消息记录一起提交,避免业务成功但消息没了;
- 后台任务负责投递消息,失败后可以继续扫描;
- 消费端按订单号做幂等,避免重复发积分;
- 每一步都有状态,出了问题能查、能补、能追。
真实项目里可以用 RocketMQ、Kafka、RabbitMQ、Pulsar,也可以用数据库任务表、延迟队列、调度平台。工具选型很重要,但比工具更重要的是:可靠性、幂等性、顺序性、可观测性和补偿能力有没有设计进去。
七、总结
异步不是把代码扔进 MQ 就完事,也不是为了显得架构很复杂。
按照金字塔思想来看:
- 基础层:拆掉低价值耗时操作;
- 消息层:做解耦和削峰;
- 应用层:做好线程池隔离和任务边界;
- 业务层:围绕最终一致性做补偿闭环。
一个好的异步方案,应该让主链路更短,让后置任务更稳,让系统在高并发下既能快一点,也能靠谱一点。
最后再总结一句:
异步的目标不是把问题往后推,而是让正确的事情在正确的位置、用正确的节奏完成。