有个月对账,我发现出图次数比预期高了三成。查下来原因很蠢:同样的提示词被重复调用了。
重复是怎么产生的
三个来源,都很常见:
一、用户手抖。生成按钮点了没反应(其实在跑),用户又点一次,两个一模一样的请求同时打出去。
二、重试打偏。网络超时触发重试,但服务端其实已经生成成功了,只是响应没回来。重试等于又生成一张。
三、批量任务重跑。跑到一半挂了,重跑的时候没有断点,前面成功的那些又生成了一遍。
三种加起来,浪费的比例相当可观。
解法:一个 key 管两件事
给每次请求算一个内容指纹,用它同时做缓存和去重。
const crypto = require('crypto'); function fingerprint({ model, prompt, aspect, size, refImage }) { return crypto.createHash('sha256') .update(JSON.stringify({ model, prompt, aspect, size, refImage: refImage || '' })) .digest('hex'); }关键是把所有影响出图结果的参数都算进去。漏掉aspect_ratio的话,同一句提示词出方图和长图会撞成同一个 key,取到错的缓存。
缓存 + 在途去重
const cache = new Map(); // fp -> 结果 const inflight = new Map(); // fp -> 正在跑的 Promise async function genImage(params) { const fp = fingerprint(params); // 1) 已经生成过,直接返回 if (cache.has(fp)) return cache.get(fp); // 2) 正在生成中,复用同一个 Promise —— 这一步挡住了「用户连点两次」 if (inflight.has(fp)) return inflight.get(fp); const p = callGenApi(params) .then((r) => { cache.set(fp, r); return r; }) .finally(() => inflight.delete(fp)); inflight.set(fp, p); return p; }inflight这层比缓存还重要。缓存只能挡住"已经跑完的重复",而用户连点、并发重试制造的是"同时发出的重复"——那时候缓存里什么都没有,只有在途表能挡住。
什么时候不该走缓存
得留一个绕过缓存的口子,否则用户会困惑:"我就是想换一张,为什么每次都给我同一张。"
async function genImage(params, { noCache = false } = {}) { if (noCache) return callGenApi(params); // ... }界面上的「重新生成」按钮走noCache: true,正常路径走缓存。
持久化
内存 Map 重启就没了,批量任务重跑的场景挡不住。生产上建议落到 Redis 或数据库:
- key:指纹
- value:图片地址 + 生成时间
- TTL:按图床的链接有效期定,别缓存得比链接还久
该服务返回的是图片链接,如果你把图转存到了自己的存储,缓存里存自己的地址,就不用担心过期问题。
上线之后
重复调用基本归零,那三成的额外消耗省下来了。
顺带的好处是用户体验变好了:连点两次不会等两倍时间,第二次直接复用第一次的结果,秒返回。
这类优化投入很小——核心逻辑就上面那二十来行——但账单和体验都能立刻看到变化,属于性价比很高的一类活。
接口服务:甜甜圈API(dashengfenshen.cn)