国际化 i18n 多语言数据库设计最佳实践

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

在数据库里实现多语言支持有很多种做法,但只有一种能够长期有效且成本可控。

常见的 i18n 方案,基本思路都是把翻译内容加载到内存里,运行时按 key 直接取。这个做法对界面文案来说没问题,因为界面文案总量有限,几千条也就几百 KB,启动时一次性加载完全扛得住。

但它有一个隐含的规模上限:需要翻译的内容越多,内存占用越大;一旦翻译内容是从数据库动态增长的,内存方案就会开始吃力。

最典型的场景是长篇文章。如果一篇文章本身上万字,几十种语言版本加起来,单篇就是几十万字的内容。这种情况下:

第一,把全文加载进内存不现实。 文章是业务数据,不是静态资源,它随用户发布持续增长。你不可能像加载界面文案那样,把所有文章的所有语言版本一次性塞进内存。并且,需要管理的翻译文件越来越大,难以维护。

第二,长文本和短文案的访问模式完全不同。 界面文案是“启动时全量加载、运行时高频读取”,文章是“按需读取、单次访问、低频更新”。把它们塞进同一套缓存策略里,只会互相拖累。

所以这篇文章讨论的多语言数据库设计,主要针对的是存储在数据库里的业务文本——标题、简介、正文、分类名这类会随业务增长的内容。界面文案可以继续用内存 i18n 方案,两者不冲突。

多语言数据库设计方案

简单实现 vs. 灵活性与功能性

本地化这个概念对软件应用来说非常关键,尤其是当应用面向全球市场时。多语言支持是其中最核心的一环:一个设计良好的多语言数据库,能让你覆盖更多目标市场,触达更多用户,也能成为你长期国际化战略的一部分。

关键不在于“能不能实现”,而在于“实现的方式会不会让开发和维护成本失控”。数据建模是软件开发中不可分割的一部分,所以你需要认真考虑:哪种数据模型策略,既能给应用提供多语言支持,又不会在后期变成负担?

一个合格的数据模型应该做到两点:第一,在保持多语言支持的前提下,你依然可以修改应用、增加新功能,而不需要额外付出高昂代价;第二,增加一门新语言时,你只需要往数据库里补充对应的翻译数据,而不需要改动应用代码。

多语言数据库设计有不同路线,每条路线都有取舍。容易实现的,往往灵活性和功能都弱;灵活性和功能强的,实现起来更复杂。

我的建议是:优先选择功能更完整、更灵活的方案,哪怕前期实现成本更高。我们常常会犯一个错误,觉得“这个应用太小了,不值得为多语言支持搞那么复杂的表结构”。但最终,随着应用程序的不断发展,往往我们都会后悔当初选择了那种看似简单且成本较低的“权宜之计”。

对于一个附属功能——不管是多语言支持、变更日志、用户认证还是别的什么——最理想的做法是:让它拥有自己的子模式(subschema),并把逻辑封装在可复用的组件里。这样一来,这个附属功能和它的子模式都能以最小的代价移植到任何新应用中。

下面的方案按“从最省事到最正确”的顺序展开,逐一分析五种方案,重点看哪种结构能同时扛住文案规模增长和语言数量增长。

方案一:给每种语言加一列

这是最容易想到、也最容易实现的做法。你的表里原本有一个文本字段,现在为每一种要支持的语言,各加一列。

比如一张电影表,原本只有标题一列。要支持中文、英文、日文,就变成三列:

movie_idtitle_zhtitle_entitle_ja
1虎胆龙威Die Hardダイ・ハード
2回到未来Back to the Futureバック・トゥ・ザ・フューチャー
3侏罗纪公园Jurassic Parkジュラシック・パーク

应用根据用户选择的语言,去读对应的那一列。用户选日文就读 title_ja,选英文就读 title_en。

这个方案之所以诱人,是因为不用 JOIN,不用建新表,查询就是最简单的 SELECT。加一列的成本看起来几乎为零。

但它的问题很明确:

第一,加语言等于改表结构。 每支持一门新语言,就要给表加一列,还要改应用代码去识别这一列。数据库表结构本该稳定,却因为语言数量变化而频繁变动。

第二,列名会失控。 如果有 10 种语言,标题就有 10 列;如果还有简介、分类名、标签,每个都要乘 10。

第三,它只适合极少数表。 如果整个应用——菜单、按钮、错误提示——都要多语言,你不可能给每个界面元素都加一张表再加列。这个方案从一开始就不是为“全应用多语言”设计的。

只适合那种“就一张表、两三种语言、以后也不会扩展”的临时场景。一旦应用要成长,它一定会拖后腿。

方案二:把翻译存进 JSON 或 XML 字段

不额外加列,而是把原来存文本的那一列改大,把所有语言的翻译都塞进同一个字段,用 JSON 或 XML 组织。

字段名不要叫 translations 这种含糊的名字,更准确的做法是按内容命名,比如 title_i18n 或 title_localized,让人一眼看出这一列装的是“标题的多语言版本”。下面为了对比清晰,暂用 title_i18n:

movie_idtitle_i18n
1{"zh":"虎胆龙威","en":"Die Hard","ja":"ダイ・ハード"}
2{"zh":"回到未来","en":"Back to the Future","ja":"バック・トゥ・ザ・フューチャー"}
3{"zh":"侏罗纪公园","en":"Jurassic Park","ja":"ジュラシック・パーク"}

用对象而不是数组,好处是可以直接用语言代码取值,不需要遍历数组找 language 字段。

不用加列,结构上看起来“干净”了。以后要加语言,只是往 JSON 里多塞一个键值对,表结构不动。

但它的问题也很明确:

第一,查询必须依赖特定数据库的函数。 标准 SQL 并不认识 JSON 内部的结构。你想按语言取标题,就得用数据库厂商提供的专用函数。比如 MS SQL Server 用 OPENJSON():

SELECT
    m.movie_id,
    j.title
FROM Movies AS m
CROSS APPLY OPENJSON(m.title_i18n)
WITH (
    title varchar(100) '$."ja"'
) AS j;

而 MySQL 要写成完全不同的样子:

SELECT
    m.movie_id,
    JSON_UNQUOTE(JSON_EXTRACT(m.title_i18n, '$."ja"')) AS title
FROM Movies AS m;

第二,你的查询被绑死在某一个数据库上。 用 MS SQL 写的查询,换到 MySQL 就跑不了。以后想换数据库,所有涉及翻译的查询都要重写。

第三,数据库无法有效索引和约束。 你没法给 JSON 里的某个语言字段建索引,也没法用外键约束保证翻译的完整性。数据质量全靠应用层自己保证。

比方案一灵活一点,但把复杂度转嫁给了查询和数据库绑定。不推荐作为长期方案。

方案三:每种语言单独存一条记录

同一个业务对象,每种语言存一行。标题字段只有一列,语言由 language_id 区分。

movie_idlanguage_idtitle
1enDie Hard
1zh虎胆龙威
1jaダイ・ハード
2enBack to the Future
2zh回到未来
2jaバック・トゥ・ザ・フューチャー

表结构简单,加语言不用改表,只是多插几行。

但这里需要先解释一个概念:数据冗余。

假设电影表里除了标题,还有导演、上映年份、时长。这些信息跟语言无关,每种语言下都应该是一样的。但按这个方案,同一部电影有三种语言,就有三行记录,导演、年份、时长就被重复存了三遍:

movie_idlanguage_idtitledirectorrelease_year
1enDie HardJohn McTiernan1988
1zh虎胆龙威John McTiernan1988
1jaダイ・ハードJohn McTiernan1988

导演和年份存了三遍,这就是数据冗余。

冗余的直接后果是:如果导演名字写错了,你要改三行;如果漏改了一行,同一部电影就有两个不同的导演。数据一致性完全靠人工保证,这在真实项目里迟早出错。

数据库设计里有一个基本原则叫范式,说白了就是:同一条事实只存一个地方,避免重复。 这个方案为了多语言,把跟语言无关的信息也重复了,等于主动破坏了这条原则。

另一个问题:查询变复杂。 你只想查电影列表,却要处理“每种语言一行”的结构。要么加 WHERE language_id = ?,要么为每种语言建视图:

CREATE VIEW Movies_ja AS
SELECT movie_id, title
FROM Movies
WHERE language_id = 'ja';

语言一多,视图就一多,维护成本随之上升。

表面简单,实际把冗余和一致性问题引入了核心业务表。只适合“这张表除了文本没有别的字段”的极端情况。

方案四:为每张业务表配一张翻译表

这是方案三的改进版。不再把翻译行和业务数据混在同一张表里,而是为每一张需要翻译的业务表,单独配一张翻译明细表。

主表只存跟语言无关的数据,翻译表通过外键指回主表,并记录语言。

Movies(主表)

movie_iddirectorrelease_year
1John McTiernan1988
2Robert Zemeckis1985

MovieTitles(标题翻译表)

movie_idlanguage_idtitle
1zh虎胆龙威
1enDie Hard
1jaダイ・ハード
2zh回到未来
2enBack to the Future
2jaバック・トゥ・ザ・フューチャー

这里翻译表的主键是 (movie_id, language_id),一部电影一种语言只有一条标题。movie_id 是指向 Movies 的外键,所以翻译表和主表之间关系明确。

如果电影还有简介需要翻译,就再配一张 MovieDescriptions,主键同样是 (movie_id, language_id)。

这样做的好处是:导演、年份这些跟语言无关的信息,在主表里只存一份,不再重复。翻译表只存真正需要翻译的文本。数据冗余问题解决了。

但它还有两个问题:

第一,每一张需要翻译的业务表,都要配一张或多张翻译表。 电影有 MovieTitles,产品有 ProductNames,分类有 CategoryNames。如果应用里有 20 张表需要翻译,你就有 20 张甚至更多翻译表。

第二,菜单、按钮、错误提示这些界面文字怎么办? 它们不属于任何一张业务表,没有 movie_id 可以关联。你没法给“首页”这个按钮建一张翻译表。

比前三种都好,方向是对的——把翻译从业务表里拿出来。但它只解决了“业务数据”的翻译,没解决“界面文字”的翻译。而且每张业务表配一张翻译表,数量一多就难以维护。

方案五:统一翻译文案管理(翻译子模式)

方案四的方向是对的,但还不够彻底。既然翻译应该从业务表里拿出来,那就干脆把所有翻译集中到一个地方,而不是每张业务表各自配一张翻译表。

核心思路一句话:任何会出现在用户眼前的文字,都不直接存在业务表里。业务表只存一个“文本编号”,真正的文字统一放在翻译中心,按语言分开管理。

三张表就够了

第一张:语言表。 记录系统支持哪些语言。

language_idlanguage_name
zh中文
enEnglish
ja日本語

第二张:文本表。 记录所有需要翻译的条目。每一条代表“一句需要翻译的话”,不管它来自电影标题、按钮还是错误提示。

text_idoriginal_textsource_language
100虎胆龙威zh
101回到未来zh
102侏罗纪公园zh
200首页zh
201保存成功zh

注意:电影标题和“首页”按钮在这里是平等的,它们都是需要翻译的文本,共用同一套编号体系。这就是“统一”的含义。

第三张:译文表。 记录每个文本条目在每种语言下的翻译。

text_idlanguage_idtranslated_text
100zh虎胆龙威
100enDie Hard
100jaダイ・ハード
101zh回到未来
101enBack to the Future
101jaバック・トゥ・ザ・フューチャー
200zh首页
200enHome
200jaホーム

到这里,翻译已经完全和业务表解耦了。新增一门语言,只需要往语言表插一行,然后往译文表补上对应语言的译文,应用代码完全不用动。

业务表怎么用

业务表不再直接存文字,而是存 text_id。

改造前的 Movies 表:

movie_idtitle
1虎胆龙威
2回到未来

改造后的 Movies 表:

movie_idtitletitle_text_id
1虎胆龙威100
2回到未来101

注意这里保留了原来的 title 字段。这个字段是“默认语言的副本”,不参与多语言逻辑,但能让日常查询、报表、调试都方便很多。

用户想看日文标题,查询就是:

SELECT
    m.movie_id,
    t.translated_text AS title
FROM Movies AS m
JOIN TextContent AS tc ON m.title_text_id = tc.text_id
JOIN Translation AS t ON tc.text_id = t.text_id
WHERE t.language_id = 'ja';

结果:

movie_idtitle
1ダイ・ハード
2バック・トゥ・ザ・フューチャー

换成英文,只需要把 'ja' 改成 'en',SQL 结构完全不变。

为什么保留 title 字段

把原来的标题字段彻底删掉、只留 title_text_id,理论上是更纯粹的。但实际项目里,保留一份原文字段会省很多事。

第一,日常查询不用 JOIN。 运营想知道“最近上架了哪些电影”,本来一句 SELECT movie_id, title FROM Movies 就够了。如果表里没有标题,每次都要 JOIN 两张表。

第二,统计和数据仓库更省心。 做报表、导数据、喂给 BI 工具时,往往只需要一个默认标题就够了,不需要关心用户当前选的是什么语言。如果业务表里没有标题,每个下游系统都要自己实现一遍 JOIN。

第三,调试方便。 打开数据库客户端 SELECT * FROM Movies,如果只有 title_text_id: 100,根本不知道是哪部电影。得再去查翻译表才知道。

第四,翻译录入界面需要原文。 给译者用的工具,要同时显示原文和译文。业务表里保留原文,可以直接取用。

保留原文的代价是:同一份原文在数据库里存了两份,一份在 Movies.title,一份在 TextContent.original_text。这就是数据冗余。冗余本身不是罪,关键是管好它。有两个原则必须守住:

原则一:Movies.title 只作为默认语言的副本,不参与翻译逻辑。 它存在的意义是让查询、报表、调试更方便,不是让应用去读它做多语言展示。多语言展示一律走翻译子模式。

原则二:写入时必须同步。 新增或修改电影标题时,两张表要在同一个事务里更新,否则两边会不一致。

BEGIN;

UPDATE TextContent
SET original_text = '虎胆龙威(重制版)'
WHERE text_id = (SELECT title_text_id FROM Movies WHERE movie_id = 1);

UPDATE Movies
SET title = '虎胆龙威(重制版)'
WHERE movie_id = 1;

COMMIT;

如果你确实不想要这份冗余,也可以用视图来兼顾两边:

CREATE VIEW MoviesWithTitle AS
SELECT
    m.movie_id,
    m.title_text_id,
    tc.original_text AS title,
    m.director,
    m.release_year
FROM Movies AS m
JOIN TextContent AS tc ON m.title_text_id = tc.text_id;

这样 Movies 表保持纯粹,只存 title_text_id,原文从 TextContent 取;日常查询、报表、调试都查这个视图。代价是所有下游系统都要改成查视图,而且视图本身也是一层间接。到底选哪种,取决于团队习惯。

我的建议是:保留原文列,但明确定位为“默认语言的副本”,不参与翻译逻辑。 数据库设计里,纯粹性固然重要,但可维护性和可调试性同样重要。为了消除一份冗余而让日常开发变复杂,往往得不偿失。真正危险的不是冗余本身,而是冗余没有被约束、两边数据逐渐漂移。只要写入时同步、使用时明确分工,保留一个原文字段是完全合理的工程取舍。

界面文字怎么用

菜单、按钮、错误提示同样走这套体系。但在讲具体做法之前,先解决一个关键问题:应用代码怎么找到自己需要的那条翻译?

业务数据有 movie_id 这样的主键可以关联,但界面文字没有天然的主键。你不能在代码里写“请给我 text_id 等于 200 的那条”,因为 200 是数据库自增生成的,代码里根本不知道。

所以界面文字需要另外一套定位方式。常见的有两种。

方式一:用人类可读的 key。

给每条界面文字起一个有意义的名字,在文本表里加一列 text_key:

text_idtext_keyoriginal_textsource_language
200menu.home首页zh
201button.save保存zh
202message.save_success保存成功zh

应用代码里直接写这个 key:

const label = i18n.t("menu.home");

好处是直观、可读、可搜索。打开代码看到 'menu.home' 就知道是首页菜单。缺点是 key 要人工维护,容易命名不一致,或者同一句话在不同地方被起了不同的 key,导致重复翻译。

方式二:用原文的 hash 作为 key。

不对文字起名字,而是对原文本身算一个 hash(比如 SHA-256 取前 16 位),把 hash 作为 key:

text_idtext_hashoriginal_textsource_language
200a3f5c9e12b7d4a08首页zh
2017b2e1d9c4f8a3e56保存zh
202c81f4a6e2d9b7f03保存成功zh

应用代码里写原文,运行时算 hash 去查:

const label = i18n.t("首页");

这个方式的优点是:

  • 不用维护 key。 原文本身就是 key,不存在“这句话该起什么名字”的纠结。
  • 天然去重。 同样的原文算出同样的 hash,同一句话在十个地方出现,也只会有一条翻译记录,不会重复。
  • 新增文案零成本。 开发者直接写中文,不需要先去翻译表里注册一个 key。

但有几个坑必须注意:

第一,hash 必须基于原文,不能基于译文。 如果哪天原文改了一个字,hash 就变了,会变成一条新记录,旧的翻译就丢了。所以原文一旦上线,尽量不要改动;要改就当成新增一条。

第二,hash 冲突。 理论上不同原文可能算出同一个 hash,但 16 位十六进制(64 bit)在几千到几万条文案的规模下,冲突概率极低,实际项目可以忽略。如果实在不放心,可以用完整 SHA-256。

第三,非中文原文的团队不友好。 如果原文是中文,英文开发者打开代码看到 i18n.t('首页') 会一头雾水。这种情况可以两种方式混用:界面文字用可读 key,数据库里的业务文本用 hash 或直接用 text_id。

第四,无法表达带变量的文案。 “已保存 3 个文件”这种带参数的句子,hash 只能对模板算,参数要另外处理:

i18n.t("已保存 {count} 个文件", { count: 3 });

这样 hash 算的是 已保存 {count} 个文件 这个模板,而不是最终渲染出来的字符串。这一点任何 i18n 方案都一样,不是 hash 特有的问题。

实际项目中,比较务实的组合是:界面文字用可读 key(便于团队协作),数据库业务文本用 text_id(由业务外键关联)。 hash 方案更适合那种“开发者不想维护 key、文案量大、团队都用同一种母语”的场景。

不管用哪种定位方式,加载逻辑是一样的:

SELECT tc.text_key, t.translated_text
FROM TextContent AS tc
JOIN Translation AS t ON tc.text_id = t.text_id
WHERE t.language_id = 'ja'
  AND tc.text_key IS NOT NULL;

结果:

text_keytranslated_text
menu.homeホーム
button.save保存
message.save_success保存しました

应用启动时把当前语言的所有界面文字一次性加载到内存,运行时按 key 取值,不再查数据库。要加一门新语言,只需要往语言表插一行,往译文表补数据,代码一行不用改。

为什么推荐这个方案

加语言不改表结构。 只需要往语言表和译文表插数据,业务表、应用代码都不动。

所有文案集中管理。 想查“日文还有哪些没翻译”,只需要查译文表里 language_id = 'ja' 缺失的记录,不用满数据库找。

业务表结构干净。 业务表只关心业务关系,翻译是独立关注点,两边互不干扰。

业务数据和界面文字用同一套机制。 不需要为“表里的文字”和“界面上的文字”分别设计两套方案。

关于性能

有人会担心:每次查电影都要 JOIN 三张表,会不会慢?

实际项目中,翻译数据量不大(通常几千到几万条),而且变化很少,非常适合缓存。常见的做法是应用启动时把当前语言的翻译全量加载到内存,查询时直接查内存,数据库只用于管理和更新翻译。

即使不走缓存,这几张表都很小,JOIN 的成本也远低于业务表本身的查询成本。为了结构清晰而接受一点 JOIN 开销,是值得的。

总结:五种方案对比

方案加语言要改表吗有数据冗余吗能管界面文字吗推荐程度
一、加列要无不能不推荐
二、JSON/XML不要无不能不推荐
三、每语言一行不要严重不能不推荐
四、每表配翻译表不要无不能勉强可用
五、统一翻译中心不要可控能推荐

多语言数据库设计,看起来只是“存几种语言的文字”,实际考验的是你有没有把翻译当成一个独立的、贯穿整个应用的问题来处理。

方案一到四的共同毛病,是只盯着“某张表里的文字”,而没有意识到菜单、按钮、错误提示同样需要翻译,而且它们不属于任何一张业务表。只要这个认知不转变,无论选哪种方案,迟早都会遇到“界面文字没地方放”的尴尬。

方案五把翻译抽出来做成独立的子模式,一次性解决所有文本的多语言问题。它前期确实要多想一步、多建几张表,但这一步想清楚了,后面加语言、改文案、查翻译进度,都会轻松得多。

有时候,数据库设计里的“最短路径”会诱惑你相信它能省时省力。如果你无视多语言数据库设计的最佳实践,最后大概率会浪费更多不必要的资源,且把同一件事反复做了一遍又一遍。


也可以看看