
做全栈开发七年去年帮朋友的贸易公司搭内部系统时踩过一个经典坑审批流里要同步生成一份带图表的报表接口响应从八百毫秒拖到九秒前端转圈后端线程被占满高峰期整个系统卡成幻灯片。最后的解法就是把耗时操作拆进异步任务队列。这篇把这个话题展开讲讲。先说定位这篇不讲任何具体消息中间件的用法讲的是思路——什么时候该拆、拆了之后怎么保证不出乱子。思路通了用什么都快思路不通工具越先进越乱。一、什么样的操作该异步化1.1 那次九秒事故先复盘事故。审批提交接口里串了四个动作写审批记录、生成报表文件、发邮件通知、更新统计缓存。前两个加起来要八秒多都是重活报表要查十几张表还要渲染图表邮件要走外部服务。同步做完这四件事再返回用户端体验就是漫长的转圈。更险的是后端并发一上来线程全被报表生成占着其他请求排队系统整体不可用。朋友当时的形容很传神页面像老式幻灯机切一页要等半天。用户不知道你后台在生成报表他们只知道这家公司的系统不行。1.2 判断标准用户需要等吗拆解后想明白一个判断标准这个操作的结果用户此刻就需 要看到吗写审批记录需要这是主流程生成报表不需要报表是给管理层事后看的发邮件不需要通知晚十秒无人察觉更新缓存不需要下次查到再算也行。用户需要等的留在线程里不需要等的全部拆出去。这个标准一刀下去四件事拆成一件接口响应回到八百毫秒以内。1.3 异步化的本质异步化的本质是错峰把重活从用户等待的黄金路径挪到后台用户拿响应后台慢慢干。代价是流程从一条线变成两条线中间靠消息衔接复杂度会上升。所以不是所有操作都值得拆拆之前先过那把尺子。后来我把它总结成一句话同步换体验的确定性异步换系统的吞吐力没有对错只有合不合场景。二、队列选型与架构2.0 选型的两条底线选型时给方案定了两条底线一是不引入需要专人运维的新组件公司没有运维编制再好的中间件没人管就是负资产二是方案要能让接手的开发者十分钟看懂花哨的技巧越少越好。两条底线把候选筛得只剩数据库队列这一条路反而干脆。约束条件多不是坏事它替你做了大部分决策。2.1 轻量方案先行项目规模不大日均任务几百个直接上重量级消息中间件属于高射炮打蚊子。选型用了最朴素的组合数据库表当队列存储后台常驻进程消费任务状态机管理生命周期。这个方案土但可靠、可观测、零额外运维。数据量再涨两个量级换成专业消息中间件也不迟接口层不用动。这是分层的好处业务代码只管往任务表插记录它不关心也不需要关心背后是谁在消费。换队列引擎是运维动作不是开发动作。2.2 任务表设计任务表是队列的心脏字段设计直接决定消费端的灵活性CREATETABLEasync_task(idBIGINTPRIMARYKEYAUTO_INCREMENT,task_typeVARCHAR(64)NOTNULL,payload JSONNOTNULL,statusTINYINTNOTNULLDEFAULT0,retry_countINTNOTNULLDEFAULT0,max_retryINTNOTNULLDEFAULT3,next_run_atDATETIMENOTNULL,created_atDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,updated_atDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,KEYidx_dispatch(status,next_run_at));两个设计点值得说payload 用 JSON 存参数新增任务类型不用改表联合索引按状态加执行时间建消费端捞任务永远走这条索引量大也不慌。顺带一提任务表要定期归档已完成的任务留在线上三十天之后搬到归档表。不然任务表越积越大捞任务的索引再优秀也扛不住无限增长。2.3 消费循环与分发消费端是个常驻进程核心循环用伪代码表示while running: tasks query SELECT ... WHERE status0 AND next_run_atNOW() ORDER BY next_run_at LIMIT 10 FOR UPDATE SKIP LOCKED if tasks.empty: sleep(1); continue for t in tasks: mark(t, RUNNING); dispatch(t) function dispatch(t): handler registry[t.task_type] try: handler(t.payload); mark(t, DONE) except e: if t.retry_count t.max_retry: mark(t, PENDING, next_run_at NOW() backoff(t.retry_count)) else: mark(t, FAILED); alert_ops(t) function backoff(n): return 30s * 2^n捞任务用了跳过锁的语法多个消费进程并行跑也不会重复领任务这是整个方案里技术含量最高的一笔。失败重试带指数退避头一次失败三十秒后再试再失败翻倍给瞬时故障留自愈时间也避免重试风暴。退避公式里的基数三十秒也是调过的起初用五秒外部服务抖动时重试太密集反而加重对方负担改成三十秒后双方都从容。重试间隔的讲究本质是对下游的礼貌。2.4 幂等异步的另一只手异步必须配幂等。任务可能重试handler 就可能对同一份载荷执行两次。发邮件重复发、报表重复生成还是小事要是带写操作的数据任务重复执行就是数据事故。每个 handler 落库前先查业务唯一键存在直接标记成功跳过。幂等不是选修课是异步架构的必修课。实现幂等有个心理关要过查重的那次查询看着多余正常情况下它永远返回不存在。但它的价值在异常时刻兑现就像消防栓平时碍眼火时救命。三、上线后的效果3.1 响应时间回来了改造后审批接口稳定在八百毫秒内报表生成平均两分钟在后台完成。用户感知零变化提交即响应报表好了邮件自然会到。中间的衔接靠一个状态查询接口前端想知道报表好没好轮询任务状态即可。后来又优化成提交时给个预计时间连轮询都省了用户体感更清爽。3.2 系统抗压能力上台阶高峰期后台积压过三百多个任务消费端按每秒十个的速度稳定消化四十分钟清完主流程毫发无损。同步时代这种量级早崩了。队列就像水库洪水来了先蓄着下游按自己的节奏放水。那天朋友盯着看板看积压曲线往下走说了句这系统总算有点大厂的样子。我笑说不是像大厂是懂得了排队。3.3 故障定位反而更清楚意外收获是可观测性所有任务的状态、重试次数、失败原因都在表里配一个简单的看板哪个环节慢、哪类任务爱失败一目了然。同步时代这些问题藏在日志堆里现在它们自己排着队报到。朋友有次开玩笑说这套看板比他请的运营还诚实运营报喜不报忧任务表报忧不报喜。玩笑背后是真价值系统的毛病藏不住才修得好。四、经验与提醒4.0 上线节奏上线没搞一刀切而是按任务类型分批搬头一批只把邮件通知异步化跑一周稳定第二批搬报表生成第三批搬缓存刷新。每批之间留观察期出了问题影响面也只有一类任务。渐进式改造多花两周换来的是每一口都咬得动、咽得下。4.1 不要过度设计这个方案没有引入任何新组件靠一张表加一个循环解决了问题。见过一些项目日活几百也上全套消息中间件加流处理平台运维成本远超收益。架构服务于业务规模规模没到先进就是负担。判断规模到没到有个简单参照当数据库队列开始拖慢主库、或任务延迟影响到业务时效再升级不迟。技术升级要等信号不要抢跑。4.2 死信与告警不能省重试耗尽的任务进了失败态怎么处理我们配了告警失败任务超过三个就通知到人。死信队列不是摆设是异步系统安全网。有次外部邮件服务故障两小时靠告警及时发现手动批量重跑没造成业务感知。那次之后又补了个演练每季度故意制造一批死信验证告警链路通不通。安全网自己也要定期检查不然关键时刻网破了都不知道。安全网平时用不上用上时就是救命。4.3 任务的优先级设计后来加了优先级报表类任务让路通知类任务插队——用户对邮件延迟的容忍度比对报表低。实现很简单捞任务的排序里加一个优先级字段。小小的字段用户体验的细节全在里面。做技术久了会有个体会架构上的大决策一年做不了几个真正拉开体验差距的全是这类小字段、小参数、小心思。五、总结异步化改造是后端走向成熟的必经一课。核心三句话用户不需要等的都别让他等拆出去的活必须幂等可重试土办法够用就别上牛刀。这套思路不绑定任何语言和框架理解了那张任务表和那个消费循环到哪里都能落地。异步化之后还有个意外发现开发节奏也变好了——新功能要加重活先注册一个任务类型主流程永远保持轻装。这个习惯让后来的每个功能都避开了九秒事故的老路。