翻译 API 缓存实战:缓存键、Redis、失效策略与字符成本评估
详解翻译缓存的租户隔离、内容哈希、Redis 读写、并发去重与版本失效,并用可复算案例区分请求命中率和字符费用节省。

商品详情、帮助中心和界面文案经常包含重复内容。如果每次构建、打开页面或重跑任务都重新翻译,消耗的不只是 API 次数,还包括字符额度、等待时间和再次检查译文的工作。
翻译缓存应该复用“同一条件下已经得到的成功结果”。这里的同一条件包括租户、语言、引擎、术语、上下文和处理规则,不能只看两段文字是否相似。一个看起来很高的缓存命中率,也不一定意味着账单按同样比例下降。
本文给出应用侧的设计与 JavaScript 示例。Redis、并发合并和版本字段需要在自己的服务中接入,不表示 TranslateHub 已为每个调用方自动提供这些功能。公开请求参数仍以开发者文档为准。
一、先选择值得缓存的内容
适合优先缓存的是会被反复使用、处理范围明确的稳定文本,例如按钮文案、公开商品说明和帮助中心段落。对于一次性聊天、包含个人信息的消息,或强依赖当前对话的短句,应先判断是否需要留存,以及允许在哪个范围内复用。
还要区分缓存与正式译文库。缓存用于加速重复计算,可以过期或被淘汰;已经审校并发布的译文,应与源文版本、审校状态一起保存在内容系统中。不能因为 Redis 清理了一个键,就让网站失去唯一一份正式译文。
同样,页面 CDN 缓存与翻译结果缓存位于不同层。前者复用整页响应,后者避免重复调用翻译接口。对需要收录的多语言页面,可以在构建时复用译文,再生成完整 HTML,参考网站本地化流程。
二、缓存键必须覆盖影响结果的条件
只用“原文加目标语言”作为键,容易把不同客户的内容、不同引擎的结果或旧术语混在一起。可以用固定顺序的结构,明确表达一次翻译的身份:
| 条件 | 忽略后的典型问题 |
|---|---|
| 租户或受控共享范围 | 不同客户之间串用结果 |
| 源语言与目标语言 | 自动检测与显式语言配置互相污染 |
| 精确原文 | 相似但含义不同的句子被错误合并 |
| 引擎与配置版本 | 切换供应商后仍读取旧结果 |
| 术语和处理规则版本 | 新术语上线后继续返回旧译法 |
| 格式与上下文指纹 | 纯文本、带标签文本或不同语境误用同一结果 |
不要擅自去掉标点、压缩所有空格或转换大小写。代码、型号和界面文案中的这些差异可能有实际意义。若业务确实需要规范化,应先定义规则,并让规范化后的文本同时用于缓存身份和实际翻译输入。
下面的字段是内部元数据。engineRevision 可以代表自己维护的适配器配置版本;上游未公开模型版本时,不要编造模型标识。没有术语表或上下文时,也显式使用 none,避免把“没有配置”与“忘记传入”混为一谈。
import { createHmac } from 'node:crypto';
export function translationCacheKey(input, secret) {
if (!secret) throw new Error('Missing cache fingerprint secret');
const identity = [
input.tenantId,
input.sourceLang,
input.targetLang,
input.engineId,
input.engineRevision,
input.glossaryRevision,
input.format,
input.contextFingerprint,
input.policyRevision,
input.text,
];
if (identity.some((value) => typeof value !== 'string' || !value.length)) {
throw new TypeError('Cache identity fields must be non-empty strings');
}
const digest = createHmac('sha256', secret).update(JSON.stringify(identity)).digest('hex');
return `tr:v1:${digest}`;
}HMAC 可以避免把原文直接放进键名,并增加没有密钥时猜测短文本的难度。它不是加密:缓存值里的译文仍需要访问控制与留存管理。指纹密钥在服务端保存,轮换后会自然生成新的一组键。
三、用 Redis 实现 cache-aside 读写
基本流程是先读取,未命中才翻译,然后写回成功结果并设置过期时间。Redis 的 SET 命令支持将写入与过期选项放在同一个操作里,避免先写值、后设过期之间出现中断。
下面示例面向使用 node-redis 的服务端应用,采用 sendCommand 表达 Redis 命令。key 由上面的函数生成,expectedEngine 必须与缓存身份一致,translate 是已经绑定指定引擎的异步函数。
const inFlight = new Map();
function readEntry(raw, expectedEngine) {
if (raw === null) return null;
try {
const entry = JSON.parse(raw);
if (
entry?.schema === 1 &&
entry.engine === expectedEngine &&
typeof entry.text === 'string' &&
entry.text.length > 0
) {
return entry;
}
} catch {
// A malformed value is a miss, not a successful translation.
}
return null;
}
export async function cachedTranslation(redis, key, expectedEngine, translate) {
const cached = readEntry(await redis.get(key), expectedEngine);
if (cached) return { ...cached, cache: 'hit' };
const existing = inFlight.get(key);
if (existing) return { ...(await existing), cache: 'coalesced' };
const work = (async () => {
const result = await translate();
if (
typeof result.text !== 'string' ||
!result.text.length ||
typeof result.engine !== 'string' ||
!result.engine.length
) {
throw new Error('Invalid translation result');
}
const entry = { schema: 1, text: result.text, engine: result.engine };
if (entry.engine === expectedEngine) {
const ttl = 3600 + Math.floor(Math.random() * 600);
try {
await redis.sendCommand(['SET', key, JSON.stringify(entry), 'EX', String(ttl)]);
} catch {
console.warn('Translation cache write failed');
}
}
return entry;
})();
inFlight.set(key, work);
try {
return { ...(await work), cache: 'miss' };
} finally {
if (inFlight.get(key) === work) inFlight.delete(key);
}
}这个实现区分直接命中、本进程等待合并与真正启动翻译。异常会继续抛给调用方,并在 finally 中清理正在进行的任务。缓存写入失败会记录告警,但不会丢弃已经得到的可用译文;缓存读取失败则直接报错,避免突然把全部流量转发给上游。
示例中的约一小时 TTL 只是演示配置。如果翻译函数内部发生故障转移,返回引擎与预期不一致时不会写入这个键;需要缓存备用结果时,应按实际引擎的身份另行处理,详见多引擎路由与故障转移。
四、并发未命中需要单独控制
十个请求同时读取一个已过期的键,都可能看到未命中。缓存随后被写满,并不代表前面九次多余调用没有发生。上面的 Map 只在同一个进程中合并等待;多个容器、进程或服务实例之间仍可能重复调用。
跨实例协调可以使用带短租约的锁。Redis 官方的 cache-aside 示例展示了 SET NX PX 和校验持有者后释放的模式。没有拿到锁的请求可以短暂等待结果,但应受自己的截止时间约束。
不要直接删除一个可能已经过期并被其他请求重新获得的锁;释放时应核对随机持有者标识,并以原子操作完成比较与删除。锁过期也不能保证原持有者停止工作,因此这类协调用于减少重复,并不承诺恰好一次处理或计费。
无论是否使用锁,都应给上游并发量设置上限。Redis 不可用时,可以选择短期返回可恢复错误,或者只允许少量请求绕过缓存;不要让“缓存降级”变成不受控的费用与流量放大。
五、失效优先使用版本,再结合 TTL
源文变化后,精确文本已经会生成新键。术语、上下文构造或处理策略变化时,则需要提升相应版本,保证新请求使用新的身份。旧键可以按留存要求主动删除,或在 TTL 到期后自然清理。
相比只删除旧键,版本变化还能避免一种竞态:删除发生时,旧任务仍在上游处理,稍后又把过时结果写回。只要新请求使用新版本,旧任务写回的键就不会再次被命中。
TTL 决定缓存最多保留多久,不负责证明内容正确。审校修订也应进入正式译文版本或审校库,而不能仅仅延长一条旧缓存的过期时间。需要“立即不再使用”的内容,还应明确所有读取层和副本的失效路径。
六、不要把近似匹配当作普通缓存命中
“可以退款”与“不可以退款”在文字上很接近,含义却相反。短句离开原本上下文,也可能对应不同对象。涉及业务条件、金额、型号和否定表达时,精确身份比语义相似更适合作为直接复用的依据。
如果要建立翻译记忆库,可以把近似结果作为人工审校的候选,并显示来源、原文和适用范围。是否批准复用,应由独立规则决定,不要在普通缓存中悄悄把相似度超过阈值当成相同请求。
七、用字符加权指标核算收益
请求命中率衡量多少请求被直接复用;字符命中率衡量这些请求对应多少源字符。按字符计费时,后者通常更接近费用变化。下面是一个便于复算的假设场景,不是客户数据或产品效果承诺;每次未命中仅调用一次上游,不计重试和并发合并。
| 请求类型 | 次数 | 每次源字符 | 缓存命中 | 对应字符 |
|---|---|---|---|---|
| 重复短文案 | 8,000 | 100 | 全部命中 | 800,000 |
| 不重复长文本 | 2,000 | 1,600 | 全部未命中 | 3,200,000 |
| 合计 | 10,000 | — | — | 4,000,000 |
这个场景中,8,000 ÷ 10,000 = 80% 请求命中率,但省下的源字符是 800,000 ÷ 4,000,000 = 20%。若单位价格为每百万源字符 P,理论上少提交的部分对应 0.8 × P;实际账单还要考虑供应商计量、重试、套餐和缓存运行成本。
建议同时记录直接命中率、合并等待次数、实际提交字符、上游尝试次数、P95 延迟和过期结果问题。预算计算可以继续结合批量翻译成本指南,不应只展示一个漂亮的命中百分比。
八、上线前用这些场景验收
- 同一租户、相同身份连续调用两次,第二次不访问上游。
- 更换租户、语言、引擎、术语版本、上下文或原文,均不会误用旧值。
- 让同一进程的多个相同请求同时进入,确认合并生效;再单独测试多实例场景。
- 注入上游错误、空结果和格式异常,确认没有写入成功缓存,之后仍能正常重试。
- 注入 Redis 读取与写入故障,核对失败策略、告警和上游并发上限。
- 更新版本时让旧任务继续完成,确认新请求不会读到旧任务写回的结果。
对于占位符、数字与 HTML,还应在缓存写入前完成业务校验。缓存会放大正确结果的价值,也会反复传播一个未经发现的错误。可以配合翻译质量评估方法建立回归样本。
九、常见问题
缓存一定能降低 80% 的调用吗?
不能。重复比例、键的粒度、TTL 和访问模式都会影响结果。上面的 80% 是假设算例,只能说明计算方式,不能代替你自己的日志与账单。
相同原文可以跨客户共享吗?
应先有明确的共享范围和处理依据。默认按租户隔离更容易保持边界;确实属于可共享的公开语料时,可以另设公共命名空间与独立规则。
开启 Redis 后,是不是就自动有翻译缓存?
不是。仍需实现缓存身份、读写流程、过期策略、失败处理和业务校验。Redis 提供存储与命令能力,翻译结果何时可以复用由应用决定。

