Rails 多语言 schema:jsonb 赢 19 表/列、约束分级、->> 查询、索引时机。
how2claude 每篇文章同时发 19 种语言(zh / en / ja / ko / ar / ...),数据层怎么存这件事得先决定清楚。如果你让 Claude 设计这张表,它大概率会给你写 19 个 string 列,或者更离谱,建 19 张表——"每种语言一个 articles 表"。两个都是坑。
正解是 1 张表 + 3 个 jsonb 列,每个 jsonb 里以 locale 为 key 存对应语言的值。本文讲为什么这个方案赢、怎么写 schema、怎么查、怎么建索引、Claude 默认会走错的 4 个方向。
先把候选方案都写出来:
方案 A:19 张表(articles_zh, articles_en, ...)
- 查单篇要 join 19 次 / 或做 19 次查询
- 加第 20 种语言 = CREATE TABLE
- 删一个 Article → 级联 19 张表
- 优点:单表查询快
- 现实:除非你每种语言的展示逻辑完全不同(几乎没有这种场景),否则这是过度分表
方案 B:19 个 string 列(title_zh, title_en, ..., summary_zh, summary_en, ...)
- 表变巨宽(3 种内容 × 19 种语言 = 57 列)
- 大部分单元是 NULL(泰语版很可能最后写、或者永远为空)
- 加第 20 种语言 = ALTER TABLE ADD COLUMN × 3
- 行内字段稀疏,IO 效率低
方案 C:3 个 jsonb 列(title jsonb, summary jsonb, content jsonb)
- 每行一条记录,locale 作为 jsonb 内部的 key
- 加语言不动 schema(直接插新 key)
- Postgres 一等公民,支持原子查询、索引、部分抓取
- 查询语法稍绕(title->>'zh'),但可以封装
选 C。下面只讲 C 怎么做对。
how2claude 的实际 articles 表(节选):
create_table :articles do |t|
t.jsonb :title, default: {}, null: false
t.jsonb :summary, default: {}
t.jsonb :content, default: {}
t.string :slug, null: false
t.boolean :free, default: false, null: false
# ... 其他 bigint、timestamp 等
end
约束选择的理由:
title:null: false + default: {}。title 是必填——一篇文章至少要有一个语言的标题。即使只有 en,也要有;不允许整个 jsonb 为 NULL。summary / content:只 default: {},允许 NULL。这两个可以为空(比如刚创建时还没写内容),所以松绑。default: {} 是必须的。如果省略,刚 Article.new 的时候 title 是 nil,随后 article.title["en"] 就炸 NoMethodError: undefined method '[]' for nil:NilClass。default 设为空 hash 让所有访问都安全。
让 Claude 写这个 migration 时它经常:
- 省略 default: {} → 上面的 nil 炸
- 把 null: false 全加上 → summary 即使是空内容也不能为 NULL,新建一篇先有 title 再填内容的 flow 就卡住了
- 写 default: "{}"(字符串字面值)→ Postgres 会存成字符串 {} 而不是空 json,查询时类型对不上
规律:jsonb 列的 default 必须是 {} 这个 Ruby 哈希字面值,不是字符串。null: false 只给必填的那个 jsonb 加。
光有 schema 不够,model 要提供带 fallback 的访问器:
class Article < ApplicationRecord
validates :title, presence: true
def title_for(locale = I18n.locale)
title[locale.to_s] || title["en"] || title.values.first
end
def summary_for(locale = I18n.locale)
summary[locale.to_s] || summary["en"]
end
def content_for(locale = I18n.locale)
content[locale.to_s] || content["en"]
end
end
三级 fallback:当前 locale → en → 随便一个。中文用户看日语文章时,日语没有就掉回英文;英文也没有就掉回"至少有的那种",反正不 nil。
validates :title, presence: true 会自动处理 {}——ActiveRecord 把空 hash 视为 blank,所以空 title 过不了校验。这正是 null: false + presence 组合的好处。
->> vs ->Postgres jsonb 两个常用操作符:
title->>'zh' → 返回 text(字符串)title->'zh' → 返回 jsonb(保持 json 类型)几乎所有时候你都要 ->>——拿字符串出来用。-> 是当你要继续嵌套查询时才用(比如 content->'metadata'->>'author')。
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? 是 jsonb 的"key 存在"操作符。返回 title 这个 hash 里有 zh 这个 key 的文章。
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# 错 1:整个 jsonb 当字符串比
Article.where(title: '{"zh": "..."}')
# 错 2:Ruby 层过滤
Article.all.select { |a| a.title["zh"].present? } # 把全表拉到内存
# 错 3:用 = 比较 jsonb 字段
Article.where("title->>'zh' = ?", "某标题") # 这个其实对,但 Claude 经常和错 1 混
规律:凡是想"过滤 / 排序 / 存在"的,都在 SQL 层用 ->> 或 ? 操作符;只有取值给用户看才在 Ruby 层 article.title["zh"]。
jsonb 不自动索引。三种常见需求分别对应三种索引:
add_index :articles, :title, using: :gin
能加速 title ? 'zh'、title @> '{"zh": ...}' 这类查询。GIN 索引写入慢、占空间,但对 jsonb 的"key/value 存在性"查询必需。
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
只给 title->>'en' = ? 或 ORDER BY title->>'en' 加速。如果你只按英文排序(比如英文站做字母序列表),加一个英文的 expression index 就够。
jsonb 是昂贵的索引对象。如果文章总量不大(几千篇以内)而且查询都是按 id/slug,别给 jsonb 预加索引。让它长大到有明确查询需求再加。
how2claude 现在没给 title/summary/content 加 jsonb 索引——总量还小、查询都是按 slug 取单篇,加了纯占空间。等增长到某个点(比如 1 万篇 + 批量查询出现频繁),再加。
让 Claude 做这类 schema 设计时它的第一反应经常错。拦下来:
"这个字段不确定要存什么,我给个 jsonb..."
拦下来:jsonb 适用的是结构稳定但 key 集合开放或稀疏宽的场景——多语言、feature flags、用户偏好。不是"我懒得建表"的借口。一旦你有清晰的 key 集合(比如固定 5 个字段),用正常列。
default: "{}" 当字符串拦下来:Rails 会把这个存成 Postgres 字符串 "{}",不是 empty json。迁移完跑 article.title["zh"] 会炸(字符串不能这么取)。必须写 default: {}(Ruby 哈希字面值)。
null: false拦下来:先问哪个是必填。title 必填(文章没标题不成立)→ null: false。summary 和 content 可以为空(草稿期)→ 只有 default:{},不加 null:false。约束分级,不是一刀切。
"我把所有 Article 取出来,在 Ruby 里
.select { |a| a.title["zh"] }..."
拦下来:jsonb 的重点就是 SQL 层原生支持。凡是过滤、存在判断、排序都让 Postgres 做,用 ->> / ? / @>。拉回 Ruby 一旦表上千行性能就跪。
让 Claude 用 jsonb 给 Rails 做多语言 schema 的 6 条:
null: false, default: {};summary / content 只 default: {}。default: {} 是哈希字面值,不是字符串。字符串 "{}" 会导致类型错配。current → en → .values.first。视图层永远不会拿到 nil。title ? 'zh'(存在)、title->>'zh'(取值/排序),不要拉回 Ruby 过滤。真正的决定不是"用不用 jsonb"——在 19 种语言的场景下这个答案显然。真正的决定是哪些字段必填 vs 可空、model 层 fallback 链怎么走、什么时候从"先不加索引"切换到"加 expression index"。这些都是产品和数据量的判断,Claude 会给你代码但不会替你做这些判断。