使用 Codex 开发首页、用户中心、订单详情或后台 Dashboard 时,经常需要一个接口同时调用多个下游服务。

例如:

/api/dashboard
↓
用户服务
订单服务
库存服务
推荐服务
通知服务

代码看起来可能很简单:

const user = await getUser();
const orders = await getOrders();
const stock = await getStock();
const recommend = await getRecommend();

return {
  user,
  orders,
  stock,
  recommend
};

本地测试时所有服务都很快,接口也没有问题。

但线上只要某一个下游出现抖动,就可能出现:

  • 推荐服务慢3秒,整个首页也慢3秒;

  • 一个非核心模块失败,整个接口直接500;

  • 上游已经快超时了,下游还在继续执行;

  • 大量请求长期占用连接;

  • 用户看到的是整页失败,而不是少一个推荐模块;

  • Codex不断增加重试,反而让下游压力越来越大。

这类问题的核心通常不是“某个服务太慢”,而是聚合接口没有定义明确的调用预算和失败边界


一、串行调用会把所有耗时直接相加

例如:

const user = await getUser();       // 100ms
const orders = await getOrders();   // 200ms
const stock = await getStock();     // 150ms
const recommend = await getRecommend(); // 800ms

总耗时大约:

100
+ 200
+ 150
+ 800
=
1250ms

但这四个接口实际上没有依赖关系。

完全可以同时执行。


二、没有依赖关系的请求应该并行

可以改成:

const [
  user,
  orders,
  stock,
  recommend
] = await Promise.all([
  getUser(),
  getOrders(),
  getStock(),
  getRecommend()
]);

此时总耗时更接近最慢的那一个:

max(
  100ms,
  200ms,
  150ms,
  800ms
)
≈ 800ms

相比1250ms明显改善。

但这里又出现一个新的问题:

Promise.all 中只要一个失败,整体就失败。


三、不是所有下游失败都应该让整个接口失败

例如 Dashboard 包含:

用户基本资料
订单统计
系统通知
猜你喜欢

如果:

用户资料失败

可能整个页面确实无法正常显示。

但如果只是:

猜你喜欢失败

完全可以返回:

{
  "user": {},
  "orders": {},
  "recommendations": []
}

而不是:

500 Internal Server Error

所以聚合接口应该先给下游分类。

核心依赖

失败后整个接口无法继续:

身份信息
核心订单数据
权限

非核心依赖

失败后可以降级:

推荐
统计
广告
通知数量
辅助信息

不同依赖不能一律使用相同失败策略。


四、非核心依赖可以使用Promise.allSettled

例如:

const results = await Promise.allSettled([
  getOrders(),
  getRecommend(),
  getNotifications()
]);

然后分别处理:

const orders =
  results[0].status === "fulfilled"
    ? results[0].value
    : [];

const recommendations =
  results[1].status === "fulfilled"
    ? results[1].value
    : [];

这样:

推荐服务失败

不会自动让整个聚合接口失败。

allSettled 不是万能方案。

真正重要的是:

业务要明确哪些结果允许为空。


五、每个下游都应该有超时

假设上游接口最大允许:

3秒

但调用推荐服务时没有设置超时。

如果推荐服务:

20秒才返回

上游请求可能早就已经超时,但内部任务仍然占用:

  • HTTP连接;

  • Socket;

  • 内存;

  • Worker;

  • 下游资源。

因此每个外部调用都应该有明确的超时。

例如:

await fetch(url, {
  signal: AbortSignal.timeout(800)
});

或者通过 HTTP Client 配置:

timeout = 800ms

不能默认一直等。


六、什么是超时预算?

假设:

API Gateway最大超时:
3000ms

聚合服务不能给每个下游都设置:

3000ms

否则链路可能完全失控。

更合理的是分配预算。

例如:

总预算:2000ms

用户服务:500ms
订单服务:800ms
推荐服务:400ms
剩余安全余量:300ms

可以理解为:

整个请求只有一笔时间预算。

每深入一层,都只能使用剩余预算,而不是重新获得完整超时时间。


七、调用链越深,越要传递Deadline

假设:

Gateway
↓
Dashboard Service
↓
Order Service
↓
Payment Service

Gateway只允许:

2秒

Dashboard已经消耗:

600ms

那么剩下:

1400ms

Order Service继续调用 Payment 时,就不能再默认等待5秒。

可以传递:

deadline

或者:

remainingTimeout

让下游知道当前请求还剩多少时间。

否则上游早已放弃,下游却还在做无意义工作。


八、上游取消以后,下游也应该尽量取消

用户关闭页面、Gateway超时或请求被取消以后,如果后端仍然继续:

查数据库
调用第三方
生成推荐

这些结果已经没有人需要。

支持取消的情况下,可以使用:

AbortController

把取消信号继续向下传递。

例如:

async function getRecommend(
  signal: AbortSignal
) {
  return fetch(recommendUrl, {
    signal
  });
}

请求取消:

上游停止
↓
下游请求也终止

这可以减少大量无意义资源消耗。


九、不要给所有超时都自动重试

假设推荐接口已经:

800ms超时

Codex又增加:

重试3次

最坏情况就可能变成:

800ms
+
800ms
+
800ms
=
2400ms

如果整个聚合接口预算只有:

2000ms

显然已经不合理。

重试必须服从总时间预算。

例如剩余:

300ms

就不应该再启动一个预计需要800ms的重试。


十、重试还会放大故障

假设下游服务已经接近极限:

正常QPS:1000

开始大量超时

如果每次失败自动重试两次:

原始1000请求
+
最多2000次重试

下游瞬间可能承受:

3000QPS

原本只是轻微故障,很快变成全面故障。

这就是常见的:

Retry Storm(重试风暴)。

所以重试必须:

限制次数
+
指数退避
+
随机抖动
+
只针对可恢复错误

十一、Circuit Breaker可以阻止持续打坏下游

假设推荐服务已经连续失败:

失败率90%

继续每次都请求它,意义不大。

可以使用熔断器。

状态大致:

Closed
↓
正常请求

失败超过阈值
↓
Open

一段时间内
↓
直接降级,不访问下游

等待恢复窗口
↓
Half Open

少量探测请求
↓
成功则恢复

这样当一个服务已经明显故障时,聚合接口可以快速返回降级结果,而不是每个请求都等待超时。


十二、熔断和限流不是同一件事

限流

解决:

请求太多

熔断

解决:

下游已经不健康,还要不要继续调用

例如推荐服务本身只能承受:

500QPS

可以限流。

如果它已经:

80%请求超时

则可以熔断。

两个机制可以同时存在。


十三、降级结果必须设计清楚

例如推荐服务失败时:

recommendations = [];

这是最简单的降级。

但不同业务可以采用:

返回空数据
返回缓存数据
返回上一次成功结果
隐藏模块
使用默认配置

例如天气接口不可用:

展示“暂时无法获取”

通常比整个页面:

500

更合理。

降级不是偷偷忽略错误。

日志和监控仍然要记录真实故障。


十四、缓存适合保护部分非实时数据

例如:

商品分类
首页配置
推荐结果
基础用户资料

并不一定需要每次都实时访问下游。

可以:

请求
↓
优先读取缓存
↓
缓存不存在
↓
调用下游

甚至在下游失败时:

返回短时间旧缓存

形成:

Stale-While-Revalidate

思路。

关键是业务必须允许短时间旧数据。


十五、一个慢下游可能拖垮整个线程池或连接池

例如聚合接口每个请求都会调用:

A
B
C
D

其中 D 平时100ms,现在变成10秒。

并发1000个聚合请求时,就可能同时存在:

1000个等待D的连接

很快耗尽:

  • HTTP连接池;

  • 文件描述符;

  • 内存;

  • Worker;

  • 下游并发槽位。

所以超时不仅是“用户体验配置”。

它实际上也是资源保护机制


十六、Promise.all也要注意并发数量

如果聚合接口只有4个下游:

Promise.all

通常没问题。

但如果代码变成:

await Promise.all(
  userIds.map(id =>
    loadRemoteProfile(id)
  )
);

其中有:

500个userId

就可能瞬间创建500个外部请求。

这已经不是普通聚合,而是无限并发。

应该使用:

并发池
批处理
队列

限制同时运行的外部调用数量。


十七、不要让非核心接口拥有和核心接口一样高的优先级

例如:

支付确认

和:

相关推荐

显然不是同一个重要程度。

系统资源紧张时,可以优先保证:

登录
支付
订单
核心查询

对:

推荐
统计
预加载
辅助数据

进行更积极的:

  • 超时;

  • 降级;

  • 限流;

  • 熔断。

这叫做:

Bulkhead / 隔舱

思想。

一个非核心服务故障,不应该拖垮整个核心业务。


十八、可以给不同下游使用独立连接池

如果:

Payment Service

和:

Recommend Service

完全共用同一个 HTTP 连接池。

推荐服务突然卡住,可能耗尽全部连接资源。

随后支付服务即使正常:

也拿不到连接。

对于重要依赖,可以考虑隔离:

独立连接池
独立并发上限
独立超时

避免一个故障域扩散到所有下游。


十九、监控必须拆到下游维度

聚合接口只记录:

/api/dashboard = 2100ms

信息还不够。

应该知道:

user-service = 80ms
order-service = 150ms
notification-service = 60ms
recommend-service = 1850ms

这样才能快速发现:

到底谁拖慢了整个请求。

建议记录:

dependency
duration
status
timeout
retryCount
traceId

二十、Trace ID要贯穿完整调用链

例如:

Gateway
↓
Dashboard
↓
Order
↓
Payment

应该继续传递同一个:

traceId

这样日志里可以直接还原:

Dashboard 2.1s
其中 Order 180ms
其中 Recommend 1.8s timeout

否则只能看到每个服务各自有一堆孤立日志。


二十一、给聚合接口设置整体Deadline

可以在入口记录:

const deadline =
  Date.now() + 2000;

调用下游前计算:

const remaining =
  deadline - Date.now();

如果只剩:

100ms

就没必要再调用一个通常需要500ms的非核心服务。

可以直接使用降级数据。

这能避免“明知道来不及还继续执行”。


二十二、测试一定要模拟一个下游特别慢

普通测试:

所有下游100ms
→ API成功

几乎没有价值。

应该测试:

非核心服务超时

推荐服务5秒

验证主接口仍能在预算内返回。

核心服务失败

验证返回明确错误。

多个非核心服务同时失败

验证系统仍能降级。

下游持续失败

验证熔断器能够打开。

下游恢复

验证 Half Open 后可以重新恢复调用。

上游取消

验证下游请求不会继续长期占用资源。


二十三、让Codex先画依赖调用图

可以先输入:

请先不要修改代码。

分析当前聚合接口:

1. 会调用哪些下游服务;
2. 哪些调用是串行的;
3. 哪些可以并行;
4. 哪些属于核心依赖;
5. 哪些允许降级;
6. 每个服务当前超时是多少;
7. 是否存在自动重试;
8. 总接口的时间预算是多少;
9. 一个下游变慢时会占用哪些资源。

先把依赖关系画出来,再开始优化。

不要一看到:

接口慢

就盲目增加 Promise.all()


二十四、把调用链规则写进AGENTS.md

# Downstream Call规则

- 无依赖的下游请求优先并行
- 聚合接口必须区分核心与非核心依赖
- 所有外部调用必须配置明确超时
- 下游超时不得超过上游剩余Deadline
- 非核心服务失败必须评估降级
- 自动重试必须服从总时间预算
- 禁止无限重试导致Retry Storm
- 持续失败的下游必须评估Circuit Breaker
- 大量外部请求禁止无限Promise.all并发
- 每个下游必须记录耗时、状态和traceId

这样 Codex 后续修改聚合服务时,就不会只关注:

所有数据最终有没有返回。

而会同时关注:

需要在多久以内返回。

二十五、Plus还是Pro?

如果主要使用 Codex 处理:

  • 单个聚合接口;

  • 普通HTTP调用;

  • 简单超时和降级;

  • 中小型后端项目;

Plus通常已经可以覆盖大部分开发场景。

如果长期维护:

  • 微服务调用链;

  • 多个下游服务;

  • 大量Trace日志;

  • 熔断、限流与降级;

  • 多轮压测和故障演练;

则可以根据实际开发强度评估 Pro。

对于复杂微服务场景,更连续的上下文可以帮助同时分析调用链、日志和多个模块。

但无论使用哪种方案,最关键的设计原则仍然是:

上游的时间不是无限的。

总结

Codex 写聚合接口后,一个下游服务变慢就导致整个接口超时,本质上是因为调用链没有清晰的时间预算和失败边界。

通过:

并行调用
超时预算
Deadline
部分降级
Circuit Breaker
并发隔离

可以让一个下游故障不再轻易扩散成整个页面故障。

真正稳定的聚合接口,不是要求所有下游服务永远都正常。

而是即使:

推荐服务超时、通知服务故障、某个非核心模块暂时不可用,核心业务仍然能够在明确时间内给用户一个可用结果。

CSDN文章描述

本文介绍 Codex 编写微服务聚合接口时常见的下游超时扩散问题,并通过并行调用、超时预算、Deadline、降级和 Circuit Breaker 控制故障传播范围。

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务,一站式满足开发者需求,帮助企业提升开发效率并创新开发模式。

更多推荐