消息队列基础:一个重接口为什么必须学会拆开做
消息队列最容易被讲成一堆中间件名词,真正落到业务里,它解决的问题其实很朴素:一个接口做的事情太多了。
下单之后要扣库存、写订单、发短信、推通知、记日志、同步别的系统,如果这些动作全都卡在用户这一次请求里同步做完,接口自然会越来越慢,也越来越脆。消息队列的价值,就是把必须立刻完成的部分和可以稍后完成的部分拆开,让链路轻下来。
后面不先讲 RabbitMQ 和 Kafka 的术语差别,而是先顺着一个重接口往下拆:为什么要异步、异步之后得到什么好处,又会额外引入哪些消息丢失、重复消费、顺序和幂等问题。
第一张图:压测时被拖垮的下单接口
后端团队在分享会上放的第一张图是十月底大促前压测的曲线。下单接口 RT(响应时间)平时 80ms 左右,压到一定并发后直接飙到 2s 以上。他们拉出火焰图一看,时间几乎全花在调用第三方短信网关和写一张审计日志大表上——这两件事跟"订单是否创建成功"根本没关系,却拖垮了整条链路。
当时的同步链路长这样:
1用户提交订单 2 -> 创建订单 3 -> 扣库存 4 -> 发短信 5 -> 发邮件 6 -> 写日志 7 -> 返回结果
如果发短信服务慢,用户就要等。如果发邮件失败,订单要不要跟着失败?这就很尴尬。
更隐蔽的坑在超时配置上。发短信用的第三方网关偶尔会超时,但他们的 HTTP 客户端默认超时设的是 30s。压测里一旦短信网关抖动,下单接口就被这 30s 拖着,连接池很快被打满,整个下单服务雪崩。
后端负责人说排查了大半天才定位到——根因不是短信失败,而是"一个不重要的下游把重要的上游拖死了"。这种问题在同步链路里几乎无解,只要还在一个调用栈里,下游的延迟和故障就会传导上来。
这个现象他给了个名字叫"故障扩散"或者说"级联失败":一个边缘依赖的抖动,顺着同步调用栈一路往上传,最后掀翻整个核心服务。异步化的一个隐性收益,就是在核心链路和非核心依赖之间插了一道队列当缓冲,把这种传导切断了——短信网关就算全挂,也只是消息在队列里堆着,下单该成还是成。这层"隔离故障"的价值,我一开始只把队列理解成"提速",完全没想到它还是个防止故障蔓延的隔离带。
后端负责人给了一个判断标准,我原句抄下来了:**一个步骤要不要留在同步链路里,看它失败时订单该不该跟着失败。**扣库存失败,订单必须失败,留在链路里;短信发失败,订单照样成立,那就该异步。
把这条想清楚,哪些动作往队列里塞就很自然了。它比"这个操作重不重要"这种模糊感觉靠谱得多——重要不重要是主观的,"失败了要不要连坐订单"是能一条条问出确定答案的。我拿它把下单后的动作过了一遍:扣库存、写订单核心数据留在同步链路,发短信、发邮件、加积分、同步搜索索引、写审计日志全都能异步,判断起来毫不含糊。
拆出去之后:队列两边各干各的
改造后的链路是这样的:
1用户提交订单 2 -> 创建订单 3 -> 扣库存 4 -> 写入消息队列 5 -> 返回成功 6 7消费者 8 -> 发送短信 9 -> 发送邮件 10 -> 同步日志
接口只做关键链路,后续任务交给消费者慢慢处理。压测里下单接口 RT 立刻回到 100ms 以内。
RT 的下降其实很好解释:原来那 2s 里,短信网关和写审计日志大表占了绝大部分,而这两件事跟"订单创建成功"没有因果关系,纯粹是搭了顺风车。把它们从同步链路里摘出去,接口自然就只剩下"创建订单、扣库存、写队列"这几件真正必要的事,快下来是必然的。这也印证了后端负责人那句判断标准——同步链路里应该只留失败时会导致订单失败的步骤,其余全是可以异步的乘客。
我举手问了一个外行问题:消息应该在什么时机发?后端负责人说这问题问得好,他们早期真踩过——有人先发消息再写数据库,结果消费者收到消息去查订单时,订单还没落库,查了个空。正确的顺序是先把订单和库存这些核心数据落库成功,再发消息。
但他接着说,光"先落库再发消息"还堵不严,中间还有个缝:万一订单落库成功了、发消息那一步却因为进程崩了没发出去呢?这时候业务是成的、消息是丢的,下游永远收不到通知。反过来,如果为了保消息把发送提前,又会出现"消息发了、事务却回滚了"的幽灵消息。这两头都不好,根子在于"写数据库"和"发消息"是两个独立的系统,没法用一个事务包住。
更严谨的做法就是冲着这个缝去的——本地消息表或事务消息。
本地消息表的思路是:把"发消息"这个动作和业务数据放在同一个数据库事务里,先在本地库写一张待发送消息表(这一步和写订单是同一个事务,要么都成要么都回滚),再由一个后台任务定时捞出这张表里"待发送"的记录去真正投递,投递成功了把状态改成"已发送"。这样即便投递那一刻崩了,记录还在表里,下一轮补偿任务会重新捞出来发,最多重复发、绝不会漏发。
事务消息(比如 RocketMQ 提供的)是把这套逻辑下沉到中间件里:先发一条"半消息"对下游不可见,本地事务提交成功后再确认让它可见,失败就回滚掉。两条路解决的是同一个"业务和消息的原子性"问题,只是一个自己在业务库里实现、一个借中间件实现。后端负责人说我们这个规模用本地消息表加定时补偿就够了,事务消息运维复杂度更高,没必要一上来就上。
我听到这里插了句嘴:本地消息表这套,不就是靠"重复发 + 消费者幂等"来换"绝不漏发"吗?后端负责人说对,你已经串起来了——正因为投递侧选了"宁可重复也不漏",消费侧才必须做幂等兜住重复,这两件事是配套的,不能只做一半。这句让我对后面要讲的幂等一下有了预期。
解耦:订单系统不需要认识短信系统
订单系统不需要知道短信系统怎么发短信,只需要往队列里丢一条消息:
1{ 2 "type": "ORDER_CREATED", 3 "orderId": "10001" 4}
短信服务订阅这个消息,自己处理,系统之间的耦合就低了。
解耦带来的实际好处,是改一边不用动另一边。原来短信、积分、推送这些逻辑全堆在下单接口里,每加一个"下单后要做的事",都得改下单代码,回归测试整条链路。用了发布订阅之后,这次双十一前会员组要加"下单后给用户发会员成长值",会员服务自己去订阅 ORDER_CREATED 就行,下单服务一行代码都没动。订单系统不知道、也不需要知道有谁在消费它的消息,这就是解耦真正值钱的地方。
不过解耦也不是没代价。消息一旦成了多个系统之间的契约,字段就不能随便改了。他们专门点了一个八月的事故:订单服务把消息里的 orderId 从字符串改成了数字类型,下游三个消费者全挂了,因为它们按字符串解析。所以消息体的结构要从一开始就当成对外 API 来对待——加字段可以,改类型、删字段要非常谨慎,必要时做版本兼容。这跟我们前端对接口返回结构的态度是一回事,我听着格外有共鸣。
一条消息发给一个人,还是发给一群人
趁着讲发布订阅,我把一直分不清的两种消费模型也问明白了:一条消息到底是"谁抢到归谁",还是"每个订阅方都收一份"。
一种叫点对点(队列模型):消息进一个队列,多个消费者都盯着这个队列,但一条消息只会被其中一个消费者拿走处理,拿走了别人就看不到了。这适合"任务分发"——比如一堆待发的短信,起五个消费者分着发,每条短信发一次就够,谁闲谁抢。
另一种叫发布订阅:消息发到一个主题(topic)上,每个订阅了这个主题的下游各自都能收到完整的一份。这适合"事件广播"——一条 ORDER_CREATED 事件,短信服务、积分服务、会员服务各要一份,各干各的,互不影响。前面会员组能自己订阅 ORDER_CREATED、下单服务一行代码不改,靠的就是发布订阅这套。
这两种模型在不同中间件里的实现口径不太一样,这也是我以前看资料容易糊涂的地方。RabbitMQ 是把"路由"这件事抽象成 exchange:生产者只管把消息发给 exchange,exchange 按绑定规则决定把消息投进哪些队列,想点对点就一个队列,想广播就 fanout 到多个队列。
Kafka 则是另一套:消息按 topic 组织,靠"消费者组"(consumer group)来区分——同一个组内的消费者分摊一个 topic 的分区,是点对点式的负载均衡;不同组之间各自独立消费全量,是发布订阅式的广播。
后端负责人点了一句我记下来了:**先想清楚你要的是"分摊处理"还是"人人一份",再去看具体中间件用什么概念实现它,别被 exchange、consumer group 这些名词绕进去。**模型是稳定的,名词是各家的皮。这话对我后来看别的中间件文档帮助很大——先在脑子里落到这两个模型上,再去对号入座,就不会被一堆新术语唬住。
削峰:五千个请求排队过五百的闸口
这是我这次最想搞懂的词。双十一零点那一波,请求瞬间涌进来,如果所有任务都直接打到下游服务,下游可能扛不住。消息队列可以先把任务排队,消费者按自己的能力慢慢处理,这就是削峰。
后端负责人打的比方很直观:下游服务每秒只能稳定处理 500 个任务,零点那一瞬间涌进来 5000 个。没有队列,这 5000 个全砸到下游,下游直接被压垮,连本来能处理的 500 个也一起没了,全军覆没。有了队列,5000 个先在队列里排着,下游还是按每秒 500 的节奏匀速消费,大概十秒就把这波峰值消化掉。代价是部分任务会有几秒延迟,但这对发短信、发积分这类异步任务完全可以接受。
削峰能成立有个前提:这些任务确实允许延迟。如果是要求实时返回结果的查询,队列帮不上忙,反而会把延迟问题藏起来。另外要盯紧队列的堆积量——他们配了告警,堆积超过阈值就报警。堆积持续上涨通常意味着消费速度长期跟不上生产速度,那就不是削峰的问题了,要扩消费者或者优化消费逻辑。削峰削的是瞬时尖峰,不是用来扛长期的处理能力不足。
怎么判断堆积是"正常削峰"还是"出事了",后端负责人给了个很实用的看法:看堆积曲线的形状,而不是看某一刻的绝对值。健康的削峰是"尖上去、很快回落",是个陡峭的三角;出问题的堆积是"缓慢爬升、迟迟不降",是条一直往上的斜线。
绝对值高低会误导人——大促零点堆几万条是正常的,平峰期堆几千条一直不降反而危险。所以他们的告警不是只设一个阈值,还盯"堆积量的变化趋势"和"最老一条消息在队列里待了多久"(消息年龄),后者尤其能直接反映"下游是不是根本没在消费"。Kafka 里对应的是消费位点的滞后量(consumer lag)——生产到了第几条、消费到了第几条,两者的差就是积压,盯这个比盯队列长度更能说明问题。
扩消费者也不是无脑加。后端负责人提醒,消费能力的天花板往往不在消费者进程数,而在下游——如果瓶颈是数据库写入,你把消费者从 5 个加到 50 个,只会让 50 个消费者一起去挤爆数据库,堆积没解决,反而多压垮一个下游。
所以扩容前得先定位真正的瓶颈在哪一环,是消费者 CPU、还是下游 IO、还是某个外部接口的限流。**队列只是把压力从"瞬间"摊到"一段时间",它不凭空变出处理能力,最终还是要下游扛得住。**这句我记得特别牢,因为它戳破了我对队列的一个误解——我一度以为加了队列就等于提升了系统吞吐,其实队列改变的是压力到达的时间分布,不是系统能处理的总量。
还有个和分区绑定的坑:Kafka 里一个消费者组内的消费者数量最多只能等于分区数,多出来的消费者会闲着抢不到分区。所以想靠加消费者提并发,前提是分区数够多;分区数是建 topic 时就要预估好的,后面加分区还会打乱原有的分区顺序,不能随手改。这类"扩容受分区数约束"的细节,是我以为"加机器就完事"时完全没想到的。
双十一凌晨监控大屏上那条堆积曲线,零点冲上去、十几秒又落回来,后端负责人说那是他今年看过最舒服的一条线——就是那种陡峭的三角,正是削峰该有的样子。
重复消费:券为什么可能发两张
第二个专题是幂等。消息队列里很重要的一个事实是:消息可能被重复消费,所以消费者必须做幂等。
为什么消息一定会重复?因为绝大多数队列保证的是"至少一次"(at-least-once)投递,而不是"恰好一次"。设想消费者处理完业务、正准备给队列回 ack 确认时,机器突然重启了——队列没收到 ack,会认为这条消息没处理成功,于是重新投递。业务其实已经执行过一遍了,但队列不知道。想做到严格的"恰好一次"成本极高,所以行业里的通用做法是:接受重复投递,由消费者自己保证幂等。
例如发优惠券,要先判断是否已经发过:
1if already_sent(orderId): 2 return 3send_coupon(orderId) 4mark_sent(orderId)
否则重复消息可能导致用户拿到两张券。
但后端负责人说这段伪代码其实还有问题:already_sent 检查和 mark_sent 标记之间不是原子的。两条重复消息几乎同时到达、被两个消费者并发处理时,可能都查到"还没发过",于是都发了。他们的做法是给幂等加一道数据库唯一约束兜底——建一张发券记录表,order_id 做唯一索引:
1insert into coupon_log (order_id, user_id) values ('10001', 2001);
插入冲突时数据库会直接报唯一键冲突,消费者捕获这个异常就知道"已经发过了",安全跳过。另一位后端同事在旁边补了一句:靠数据库的唯一约束做最后一道防线,比纯靠应用层判断可靠得多。幂等键的选择也有讲究,得用业务上真正唯一的东西,比如订单号;用消息自带的 messageId 不一定靠谱,因为生产者重发时可能生成新的 messageId。
我追问了一句:那不落库的场景怎么办,比如只是往缓存里写一下。后端负责人说手段不止唯一索引一种,看你的一致性要求和性能预算。轻一点的可以用 Redis 的 SETNX(set if not exists)——拿业务唯一键当 key,抢到锁才执行、抢不到说明已处理过,注意给这个 key 设个合理的过期时间,别让它永远占着。
更彻底的一类是"把操作本身设计成天然幂等",这样连查重都省了:比如"把订单状态置为已支付"这种写死值的操作,执行一次和执行十次结果一样,重复消费根本不会出错;真正危险的是"余额加 100"这种累加型操作,重复一次就多加一次,这类才必须靠幂等键兜住。区分"覆盖型"和"累加型"操作,是判断一段消费逻辑到底需不需要额外做幂等的一把快尺子。
他给的排序是:**能把操作设计成幂等的,优先改操作;改不了的,用唯一约束或 Redis 去重;SETNX 这种应用层手段做快路径,数据库唯一约束做慢路径的最终兜底。**这几层叠起来,才算真的挡住了重复。
还有个细节容易被忽略:幂等的判断和真正的业务操作最好在一个事务里,或者用"先占坑再干活"的顺序。如果先干活、再写去重标记,两者之间崩了,下次重放又会干一遍;先写标记、再干活,万一干活失败标记却留下了,这条消息就再也不会被处理——所以标记和业务的先后、以及失败时要不要回滚标记,都得想清楚,不是加张表就万事大吉。
消息丢失:三个环节漏一个都不行
可靠性是另一面。生产者发送失败怎么办?队列宕机怎么办?消费者处理失败要不要重试?
后端负责人把消息的一生拆成三段来看,每段都有各自的丢法:生产者发出去到队列收到、消息在队列里存着、队列投给消费者到消费者处理完。丢消息只会发生在这三段的交接处,把这三处都焊死,消息才算真的可靠。
落到配置上,可靠投递要把三个环节都拧紧:生产者发送后等待队列的确认(confirm),没确认到就重发;队列本身要开持久化,让消息落盘而不是只在内存里,否则 Broker 一重启消息就没了;消费者要等业务真正处理完再 ack,而不是一收到就 ack。这三个环节但凡漏一个,消息都可能在某个缝隙里丢掉。
持久化这一环还有个容易漏的细节:光消息落盘不够,队列本身的元数据(比如队列声明是不是持久的)也得是持久的,否则 Broker 重启后队列都没了,消息落在哪儿都没用。
而且落盘也不是绝对保险——如果配的是异步刷盘,消息先进操作系统的页缓存、还没真正写进磁盘时机器断电,照样会丢那一小段。要一条都不丢就得同步刷盘,但同步刷盘慢,又是一笔性能和可靠性的取舍。后端负责人说他们的选择是:核心的交易类消息同步刷盘、允许损失一点性能,日志类的异步刷盘、图快。没有免费的可靠性,每拧紧一环都在拿性能或复杂度去换,得按消息的重要程度分级对待,不能一刀切。
后端负责人讲了他们早期的教训:消费者用了自动 ack,结果消费逻辑抛异常时消息已经被确认掉了,相当于静默丢消息,排查起来非常痛苦,因为没有任何报错。
改成手动 ack 之后,逻辑变成"业务处理成功才 ack、失败就 nack 让它重回队列",消息才不会在异常时凭空消失。他特意强调"静默"两个字最要命——丢消息如果能报个错,至少能顺着报错查;自动 ack 这种是消息神不知鬼不觉地没了,业务方还以为一切正常,往往是下游过了很久发现数据对不上,才反推回来是消息丢了,排查链路极长。所以可靠性设计里,比"不丢"更进一步的要求是"丢了要能被立刻发现"。
这里其实牵出一个"至少一次"和"最多一次"的取舍:自动 ack、一收到就确认,是"最多一次"——不会重复,但一崩就丢;手动 ack、处理完才确认,是"至少一次"——不会丢,但可能重复。
绝大多数业务选后者,宁可重复也别丢,重复的问题交给前面讲的幂等去兜。想通这一点,我才理解为什么"至少一次 + 消费者幂等"会成为行业的默认组合,而不是去死磕那个成本极高的"恰好一次"。所谓"恰好一次",业界的实现要么是"至少一次投递 + 幂等消费"凑出来的等效效果,要么是把去重逻辑做进中间件和存储的强耦合方案,成本和限制都很高,绝大多数业务用不着,自己在消费端做好幂等反而更简单可靠。
消费失败的消息也不能无限重试。一条因为数据格式错误而必然失败的消息,重试一万次还是失败,反而会卡住后面正常的消息。他们的做法是设重试上限,比如重试 3 次还失败就投到死信队列(DLQ),单独人工排查,不让它阻塞主流程。
死信队列这块后端负责人多讲了几句,因为踩过坑。
一是重试别马上重试,要带退避——失败了立刻重投,大概率还是失败,白白空转,稍微隔几秒、逐次拉长间隔更稳。他还提醒要分清失败的类型:像下游临时抖动、网络超时这种"过一会儿可能就好"的错误,重试有意义;而消息格式错误、字段缺失这种"重试一万次都不会变好"的错误,重试纯属浪费,应该直接进死信、别在重试上耗。能不能在代码里区分这两类异常,很影响重试策略的效率。
二是进了死信队列不等于就没事了,得给死信队列本身配告警和值班看板,不然消息静静躺在里面没人管,跟丢了没区别,他们出过一次"死信堆了一晚上没人发现,第二天才发现一批券没发出去"的事。
三是死信里的消息要能重放——排查修好根因后,得有办法把它们捞回主流程重新消费,所以死信消息最好带上原始的完整信息和失败原因,别只存个 ID。死信队列不是垃圾桶,是待处理的问题清单,这个定位摆正了,它才真正兜住底。
顺带我还问了消费是队列推给我、还是我主动去拉。后端负责人说两种模型都有:推(push)实时性好,但如果消费者处理不过来,消息会往消费者这边堆、甚至压垮消费者;拉(pull)是消费者按自己的节奏去队列取,天然带了流控、不会被打爆,代价是实时性稍逊、要自己控制拉取频率。
Kafka 是典型的拉模型,这也是它扛得住大流量削峰的一个原因——消费速度由消费者自己说了算,快慢都不会把自己搞崩。这跟前面削峰那节能对上:削峰要成立,前提就是消费端能按自己的节奏匀速消费、不被瞬时洪峰打垮,而拉模型天然满足这个前提。几个知识点串到这儿,我发现它们不是孤立的特性,而是互相咬合的一套设计。
顺序问题:先退款后支付?
散会前运维同事提了一个问题,我觉得特别值得记:消息默认是不保证全局顺序的。同一个订单先后发了"已支付"和"已退款"两条消息,如果它们被分到不同的队列分区、由不同消费者并发处理,完全可能"已退款"先被处理、"已支付"后被处理,状态就乱了。
需要顺序的场景,得把相关消息路由到同一个分区(比如 Kafka 按 orderId 做分区 key),保证同一个订单的消息进同一个分区、被同一个消费者按序消费。但这又会牺牲并发度。
这里有个概念得分清:所谓"保证顺序",实践里几乎从不是"全局顺序",而是"分区内顺序"。全局顺序意味着所有消息都得挤到一个分区、一个消费者串行处理,那还要队列干嘛,直接同步就行了。真正有用、也做得到的是"局部有序"——同一个订单的消息有序,不同订单之间无所谓。按 orderId 做分区 key,正是把"需要有序的那一撮"框进同一个分区,既保住了这撮的顺序,又让不同订单还能并行,是个精准的折中。想通"我要的其实是局部有序而不是全局有序",很多顺序难题一下就没那么可怕了。
后端负责人的经验是:能不依赖顺序就别依赖,消费逻辑尽量设计成跟顺序无关。他给的具体手段就是前面幂等那节提过的状态版本号——每条状态变更消息带一个版本号或时间戳,消费者只接受比当前记录更新的状态,收到一条更旧的就直接丢弃。这样哪怕"已退款"先于"已支付"到达,靠版本号比对也能拒绝掉过期的那条,顺序错乱就不再致命。
这招的好处是把"依赖消息到达顺序"换成了"依赖消息自带的顺序信息",后者不受并发和分区影响。实在绕不开严格顺序,再用分区 key,而且要清楚它会限制扩展性——一个订单的消息永远只能被一个消费者串行处理,热点订单会成为吞吐瓶颈。优先改消费逻辑让它不在乎顺序,其次才用牺牲并发的分区顺序,这个先后顺序跟前面幂等那节"优先把操作设计成幂等"是同一种思路:能从设计上消解掉的约束,就别用会牺牲性能的机制去硬扛。
轮到前端:接口成功不等于事情办完
最后十分钟轮到我发言,因为有一个客诉单挂在前端名下。
双十一当晚有用户反馈:下单页面提示"下单成功",跳到订单列表却看不到刚下的单,以为下单失败,又下了一遍。原因现在大家都清楚了——下单接口只保证订单落库,后面的一串动作在队列里排队,订单列表的数据同步慢了几秒。用户没错,是我们的文案把话说死了。
更糟的是,用户"又下了一遍"这个动作本身还制造了重复订单,这下把服务端的幂等也一起考了——如果下单接口没做防重,用户手一抖就是两笔真实订单。所以这个客诉表面是文案问题,底下牵着的是"前端要不要防重复提交""服务端下单要不要幂等"一整串。前端异步链路那节讲的重复消费幂等,原来在用户这一侧也有个对应版本:用户的重复操作,和消息的重复投递,本质是同一类问题,都得靠幂等兜。
这就是异步链路给前端上的课:**接口返回成功,不代表所有异步任务都已经完成;"提交"和"完成"是两件事。**页面文案要诚实,比如上传大文件后服务端进入转码队列,前端不该提示"处理完成",而该提示"上传成功,处理中"。
想明白这一层,我回头审了下自己经手的几个接口,发现"成功"这个词被用得太随意了。有些接口返回 200 其实只代表"请求已受理",后面还有异步流程没跑完,但前端一律弹"操作成功"。用户不管你后台异步不异步,他看到"成功"就默认事情办完了,一旦结果没立刻出现,体感就是"骗我"。所以异步化之后,前端最该改的其实是措辞的颗粒度:把"成功"拆成"已提交"和"已完成"两种语义,分别对应队列的入口和出口,别再用一个"成功"含糊带过整条链路。
状态也要设计成一个小状态机,而不是成功失败二选一:
1pending 已提交,排队中 2processing 处理中 3success 完成 4failed 失败
我在会上认领了两个改造。一是文案,把"下单成功"改成"订单已提交",列表里给还在同步中的订单一个"处理中"的占位状态。二是轮询:提交后前端每隔一小段时间查一次订单状态,查到终态再更新界面。轮询间隔我打算做成递增的,别一上来就每秒打一次接口,给刚被双十一折腾过的后端留点余地:
1function pollOrderStatus(orderId, onDone) { 2 var delay = 1000 3 var maxDelay = 8000 4 5 function poll() { 6 queryOrder(orderId).then(function (order) { 7 if (order.status === 'success' || order.status === 'failed') { 8 onDone(order) 9 return 10 } 11 delay = Math.min(delay * 2, maxDelay) 12 setTimeout(poll, delay) 13 }) 14 } 15 16 setTimeout(poll, delay) 17}
轮询请求记得走静默模式,别让全局 loading 每两秒闪一下——这个坑我去年封装 axios 的时候就踩过,silent: true 这种开关就是为轮询这类请求留的。轮询还得记得设个总时限或最大次数,别让某个卡在 processing 永远不动的订单把前端拖成无限循环,超时了就给用户一个"处理时间较长,请稍后在订单列表查看"的兜底提示,比一直转圈体面。页面关掉或路由切走时也要把定时器清掉,不然攒一堆后台轮询在偷偷打接口,用户看不见、后端却在挨打。
后端负责人还提到,如果对实时性要求再高一点,可以考虑 WebSocket 或者长连接推送,服务端处理完主动通知前端,比轮询省流量也更及时。我权衡了一下还是先用轮询:WebSocket 要多维护一条长连接、要处理断线重连、要在服务端记住"哪个用户在等哪个订单",复杂度陡增;而我们这个场景用户就盯着一两个订单看几秒钟,指数退避轮询的开销完全可以接受。技术选型不是挑最先进的,是挑跟问题规模匹配的——为一个"等几秒"的需求上长连接,是拿工程复杂度换用不上的实时性。等哪天有了消息中心、站内信这种真正需要服务端主动推的场景,再上 WebSocket 也不迟。
那个状态机也不是随便设的,它其实对应着后端那条异步链路的真实阶段:pending 是消息还在队列里排队、processing 是消费者正在处理、success/failed 是终态。前端状态跟着后端的处理阶段走,展示才不会撒谎。这里有个我提前想到的坑:终态一定要设计得完备,failed 不能漏——如果消费者处理失败、消息进了死信队列,订单状态得能翻到 failed 让用户看到"下单失败请重试",而不是永远卡在 processing 让用户干等。所以前端的状态机和后端的死信处理,其实是同一件事的两头,得对齐了设计。
散会之后
回工位的路上我跟身边同事说,这场会最大的收获不是学会了几个名词,而是看清了一条完整的因果链:同步链路太重,所以拆异步;拆了异步,所以有延迟;有延迟,所以消息会重复、会丢、会乱序;为了兜住这些,所以要幂等、要 ack、要分区。每个机制都不是凭空发明的,都是被前一个决定逼出来的。
前端不直接维护队列,但只要理解了这条链,就知道哪些接口的"成功"其实是"已受理",页面的状态和文案就能设计得诚实。削峰填谷削的是服务器的峰,前端要填的,是用户等待时心里的那道谷。