Schema đa ngôn ngữ Rails: jsonb thắng 19 bảng/cột, ràng buộc phân tầng, truy vấn ->>, khi nào đánh index.
how2claude xuất bản mỗi bài viết bằng 19 ngôn ngữ (zh / en / ja / ko / ar / ...), và cách lưu điều đó ở tầng dữ liệu phải quyết định trước. Bảo Claude thiết kế bảng này thì thường nó sẽ cho bạn 19 cột string, hoặc tệ hơn, 19 bảng — "một bảng articles cho mỗi ngôn ngữ". Cả hai đều là bẫy.
Đáp án đúng là 1 bảng + 3 cột jsonb, mỗi jsonb dùng locale làm key. Bài này nói vì sao cách này thắng, viết schema thế nào, query ra sao, khi nào đánh index, và 4 hướng Claude mặc định sai.
Bày hết các ứng viên ra:
Lựa chọn A: 19 bảng (articles_zh, articles_en, ...)
- Lấy một bài viết cần 19 lần join / 19 câu truy vấn
- Thêm ngôn ngữ thứ 20 = CREATE TABLE
- Xoá một Article → cascade qua 19 bảng
- Ưu: query bảng đơn nhanh
- Thực tế: trừ khi logic hiển thị thật sự khác theo ngôn ngữ (gần như không), đây là phân bảng quá mức
Lựa chọn B: 19 cột string (title_zh, title_en, ..., summary_zh, summary_en, ...)
- Bảng trở nên khổng lồ (3 loại nội dung × 19 ngôn ngữ = 57 cột)
- Phần lớn ô là NULL (bản dịch tiếng Thái thường đến cuối cùng hoặc không bao giờ)
- Thêm ngôn ngữ thứ 20 = ALTER TABLE ADD COLUMN × 3
- Bố cục hàng thưa, hiệu suất IO kém
Lựa chọn C: 3 cột jsonb (title jsonb, summary jsonb, content jsonb)
- Một hàng cho mỗi bài, locale là key bên trong jsonb
- Thêm ngôn ngữ không đụng schema (chỉ chèn key)
- Công dân hạng nhất trong Postgres — truy vấn nguyên tử, index, lấy từng phần
- Cú pháp truy vấn hơi ồn (title->>'zh') nhưng có thể bọc lại
Chọn C. Phần còn lại bài này nói cách làm C đúng.
Bảng articles thật của how2claude (trích):
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 khác, v.v.
end
Lý do các ràng buộc khác nhau:
title: null: false + default: {}. Tiêu đề là bắt buộc — một bài phải có tiêu đề ít nhất một ngôn ngữ. Dù chỉ en cũng được. Toàn bộ jsonb không được NULL.summary / content: chỉ default: {}, cho phép NULL. Hai cái này có thể trống (ví dụ ngay sau khi tạo, chưa viết nội dung), nên nới.default: {} là bắt buộc. Bỏ ra thì Article.new vừa tạo có title = nil, ngay sau đó article.title["en"] nổ: NoMethodError: undefined method '[]' for nil:NilClass. Mặc định là hash rỗng giúp mọi truy cập an toàn.
Khi Claude viết migration này, nó hay:
- Bỏ default: {} → nổ nil như trên
- Thêm null: false cho mọi jsonb → summary không thể NULL trên bản ghi mới đang được điền, phá luồng "tiêu đề trước, nội dung sau"
- Viết default: "{}" (chuỗi) → Postgres lưu chuỗi "{}" chứ không phải json rỗng, lệch kiểu khi truy vấn
Quy tắc: default cột jsonb phải là {} (literal hash Ruby), không phải chuỗi. null: false chỉ cho jsonb bắt buộc.
Chỉ schema không đủ — model cần accessor có 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 ba bước: locale hiện tại → en → bất cứ thứ gì có. Người đọc tiếng Trung vào bài tiếng Nhật, không có Nhật thì rơi về Anh; không có Anh thì rơi về "cái gì có", không bao giờ nil.
validates :title, presence: true tự xử lý {} — ActiveRecord coi hash rỗng là blank, nên title rỗng không qua validation. Đó là lợi ích của cặp null: false + presence.
->> vs ->Hai toán tử jsonb Postgres dùng hàng ngày:
title->>'zh' → trả về text (chuỗi)title->'zh' → trả về jsonb (giữ kiểu json)99% thời gian bạn muốn ->> — lấy chuỗi ra dùng. Dùng -> chỉ khi lồng tiếp (ví dụ content->'metadata'->>'author').
Article.where("title ? :loc", loc: "zh")
# SELECT * FROM articles WHERE title ? 'zh'
? là toán tử "key tồn tại" của jsonb. Trả về bài mà hash title có key zh.
Article.order("title->>'en' ASC")
Article.where("title ? :loc", loc: "ja")
.order("title->>'ja' ASC")
# Sai 1: so sánh toàn jsonb như chuỗi
Article.where(title: '{"zh": "..."}')
# Sai 2: lọc trong Ruby
Article.all.select { |a| a.title["zh"].present? } # kéo toàn bảng vào bộ nhớ
# Sai 3: dùng = không có toán tử
Article.where("title->>'zh' = ?", "tiêu đề nào đó") # cái này đúng, nhưng Claude trộn với Sai 1
Quy tắc: mọi lọc / sắp xếp / kiểm tra tồn tại xảy ra ở SQL qua ->> hoặc ?; chỉ dùng Ruby article.title["zh"] khi render cho người dùng.
jsonb không tự index. Ba nhu cầu thường gặp ánh xạ sang ba loại index:
add_index :articles, :title, using: :gin
Tăng tốc title ? 'zh', title @> '{"zh": ...}', v.v. GIN nặng ghi và tốn dung lượng, nhưng bắt buộc cho truy vấn tồn tại key/value jsonb.
add_index :articles, "(title->>'en')", name: "idx_articles_title_en"
Chỉ tăng tốc title->>'en' = ? hoặc ORDER BY title->>'en'. Nếu chỉ sắp xếp theo tiêu đề tiếng Anh (ví dụ site tiếng Anh liệt kê theo chữ cái), một index biểu thức tiếng Anh là đủ.
Index jsonb là đối tượng đắt. Nếu tổng số bài nhỏ (hàng nghìn) và mọi truy vấn theo id/slug, đừng preindex jsonb. Chờ tới khi có nhu cầu truy vấn rõ.
how2claude hiện không có index jsonb trên title/summary/content — khối lượng thấp, truy vấn theo slug, index chỉ tốn dung lượng. Thêm khi đạt một mức (ví dụ 10k bài + truy vấn hàng loạt thường xuyên).
Làm loại thiết kế schema này, phản xạ đầu của Claude thường sai. Chặn từng hướng:
"Tôi không chắc trường này chứa gì, để jsonb thôi..."
Chuyển hướng: jsonb phù hợp với cấu trúc ổn định nhưng tập key mở hoặc thưa rộng — đa ngôn ngữ, feature flags, sở thích người dùng. Không phải cớ để bỏ qua thiết kế schema. Nếu đã có tập key rõ (ví dụ 5 trường cố định), dùng cột thường.
default: "{}" dưới dạng chuỗiChuyển hướng: Rails sẽ lưu đây là chuỗi literal "{}" trong Postgres, không phải json rỗng. Sau migration, article.title["zh"] nổ (chuỗi không subscript như vậy). Phải là default: {} (literal hash Ruby).
null: falseChuyển hướng: Hỏi trước cái nào thật sự bắt buộc. Title bắt buộc (bài không tiêu đề vô lý) → null: false. summary và content có thể rỗng (giai đoạn draft) → chỉ default: {}, không null: false. Phân tầng ràng buộc; đừng rải đều.
"Tôi
Article.allrồi.select { |a| a.title["zh"] }..."
Chuyển hướng: Điểm của jsonb là hỗ trợ SQL gốc. Mọi lọc / tồn tại / sắp xếp sống trong Postgres qua ->> / ? / @>. Kéo về Ruby thì khi bảng chạm nghìn hàng, bạn chết.
Để Claude thiết kế schema đa ngôn ngữ Rails với jsonb — 6 quy tắc:
null: false, default: {}; summary / content chỉ default: {}.default: {} là literal hash, không phải chuỗi. Chuỗi "{}" gây lệch kiểu.current → en → .values.first. View không bao giờ thấy nil.title ? 'zh' (tồn tại), title->>'zh' (giá trị/sắp xếp). Đừng kéo về Ruby để lọc.Quyết định thật không phải "dùng jsonb hay không" — với 19 ngôn ngữ đáp án hiển nhiên. Quyết định thật là trường nào bắt buộc vs tuỳ chọn, chuỗi fallback của model đi thế nào, khi nào chuyển từ "chưa index" sang "thêm index biểu thức". Đó là phán đoán sản phẩm và dữ liệu. Claude cho bạn mã nhưng không ra phán đoán thay bạn.