Skip to content

Đa ngôn ngữ ​

Tên sản phẩm, mô tả, danh mục cần hiện theo ngôn ngữ của người dùng. Trang này so sánh ba cách lưu bản dịch, cách lấy bản dịch khi thiếu ngôn ngữ, và chuyện sắp xếp, tìm kiếm theo từng ngôn ngữ, nhất là với tiếng Việt.

Trang này chỉ nói về nội dung do người dùng hay nhân viên nhập (tên sản phẩm, bài viết). Chữ cố định trên giao diện như nút bấm, thông báo lỗi thì để trong file dịch của frontend hay backend, không cần nằm trong database.

Ba cách lưu ​

CáchVí dụHợp khiNhược điểm
Bảng dịch riêngproduct_translations(product_id, locale, name, description), khoá chính (product_id, locale)Số ngôn ngữ thay đổi, nhiều trường cần dịch, cần biết bản nào còn thiếuMọi query phải JOIN thêm bảng dịch
Cột JSON theo localename = {"vi": "Áo thun", "en": "T-shirt"}Ít trường cần dịch, muốn đọc một dòng là đủIndex và ràng buộc theo từng ngôn ngữ phải làm riêng
Mỗi ngôn ngữ một cộtname_vi, name_enSố ngôn ngữ cố định, ít, gần như không đổiThêm ngôn ngữ là thêm cột cho mọi bảng

Bảng dịch riêng ​

Đây là cách tổng quát nhất. Bảng chính giữ phần không phụ thuộc ngôn ngữ (mã, giá, tồn kho), còn bảng dịch giữ phần cần dịch:

sql
CREATE TABLE products (id INT PRIMARY KEY, sku VARCHAR(20) NOT NULL, price INT NOT NULL);
CREATE TABLE product_translations (
  product_id INT NOT NULL,
  locale     VARCHAR(10) NOT NULL,          -- 'vi', 'en', 'ja' hoặc 'en-US'
  name       VARCHAR(200) NOT NULL,
  PRIMARY KEY (product_id, locale),
  FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE
);

Khoá chính (product_id, locale) vừa chống trùng bản dịch, vừa là index cho việc lấy bản dịch của một sản phẩm. Thêm ngôn ngữ mới không phải sửa schema. Tìm sản phẩm còn thiếu bản dịch cũng dễ, chỉ cần NOT EXISTS trên bảng dịch.

Cột JSON ​

Gọn hơn, nhưng database không biết bên trong JSON có gì, nên muốn index hay ràng buộc theo một ngôn ngữ thì phải làm riêng (EAV, JSON & thuộc tính động):

  • MySQL: tạo cột sinh ra (generated column) từ JSON rồi index cột đó, ví dụ name_vi VARCHAR(200) AS (name ->> '$.vi') STORED cộng index trên name_vi.
  • PostgreSQL: index theo biểu thức CREATE INDEX ON products ((name ->> 'vi')), và CHECK (name ? 'vi') để bắt buộc mỗi sản phẩm có tên tiếng Việt.

Không có ràng buộc như vậy thì database nhận cả sản phẩm thiếu tên ở ngôn ngữ mặc định, và lỗi chỉ lộ ra trên giao diện.

Ngôn ngữ dự phòng ​

Bản dịch hiếm khi đầy đủ: sản phẩm mới thêm thường chỉ có tiếng Việt. Khi thiếu bản theo ngôn ngữ của người dùng thì hiện bản ở ngôn ngữ mặc định, thay vì để trống:

sql
SELECT p.id, p.sku,
       COALESCE(t.name, d.name)     AS name,
       COALESCE(t.locale, d.locale) AS shown_locale   -- để giao diện biết đang hiện bản dự phòng
FROM products p
LEFT JOIN product_translations t ON t.product_id = p.id AND t.locale = 'en'   -- ngôn ngữ người dùng
LEFT JOIN product_translations d ON d.product_id = p.id AND d.locale = 'vi'   -- ngôn ngữ mặc định

Có chuỗi dự phòng dài hơn (ví dụ en-US → en → vi) thì thêm một JOIN cho mỗi bậc, hoặc lấy mọi bản dịch của sản phẩm rồi chọn trong code. Với cột JSON thì là COALESCE(name ->> 'en', name ->> 'vi').

Sắp xếp và so sánh theo ngôn ngữ ​

Mỗi ngôn ngữ có quy tắc sắp xếp riêng. Tiếng Việt coi ă, â, đ, ê, ô, ơ, ư là những chữ riêng trong bảng chữ cái, xếp sau chữ gốc: D trước Đ, O trước Ô trước Ơ. Database sắp theo collation của cột, và collation mặc định thường không theo quy tắc này:

CollationThứ tự của một danh sách tỉnh thành
MySQL utf8mb4_0900_ai_ci (mặc định)Cần Thơ < Châu Đốc < Đà Nẵng < Dĩ An < Đồng Hới < Duy Tiên < Ô Môn < Óc Eo < Ơn
MySQL utf8mb4_vi_0900_ai_ciCần Thơ < Châu Đốc < Dĩ An < Duy Tiên < Đà Nẵng < Đồng Hới < Óc Eo < Ô Môn < Ơn
PostgreSQL vi-x-icuGiống utf8mb4_vi_0900_ai_ci
PostgreSQL trên image Alpine (en_US.utf8)Sắp theo byte: Châu Đốc < Cần Thơ < … < Đà Nẵng < Đồng Hới < Ơn

Collation mặc định của MySQL coi Đ như D nên trộn hai nhóm vào nhau. PostgreSQL trên image Alpine dùng thư viện C musl, vốn không có quy tắc sắp xếp theo ngôn ngữ, nên kết quả là thứ tự byte. Collation ICU của PostgreSQL (vi-x-icu) và utf8mb4_vi_0900_ai_ci của MySQL thì sắp đúng.

Collation cũng quyết định hai chuỗi có bằng nhau không, tức ảnh hưởng tới WHERE, UNIQUE và tìm kiếm:

  • utf8mb4_0900_ai_ci: 'Đà Nẵng' = 'Da Nang' là đúng. Tiện cho tìm không dấu, nhưng UNIQUE trên cột này cũng coi hai tên đó là trùng.
  • utf8mb4_vi_0900_ai_ci: 'Đà Nẵng' = 'Đa Năng' (chỉ bỏ thanh điệu) là đúng, nhưng 'Đà Nẵng' = 'Da Nang' là sai, vì ă và đ là chữ khác.

Vì vậy thường chọn collation theo việc chính của cột, và chỉ định collation khác ngay trong query khi cần: ORDER BY name COLLATE utf8mb4_vi_0900_ai_ci để sắp theo tiếng Việt, hay so sánh bằng collation ai_ci để tìm không dấu. Với bảng dịch, mỗi locale có thể cần collation khác nhau khi sắp xếp, nên chỉ định trong query hợp hơn là cố định trên cột. Tìm kiếm theo từng ngôn ngữ, kể cả không dấu, ở trang Full-text search.

Ghi nhớ

  1. Bảng dịch riêng với khoá (product_id, locale) là cách tổng quát nhất; cột JSON gọn hơn nhưng phải tự làm index và ràng buộc theo từng ngôn ngữ.
  2. Luôn có ngôn ngữ dự phòng: COALESCE bản theo ngôn ngữ người dùng với bản mặc định, và trả kèm locale thật sự được hiện.
  3. Collation quyết định cả thứ tự sắp xếp lẫn hai chuỗi có bằng nhau không. Collation mặc định của MySQL trộn Đ với D; PostgreSQL trên Alpine sắp theo byte. Dùng utf8mb4_vi_0900_ai_ci hoặc ICU vi-x-icu khi cần sắp theo tiếng Việt.

Nguồn tham khảo ​

Kiểm bằng lab, chưa đối chiếu tài liệu lần này: thứ tự sắp xếp và kết quả so sánh của các collation trong bảng trên; CHECK (name ? 'vi') của PostgreSQL và cột sinh ra từ JSON của MySQL. Từ kiến thức chung: musl không có quy tắc sắp xếp theo ngôn ngữ (giải thích cho kết quả trên image Alpine); thứ tự chữ cái tiếng Việt.

Số đo chi tiết ​

Chạy ngày 2026-10-23 trên MySQL 8.0 và PostgreSQL 17 (postgres:17-alpine, database có datcollate = en_US.utf8, provider libc) trong Docker.

Kết quả chạy thử
  • Lấy tên theo en, thiếu thì dùng vi: sản phẩm 1, 2 hiện bản tiếng Anh, sản phẩm 3 hiện "Túi vải" với shown_locale = vi, trên cả hai database.
  • MySQL: EXPLAIN tìm theo name_vi (cột sinh ra từ JSON) dùng index idx_name_vi. Không có CHECK thì chèn được sản phẩm chỉ có {"en": "Pen"}, và name_vi là NULL.
  • PostgreSQL: CHECK (name ? 'vi') chặn sản phẩm thiếu tên tiếng Việt với lỗi violates check constraint.
  • MySQL: 'Đà Nẵng' = 'Da Nang' đúng với utf8mb4_0900_ai_ci, sai với utf8mb4_vi_0900_ai_ci; 'Đà Nẵng' = 'Đa Năng' đúng với utf8mb4_vi_0900_ai_ci.

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- da-ngon-ngu sau npm run lab:up. Xem Lab.

lab/da-ngon-ngu/mysql.sql
sql
-- Trang database/da-ngon-ngu, MySQL 8.0. Chạy bằng: node common/run-sql.mjs mysql i18n da-ngon-ngu/mysql.sql
DROP TABLE IF EXISTS product_translations, products, products_json;

SELECT '--- 1. Bảng dịch riêng, khoá chính (product_id, locale)' AS '';
CREATE TABLE products (id INT PRIMARY KEY, sku VARCHAR(20) NOT NULL, price INT NOT NULL);
CREATE TABLE product_translations (
  product_id INT NOT NULL,
  locale     VARCHAR(10) NOT NULL,
  name       VARCHAR(200) NOT NULL,
  PRIMARY KEY (product_id, locale),
  FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE
);
INSERT INTO products VALUES (1, 'TEE-01', 199000), (2, 'MUG-02', 89000), (3, 'BAG-03', 350000);
INSERT INTO product_translations VALUES
  (1, 'vi', 'Áo thun cotton'), (1, 'en', 'Cotton T-shirt'), (1, 'ja', 'コットンTシャツ'),
  (2, 'vi', 'Cốc sứ'),         (2, 'en', 'Ceramic mug'),
  (3, 'vi', 'Túi vải');        -- sản phẩm 3 chưa có bản tiếng Anh

SELECT '--- Lấy tên theo ngôn ngữ người dùng (en), thiếu thì dùng ngôn ngữ mặc định (vi)' AS '';
SELECT p.id, p.sku, COALESCE(t.name, d.name) AS name, COALESCE(t.locale, d.locale) AS shown_locale
FROM products p
LEFT JOIN product_translations t ON t.product_id = p.id AND t.locale = 'en'
LEFT JOIN product_translations d ON d.product_id = p.id AND d.locale = 'vi'
ORDER BY p.id;

SELECT '--- Sản phẩm còn thiếu bản dịch tiếng Anh' AS '';
SELECT p.id, p.sku FROM products p
WHERE NOT EXISTS (SELECT 1 FROM product_translations t WHERE t.product_id = p.id AND t.locale = 'en');

SELECT '--- 2. Cột JSON theo locale, index theo tên tiếng Việt bằng cột sinh ra (generated column)' AS '';
CREATE TABLE products_json (
  id INT PRIMARY KEY,
  name JSON NOT NULL,
  name_vi VARCHAR(200) AS (name ->> '$.vi') STORED,
  INDEX idx_name_vi (name_vi)
);
INSERT INTO products_json (id, name) VALUES
  (1, '{"vi": "Áo thun cotton", "en": "Cotton T-shirt"}'),
  (2, '{"vi": "Cốc sứ", "en": "Ceramic mug"}'),
  (3, '{"vi": "Túi vải"}');
SELECT id, COALESCE(name ->> '$.en', name ->> '$.vi') AS name FROM products_json ORDER BY id;
EXPLAIN SELECT id FROM products_json WHERE name_vi = 'Cốc sứ';
SELECT '--- Không ràng buộc được mỗi sản phẩm phải có tên tiếng Việt, trừ khi thêm CHECK' AS '';
INSERT INTO products_json (id, name) VALUES (4, '{"en": "Pen"}');
SELECT id, name_vi FROM products_json WHERE id = 4;

SELECT '--- 3. Sắp xếp tên tiếng Việt: collation mặc định so với collation tiếng Việt' AS '';
DROP TABLE IF EXISTS cities;
CREATE TABLE cities (name VARCHAR(50)) DEFAULT CHARSET = utf8mb4 COLLATE utf8mb4_0900_ai_ci;
INSERT INTO cities VALUES ('Đà Nẵng'), ('Dĩ An'), ('Đồng Hới'), ('Duy Tiên'), ('Cần Thơ'), ('Châu Đốc'), ('Ơn'), ('Ô Môn'), ('Óc Eo');
SELECT GROUP_CONCAT(name ORDER BY name SEPARATOR ' < ') AS mac_dinh_0900_ai_ci FROM cities;
SELECT GROUP_CONCAT(name ORDER BY name COLLATE utf8mb4_vi_0900_ai_ci SEPARATOR ' < ') AS tieng_viet_vi_0900_ai_ci FROM cities;
SELECT 'Đà Nẵng' = 'Da Nang' COLLATE utf8mb4_0900_ai_ci AS bang_nhau_0900_ai_ci,
       'Đà Nẵng' = 'Da Nang' COLLATE utf8mb4_vi_0900_ai_ci AS bang_nhau_vi_ai_ci,
       'Đà Nẵng' = 'Đa Năng' COLLATE utf8mb4_vi_0900_ai_ci AS chi_bo_thanh_dieu_vi_ai_ci,
       'Đà Nẵng' = 'Đà Nẵng' COLLATE utf8mb4_vi_0900_as_cs AS giong_het_vi_as_cs;
lab/da-ngon-ngu/pg.sql
sql
-- Trang database/da-ngon-ngu, PostgreSQL 17. Chạy bằng: node common/run-sql.mjs pg i18n da-ngon-ngu/pg.sql
DROP TABLE IF EXISTS product_translations, products, products_json, cities;

\echo --- 1. Bảng dịch riêng, lấy tên theo en, thiếu thì dùng vi
CREATE TABLE products (id INT PRIMARY KEY, sku VARCHAR(20) NOT NULL, price INT NOT NULL);
CREATE TABLE product_translations (
  product_id INT NOT NULL REFERENCES products (id) ON DELETE CASCADE,
  locale     VARCHAR(10) NOT NULL,
  name       VARCHAR(200) NOT NULL,
  PRIMARY KEY (product_id, locale)
);
INSERT INTO products VALUES (1, 'TEE-01', 199000), (2, 'MUG-02', 89000), (3, 'BAG-03', 350000);
INSERT INTO product_translations VALUES
  (1, 'vi', 'Áo thun cotton'), (1, 'en', 'Cotton T-shirt'),
  (2, 'vi', 'Cốc sứ'), (2, 'en', 'Ceramic mug'), (3, 'vi', 'Túi vải');
SELECT p.id, COALESCE(t.name, d.name) AS name, COALESCE(t.locale, d.locale) AS shown_locale
FROM products p
LEFT JOIN product_translations t ON t.product_id = p.id AND t.locale = 'en'
LEFT JOIN product_translations d ON d.product_id = p.id AND d.locale = 'vi'
ORDER BY p.id;

\echo --- 2. Cột JSONB, index theo biểu thức name->>'vi'
CREATE TABLE products_json (id INT PRIMARY KEY, name JSONB NOT NULL CHECK (name ? 'vi'));
CREATE INDEX idx_name_vi ON products_json ((name ->> 'vi'));
INSERT INTO products_json VALUES (1, '{"vi": "Áo thun cotton", "en": "Cotton T-shirt"}'), (2, '{"vi": "Cốc sứ"}');
SELECT id, COALESCE(name ->> 'en', name ->> 'vi') AS name FROM products_json ORDER BY id;
\echo --- CHECK (name ? 'vi') chặn sản phẩm thiếu tên tiếng Việt
INSERT INTO products_json VALUES (3, '{"en": "Pen"}');

\echo --- 3. Sắp xếp tên tiếng Việt: collation "C", collation mặc định của database, ICU tiếng Việt
CREATE TABLE cities (name TEXT);
INSERT INTO cities VALUES ('Đà Nẵng'), ('Dĩ An'), ('Đồng Hới'), ('Duy Tiên'), ('Cần Thơ'), ('Châu Đốc'), ('Ơn'), ('Ô Môn'), ('Óc Eo');
SELECT datcollate, datlocprovider FROM pg_database WHERE datname = current_database();
SELECT string_agg(name, ' < ' ORDER BY name COLLATE "C") AS theo_ma_byte_c FROM cities;
SELECT string_agg(name, ' < ' ORDER BY name) AS mac_dinh_database FROM cities;
SELECT string_agg(name, ' < ' ORDER BY name COLLATE "vi-x-icu") AS icu_tieng_viet FROM cities;