统一用通用 translations 表替代每张业务表的独立翻译表是更可持续的选择,但必须配合 translations_config 元配置、强复合索引、视图封装、fallback 优先级控制及缓存同步机制,否则易沦为脏数据黑洞。

用通用 translations 表替代每张业务表配一个 *_translations 表
当 CMS 有 pages、categories、products 等 20+ 主表需支持多语言时,为每张表单独建翻译表(如 pages_translations)会导致 DDL 膨胀、ORM 映射重复、迁移脚本难维护。此时统一用一张 translations 表承载全部翻译,是更可持续的选择。
关键不是“能不能”,而是“怎么让泛化表不变成脏数据黑洞”——必须配合元配置和强索引:
-
parent_table字段值必须小写蛇形(如pages),禁止动态拼接或大小写混用,否则 ORM 和 SQL 查询会因字符串匹配失效 -
lang推荐用CHAR(2)存 ISO 639-1(如en、zh),而非zh-CN;后者虽精确但增加索引碎片,且多数 CMS 后台按语种粒度管理翻译 - 务必建立复合索引
INDEX idx_parent_lang_key (parent_table, parent_id, lang, key),漏掉这个,JOIN 查询在百万级翻译记录下会直接变全表扫描
translations_config 表必须存在,且要驱动校验逻辑
没有 translations_config 的泛化表只是语法糖,实际等于放弃数据库层约束。这张表不是可选配件,而是翻译契约的强制声明者:
例如 products 表要求 name 和 description 必填多语言,而 meta_title 可选,那么配置应为:
{"required_translations": ["name", "description"], "optional_translations": ["meta_title"]}
后台录入或 API 提交翻译时,必须校验 key 是否在对应表的 required_translations 列表中;缺失必填项应拒绝写入,而不是留空或默认 fallback —— 否则前端展示时要么报错,要么静默降级,问题难以追溯。
- 不要把校验逻辑只放在应用层:DBA 或运维直连数据库插入数据时,绕过应用层校验,
translations_config本身无法阻止非法key,所以还需配合触发器或应用层强守门 -
table字段必须设UNIQUE,避免同一主表出现多条配置导致行为不一致 - 如果 CMS 支持自定义字段(如插件扩展的
custom_field_1),translations_config应允许运行时更新 JSON,而不是每次加字段都改表结构
查询时 JOIN 的写法直接影响性能与可读性
泛化表最大的认知负担来自 SQL —— 不能写 JOIN translations ON t.id = tr.parent_id AND tr.parent_table = 'pages' 这类硬编码字符串条件,它既难复用,又无法被 MySQL 查询优化器有效利用。
正确做法是封装为视图或预编译语句,例如为 pages 构建专用查询视图:
CREATE VIEW pages_with_i18n AS
SELECT p.*,
tn.value AS name_zh,
te.value AS name_en,
td.value AS description_en
FROM pages p
LEFT JOIN translations tn ON tn.parent_table = 'pages' AND tn.parent_id = p.id AND tn.lang = 'zh' AND tn.key = 'name'
LEFT JOIN translations te ON te.parent_table = 'pages' AND te.parent_id = p.id AND te.lang = 'en' AND te.key = 'name'
LEFT JOIN translations td ON td.parent_table = 'pages' AND td.parent_id = p.id AND td.lang = 'en' AND td.key = 'description';
这样前端或报表只需查 pages_with_i18n,不用每次拼字符串;同时每个 JOIN 条件都命中 idx_parent_lang_key 索引前缀。
- 避免在 WHERE 子句里用
translations.parent_table = ?做参数化过滤:MySQL 对字符串列的等值判断选择率差,容易跳过索引 - 如果某次查询只需要查「所有中文页面标题」,别写
WHERE parent_table = 'pages' AND lang = 'zh' AND key = 'name',而应走idx_lang_key索引,再反向关联主表 - ORM 如 Laravel Eloquent 或 Django ORM 不原生支持这种泛化 JOIN,需手写 raw query 或用 view 映射,别强行套用 hasMany 关系
is_default 字段不是摆设,它决定 fallback 行为是否可控
is_default 的作用不是标记“主语言”,而是明确告诉系统:当请求 es 但无对应翻译时,该回退到哪一版。CMS 内容编辑者可能把 zh 设为默认,也可能把 en 设为默认,这取决于运营策略,而非技术约定。
真正容易出问题的是 fallback 链路没对齐:
- 数据库里
is_default = 1的记录,必须保证lang值在所有required_translations字段上完整存在;否则 fallback 时仍会缺字段 - 应用层做 fallback 查询时,不能只查一次
is_default = 1,而应按优先级列表逐个尝试(如['es', 'en', 'zh']),直到命中有值的value——is_default只是兜底的最后一环 - 千万别用
COALESCE(t1.value, t2.value, t3.value)在 SQL 层硬写 fallback 顺序:字段缺失时返回 NULL,COALESCE 会跳过,但语义上你其实需要的是「第一个非空 value」,得靠应用层控制
最常被忽略的一点:is_default 的变更必须同步触发缓存失效。CMS 后台切换默认语言后,如果旧翻译还卡在 Redis 里,前端看到的就是错乱内容,且难以排查。











