画布开着十几分钟之后,上面的素材图集体变成「图片加载失败」。
刷新能好,但好十几分钟,然后再裂。用户以为是网络问题,我们一开始也这么以为——直到发现刷新只是换来另一条同样会死的链接。
三处独立的问题叠在一起。
一、每请求重建一次签名客户端
起因是压测。素材库场景下测图片端点的吞吐:
素材库场景压测(100 用户 × 24 张图)实测 /api/media 卡在约 1400 req/s,
同等并发下 /health 有 4100 req/s。
差了三倍。定位到签名逻辑:每次请求都新建一个 S3 客户端。
对比两种写法:
每请求新建 client + 签名 1.223ms/次 → 818 次/秒/核
复用 client 只签名 0.385ms/次 → 2598 次/秒/核
818 × 2 个 api 副本约等于 1636,与实测的 1400 吻合。
差的那部分不在构造函数上。构造函数本身只要 0.069ms,剩下的一毫秒多花在首次签名:新客户端要重新解析中间件栈、区域、凭证链,复用之后这些被记住。
改法是缓存,但缓存键不是「无脑单例」:
签名客户端按配置缓存。素材库一屏 24 张图,每张都重建 client 时实测 1.223ms/次,
复用后 0.385ms/次——快 3.2 倍。
按配置做键而不是裸单例:配置换了必须重建,否则会拿旧域名的 client 继续签,
生产改配置不生效,测试之间也会互相污染。
键包含公开域名、端点、区域、桶名、路径风格、访问凭证。反向验证过:注入一个裸单例,三条测试转红。
这一处只解决吞吐,不解决裂图。
二、前端把签名 URL 当永久地址存
裂图的直接原因在前端。
签名 URL 的有效期是 15 分钟(900 秒)。后端每次都现签,这个行为是对的。错的是前端把签好的 URL 存进了缓存,且没有有效期:
/**
* 签名 URL 的缓存寿命。
*
* 服务端签的是 900 秒,这里只敢存一半多一点——差值是留给「拿到 URL 到图片
* 真正加载完」这段时间的。
* 缓存不设期限会让画布开着超过 15 分钟后素材图**集体裂**:
* 后端每次都现签是对的,栽的是前端把签好的 URL 当永久地址存了。
*/
const URL_CACHE_TTL_MS = 8 * 60 * 1000;
8 分钟:服务端签 15 分钟,前端只存一半多,剩下的留给加载。
光靠 TTL 还不够。页面可能在后台挂着很久,计时器不准;而且用户的操作序列无法预判。所以补第二个机制——把裂图当作过期信号:
图片加载失败时调用:作废该 URL 的缓存并重新现签一次。
签名 URL 会过期,光靠 TTL 猜不准(页面可能在后台挂很久)。裂图本身
就是最可靠的过期信号——收到它就重签一次,让节点自己好起来。
同一个 URL 只重试一次,避免真·坏图把请求打成死循环。
一个 URL 只重试一次。真的坏图不能把请求打成死循环——测试里连续上报 5 次加载失败,断言请求数不超过 2。
三、面向浏览器的链路,签了 15 分钟的直链
做到这里,症状没了。但同一个坑之前已经踩过一次,这一次是第三次。
前两次都在画布上:
线上报障:画布上素材图集体「图片加载失败」。根因是这个端点返回了
resolveAssetUrl 签的 15 分钟 S3 直链,而不是 /api/media 稳定路径。
只断言「有 url 字段」的用例抓不到这种错,必须钉住 URL 的形态。
另一次在画布运行产物:
- // 读时现签,复用素材那边同一个签名实现
- signObjectUrl: (objectKey: string) => createPrivateObjectReadUrl(objectKey),
+ // 必须是 stableAssetUrl(/api/media 稳定路径、7 天有效、走后端代理),
+ // 不能是 createPrivateObjectReadUrl 签的 15 分钟 S3 直链——这条 URL 是
+ // 直接交给浏览器渲染 <img> 的,画布开十几分钟产物图就集体裂(线上报障过)。
+ // 「读时现签」只解决了「不存旧 URL」,没解决「签出来的只活 15 分钟」。
+ signObjectUrl: (objectKey: string) => stableAssetUrl(objectKey, ''),
最后那句是这次的实质收获。「读时现签」听起来已经解决了过期问题——每次读都是新的。但签出来的东西只活 15 分钟,而浏览器渲染 <img> 的 URL 会被页面持有到下一次刷新。两个时间尺度不匹配。
真正的修法是给「吐给浏览器」这条路单独一档地址,不再用签名直链,改走后端代理的稳定路径:
// 稳定媒体 URL:给前端 <img>/<video> 用的持久地址,替代 15 分钟就过期的 S3 签名 URL——
// 页面长驻后图片重新加载 403 裂图的根治方案。请求到达 /api/media 验签后 302 到读时现签的 S3 URL。
// HMAC 即凭证(与 slides raw 下载、local-business-promo blob 同范式,域分隔前缀防跨用),
// 不依赖登录态:img 标签发不出 Authorization 头。
//
// TTL 7 天:覆盖任何真实的页面停留场景。
// exp 对齐到天窗口:同一对象同一天内产出字节级相同的 URL,浏览器缓存可命中;
// 任意时刻拿到的 URL 剩余有效期至少 6 天,不存在「刚拿到就过期」。
export const MEDIA_URL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
路径形态是 /api/media?key=…&exp=…&sig=…。签名用 HMAC,不依赖登录态——img 标签发不出 Authorization 头。
exp 对齐到天窗口这一条值得单独说:同一对象同一天内产出的 URL 字节级相同,浏览器的缓存能命中;而因为窗口按天滚动,任何时刻拿到的 URL 剩余有效期至少 6 天。
四、把规矩变成测试
三档地址的分工写进了代码注释,放在存储工具的末尾:
吐给浏览器(渲染 <img>/<video>)→ stableAssetUrl:返回 /api/media 稳定路径,
7 天有效,根治「页面长驻后签名过期裂图」
交给上游拉取 → resolveUpstreamAssetUrl:6 小时
本进程内立刻用 → resolveAssetUrl:15 分钟
注释拦不住。这事已经栽过两次,两次的症状一模一样:
两次的症状一模一样:图刚打开好好的,画布放十几分钟就集体「图片加载失败」,刷新只是换来另一条同样 15 分钟后会死的链接,排查时极容易误判成前端缓存问题。
所以加了一条源码级断言。它不是接口测试——它读源码字符串,剥掉注释之后断言不该出现的函数名:
/** 只活 15 分钟、且指向对象存储域名的签名函数——绝不能出现在面向浏览器的链路里 */
const SHORT_LIVED_SIGNERS = ["createPrivateObjectReadUrl", "resolveAssetUrl"];
/** 这些文件产出的 URL 会被前端直接塞进 <img>/<video> */
const BROWSER_FACING_FILES = [
"canvas-flow/run-routes.ts",
"canvas-adapter/material-url-route.ts",
];
再加一条断言钉住 URL 的形态:产出的地址里不能出现 X-Amz-Signature。
这是这次修法里唯一带点非常规的做法:用测试来守一条架构约定。普通的测试守行为,这条守的是「哪个函数可以在哪里被调用」。
之所以需要它,是因为这类错误的三个特征叠在一起:编译期查不出(函数签名都一样)、单测查不出(旧用例只断言「有 url 字段」)、运行时查不出(图在头十几分钟是好的)。三个环节都不拦,只能靠一条读源码的断言。
五、时间尺度
回头看,这三处其实是同一个问题在三个层面的表现:链接的有效期和它的使用方式不匹配。
- 签名客户端:复用的时间尺度是「进程生命周期」,实现给的是「单次请求」。
- 前端缓存:URL 被持有的时间尺度是「页面停留时长」,缓存给的是「无限」。
- 直链:浏览器的取用方式是「随时重新加载」,签名给的是 15 分钟。
修完之后的取值是有依据的,不是拍的:
| 用途 | 有效期 | 依据 |
|---|---|---|
| 本进程立刻取用 | 15 分钟 | 签完就 fetch,够用且泄露窗口小 |
| 交给上游排队拉取 | 6 小时 | 实测排队 10~25 分钟才轮到上游下载 |
| 浏览器渲染 | 7 天(按天对齐) | 覆盖任意页面停留,且同一天 URL 相同可命中缓存 |
最后一条还有一层:它不走签名直链,走后端代理。这换来两个好处——不受对象存储域名可达性影响,也不受签名有效期约束。代价是流量过后端,但素材图不走这条路的代价更大。
■