27、MVC中的异步处理:让应用不再“卡死”

说实话,做MVC开发最怕什么?

不是逻辑复杂,也不是代码难写。而是——用户点了个按钮,整个页面卡住了。

我记得刚入行那会儿,有个导出报表的功能。用户一点“导出”,服务器要跑几十万条数据。结果呢?浏览器转圈圈,用户以为死机了,疯狂刷新。最后数据库连接池爆了,服务器挂了。老板脸都绿了。

从那以后,我养成了一个习惯:凡是可能超过2秒的操作,一律异步处理

异步处理的核心思想

说白了,异步处理就是“先答应你,回头再办”。

用户发起一个请求,Controller收到后,不直接去干重活。而是把任务丢给后台,立刻返回一个“任务已接收”的响应。用户该干嘛干嘛,后台慢慢跑。跑完了再通知用户。

你想想看,这跟去餐厅吃饭一样。你点完菜,服务员说“稍等,我去催一下后厨”,然后你就干等着?不,你刷手机、聊天、喝茶。菜好了服务员端上来。这就是异步。

MVC中的异步处理三要素:

  • 任务提交:Controller接收请求,将任务封装成消息
  • 任务队列:消息中间件暂存任务,按顺序消费
  • 任务执行:后台Worker拉取任务,执行并反馈结果

消息队列:异步的“快递中转站”

消息队列是异步处理中最常用的工具。我个人习惯用RabbitMQ或Redis的List结构。为什么?因为简单、稳定、够用。

来看一个典型的流程:

用户 Controller 消息队列 Worker 请求 发布消息 消费消息 立即返回“任务已提交” 完成后通知用户

这个图我画了很多次,每次培训都要讲。核心就一句话: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 + 幂等性 = 可靠异步
  • 不要滥用异步,分清场景
公众号:蓝海资料掘金营,微信 deep3321