免费

让 Claude 用 jsonb 给 Rails 做多语言 schema

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 怎么做对。

Schema:3 个 jsonb 列 + 约束的取舍

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

约束选择的理由

  • titlenull: false + default: {}。title 是必填——一篇文章至少要有一个语言的标题。即使只有 en,也要有;不允许整个 jsonb 为 NULL
  • summary / content:只 default: {},允许 NULL。这两个可以为空(比如刚创建时还没写内容),所以松绑。

default: {} 是必须的。如果省略,刚 Article.new 的时候 titlenil,随后 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 加。

Model 层:封装 locale fallback

光有 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 组合的好处。

查询 pattern:->> vs ->

Postgres jsonb 两个常用操作符:

  • title->>'zh'返回 text(字符串)
  • title->'zh'返回 jsonb(保持 json 类型)

几乎所有时候你都要 ->>——拿字符串出来用。-> 是当你要继续嵌套查询时才用(比如 content->'metadata'->>'author')。

有某个 locale 翻译的文章

Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'

? 是 jsonb 的"key 存在"操作符。返回 title 这个 hash 里zh 这个 key 的文章。

按特定语言的标题排序

Article.order("title->>'en' ASC")

按 locale 过滤 + 排序

Article.where("title ? :loc", loc: "ja")
       .order("title->>'ja' ASC")

Claude 默认会写错的查询

# 错 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 不自动索引。三种常见需求分别对应三种索引:

1. 存在查询 / @> 包含查询 → GIN index

add_index :articles, :title, using: :gin

能加速 title ? 'zh'title @> '{"zh": ...}' 这类查询。GIN 索引写入慢、占空间,但对 jsonb 的"key/value 存在性"查询必需。

2. 按特定 locale 排序 / 精确过滤 → expression index

add_index :articles, "(title->>'en')", name: "idx_articles_title_en"

只给 title->>'en' = ?ORDER BY title->>'en' 加速。如果你只按英文排序(比如英文站做字母序列表),加一个英文的 expression index 就够。

3. 没有高频查询 → 不加

jsonb 是昂贵的索引对象。如果文章总量不大(几千篇以内)而且查询都是按 id/slug,别给 jsonb 预加索引。让它长大到有明确查询需求再加。

how2claude 现在没给 title/summary/content 加 jsonb 索引——总量还小、查询都是按 slug 取单篇,加了纯占空间。等增长到某个点(比如 1 万篇 + 批量查询出现频繁),再加。

Claude 默认走错的 4 个方向

让 Claude 做这类 schema 设计时它的第一反应经常错。拦下来:

1. 把 jsonb 当"我不知道 schema 就这么塞"的逃生口

"这个字段不确定要存什么,我给个 jsonb..."

拦下来:jsonb 适用的是结构稳定但 key 集合开放或稀疏宽的场景——多语言、feature flags、用户偏好。不是"我懒得建表"的借口。一旦你有清晰的 key 集合(比如固定 5 个字段),用正常列。

2. default: "{}" 当字符串

拦下来:Rails 会把这个存成 Postgres 字符串 "{}",不是 empty json。迁移完跑 article.title["zh"] 会炸(字符串不能这么取)。必须写 default: {}(Ruby 哈希字面值)。

3. 所有 jsonb 列都 null: false

拦下来:先问哪个是必填。title 必填(文章没标题不成立)→ null: false。summary 和 content 可以为空(草稿期)→ 只有 default:{},不加 null:false。约束分级,不是一刀切

4. 应用层做 jsonb 过滤

"我把所有 Article 取出来,在 Ruby 里 .select { |a| a.title["zh"] }..."

拦下来:jsonb 的重点就是 SQL 层原生支持。凡是过滤、存在判断、排序都让 Postgres 做,用 ->> / ? / @>。拉回 Ruby 一旦表上千行性能就跪。

清单

让 Claude 用 jsonb 给 Rails 做多语言 schema 的 6 条:

  1. 先排除 19 表 / 19 列方案。除非确认 locale 之间展示逻辑根本不同,否则 jsonb 是单选题。
  2. 3 个 jsonb 列 + 分级约束:title null: false, default: {};summary / content 只 default: {}
  3. default: {} 是哈希字面值,不是字符串。字符串 "{}" 会导致类型错配。
  4. Model 提供带 fallback 的访问器current → en → .values.first。视图层永远不会拿到 nil。
  5. 查询在 SQL 层title ? 'zh'(存在)、title->>'zh'(取值/排序),不要拉回 Ruby 过滤。
  6. 索引等有查询需求再加。先上 GIN 是过早优化;总量小时按 slug/id 取单篇不需要任何 jsonb 索引。

真正的决定不是"用不用 jsonb"——在 19 种语言的场景下这个答案显然。真正的决定是哪些字段必填 vs 可空、model 层 fallback 链怎么走、什么时候从"先不加索引"切换到"加 expression index"。这些都是产品和数据量的判断,Claude 会给你代码但不会替你做这些判断。