i18n 国际化多语言网站架构演进:从 po 文件到全量内存

文章目录
微信公众号二维码
本文已同步发布到微信公众号「人言兑」
👈 扫描二维码关注,第一时间获取更新!

前面写过一篇 i18n多语言数据库设计的文章 ,介绍了最佳的数据库表结构,以及“数据库 + Redis + LRU 三级缓存”的国际化多语言网站数据库设计方案。当时的设计目标是:既要减少数据库压力,又要把常用语言的响应速度提上来。

方案上线后,很快就遇到了问题。经过几轮调整,最终改成了一套更简单的架构:翻译数据只在 DB 里维护,启动时全量加载到内存,运行时零 DB 查询。

这篇文章记录这次演进的过程:从 po 文件到三级缓存,再到全量内存,每一阶段遇到的问题、做出的取舍,以及最终方案的适用边界。文中涉及的数据规模、内存占用都是真实数字。

最初的三级缓存多语言方案

设计初衷

原文的思路大致是:

  1. L1 缓存(LRU,进程内):命中率最高的热数据放在进程内存里,零网络开销。
  2. L2 缓存(Redis):跨进程共享,避免每个实例都从数据库拉。
  3. L3(MySQL):最终兜底,命中率低但数据完整。

翻译链路大致是:Get(text, lang) → 查 L1 → 未命中查 L2 → 未命中查 L3 → 回填 L2、L1。

这个结构看起来很标准,也是很多“高性能系统”的通用模板。

上线后遇到的实际问题

问题不在缓存本身,而在于查询模式和缓存粒度不匹配。

项目的历史代码里,多语言的调用方式是零散的。一个页面模板里可能有上百处 T("文案", lang) 这样的调用,每一处都是一次独立查询。

  • 一次页面渲染,可能会触发几百次 Get
  • 每次 Get 都走一遍 L1 → L2 → L3
  • 即使 L2(Redis)命中率 95%,一个页面还是会有若干次真的打到 Redis 或 DB

这里的关键是:Redis 很快,但再快也是网络往返。单次可能 0.5ms,几百次累计起来就是几百毫秒。页面加载时时一部分一部分的展示出来,需要等待后面的每一次查询结果。页面全部加载完成的时间一下慢了好几倍。

当时想的是“加缓存就能快”,实际发现调用次数本身才是瓶颈。

历史代码决定的查询模式

三级缓存方案还忽略了一个现实:历史代码是按 key 逐处查询的。

我们最早的多语言方案用的是 gettext,代码里散落着:

Gettext("some text", lang)
Gettext("another text", lang)

每一处调用都是通过 Gettext 拿译文,底层是查 .mo 文件或者内存里的 map。

新的方案如果继续沿用这个调用方式,每一次 Gettext 就是一次 Get。迁移到三级缓存后,Get 变成“查 L1 → 查 L2 → 查 L3”,一次页面渲染就是几百次这样的链路。

兼容历史代码是硬约束。项目不可能为了让缓存方案更优雅,把全站几百个模板文件以及后端代码里的 Gettext 调用全部重构成批量接口。所以只能在 Gettext 的实现里做文章。

按 hash 查询

现在这套方案的查询方式其实和原文里假设的并不一样:我们的 Get 是按原文的 hash 去查,而不是按“语言 + key”去查。

传统的 i18n:

key = "home.title" → 译文

每条文案有一个稳定的逻辑 key,翻译系统按 key 索引。

但我们的历史代码用的是“原文本身作为 key”:

Gettext("欢迎来到首页", "en")

没有额外的逻辑 key,中文原文就是 key。所以现在必须要兼容这种调用方式,比如:

i18n.Get("欢迎来到首页", "en")

内部逻辑是:

  1. 计算原文的 xxHash64
  2. 用 hash 在内存的 map[uint64]string 里查译文

好处是兼容历史代码。模板里的 Gettext("中文", lang) 直接映射到 Get("中文", lang),不需要给每条文案起 key,也不需要维护 key 和原文的对应关系。

代价是:如果原文改了一个标点,hash 就变了,译文失效。这在新方案里是接受的取舍 —— 我们在业务侧(比如编辑文章时)显式调用 UpdateTextByRef,让它知道“旧原文改成了新原文”,从而更新记录、清掉旧译文。

LRU 对 SEO 的副作用

另一个影响较大的问题是 SEO。

LRU 的语义是“用过的才缓存”。搜索引擎爬虫第一次抓页面的时候,正好是 LRU 未命中的时刻。此时页面上渲染的是默认语言(源语言)的文案,而不是目标语言。

也就是说,搜索引擎索引到的内容,是未经翻译的版本。

有人会说可以预热:爬虫来之前,先把热门语言全加载到 LRU。但问题又绕回来了:

  • 有多少页面?每页多少条文案?
  • 全语言 × 全页面的组合,预热成本可能比直接全量加载还高。
  • 长尾页面永远预热不到。
  • 爬虫可能爬取任意语言的页面,LRU可能永远是全量内容。

对 SEO 场景来说,“首次访问就有正确语言”是刚需,而 LRU 不太能满足这个需求。

po 文件的维护成本

除了缓存和 SEO,还有一层现实问题:历史方案的翻译数据存在 po 文件里。我们支持近 20 种语言,每种语言的 po 文件体积都很大。

po 文件的问题比想象中多:

  • 改一个文案就是灾难。打开一个上百 MB 的 po 文件本身就非常卡,编辑器经常需要几秒甚至十几秒才能响应。改一个字,保存都要等。
  • 版本管理困难。这些文件必须用 git 管理,但单个文件超过50M会触发 git 的限制,所以还要按模块或按语言拆成小文件。拆了之后,每次更新或使用都要先合并,流程一下就复杂了。
  • 合并容易出问题。手动编辑 po 文件时,很容易因为编码不对、格式不对导致后续合并失败,甚至文件损坏无法恢复。恢复起来只能从 git 历史里翻。
  • 格式不适合程序化处理。po 的语法是给翻译工具设计的,自动翻译想直接改 po,需要额外的解析和序列化。
  • 和业务数据割裂。文章标题、题目文案这些业务数据本身就在 MySQL 里,为什么要绕到 po 文件?

所以最终的方向就明确了:翻译数据统一进数据库,运行时全量进内存。

现方案:DB + 全量内存

核心设计

四条原则:

  1. DB 是唯一真相。翻译数据只在 MySQL 里维护,po 文件退出历史舞台。
  2. 启动时全量加载到内存。运行时零 DB 查询、零 Redis 查询。
  3. 内存用 hash 索引,所有语言共享同一份 key。
  4. 翻译是离线任务。翻译完成后,通过接口通知服务进程增量同步。

数据结构非常简单:

// 单个语言:text_hash → 译文
type translationsByHash map[uint64]string

// 全部语言:langID → 单个语言的译文表
type translationsByLang map[string]translationsByHash

源语言不加载,Get 时直接短路返回原文。

为什么用 hash 而不是原文做 key

内存里直接存字符串作为 key,空间开销会很大。原文长的话一条几万个字符,几万条原文 × 近 20 种语言,字符串本身占的内存很可观。

用 uint64 的 hash 做 key:

  • 每条原文 8 字节
  • 内存中的 map 每个 entry 约 16 字节(hash → 译文字符串指针)
  • 译文字符串另算

我们的项目里原文和译文都是文本,一部分内容可以到几 MB 级别(用 mediumtext 存)。这部分是内存占用的主要来源。

选 hash 算法时考虑过几个:

算法速度输出说明
MD5慢16 字节加密哈希,杀鸡用牛刀
SHA1更慢20 字节同上
xxHash64极快8 字节非加密,适合索引
FNV-1a快8 字节碰撞率比 xxHash 高

最终选了 xxHash64。速度是 MD5 的十几倍,输出 8 字节,唯一的优势就是做索引匹配。碰撞概率方面,10 亿条数据下大约 10^-3,对万级规模可以完全忽略。

Go 里可以直接用 github.com/cespare/xxhash/v2:

import "github.com/cespare/xxhash/v2"

func TextHash(s string) uint64 {
    return xxhash.Sum64String(s)
}

启动加载策略

服务启动时,LoadAll 做这几件事:

  1. 查所有启用的语言(排除源语言)
  2. 同步加载默认 fallback 语言
  3. 其余语言顺序加载

为什么 fallback 语言同步、其他顺序?因为 fallback 语言是最常用的兜底。用户的语言如果没有对应译文,会 fallback 到它。fallback 加载完了,绝大多数请求就已经有正确响应。

没有做并发加载。近 20 种语言并发拉 DB,对数据库和带宽都是压力。顺序加载慢一点,但可控。如果服务器资源够,并发加载更好。

内存占用

目前数据规模:

  • 原文 63073 条
  • 近 20 种语言
  • 部分原文是长文章,用 mediumtext(16MB 上限)

完整加载后(近 20 种语言,部分语言还在逐步翻译中),内存占用在 1.5GB - 2GB 之间波动,取决于已经翻译完成的比例。

对比之前的方案(gettext 的二进制 .mo/.po 文件 + 缓存),省了大约 1GB 内存(内存使用率降低了15%)。省下来的主要原因是:

  1. hash key 而不是字符串 key。字符串 key 每条至少几十字节。
  2. 共享 key。近 20 种语言共用同一份 text_hash,不需要每种语言独立维护 key。
  3. 没有额外的缓存层。LRU、Redis 客户端库、序列化中间产物都省了。

省下来的 1GB,又可以多能跑几个业务服务实例了。

查询路径

运行时 Get 的路径:

  1. 源语言短路(英文直接返回原文)
  2. 目标语言已加载 → 查 map
  3. 目标语言未加载 → fallback 到默认 fallback 语言
  4. 默认 fallback 语言也未加载 → fallback 到原文

没有懒加载。所有语言要么在启动时加载完,要么在翻译完成后的增量同步中被更新。运行时的 Get 是纯读,不会触发任何 DB 查询。

增量同步

翻译是离线任务,跑在独立的 CLI 进程里。翻译完成后,DB 里就多了一批新译文。但常驻进程的内存还是旧的。

这里没有用定时轮询,而是让 CLI 主动通知:

  1. 翻译命令结束后,向服务的 HTTP 接口发一个请求
  2. 服务收到请求后,按 updated_at 增量拉取译文变更
  3. 更新内存

增量同步的关键是用 updated_at 做游标:

删除不处理。内存里的旧数据保留,重启后自然干净。这是有意的取舍:

  • 翻译数据的删除频率很低(主要是重翻时清掉脏译文)
  • 内存里留着旧译文,不影响新数据
  • 重启一次就能清理干净,不需要额外的“删除日志”机制

免费的多语言自动翻译方案以及工程细节

调用谷歌翻译接口可以实现免费的自动文案翻译,但接口不太稳定,翻译逻辑本身需要做不少处理。这部分需要说明一下:下面的预处理、代码块保护、术语映射都是为了绕开谷歌翻译这类通用翻译 API 的固有问题。如果换成大模型 API,准确性更高、可控性更好,这些处理大都可以省掉,直接让模型按上下文翻译即可。

从 pygtrans 到 gogtrans

最开始自动翻译是依赖 pygtrans 这个 Python 库,通过外部脚本调用来实现的。这种方式的痛点很明显:

  • 项目是 Go 的,翻译功能要跨进程调用 Python 脚本,部署和维护都多了一层
  • 翻译任务的进度、错误处理、并发控制都要在两个语言之间传递,很别扭
  • 想集成到项目里做术语映射、脏数据检查这些额外逻辑,脚本里的代码难以复用

我把这部分实现成了一个 Go 库 gogtrans ,直接集成到项目里。它的能力大致是:

  • 纯 Go 实现,无 CGO,无外部进程
  • 支持免费接口和 APIKEY 两种模式,方便开发和生产的切换
  • 支持代理配置(免费接口在国内访问需要科学上网)
  • 支持兜底 API:主接口失败时自动 fallback 到自建中转服务,提升稳定性

对用 Go 做项目、又需要集成谷歌翻译的同学,可以参考一下。集成方式很简单:

import "github.com/axiaoxin-com/gogtrans"

client, err := gogtrans.NewTranslate(&gogtrans.TranslateOptions{
    Timeout: 60 * time.Second,
    Proxies: map[string]string{
        "https": "socks5://127.0.0.1:1080",
    },
    Domain: "com",
})
if err != nil {
    log.Fatal(err)
}

results, err := client.Translate(ctx, []string{"你好", "世界"}, "en", "zh-CN")

如果生产环境不想依赖公共翻译接口,可以用自建中转服务,配置 ApiKeyTranslateOptions 走官方 API。这部分在 gogtrans 的 README 里有说明。

Markdown 预处理

原文如果是数据库中的文章内容,则可能包含 Markdown,直接丢给翻译 API,语法会被破坏。

用 goldmark 把 Markdown 转成 HTML,再交给翻译。翻译 API 对 HTML 更宽容,翻译后再还原。

术语映射

品牌名、专有名词不能直接翻译。维护一个映射表,翻译前替换为英文占位,翻译后还原。

var defaultTermMapping = map[string]string{
    "品牌名":  "BrandName",
    "产品名":  "ProductName",
    // ...
}

脏数据判定

谷歌翻译不太稳定,有时候对将传入的内容“原样返回”,这类译文属于脏数据,写入前应该跳过。

判定逻辑:

  1. 原文去掉 HTML 标签
  2. 提取原文中第一段和最后一段连续文字
  3. 如果这些中文片段出现在译文里,就是脏数据

自适应重试

谷歌翻译 API 偶尔会对某些文案返回空或脏数据。重试是有意义的,但不能无脑重试。

策略是:

  • 每批翻译完成后,收集本批失败和脏数据的原文
  • 对这批重新翻译
  • 只要本轮重试有成功(待重试集合变小),就继续
  • 某轮重试 0 成功(待重试集合不变),立即停止
  • 没有硬上限:如果每轮都有进展,就重试到全部成功

这比“固定重试 3 次”更灵活:

  • 一次性就能成功的,不浪费重试
  • 顽固的脏数据,第一次重试无进展就停
  • 逐步能成功的,会一直重试到底

数据模型

三张表:

-- 语言表
CREATE TABLE `i18n_language` (
  `id`          varchar(20) NOT NULL DEFAULT ''   COMMENT '语言代码',
  `native_name` varchar(64) NOT NULL DEFAULT ''   COMMENT '本地化名称',
  `is_active`   tinyint(1)  NOT NULL DEFAULT 1    COMMENT '是否启用',
  `sort_order`  int         NOT NULL DEFAULT 0    COMMENT '排序权重',
  `created_at`  bigint      NOT NULL DEFAULT 0,
  `updated_at`  bigint      NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  KEY `idx_active_sort` (`is_active`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 原文表
CREATE TABLE `i18n_text` (
  `id`         bigint          NOT NULL AUTO_INCREMENT,
  `text_hash`  bigint unsigned NOT NULL DEFAULT 0  COMMENT '原文 XXHash64',
  `text`       mediumtext      NOT NULL            COMMENT '原文内容',
  `lang_id`    varchar(20)     NOT NULL DEFAULT '' COMMENT '源语言代码',
  `ref_type`   varchar(20)     NOT NULL DEFAULT '' COMMENT '业务类型',
  `ref_id`     bigint          NOT NULL DEFAULT 0  COMMENT '业务对象 ID',
  `created_at` bigint          NOT NULL DEFAULT 0,
  `updated_at` bigint          NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_text_hash` (`text_hash`),
  KEY `idx_ref` (`ref_type`, `ref_id`),
  KEY `idx_lang_id` (`lang_id`, `id`),
  FULLTEXT KEY `ft_text` (`text`) WITH PARSER ngram
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 译文表
CREATE TABLE `i18n_translation` (
  `text_id`    bigint      NOT NULL DEFAULT 0,
  `lang_id`    varchar(20) NOT NULL DEFAULT '' COMMENT '目标语言代码',
  `text`       mediumtext  NOT NULL            COMMENT '译文内容',
  `translator` varchar(32) NOT NULL DEFAULT '' COMMENT '翻译器名称',
  `created_at` bigint      NOT NULL DEFAULT 0,
  `updated_at` bigint      NOT NULL DEFAULT 0,
  PRIMARY KEY (`text_id`, `lang_id`),
  KEY `idx_lang_text` (`lang_id`, `text_id`),
  FULLTEXT KEY `ft_text` (`text`) WITH PARSER ngram
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个设计点:

text_hash 是 uint64,不是字符串。对应的 Go 类型是 uint64,MySQL 里是 bigint unsigned。加唯一索引。

ref_type / ref_id 标记原文的业务归属。UI/源码中的文案 ref_type='ui',ref_id=0;业务文字按业务对象 ID 存。这个主要是为了按多语言做全文检索时可以关联到业务对象结果,方便展示结果,和翻译逻辑本身无关。

因为翻译数据统一进了 DB,跨语言全文检索变得可能。FULLTEXT 索引用 ngram parser。这是 MySQL 对中文全文检索的支持,默认的分词器按空格切分,中文不行。

这个功能在之前的 po 文件方案里做不了。有了 DB + ngram 索引,跨语言搜索就成了顺手的事。

创建 FULLTEXT 索引的 SQL:

ALTER TABLE `i18n_text` ADD FULLTEXT KEY `ft_text` (`text`) WITH PARSER ngram;
ALTER TABLE `i18n_translation` ADD FULLTEXT KEY `ft_text` (`text`) WITH PARSER ngram;

GORM 的 AutoMigrate 不支持 WITH PARSER ngram,需要手动执行。

这套方案适合什么场景

适合:

  • 原文数量在万级到十万级
  • 译文数据可以全量放进内存(比如几十 GB 内存的机器完全够)
  • 翻译是离线任务,不需要实时一致性
  • 希望 SEO 首访就有正确语言
  • 需要跨语言全文检索
  • 历史代码按“原文即 key”的模式调用,难以重构

不适合:

  • 原文是百万级以上。内存会爆。
  • 服务多实例部署,且每实例独立内存。这时候增量同步要每个实例单独做,比较麻烦。
  • 需要“翻译后毫秒级生效”。虽然有增量同步,但毕竟有延迟。
  • 有严格的删除语义。内存里删除的译文不会立刻消失,只能靠重启清理。

我们项目在“适合”这一栏里。原文 6 万多条,8GB 内存够用,翻译确实可以离线跑。

总结

回头看,最初的三级缓存设计,问题不在缓存技术本身,而在没有搞清楚调用模式。历史代码的零散查询方式,决定了“每一次查都走一遍缓存”这个假设不成立。缓存层再多,也挡不住调用次数本身的io等待。

全量内存方案的核心取舍是:

  • 用内存换零延迟。代价是启动时间(约 1-2 分钟)和内存占用(约 2GB)。
  • 用“接受内存脏数据”换简化。删除、原文变更不主动从内存清理,重启兜底。
  • 用“离线翻译 + 增量同步”换实时性。翻译完通知一次就够,不做定时轮询。

对“原文不多、查询频繁、SEO 敏感”的场景来说,这套方案的实际效果比三级缓存好。工程上没有银弹,只有取舍。

如果未来原文数量再上一个数量级,这套方案可能要重新调整。但至少在现在的规模下,它让我省了 1GB 内存(不够理想)、少维护一堆 po 文件,本次多语言方案重构目标基本达成。


也可以看看