news 2026/9/11 5:30:29

异步消息驱动架构改造实战:从同步网关到高可用消息链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
异步消息驱动架构改造实战:从同步网关到高可用消息链路

1. 为什么我会把同步网关链路改成异步消息驱动

1.1 原来的架构其实很干净,但流量不按套路出牌

我接手这个系统的时候,架构并不复杂:Nginx做负载均衡,入口处挂了一个API网关层,负责路由、鉴权、限流、灰度,再往下是订单、库存、支付这几个核心微服务。服务之间用Feign同步调用,链路大概就是网关转给订单服务,订单服务调库存服务,库存服务再调支付服务,一次请求把所有事情干完,最后把结果返回给客户端。

前期这套结构很舒服。同步模型最好理解,开发效率高,团队新人上手也快。一个请求进来,顺序往下调用,日志顺着链路一追到底,问题定位非常直观。我还记得系统早期我们一个星期就能上线两三个小功能,大家都觉得这套架构再战三年没问题。

但等到真实流量上来了,问题开始一点点显现。我印象最深的是一次大促前的压测,网关线程池最先被打满,订单服务的数据库连接池紧接着扛不住,整个链路像多米诺骨牌一样塌下去。当时排查了三天,根因非常清晰:同步调用让大量线程阻塞在下游服务的响应上,而下游任何一个服务出现抖动,都会顺着调用链传导到网关层。

具体来说,痛点集中在三个方面。第一是线程资源被无限消耗,每个请求在等待下游时都占着一个线程,下游慢,线程堆积,新的请求进不来。第二是依赖链太脆,订单服务依赖库存,库存依赖支付,任何一个服务出问题,整条链路成功率都掉,而且很难通过简单加机器解决,瓶颈可能卡在数据库连接池,也可能卡在一个外部接口的响应时间上。第三是大量写操作集中在高峰期,用户下单就那么几秒钟,但下单之后的库存扣减、优惠券核销、积分发放、短信通知,全部同步做完的话,用户等待时间被拉长,系统的峰值承载能力也浪费在这些用户根本不关心的操作上。

1.2 异步化的本质,是把“等待”改成“事件通知”

那时候我反复在团队里讲一句话:用户下单之后,其实能接受“已受理”这个状态,很多衍生动作完全没必要让用户在线等着。这个想法最终推动了一次架构演进,把部分同步调用从请求链路中剥离出去,改成基于消息队列的异步消息驱动。

异步消息驱动并不是把系统推倒重来,它的核心是把链路里的操作分成两类。一类是核心主流程,比如订单创建、订单状态更新,这些必须同步完成,客户端必须在请求周期内拿到明确结果。另一类是衍生动作,比如扣库存、发积分、发短信、写物流信息、更新搜索索引,这些都可以放进事件里,由下游消费者异步处理,不阻塞主流程。

有一个点特别容易搞混:异步化不等于库存扣减不重要了。库存仍然要扣,只是“发起扣减”和“扣减完成”解耦了。订单服务只负责发出“订单已创建”的消息,库存服务监听这个消息去执行扣减,扣减成功后再发“库存扣减完成”的事件往下游走。从调用模型上看,原来是一次HTTP请求里串行等三次RPC,现在变成一个事件在几毫秒内进入消息总线,后续各个消费者按自己的消费逻辑并行处理,主流程和衍生动作彻底分开。

改造之后,网关层也压力骤减。以前网关要把请求转给订单服务,然后等订单服务把所有下游都调用完再返回。现在网关的行为变轻了:路由、鉴权、限流照旧,写请求转给业务服务后,业务服务只需要落库订单状态并发送消息,然后立刻返回“已受理”。网关线程不再被下游拖死,整体吞吐能力上限一下子打开了。

1.3 异步化顺带解决了削峰填谷的问题

还有一个隐藏收益,就是削峰填谷。互联网系统最怕的就是瞬时流量高峰,比如秒杀、促销、热点事件带来的流量尖峰,如果按照峰值流量部署资源,成本极高。消息队列天然就像一个缓冲区,瞬时高峰把消息积压下来,下游消费者按照自己的能力慢慢消费。

我们后续做过一个小实验,把同一个促销接口的流量压到原来的三倍,同步调用版本直接超时率飙升,异步消息驱动版本只是消息积压量涨了一截,下游按部就班处理,前端用户几乎没有感知。这让我真正体会到为什么很多高并发系统最终都要走消息驱动这条路,它不仅仅是性能优化,更是系统稳定性的兜底机制。

2. 方案设计与中间件选型,别急着写代码

2.1 异步化改造之前,先回答三个问题

我见过不少团队一听到“异步化”就把所有接口往MQ里塞,结果比原来更乱。异步化确实增加了链路复杂度,如果不能在动手前想清楚边界,后患无穷。我们在设计阶段强制要求每个候选接口回答三个问题:

第一,用户是否关心这个操作的最终结果?比如支付结果查询,用户必须立刻知道支付是不是成功了,这种绝对不能只发消息就完事,必须同步确认。而库存预占、优惠券标记,用户在乎的是最终能不能下单成功,在请求链路里先做预检查,实际扣减放到消费端,失败了走补偿,这是可以异步化的。

第二,操作失败后能否通过消息重试补偿,还是必须同步返回告警?如果失败之后没法自动重试,或者重试的成本极高,那就要谨慎。有些操作必须严格保证实时性,比如账号锁定、安全风控,这些不适合异步。

第三,下游消费者如果挂了,业务是否能接受暂缓处理?异步化意味着下游故障不会立即反馈到上游,但会造成消息积压,如果积压过久,业务上会产生新的问题,比如优惠过期、库存超卖。所以还需要配套制定“积压多久算异常”的监控阈值。

我们最终圈定的改造范围是:订单创建、库存扣减、积分变动、消息通知、搜索索引更新这一批业务;支付结果确认、账务实时冻结、风控拦截这类坚决不动,保持同步链路。

2.2 消息中间件选型:RocketMQ、Kafka、RabbitMQ怎么选

市面上的消息中间件可选的不算少,中小团队主要就是RocketMQ、Kafka、RabbitMQ三个里面挑,也有小部分用Pulsar的,但运维成本不低。我们把几个核心维度拉了一个对比表,这里也分享出来:

中间件吞吐能力消息可靠性顺序消息延迟消息死信队列事务消息运维复杂度
RocketMQ高,主从同步刷盘支持支持,多级延迟支持支持中等,配套完善
Kafka极高高,ISR机制分区内有序需自研需自研有但弱中等,依赖ZooKeeper或KRaft
RabbitMQ中等单队列有序支持支持需插件

最终我们选了RocketMQ。原因很直接:Kafka的吞吐量确实最强,但它的定位偏日志管道,很多面向业务的消息语义需要自己搭,比如延迟消息、事务消息、死信队列,RocketMQ基本上开箱即用。RabbitMQ更轻,运维也简单,但消息量一旦涨到分区级别,队列性能下滑和集群稳定性都不如RocketMQ。考虑到我们要做订单、库存这类对事务和顺序有明确要求的业务场景,RocketMQ的综合匹配度最高。

2.3 Topic设计是架构的地基,别把一锅粥放在一个队列里

Topic设计是我在异步化改造里吃过亏最多的地方,也是被大多数团队忽略的地方。Topic划分太粗,各种业务事件混在一起,消费者要做大量无谓的消息过滤,消费端代码写起来非常臃肿。划分太细,Topic数量爆炸,运维和管理成本陡增。

我们最终确定了“按业务域划分,按事件语义命名”的原则,并且明确区分命令消息和事件消息两种类型:

  • 命令消息,比如“扣减库存”“核销优惠券”,目标是明确通知某个服务执行某个动作。
  • 事件消息,比如“订单已创建”“支付已完成”,表示某个事实已经发生,生产方不关心谁来消费。

这两种消息的语义差别很大,发送方和消费方的协作契约也不同。命令消息要求消费方必须执行成功,失败要重试或告警;事件消息更像广播,下游自己决定如何处理。

实际Topic命名方面,我们用下单链路举个例子。订单服务发一个“ORDER_CREATED”事件,库存服务监听后扣库存,扣完再发一个“INVENTORY_DEDUCTED”事件,支付服务监听后发起支付,支付完成发“PAYMENT_COMPLETED”。每个Topic的事件Payload里带的是业务事实本身,比如orderId、userId、商品列表、金额,而不是“请调用某某接口”这种命令式参数。这样设计的好处是下游消费者可以灵活决定自己的处理逻辑,多个消费组监听同一个Topic,各做各的事情,互不干扰。

3. 实操记录:从订单服务开始做第一个异步化改造

3.1 生产端改造:先保证主流程落库,再发消息

我拿最典型的下单链路做讲解。改造之前的Controller里,直接调库存、优惠券、积分三个服务,改造之后变成:先保存订单状态为“已创建”,然后向Topic发送“订单创建成功”事件,随即返回客户端。

这里有一个极其关键的细节,生产端到底应该先落库再发消息,还是先发消息再落库?很多人初学时会直接先发消息,但这样做风险很高:如果消息发成功了,业务事务回滚了,下游消费者会拿着一个不存在的订单去扣库存,出现脏数据。反过来如果先落库,再发消息,消息发送失败了,也能靠补偿机制补发。

我们采用的方式是“本地消息表 + 定时任务补偿”。在订单表旁边建一张消息记录表,业务事务提交成功后,在同一条数据库事务里写一条初始化的消息记录,消息发送成功后再更新这条记录为已发送状态。如果发送失败,定时任务会扫描超时的未发送记录,重新推送,从而保证消息不丢。生产端核心逻辑如下:

@Transactional public OrderVO createOrder(CreateOrderRequest request) { // 1. 核心订单数据落库,状态 CREATED Order order = orderRepository.save(buildOrder(request)); // 2. 同事务记录一条业务消息,用于可靠发送 LocalMessage message = LocalMessage.of(order.getId(), "ORDER_CREATED", buildEventPayload(order)); localMessageRepository.save(message); // 3. 事务提交后,异步发送并刷新消息状态 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { SendResult result = messageProducer.send( "order_event", "ORDER_CREATED_" + order.getId(), order.getId(), message.getPayload()); if (result.getStatus() == SendStatus.SEND_OK) { localMessageRepository.markSent(message.getId()); } } }); return OrderVO.from(order); }

这里有几个细节值得注意。发送消息时我把orderId作为消息的key传进去了,这是为了后续做顺序消息,同一个订单的消息可以保证进入同一个分区,消费端处理时会方便很多。另外发送逻辑放在事务afterCommit里,而不是直接在业务代码中同步发送,原因是避免数据库事务还没提交时消息已经发出去了,消费端查询不到订单数据导致空处理。

3.2 本地消息表和事务消息,我们为什么选前者

很多团队会直接用RocketMQ的事务消息机制。这个方案的原理是:先发一条半消息,业务本地事务提交成功后,把半消息置为可投递状态,业务失败就删除半消息。如果中间长时间没有收到确认,MQ会主动回查本地事务的结果,决定是否投递。

我们当时评估过事务消息,但最终选了本地消息表方案。原因有两个:一是我们团队对数据库非常熟悉,本地消息表可以随时查看消息发送状态,可观测性更强,出了问题直接查数据库就行;二是事务消息对TPS有一定损耗,回查机制的实现也容易出错,一旦回查状态逻辑写反,后果很严重。本地消息表虽然多了一张表,但对于核心链路的稳定性保障,完全值得。

3.3 消费端改造:幂等是异步架构的命根子

消费端相比生产端要谨慎得多。消息队列的投递语义是“至少一次”,也就是说在极端情况下,消息可能会被重复投递。消费端如果没做幂等,就会出现重复扣库存、重复发短信这种事故。我第一个消费端就踩了这个坑,后来总结出一套标准模板:

public void onOrderCreated(MessageExt message) { String orderId = message.getKeys(); String dedupKey = "dedup:order_created:" + orderId; // 1. 用 Redis SETNX 做消费幂等 Boolean first = redisTemplate.opsForValue() .setIfAbsent(dedupKey, "1", 24, TimeUnit.HOURS); if (!Boolean.TRUE.equals(first)) { // 重复消息,直接返回 return; } try { // 2. 核心业务逻辑 inventoryService.deductStock(orderId); } catch (Exception e) { // 3. 消费失败,抛出异常让MQ根据重试策略重新投递 log.error("deduct stock failed, orderId={}", orderId, e); throw e; } }

这里有一个非常多人写错的细节:业务逻辑执行失败后,要不要立刻删除Redis里的幂等标记?我的答案是绝不删除。消息消费失败后MQ会重试,重试时第一步SETNX如果能设置成功,消费逻辑就能重新执行一遍,这样才有补偿的机会。如果你把幂等标记删掉了,重试时会被误判为重复消息直接跳过,业务一辈子都不会成功。

当然,Redis幂等方案也有局限,如果业务处理耗时较长,超过幂等key的过期时间,就会出现同一消息重复执行。我们最终在生产环境做了一个升级:对于订单、库存这类核心业务,用数据库唯一索引做持久化幂等,商业上更加可靠。

CREATE TABLE msg_consume_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_key VARCHAR(64) NOT NULL, topic VARCHAR(64) NOT NULL, consumer_group VARCHAR(64) NOT NULL, consume_status TINYINT NOT NULL DEFAULT 0, consume_time DATETIME DEFAULT NULL, UNIQUE KEY uk_msg_consume (msg_key, topic, consumer_group) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

消费端开启事务,先insert这条消费记录,唯一键冲突说明是重复消息,直接忽略。如果插入成功才执行业务逻辑,业务逻辑和消费记录插入在同一个本地事务中,要么一起成功要么一起回滚。这套方案上线后,重复消费导致的业务问题基本绝迹。

4. 上线后踩过的坑,以及排查实录

4.1 重复消费搞出来的“短信轰炸”

改造后第一周就碰到一次事故。用户反馈收到了重复的优惠券领取短信,而且每个人收到的次数还不一样,有人收到两条,有人收到五条。排查之后发现是两个问题叠加出来的。

第一,起初我把幂等key的过期时间设置成了30分钟,但有一个下游消费者因为外部短信接口抖动,一条消息积压了超过30分钟才开始处理,中间MQ又重投了几次,每次幂等key都重建了,业务逻辑就执行了多次。第二,消费成功逻辑里还写了一段“处理完成后主动删除幂等key”的代码,这两个问题叠一起,重复消费被彻底放大了。

修复方案就是前面讲的:核心业务的幂等从Redis换成数据库唯一索引,过期时间不再依赖Redis的TTL,而是永久保留记录。同时把消费成功日志里自动删除幂等key的代码全部下线。

4.2 顺序消息:库存扣减千万别跑到订单创建前面

入手异步化之后,业务又开始涉足状态流转类场景。订单从“已创建”到“已支付”再到“已发货”,状态变化是有严格顺序的,如果“已支付”的消息先被消费,而“已创建”的消息还在队列里排队,就会发生状态倒置,业务上完全不可接受。

RocketMQ对顺序消息的支持是“局部顺序”,也就是同一个业务key的所有消息会被路由到同一个队列,消费者对该队列单线程处理,从而保证顺序。

关键实现有两个点。生产端要使用MessageQueueSelector,根据业务key做哈希取模选队列:

SendResult sendResult = producer.send(message, new MessageQueueSelector() { @Override public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) { String orderId = (String) arg; int index = Math.abs(orderId.hashCode()) % mqs.size(); return mqs.get(index); } }, orderId);

消费端要保持每个队列单线程消费,不要在消费方法里再引入线程池异步处理,否则顺序会乱。很多初级的坑是:生产者配好了按订单哈希路由,但消费者内部为了追求速度快,把消息丢进线程池并发处理,结果顺序完全失去了意义。

还有一点要注意:顺序消息Topic的分区数尽量不要动态扩容。如果队列数从4扩容到8,原来hash到队列0的订单,一方面另一批新的消息可能被hash到别的队列,同一订单的后续消息跑到另一个队列去了,疫情期间的乱序问题会在扩容窗口集中爆发。所以线上对顺序性要求高的Topic,扩容一定安排在业务低谷,而且要有消息对账机制兜底。

4.3 消费积压把数据库连接池打爆了

一次大促期间,订单量暴增,某个下游消费者消费能力跟不上,消息积压了几百万条。当时没有第一时间发现,直到数据库连接池报警被打爆,才顺着监控反查到消息积压。

排查过程是这样:打开RocketMQ控制台,看到某个Topic的积压数量持续上涨,消费组在线状态,但消费TPS一直上不去。再看消费日志,大量消费线程全部阻塞在调用外部优惠券接口的位置,这个外部接口单次响应已经超过5秒。消费者线程处理一条消息需要5秒,消息排队自然越来越长。

这种问题的标准解法是把消费端拆成两层:第一层快速接收消息,只做数据入库和去重,然后立刻确认消费。第二层通过线程池异步去调用那些慢速外部服务,不再阻塞消费线程。但要注意,拆分之后业务成功确认就变复杂了,第一层确认了不代表第二层成功,最终还是要靠“业务状态标记 + 定时对账”来兜底。

我后来养成的习惯是消费端必须监控两个基础指标:单条消息处理耗时和消费TPS。平均处理耗时一旦超过500毫秒,必须人工介入调查,潜在的问题越早暴露越好。

4.4 死信和重试,别让一条坏消息堵住整个队列

消息消费失败后如果不断重试,会一直占据消费线程,把后面的消息全部挡住。RocketMQ的默认策略是消费失败后重试16次,间隔时间从10秒逐步扩大到2小时,16次之后进入死信队列。

这个策略最大的问题就是间隔时间太长了。如果有一条消息因为代码bug会稳定失败,它会在消费端反复重试十几个小时,期间把这个消息所在分区后面的所有消息全部堵住,整个队列的消费进度停滞不前。

我们后面给不同的业务配置了不同的重试策略。订单类核心链路用快速重试,最多重试3次,间隔分别是5秒、30秒、5分钟,三次都失败直接进死信队列。这样即使有问题,也能快速暴露,不会长时间阻塞队列。

死信队列也要有人盯。我们写了一个定时任务,定期扫描死信队列里最近1小时的新消息,自动推送到告警群,同时把消息内容刷进一张死信记录表,方便人工查看原因和手动重放。

4.5 消息轨迹与全链路追踪,异步排查的救命稻草

异步化改造最大的痛点之一,就是排障模式的改变。以前同步调用,一条日志链路直接追踪到底,现在消息被解耦之后,你从网关看到一个订单号,但后续消息在哪个节点消费、处理耗时多少、最终成功还是失败,如果不在同一个日志链路里,排查起来非常痛苦。

我们的解法是给每个请求和每条消息绑定统一的traceId。网关入口生成traceId放进HTTP Header,业务服务在处理时把traceId取出来透传,发消息的时候把traceId写进消息属性,消费者消费时从消息属性取出traceId,打印到自己的业务日志里。

这样只要拿到一个订单号,在整个日志平台上搜索traceId,就能把网关、订单服务、库存服务的日志按时间线全部串起来。异步链路的排查效率提升非常明显。我强烈建议把全链路追踪放在改造初期就做,后面再补会很麻烦。

4.6 监控指标与告警规则,异步系统的眼睛

异步化改造完成之后,团队对“系统健康”的判断方式也要跟着变。以前只要盯着接口错误率和响应时间,后来我发现还需要增加几个消息维度的指标。

生产和消费QPS的差值,如果差值持续扩大,说明消费能力跟不上生产速度。每个消费组的积压数量和积压消息的最大年龄,积压年龄比积压数量更重要,积压10万条但如果1分钟能消费完,其实不可怕;积压1000条但是年龄已经1小时,反而说明消费已经卡死了。还有消费失败率和重试次数分布,失败率异常升高往往意味着下游依赖服务出了问题。

我们把这几项指标都加到了告警规则里,阈值是消息积压量超过5万条,或者积压消息最大年龄超过10分钟。上线这套监控后,再没出现过大促期间消息积压到数据库连接池被打爆的情况。

5. 容量规划与团队协作,异步化落地后我的一点复盘

5.1 消息集群不是无限的缓冲池,容量还是要算的

关于异步消息驱动,很多人有一个误解,觉得消息队列是万能的“大水池”,可以无限吞流量。但MQ集群本身也有网络带宽、磁盘IO、存储空间的上限,它只是比业务服务的缓冲能力强很多,不是无限强。

容量规划上,我们按大促峰值下单量的三倍作为生产端峰值来计算。单个broker节点实测能稳定扛住每秒8000条消息的生产和消费,预估大促峰值是每秒1.5万条,那就至少准备3个broker节点,留出冗余。磁盘方面,按消息保留3天来计算,单条业务消息平均大小约1KB,日均消息量在亿级别,估算一天大概100GB,3天就是300GB,再加上副本冗余,每台broker准备500GB磁盘,上线后基本稳定。

5.2 团队协作方式也跟着变了

异步化改造不只是一个纯技术问题,还会改变团队内部的协作模式。以前订单服务出问题,查日志链路直接锁死是哪个下游服务,现在消息解耦,订单服务只关心自己发没发消息,库存服务要关心自己消费成不成功,两边必须对消息格式有清晰的约定。

我们后来整理了一份内部的消息规范文档,内容包括:事件命名的语法、每个Topic的用途和负责人、消息Payload的JSON字段定义、消费者组的命名规范、幂等key的定义规则。这份文档成了团队后续迭代和新人培训的重要素材。没有这份规范的话,团队越大越容易乱。

5.3 异步化改造的真实收益和一句忠告

这次改造上线后,系统峰值吞吐能力提升明显,核心下单接口的P99响应时间从800毫秒降到120毫秒,网关层线程池从频繁报警变得基本稳定。最让我满意的不是性能数据,而是系统面对故障时表现“软”了:一个下游服务抖动,不再像以前那样立刻传导到整条链路,而是通过消息积压、消费降速慢慢体现出来,给团队留下了处理时间。

如果让我重新做一次,我会一开始就安排两件事:第一,消息规范文档在动手写代码之前先定稿;第二,把消息轨迹和全链路追踪优先排期,而不是等出了问题再补。异步化本身不是银弹,它只是把同步问题的复杂度转移到了消息中间件和分布式一致性上,用好了是解放,用不好就是另一个运维噩梦。

最后再给一个建议:如果你的系统还在同步调用阶段,不要一上来就全量异步化。挑一个链路最长、对实时性要求最低的业务场景做试点,把监控、幂等、补偿这一整套闭环跑通,再逐步推广。这条路我走了一遍,方向是对的,剩下就看你的系统要不要做这个选择了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 5:29:36

render-middleware:为 Remix 路由注入请求级渲染器的中间件包解析

render-middleware&#xff1a;为 Remix 路由注入请求级渲染器的中间件包解析 【免费下载链接】remix The fully-stacked web framework 项目地址: https://gitcode.com/GitHub_Trending/re/remix remix-run/render-middleware 是 Remix 全栈框架中负责"请求级响应…

作者头像 李华
网站建设 2026/9/11 5:26:53

ESP32双屏GIF稳定播放的SPI与LVGL协同设计

1. 为什么“能播放”不等于“能扛住八小时”&#xff1a;一个被低估的嵌入式显示稳定性陷阱刚把GIF在ESP32双屏上跑起来那会儿&#xff0c;我拍着桌子跟同事说&#xff1a;“成了&#xff01;”——主屏滚动文字&#xff0c;副屏循环播放一个12864像素的齿轮转动GIF&#xff0c…

作者头像 李华
网站建设 2026/9/11 5:23:46

智能体系统生存指南:隔离、集成与治理三位一体架构

1. 这不是又一个“架构图PPT”&#xff0c;而是一套能落地的智能体系统生存指南“智能体系统架构&#xff1a;隔离、集成与治理的综合调研”——看到这个标题&#xff0c;你脑子里是不是立刻浮现出几张叠满箭头的分层框图、几个带阴影的云朵图标&#xff0c;再配上“高内聚、低…

作者头像 李华
网站建设 2026/9/11 5:22:45

如何配置 blackd 的 --cors-allow-origin 允许浏览器跨域请求

如何配置 blackd 的 --cors-allow-origin 允许浏览器跨域请求 【免费下载链接】black The uncompromising Python code formatter 项目地址: https://gitcode.com/GitHub_Trending/bl/black 如果你在浏览器侧写了客户端&#xff08;网页或浏览器内工具通过 fetch 调用 b…

作者头像 李华