27、MVC中的异步处理:让应用不再“卡死”
说实话,做MVC开发最怕什么?
不是逻辑复杂,也不是代码难写。而是——用户点了个按钮,整个页面卡住了。
我记得刚入行那会儿,有个导出报表的功能。用户一点“导出”,服务器要跑几十万条数据。结果呢?浏览器转圈圈,用户以为死机了,疯狂刷新。最后数据库连接池爆了,服务器挂了。老板脸都绿了。
从那以后,我养成了一个习惯:凡是可能超过2秒的操作,一律异步处理。
异步处理的核心思想
说白了,异步处理就是“先答应你,回头再办”。
用户发起一个请求,Controller收到后,不直接去干重活。而是把任务丢给后台,立刻返回一个“任务已接收”的响应。用户该干嘛干嘛,后台慢慢跑。跑完了再通知用户。
你想想看,这跟去餐厅吃饭一样。你点完菜,服务员说“稍等,我去催一下后厨”,然后你就干等着?不,你刷手机、聊天、喝茶。菜好了服务员端上来。这就是异步。
MVC中的异步处理三要素:
- 任务提交:Controller接收请求,将任务封装成消息
- 任务队列:消息中间件暂存任务,按顺序消费
- 任务执行:后台Worker拉取任务,执行并反馈结果
消息队列:异步的“快递中转站”
消息队列是异步处理中最常用的工具。我个人习惯用RabbitMQ或Redis的List结构。为什么?因为简单、稳定、够用。
来看一个典型的流程:
这个图我画了很多次,每次培训都要讲。核心就一句话:Controller只负责接单,不负责干活。
代码示例:Spring Boot + RabbitMQ
来,直接上代码。这是我在一个电商项目中用过的模式:
// Controller层 - 只负责接收请求,发布消息
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private RabbitTemplate rabbitTemplate;
@PostMapping("/export")
public ResponseEntity<String> exportOrders(@RequestBody ExportRequest request) {
// 生成一个任务ID
String taskId = UUID.randomUUID().toString();
request.setTaskId(taskId);
// 把任务丢到队列里
rabbitTemplate.convertAndSend("order.export.queue", request);
// 立刻返回,告诉用户任务已接收
return ResponseEntity.accepted()
.body("任务已提交,任务ID:" + taskId);
}
}
// Worker层 - 真正干活的
@Component
public class OrderExportWorker {
@RabbitListener(queues = "order.export.queue")
public void handleExport(ExportRequest request) {
try {
// 1. 查询数据库(可能很慢)
List<Order> orders = orderService.queryLargeOrders(request);
// 2. 生成Excel文件
File file = exportService.generateExcel(orders);
// 3. 上传到OSS或发邮件通知用户
notificationService.notifyUser(request.getUserId(), file);
// 4. 更新任务状态
taskService.markCompleted(request.getTaskId());
} catch (Exception e) {
// 失败处理
taskService.markFailed(request.getTaskId(), e.getMessage());
log.error("导出任务失败", e);
}
}
}
💡 我的经验:
任务ID一定要生成并返回给前端。这样用户可以通过轮询或WebSocket查询任务状态。我曾经见过一个项目没做任务ID,用户不知道导出到哪一步了,疯狂找客服投诉。
后台作业:定时任务与批处理
除了消息队列,还有一种常见的异步场景:定时后台作业。
比如每天凌晨统计报表、每周清理过期数据、每月生成账单。这些不需要用户触发,系统自己跑。
在MVC架构中,我建议把后台作业单独抽成一个模块,不要跟Controller混在一起。为什么?因为职责不同。Controller处理用户交互,Worker处理后台任务。混在一起,代码会变得很难维护。
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| 用户触发的大任务 | 消息队列 + Worker | 导出报表、批量处理、发送邮件 |
| 定时执行的批处理 | @Scheduled / Quartz | 每日统计、数据清理、账单生成 |
| 实时性要求高的异步 | WebSocket + 消息队列 | 实时通知、进度推送 |
| 延迟执行的任务 | 延迟队列 / Redis ZSet | 订单超时取消、定时提醒 |
避坑指南:我踩过的三个坑
⚠️ 坑一:任务重复执行
我曾经遇到过一个问题:Worker处理到一半挂了,重启后任务重新消费,导致用户收到了两封邮件。解决方案是幂等性设计。每个任务带上唯一ID,处理前先检查是否已经处理过。
⚠️ 坑二:消息堆积
有一次双十一,订单量暴增,消息队列堆积了几十万条。Worker处理不过来,用户等了一小时还没收到确认。后来我加了动态扩缩容机制,根据队列长度自动增加Worker数量。
⚠️ 坑三:事务一致性
异步处理最大的难题是:数据库操作和消息发送如何保证一致性?我现在的做法是本地消息表。先写数据库,再写本地消息表,然后定时扫描消息表发送到队列。这样即使MQ挂了,也不会丢消息。
异步处理的MVC分层建议
嗯,这里要注意。异步处理虽然好,但不能滥用。我见过有人把所有数据库查询都改成异步,结果代码复杂度翻了三倍,性能反而下降了。
我的原则是:
- 读操作:尽量同步,除非数据量特别大
- 写操作:关键路径同步,非关键路径异步
- 第三方调用:必须异步,防止外部服务拖垮你的应用
最后说一句。异步处理不是银弹,它带来了性能提升,也带来了复杂度。但只要你掌握了消息队列、Worker、任务状态管理这三板斧,大部分场景都能应对。
我在项目中用这套模式做了不下十个功能,从导出报表到批量推送,从数据同步到定时任务。稳定运行了三年,没出过大问题。
你想想看,如果用户点个按钮,页面秒回“任务已提交”,而不是转圈圈卡死,体验是不是好很多?
核心总结:
- Controller只负责接单,不负责干活
- 消息队列是异步的“快递中转站”
- Worker负责真正执行任务
- 任务ID + 幂等性 = 可靠异步
- 不要滥用异步,分清场景