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 會給你程式碼但不會替你做這些判斷。