Trong các hệ thống Nhật (đặc biệt là hệ thống cũ), chuyện lỗi font, chữ “髙橋” biến thành ký tự lạ, hay dữ liệu khó migrate không phải chuyện hiếm. Điểm chung: đa phần không bắt đầu từ bug code phức tạp, mà từ một thứ nghe rất nhàm chán:

Charset & encoding.

Bài này dành cho dev fresher/junior đến mid, đang làm với API, Golang, PHP, MySQL trong môi trường Nhật. Mục tiêu sau khi đọc:

  • Hiểu vì sao lỗi font (mojibake) xảy ra.
  • Đọc được luồng dữ liệu: từ client → API → MySQL → API khác → browser.
  • Nhận ra các “cái bẫy” điển hình với eucjpms, EUC-JP, Shift_JIS, UTF-8.
  • Thiết kế flow an toàn hơn, đặc biệt khi có nhiều app cùng dùng chung DB.

Không cần thuộc lòng lý thuyết encoding kinh điển, chỉ cần nắm đúng một số nguyên lý.


1. Mojibake không phải chuyện “font xấu”

Hãy bắt đầu bằng một ví dụ rất đời thường:

  • Người dùng nhập: 髙橋 さん
  • Ở màn hình khác lại thấy: ?橋 さん hoặc ô vuông, hoặc ký tự loằng ngoằng.

App không crash. Insert thành công. SELECT vẫn trả về record.
Vậy lỗi ở đâu?

Không phải font. Vấn đề nằm ở chỗ:

  • Byte vẫn đó, nhưng:
  • Bên gửi và bên nhận không cùng cách giải thích byte đó.

Muốn giải quyết tận gốc, ta phải nhìn qua 3 lớp khái niệm (phiên bản dành cho dev, không học thuật):


2. Ba lớp: ký tự, encoding, và “cảm giác chủ quan của lập trình viên”

2.1. Bảng ký tự (Character Set / CCS)

Đây là danh sách: mỗi ký tự ↔ một mã số (code point).

  • Ví dụ: Unicode, JIS X 0208.
  • Ví dụ: 'あ' trong Unicode là U+3042.

2.2. Encoding (Character Encoding Scheme)

Là cách biến code point thành bytes để lưu trữ/truyền đi.

  • UTF-8, Shift_JIS, EUC-JP, eucJP-ms (eucjpms), CP932, v.v.

Quan trọng:
Cùng một chữ, khác encoding → khác bytes.

Ví dụ (UTF-8):

  • 'あ'E3 81 82

Trong Shift_JIS:

  • 'あ'82 A0

2.3. Hiểu nhầm phổ biến của dev

  • “File code em lưu UTF-8 rồi, nghĩa là app em là UTF-8 hết” → Sai.
  • “Em echo ra trình duyệt thấy ok, chắc DB cũng UTF-8” → Chưa chắc.
  • “Đổi DSN charset= mà không đổi chỗ khác” → Dễ toang.

Điều quan trọng không phải file code là gì, mà là:

Dữ liệu thực tế đi qua HTTP, driver DB, MySQL, API…
ở mỗi bước đang được encode theo charset nào, và khai báo là gì.


3. Cách MySQL xử lý charset (phần dev cần nắm)

MySQL có nhiều charset: utf8mb4, latin1, sjis, ujis, eucjpms, …

Các biến quan trọng:

  • character_set_client: MySQL nghĩ dữ liệu client gửi lên là charset gì.
  • character_set_connection: charset để parse query nội bộ.
  • character_set_results: charset khi trả về cho client.
  • Charset của database/table/column.

Cơ chế:

  1. Client gửi bytes + “anh ơi, em đang dùng charset X” (qua DSN/SET NAMES).

  2. MySQL decode bytes đó theo X thành “ký tự”.

  3. Nếu column dùng charset Y khác X:

    • MySQL convert ký tự sang Y rồi lưu.
  4. Khi SELECT:

    • Từ column (Y), MySQL convert sang character_set_results rồi gửi ra.

Nếu cấu hình đúng → MySQL làm rất tốt việc convert.

Nếu bạn nói dối (ví dụ gửi UTF-8 nhưng khai là SJIS) → MySQL vẫn convert rất nhiệt tình, nhưng từ dữ liệu đã hiểu nhầm → dữ liệu rác “hợp lệ”.

Đây chính là cái bẫy số 1.


4. Flow đúng: App UTF-8, DB eucjpms, MySQL tự convert

Đây là mô hình an toàn, dễ hiểu, phù hợp hệ thống đang dần hiện đại hóa.

Giả sử:

  • Client, API, Golang: UTF-8.

  • MySQL table: eucjpms (di sản).

  • DSN (cả đọc & ghi):

    charset=utf8mb4
    

Khi INSERT '髙'

  1. App gửi UTF-8: E9 AB 99.

  2. DSN utf8mb4 → MySQL biết: “client = utf8mb4”.

  3. MySQL decode đúng '髙'.

  4. Column là eucjpms:

    • MySQL convert '髙' sang bytes eucjpms tương ứng.
    • Lưu đúng.

Khi SELECT

  1. MySQL đọc bytes eucjpms.
  2. character_set_results = utf8mb4.
  3. Convert eucjpms → UTF-8 → trả về E9 AB 99.
  4. App trả JSON/HTML UTF-8 → browser hiển thị đúng '髙'.

✅ Không cần callback encode/decode thủ công.
✅ Không mojibake, miễn không dùng ký tự nằm ngoài khả năng eucjpms.


5. Flow sai kinh điển: gửi UTF-8, khai SJIS/eucjpms

Đây là thứ đã (hoặc sẽ) xảy ra nếu:

  • App string là UTF-8,
  • Nhưng DSN là charset=sjis hoặc charset=eucjpms,
  • Và bạn không convert trước khi gửi.

Ví dụ đơn giản với 'あ':

  1. App gửi UTF-8: E3 81 82.

  2. DSN charset=sjis → MySQL nghĩ đây là SJIS.

  3. Decode E3 81 82 như SJIS:

    • Không phải 'あ', thành ký tự khác hoặc lỗi.
  4. MySQL convert cái ký tự sai đó sang utf8mb4/eucjpms → lưu “rác đúng chuẩn”.

Khi SELECT:

  • Nếu vẫn dùng sai y chang:

    • 'あ' trong code lại bị hiểu sai giống vậy → so sánh “rác với rác” → vẫn match.
    • Dev nghĩ hệ thống chạy ổn.
  • Đến ngày đổi DSN cho đúng (utf8mb4) → so sánh đúng 'あ' với dữ liệu rác → không match → mới tá hỏa.

Thông điệp:

Sai cấu hình charset có thể không nổ ngay. Nó âm thầm phá dữ liệu.


6. eucjpms vs EUC-JP và ca khó

Đây là chỗ rất nhiều hệ thống Nhật “dính chưởng”.

  • MySQL có charset: eucjpms (eucJP-ms), không phải EUC-JP thuần.

  • eucjpms hỗ trợ một số ký tự mở rộng như:

    • (はしご高),
    • một số dị thể của , , v.v.
  • Nhiều code Go/PHP/JAVA lại dùng thư viện:

    • EUC-JP chuẩn,
    • hoặc CP51932,
    • không trùng mapping với eucjpms của MySQL.

Kịch bản hay gặp

  1. INSERT:

    • App dùng UTF-8 + DSN utf8mb4.
    • MySQL (table eucjpms) store đúng theo bảng eucjpms.
  2. SELECT:

    • App dùng DSN charset=eucjpms.

    • Sau đó dùng callback:

      • “convert từ EUC-JP sang UTF-8” bằng thư viện japanese.EUCJP.
    • Vấn đề:

      • Bytes của trong eucjpms không khớp với mapping của EUC-JP chuẩn.
      • Thư viện decode sai → ra ký tự rác/PUA/.
  3. API trả về với charset=utf-8:

    • Dữ liệu là UTF-8 hợp lệ, nhưng nội dung đã sai.
    • Browser hiển thị: ?, ô vuông, hoặc ký tự lạ.

Trong mắt dev:

“Ủa em đã convert về UTF-8 rồi mà sao vẫn lỗi font?”

Thực tế:

  • DB: đúng (eucjpms).
  • MySQL: đúng.
  • Sai ở chỗ: callback decode bằng bảng mã khác với DB.

7. Trường hợp 2 app dùng chung DB: một viết, một đọc

Giả sử kiến trúc như bạn đang có:

  • App1 (writer):

    • DSN charset=utf8mb4.
    • Nhiệm vụ: insert/update.
    • Table: eucjpms.
    • → MySQL auto convert UTF-8 → eucjpms. OK.
  • App2 (reader):

    • DSN charset=eucjpms.

    • Sau SELECT:

      • GORM callback tự ConvertStructToUTF8 dùng EUC-JP chuẩn.
    • API trả charset=utf-8.

Hiện tượng:

  • Một số chữ bình thường → hiển thị đúng.
  • Một số chữ như → lỗi font.

Giải thích:

  • App2 nhận đúng bytes eucjpms từ MySQL.
  • Callback decode sai bảng mã → đổi thành rune khác, hoặc .
  • Trả về UTF-8 “đúng kỹ thuật nhưng sai chữ”.
  • Browser render trung thực → thấy “lỗi”.

Kết luận:

Vấn đề không nằm ở MySQL hay UTF-8,
mà là: App2 dùng sai bộ giải mã so với charset thực tế của DB.


8. Best practice: Làm sao cho đơn giản & an toàn hơn?

Từ các case trên, rút ra một số nguyên tắc rất thực tế:

8.1. Ưu tiên “UTF-8 everywhere”

Cho hệ thống mới hoặc đang refactor:

  1. App, API, front-end: UTF-8 / utf8mb4.

  2. MySQL:

    • Dùng utf8mb4 cho database/table/column.
  3. Chỉ khi cần tương tác legacy (CSV SJIS, hệ thống ngoài) mới convert cục bộ.

8.2. Nếu bắt buộc giữ DB eucjpms một thời gian

Giải pháp an toàn, dễ triển khai:

  • App (cả đọc & ghi):

    • DSN = charset=utf8mb4.
  • Không dùng callback encode/decode EUC-JP thủ công trong ORM.

  • Để MySQL:

    • Khi INSERT: utf8mb4 → eucjpms.
    • Khi SELECT: eucjpms → utf8mb4.

Lưu ý:

  • Một số ký tự không có trong eucjpms (emoji, một số kanji hiếm) sẽ:

    • bị chuyển thành ? hoặc gây lỗi.
  • Đây là giới hạn charset chứ không phải bug.

8.3. Nếu thực sự cần callback

Nếu vì lý do nào đó bạn phải:

  • DSN App2 = charset=eucjpms,
  • Tự gọi ConvertToEUCJP / ConvertToUTF8FromEUCJP,

thì:

  1. Phải dùng bảng mã eucjpms đúng (eucJP-ms) phù hợp với MySQL.

  2. Không được dùng mỗi japanese.EUCJP rồi coi như xong.

  3. Cần test round-trip với bộ ký tự “nhạy cảm”:

    • , , các chữ dị thể, , v.v.

Nếu không chắc làm chuẩn → quay lại 8.2: cho MySQL làm, app chỉ dùng UTF-8.


9. Kết

Charset không phải chủ đề sexy. Nhưng trong hệ thống Nhật, nó là thứ phân biệt:

  • Một hệ thống chạy mượt 10 năm,
  • Với một hệ thống “đụng đâu lỗi đó”, migrate dữ liệu là ác mộng.

Tóm lại:

  • Hiểu luồng: Client → App → Driver → MySQL (client/connection/results) → Table charset → App khác → Browser.
  • Nói thật với MySQL về charset mình dùng.
  • Tránh double-convert và tránh xài nhầm bảng mã (đặc biệt với eucjpms).
  • Ưu tiên chuẩn hóa về UTF-8 end-to-end khi có thể.