起点:前端偶发报 timeout of 15000ms exceeded
这次排障的起点并不复杂。
前端部署在 Cloudflare Pages,API 走单独域名,先到 Cloudflare Workers,再由 Worker 转发到后端容器服务。用户侧的现象是:
- 偶发接口超时
- 不是某一个固定接口
- 前端提示
timeout of 15000ms exceeded - 后端应用没有异常
- 没看到慢 SQL
这类问题最容易先怀疑三个方向:
- Cloudflare Workers 会不会偶发丢请求
- 后端应用是不是偶发慢,但日志没打出来
- 数据库或下游有没有隐藏的阻塞
真正麻烦的地方在于:没有统一的请求标识时,三段日志根本对不起来。
第一个关键判断:这不是 Cloudflare 固定报错,而是前端自己超时
先看前端代码,确认这句报错是谁抛出来的。
结论很快明确:timeout of 15000ms exceeded 来自前端 axios 的 15 秒超时配置,而不是 Cloudflare 自己返回的固定错误页。
这件事很重要,因为它直接改变了问题定义:
- 它不代表 Cloudflare 一定失败了
- 它只代表浏览器在 15 秒内没有拿到完整响应
也就是说,真正可能卡住的点有很多:
浏览器
-> Cloudflare Pages/Worker
-> Worker 到源站入口
-> 源站入口到应用
-> 应用代码
-> 应用响应回传如果一开始就把问题定义成“Workers 丢请求”,排查会很容易跑偏。
原始误判:只看后端异常日志和慢 SQL,证据不够
最初后端能提供的信息很有限:
- 没有异常
- 没有慢 SQL
这只能说明一件事:应用代码层没有明显报错。
它完全不能说明:
- 请求一定到过应用
- 请求一定在应用里执行过
- 请求不是卡在容器入口、反向代理、负载均衡、连接建立、TLS 握手、平台排队
这类跨 CDN、边缘计算和容器平台的链路问题,最大陷阱就是“应用没报错”被误解成“应用一定没问题”。
第一轮证据:Worker 看到的是 outcome: canceled
在 Cloudflare Workers 日志里,先看到过这种记录:
{
"outcome": "canceled",
"wallTimeMs": 14786,
"cpuTimeMs": 0
}结合前端固定的 15 秒超时,这类日志的含义很清楚:
- Worker 收到了请求
- Worker 自己几乎没做计算,
cpuTimeMs = 0 - Worker 更像是在等待
fetch(origin) - 浏览器先在 15 秒时放弃
- Worker 被动记录成
canceled
这一步已经能排除一种常见误判:
不是“浏览器请求都没到 Cloudflare”,而是请求到了 Worker,但 Worker 等源站等太久。
真正开始定位之前,必须先做统一链路追踪
旧方案为什么不够
最初后端里只有本地生成的 traceId。这意味着:
- 前端看不到它
- Worker 也看不到它
- 一次超时请求发生后,没法把三端串起来
这时候即使日志很多,也很难回答最关键的问题:
这条超时请求到底有没有到后端?如果到了,是什么时候才到的?
新方案:把“客户端请求 ID”和“服务端 Trace ID”拆开
排障过程中我最后定下来的策略是:
- 前端生成
X-Client-Request-Id - Worker 原样透传
X-Client-Request-Id - 后端记录
clientRequestId - 后端再单独生成自己的
X-Trace-Id
这一步后来被证明是必要的,因为链路中还有一层网关/Envoy,会自己处理 x-request-id。如果继续把所有含义都塞进 X-Request-Id,后面会越来越乱。
为什么不能继续把业务链路 ID 放在 X-Request-Id
这是这次排障里非常关键的一次转向。
一开始前端用的是 X-Request-Id。后来发现有些请求里:
- 浏览器发出的
x-request-id和后端日志里的requestId不一致 - 但
cf-ray又能对上
继续排后发现两个事实:
- 浏览器响应头里有
x-envoy-upstream-service-time - 后端入口日志里能读到一个并非前端生成的
headerRequestId
这说明 Spring 前面还有一层 Envoy / 平台网关,而这一层会生成或重写 x-request-id。
这一步的教训很直接:
X-Request-Id 更适合交给基础设施自己用,不适合拿来当业务侧的唯一链路 ID。
所以后面把前端和 Worker 追踪头改成了自定义的:
X-Client-Request-Id服务端自己的链路 ID 则单独放在:
X-Trace-Id这样日志就不会再把两套 ID 混在一起。
实际增加了哪些代码
这次排障里,真正起作用的不是“看更多日志”,而是改造日志结构。
1. 前端:生成并打印 X-Client-Request-Id
前端的改动目标很简单:
- 每个请求生成一个
clientRequestId - 请求时打印一次
- 响应成功打印一次
- 响应失败打印一次
重点字段包括:
clientRequestId- 方法
- URL
- 状态码
- 耗时
cfRaytraceIdupstreamTraceId
这样前端控制台里的超时样本,终于能和 Worker / 后端对齐。
2. Worker:把“收到请求”和“回源完成”拆成两段日志
Worker 侧增加了四类日志:
worker.request.startworker.origin.fetch.startworker.origin.fetch.completeworker.origin.fetch.error
其中最关键的是这两条:
worker.origin.fetch.start
worker.origin.fetch.complete因为它们能直接告诉我:
- Worker 什么时候开始请求源站
- 请求的是哪个
targetUrl - 实际透传了哪个
clientRequestId - 源站有没有回响应
- 源站状态码是多少
- Worker 等了多久
这是判断“卡在 Worker 之前”还是“卡在 Worker 之后”的核心证据。
3. 后端:同时记录 clientRequestId 和 traceId
后端增加了两层日志:
TraceIdFilterRequestTraceLoggingFilter
TraceIdFilter 负责:
- 读取
X-Client-Request-Id - 读取 Cloudflare 透传的
cfRay - 生成服务端
traceId - 把
X-Trace-Id写回响应
RequestTraceLoggingFilter 负责:
- 请求开始日志
- 请求完成日志
- 请求异常日志
最终后端请求日志会稳定长成这样:
http.request.start traceId=... clientRequestId=... cfRay=... method=... uri=...
http.request.complete traceId=... clientRequestId=... cfRay=... status=... durationMs=...这一步之后,整个链路终于可以用这三组字段串起来:
clientRequestIdcfRaytraceId
还踩过一个坑:Worker 环境变量没配,直接回退到了 mora.local
排障中间还遇到过一次看起来很离谱的错误:
targetUrl: https://mora.local/auth/captcha
status: 530这不是 Cloudflare 随机抽风,而是代码里给 BACKEND_ORIGIN 写了默认值:
https://mora.local如果线上没有正确配置环境变量,Worker 就会回退到这个默认值。
而 mora.local 这种地址对 Cloudflare 的公网回源来说通常并不成立,于是直接得到 530。
这里的教训也很实用:
- Worker 代码里可以写默认值
- 但像
BACKEND_ORIGIN这种关键配置,不应该 silent fallback 到一个看似“开发态”的地址
更稳妥的做法是:
WORKER_ORIGIN必填BACKEND_ORIGIN必填STRIP_PREFIX可选
缺少关键变量时,Worker 应该直接明确报错,而不是偷偷回退到 mora.local。
真正把问题钉住的一组日志
最终真正把问题定位下来的,是这样一组完整链路。
前端
前端发出:
X-Client-Request-Id = 0551be19-3c48-4861-889e-1df28880361a
GET /prod-api/sys/dict/type/list?pageNum=1&pageSize=10Worker
Worker 记录到:
worker.request.start
clientRequestId = 0551be19-3c48-4861-889e-1df28880361a
cfRay = 9e3626687c257d8f
worker.origin.fetch.start
targetUrl = https://...clawcloudrun.com/sys/dict/type/list?pageNum=1&pageSize=10
forwardedClientRequestId = 0551be19-3c48-4861-889e-1df28880361a但没有看到 worker.origin.fetch.complete,取而代之的是:
outcome = canceled
wallTime = 14783
cpuTime = 0后端
后端同时记录到:
headerClientRequestId = 0551be19-3c48-4861-889e-1df28880361a
cfRay = 9e3626687c257d8f
http.request.start ...
http.request.complete ... durationMs = 82这组日志的结论非常直接:
- 前端请求 ID 到了 Worker
- Worker 又把它原样发给了源站
- 最终 Spring 应用也收到了同一个
clientRequestId - Spring 业务处理只花了
82ms - 但浏览器仍然在
15s超时
如果应用真正处理只花了 82ms,那问题就不可能在业务代码和 SQL 本身。
最终定位:慢点在应用之前,而不是应用之内
到这一步,问题已经能比较清楚地描述成:
请求不是没到后端,而是太晚才到后端。
更准确一点说,是慢在:
Worker
-> 云平台入口 / Envoy / Ingress
-> 容器真正接到请求而不是慢在:
Spring Controller
-> Service
-> Mapper
-> SQL为什么能这么判断?
- Worker
cpuTime = 0,说明不是 Worker 自己计算慢 - Worker
wallTime ≈ 15s,说明它是在等回源 - 后端一旦真正开始处理,只要几十毫秒
- 说明请求在“应用收到之前”已经消耗掉了大部分时间
那它最可能卡在哪?
结合部署形态,最值得怀疑的是这些层:
- 容器平台的入口网关
- Envoy / Ingress 排队
- 容器冷启动
- 平台层连接建立
- 平台层 TLS / 代理转发
如果你的源站跑在支持 scale-to-zero 的容器平台上,这种现象尤其值得优先考虑:
- 请求先到平台入口
- 平台准备实例 / 恢复实例 / 连接实例
- 应用真正收到请求时,浏览器侧 15 秒已经所剩无几
这也是为什么“后端没有异常”并不能洗清平台入口层的嫌疑。
这次排障里最有价值的几个结论
1. axios 15s timeout 不是根因,只是观察窗口
一开始最显眼的是浏览器报错:
timeout of 15000ms exceeded但它不是根因,只是一个时间窗口。
真正的问题是:15 秒内浏览器没有拿到完整响应。
这句话背后可能对应的根因完全不同:
- Worker 到源站 DNS 错误
- Worker 到源站连接失败
- 平台入口排队
- 容器冷启动
- 应用代码慢
所以第一步一定要先把报错语义拆开。
2. cfRay 非常适合用来跨 Cloudflare 和源站对时
当 clientRequestId 还没打通时,cfRay 是很好的兜底字段。
它至少能回答两件事:
- 这是不是同一条经过 Cloudflare 的请求
- Cloudflare 侧看到的请求,源站最终有没有对应记录
3. X-Request-Id 不要轻易当作唯一业务链路 ID
如果链路中有 Envoy、Ingress、API Gateway、云平台网关,这个字段极可能被基础设施拿去使用。
更稳妥的方式是:
- 自己单独定义
X-Client-Request-Id - 服务端自己生成
X-Trace-Id - 把两个概念彻底分开
4. 只看“应用异常日志”会漏掉大量平台层问题
这次最典型的误区就是:
- 应用没有报错
- 没有慢 SQL
- 所以直觉上开始怀疑 Worker
但最后的证据链恰恰说明,应用代码反而是最清白的一段。
现在这套日志应该怎么用
后面再遇到同类超时,可以按下面这个顺序判断:
情况 1:前端有 clientRequestId,Worker 根本没日志
优先查:
- 浏览器到 Cloudflare
- 域名、DNS、前端跨域、请求是否真的发出
情况 2:Worker 有 worker.request.start,但没有 worker.origin.fetch.start
优先查:
- Worker 自己的逻辑
- CORS 拒绝
- 路径重写
- 关键环境变量缺失
情况 3:Worker 有 worker.origin.fetch.start,但没有 worker.origin.fetch.complete
优先查:
- Worker 到源站
- DNS
- 建连
- TLS
- 源站平台入口
- 浏览器先超时取消
情况 4:后端已经打印了 http.request.start,但浏览器仍然超时
优先查:
- 平台入口到应用之间的剩余耗时
- 响应回传
- 长连接 / 大响应体 / SSE
- 应用真正处理时长
这次排障最后该怎么落地
如果把这次结论转成工程动作,我会优先做这几件事:
- 保留
X-Client-Request-Id + X-Trace-Id + cfRay这套日志结构 - Worker 缺少
BACKEND_ORIGIN时直接报错,不再回退到开发态域名 - 检查容器平台是否开启了 scale-to-zero 或存在明显冷启动
- 查云平台入口网关 / Envoy / Ingress 的访问日志和转发耗时
- 视情况把前端超时从 15 秒临时调高到 30 秒,仅作为定位手段,而不是最终修复
真正的修复通常不在前端,而在源站平台层:
- 让实例常驻
- 优化入口层转发
- 缩短冷启动
- 优化跨区域部署
小结
这次问题表面上看像是:
Cloudflare Workers 偶发把请求弄丢了。
但完整证据链给出的答案其实是:
Worker 没丢请求。请求到过 Worker,也最终到过应用,只是太晚才到应用。真正的慢点在应用之前的平台入口层。
这类问题最难的不是修某一行代码,而是先建立一条足够可信的证据链。
一旦 clientRequestId、traceId、cfRay 三个字段都打通,很多原本只能靠猜的判断,就会变成可以直接对日志下结论。
如果以后再遇到类似的跨 CDN、边缘计算、网关、容器平台链路超时,这套方法基本可以原样复用。