# Tìm Nguyễn không ra: chuẩn hóa Unicode NFC cho tìm kiếm tiếng Việt trong MySQL

> Tìm "Nguyễn" không ra dù dữ liệu có đúng chữ đó là do một chữ ê được lưu theo hai cách: NFC (một mã Unicode) và NFD (chữ e cộng hai dấu rời). Trên MySQL 8.4.8

URL: https://mona.software/unicode-nfc-nfd-tim-kiem-tieng-viet-khong-dau/
Ngày đăng: 2026-10-26T10:32:59+07:00
Cập nhật: 2026-10-26T10:37:24+07:00
Tác giả: monamedia

Tìm “Nguyễn” không ra dù dữ liệu có đúng chữ đó là do một chữ ê được lưu theo hai cách: NFC (một mã Unicode) và NFD (chữ e cộng hai dấu rời). Trên MySQL 8.4.8, hai dạng này là hai chuỗi khác nhau ở cột `utf8mb4_bin`, `utf8mb4_general_ci` và ba collation tiếng Việt. Cách sửa gồm ba việc: đổi mọi chuỗi sang NFC trước khi ghi và trước khi tìm, viết lại các dòng NFD đang có trong bảng, rồi tìm không dấu trên cột `utf8mb4_0900_ai_ci`. Mọi kết quả trong bài đo ngày 26/10/2026 trên MySQL 8.4.8 và Python 3.9.6; MariaDB và các bản MySQL khác chưa đo.

## Hai chữ Nguyễn giống nhau trên màn hình nhưng dài 8 và 10 byte

NFC (Normalization Form C) ghép chữ và dấu thành một mã Unicode. NFD (Normalization Form D) tách thành chữ gốc cộng các dấu rời. Unicode coi hai dạng là cùng một chữ, nhưng so sánh nhị phân thì chúng là hai chuỗi khác nhau. Hàm [`unicodedata.normalize`](https://docs.python.org/3.9/library/unicodedata.html) của Python đổi giữa hai dạng:

```
import unicodedata as u
nfc = u.normalize("NFC", "Nguyễn")
nfd = u.normalize("NFD", "Nguyễn")
for n, s in (("NFC", nfc), ("NFD", nfd)):
    print(n, s, "len=", len(s), "bytes=", len(s.encode()), [f"U+{ord(c):04X}" for c in s])
print("==", nfc == nfd, "| bằng nhau sau NFC:", u.normalize("NFC", nfd) == nfc)

NFC Nguyễn len= 6 bytes= 8 ['U+004E', 'U+0067', 'U+0075', 'U+0079', 'U+1EC5', 'U+006E']
NFD Nguyễn len= 8 bytes= 10 ['U+004E', 'U+0067', 'U+0075', 'U+0079', 'U+0065', 'U+0302', 'U+0303', 'U+006E']
== False | bằng nhau sau NFC: True
```

Chữ ễ ở dạng NFC là một mã (U+1EC5). Ở dạng NFD, ễ thành ba mã: e (U+0065), dấu mũ (U+0302) và dấu ngã (U+0303). Quy tắc này nằm trong [Unicode Standard Annex #15 (UAX #15)](https://www.unicode.org/reports/tr15/), bản 18.0.0 sửa đổi ngày 12/08/2026. Các chuỗi tương đương chính tắc cho cùng một kết quả sau khi chuẩn hóa, còn so sánh nhị phân chuỗi chưa chuẩn hóa coi chúng là khác nhau.

![Bảng ví dụ tương đương chính tắc trong Unicode: Ç bằng C cộng dấu móc kết hợp](https://mona.software/wp-content/uploads/2026/10/unicode-uax15-canonical-equivalence-mona.webp)

Hình 1 của UAX #15: chữ Ç dựng sẵn và chữ C cộng dấu móc rời là cùng một ký tự. Nguồn unicode.org, chụp ngày 26/10/2026.

Chữ đ là ngoại lệ cần nhớ: Python cho thấy `normalize("NFD", "đ")` vẫn là một mã U+0111, tức đ không có dạng tách.

## Cùng một phép so sánh, collation khác nhau cho kết quả khác nhau

Bảng thử có bảy dòng: “Nguyễn” dạng NFC (id 1) và NFD (id 2), “Nguyen” (id 3), “Nguyên” (id 4), cùng “Đặng” NFC, NFD và “Dang”. Cột `ten` khai báo `utf8mb4_0900_ai_ci`, còn mỗi truy vấn dưới đây ghi `COLLATE` rõ để so từng collation. Câu lệnh SQL trong bài được viết hoa từ khóa cho dễ đọc, kết quả là nguyên văn lần chạy cuối. Câu hỏi là dòng nào trả về khi gõ `WHERE ten = 'Nguyễn'` (chuỗi gõ vào ở dạng NFC):

```
mysql> SELECT 'utf8mb4_bin' collation_used, GROUP_CONCAT(id ORDER BY id) ids FROM khach WHERE ten COLLATE utf8mb4_bin = 'Nguyễn'
    -> UNION ALL SELECT 'utf8mb4_vi_0900_ai_ci', GROUP_CONCAT(id ORDER BY id) FROM khach WHERE ten COLLATE utf8mb4_vi_0900_ai_ci = 'Nguyễn'
    -> UNION ALL SELECT 'utf8mb4_0900_ai_ci', GROUP_CONCAT(id ORDER BY id) FROM khach WHERE ten COLLATE utf8mb4_0900_ai_ci = 'Nguyễn';
+-----------------------+---------+
| collation_used        | ids     |
+-----------------------+---------+
| utf8mb4_bin           | 1       |
| utf8mb4_vi_0900_ai_ci | 1,4     |
| utf8mb4_0900_ai_ci    | 1,2,3,4 |
+-----------------------+---------+
```

Cột `utf8mb4_bin` chỉ trả dòng 1. Cột `utf8mb4_vi_0900_ai_ci` trả dòng 1 và dòng 4 (“Nguyên”, vì tiếng Việt bỏ qua dấu thanh nhưng giữ ê khác e), mà bỏ sót dòng 2, chính là “Nguyễn” dạng NFD. Cột `utf8mb4_0900_ai_ci` trả cả bốn dòng, kể cả “Nguyen” không dấu. Để thấy rộng hơn, bảng dưới so bốn cặp chuỗi trên bảy collation:

| Collation | Nguyễn NFC = NFD | Nguyễn = Nguyen | Nguyễn = Nguyên | Đặng = Dang |
| --- | --- | --- | --- | --- |
| utf8mb4_bin | khác | khác | khác | khác |
| utf8mb4_general_ci | khác | bằng | bằng | khác |
| utf8mb4_unicode_ci | bằng | bằng | bằng | khác |
| utf8mb4_0900_ai_ci | bằng | bằng | bằng | bằng |
| utf8mb4_0900_as_cs | bằng | khác | khác | khác |
| utf8mb4_vi_0900_ai_ci | khác | khác | bằng | khác |
| utf8mb4_vietnamese_ci | khác | khác | bằng | khác |

Từ bảng này: chọn `utf8mb4_0900_ai_ci` khi cần tìm không dấu và dữ liệu có thể lẫn NFD, chọn `utf8mb4_0900_as_cs` khi cần phân biệt dấu mà vẫn gộp NFC với NFD, và chỉ dùng collation tiếng Việt để sắp xếp sau khi mọi dòng đã là NFC.

Hậu tố của [tên collation](https://dev.mysql.com/doc/refman/8.4/en/charset-collation-names.html) giải thích phần lớn bảng: `ai` là bỏ qua dấu, `as` là phân biệt dấu, `ci` là bỏ qua hoa thường, `cs` là phân biệt hoa thường, `bin` là so mã nhị phân. Tên không ghi `ai` hay `as` thì `ci` hàm ý `ai` và `cs` hàm ý `as`, nên `utf8mb4_general_ci` mặc nhiên bỏ qua dấu. MySQL 8.4.8 đặt `utf8mb4_0900_ai_ci` làm collation mặc định của `utf8mb4` (cột Default của `SHOW COLLATION` ghi Yes).

![Bảng hậu tố collation MySQL: ai, as, ci, cs, ks và bin](https://mona.software/wp-content/uploads/2026/10/mysql-84-hau-to-collation-mona.webp)

Bảng 12.1 trong sách hướng dẫn MySQL 8.4 giải thích hậu tố collation. Nguồn dev.mysql.com, chụp ngày 26/10/2026.

Hai kết quả đo dưới đây không có trong các trang MySQL mà MONA đã đọc. Một: các collation `0900` và `unicode_ci` coi NFC và NFD là một, nhưng collation tiếng Việt thì không, với các chữ ê, ô, ơ, ư, ă, â (các chữ có chỗ riêng trong bảng chữ cái). Các chữ chỉ thêm dấu thanh như à, á, hay đ vẫn bằng nhau ở cả hai dạng. Hai: `vi_0900_ai_ci` coi “Nguyễn” NFD bằng “Nguyen” nhưng khác “Nguyễn” NFC, vì nó đọc e cộng hai dấu rời thành chữ e trơn.

Phép `LIKE` còn có lỗi riêng. Dấu `_` chỉ khớp một mã Unicode, nên `LIKE 'Nguy_n'` không bao giờ khớp dòng NFD (ê của dòng đó là ba mã). Câu lệnh này trả dòng 1, 3, 4 trên cả `utf8mb4_bin`, `0900_ai_ci` lẫn `vi_0900_ai_ci` và bỏ dòng 2. Dấu `%` không gặp lỗi này: `LIKE '%ễ%'` trên `0900_ai_ci` trả cả bốn dòng 1, 2, 3, 4, vì collation này coi ê bằng e ở cả hai dạng.

![Màn hình thống kê trong phần mềm MONA làm cho The IELTS Workshop](https://mona.software/wp-content/themes/monatheme/home-v2/assets/img/cs/ielts-thongke.webp)

Phần mềm cho việc này

Quy trình đang chạy mà phần mềm có sẵn không làm được?

MONA khảo sát nghiệp vụ trước dòng code đầu tiên.

[Viết phần mềm theo yêu cầu](https://mona.software/viet-phan-mem-theo-yeu-cau/)[Xem case study](https://mona.software/#case-study)

## Tìm các dòng NFD đang nằm trong bảng và viết lại sang NFC

Dữ liệu tiếng Việt dạng NFC không chứa mã nào trong dải dấu kết hợp U+0300 đến U+036F; một câu `REGEXP` bắt được đúng các dòng NFD:

```
mysql> SELECT id, HEX(ten) FROM khach WHERE ten REGEXP '[\\x{0300}-\\x{036F}]';
+----+----------------------+
| id | hex(ten)             |
+----+----------------------+
|  2 | 4E67757965CC82CC836E |
|  6 | C49061CCA3CC866E67   |
+----+----------------------+
```

Trên MySQL 8.4.8, `SELECT NORMALIZE('x')` báo “FUNCTION blog_lab_unicode.normalize does not exist” (hàm `UNICODE_NORMALIZE` cũng không có), nên việc chuyển nằm ở code ứng dụng. Python (PyMySQL 1.2.3, mật khẩu trong code thay bằng dấu ba chấm) tìm dòng chưa NFC bằng `is_normalized` rồi ghi lại:

```
import unicodedata, pymysql

db = pymysql.connect(host="127.0.0.1", user="root", password="...",
                     database="blog_lab_unicode", charset="utf8mb4", autocommit=True)
cur = db.cursor()

cur.execute("SELECT id, ten FROM khach")
bad = [(i, t) for i, t in cur.fetchall() if not unicodedata.is_normalized("NFC", t)]
print("rows not in NFC:", [i for i, _ in bad])

for i, t in bad:
    cur.execute("UPDATE khach SET ten = %s WHERE id = %s",
                (unicodedata.normalize("NFC", t), i))
print("updated:", len(bad))

rows not in NFC: [2, 6]
updated: 2
```

Ghi lại xong, hai dòng NFD có cùng mã hex với dòng NFC tương ứng (`4E677579E1BB856E` cho Nguyễn, `C490E1BAB76E67` cho Đặng) và `char_length` về 6 và 4. Độ dài tính bằng byte cũng co lại, từ 10 xuống 8 với Nguyễn và từ 9 xuống 7 với Đặng. Phép so sánh sau sửa trả kết quả sạch ở mọi collation:

```
+----------------------+---------+
| c                    | hits    |
+----------------------+---------+
| bin                  | 1,2     |
| vi_0900_ai_ci        | 1,2,4   |
| vi_0900_ai_ci Nguyen | 3       |
| 0900_ai_ci Nguyen    | 1,2,3,4 |
+----------------------+---------+
```

Dòng 1 và 2 giờ khớp nhau ngay cả ở `utf8mb4_bin`. Việc còn lại là chặn dòng NFD mới. Gọi `unicodedata.normalize("NFC", ...)` hoặc hàm tương đương của ngôn ngữ đang dùng, cho cả giá trị ghi vào lẫn chuỗi người dùng gõ vào ô tìm. Chuỗi tìm ở dạng NFD sẽ không khớp dữ liệu NFC ở cột nhị phân. Hàm Python nằm trong `unicodedata`, tài liệu cảnh báo đúng điều này: hai chuỗi trông giống nhau vẫn có thể so sánh khác nhau nếu một chuỗi dùng ký tự kết hợp.

![Tài liệu Python unicodedata.normalize giải thích bốn dạng chuẩn hóa NFC, NFD, NFKC, NFKD](https://mona.software/wp-content/uploads/2026/10/python-unicodedata-normalize-mona.webp)

Mô tả hàm `unicodedata.normalize` trong tài liệu Python 3.9. Nguồn docs.python.org, chụp ngày 26/10/2026.

Cách này hỏng khi có chương trình khác ghi thẳng vào bảng mà không qua code của anh chị, chẳng hạn một công cụ nhập liệu hay một script chạy riêng trên [phần mềm quản lý dữ liệu](https://mona.software/phan-mem-quan-ly-du-lieu/) dùng chung. Khi đó dòng NFD vẫn xuất hiện, và đó là một [bug](https://mona.software/bug-la-gi/) của khâu [quản lý dữ liệu](https://mona.software/quan-ly-du-lieu-la-gi/) cần ghi kèm hai dòng mẫu. Dải U+0300 đến U+036F cũng chứa dấu móc U+031B của ơ và ư (Python cho ơ ở dạng NFD là o + U+031B), nên câu `REGEXP` bắt được cả hai chữ này. Cách kiểm là chạy lại câu `REGEXP` ở trên theo lịch.

## Chọn collation cho tìm không dấu và cho sắp xếp

Tìm “dang” ra “Đặng” cần collation coi đ bằng d. Trong bảng trên chỉ `utf8mb4_0900_ai_ci` làm được; `utf8mb4_unicode_ci` và các collation tiếng Việt coi đ khác d. Collation tiếng Việt lại là lựa chọn đúng cho thứ tự sắp xếp, vì đ đứng sau d. Mười tám tên mẫu cho kết quả:

```
utf8mb4_vi_0900_ai_ci : An Ánh Ân Bảo Bình Dũng Dương Đào Đạt Điệp Hà Hải Hạnh Hằng Thanh Thành Thảo Trần
utf8mb4_0900_ai_ci    : Ân An Ánh Bảo Bình Đào Đạt Điệp Dũng Dương Hà Hải Hằng Hạnh Thanh Thành Thảo Trần
utf8mb4_bin           : An Bình Bảo Dũng Dương Hà Hạnh Hải Hằng Thanh Thành Thảo Trần Ánh Ân Điệp Đào Đạt
```

Cột `vi_0900_ai_ci` xếp An, Ánh, Ân và Dũng, Dương, Đào đúng thứ tự tiếng Việt. Cột `0900_ai_ci` coi Ân, An, Ánh là bằng nhau và xếp chúng theo id, và Đào đứng trước Dũng. Cột `bin` xếp theo mã, đẩy Ánh, Ân, Điệp, Đào, Đạt ra sau Trần, và đặt Hạnh trước Hải. Cột `utf8mb4_vietnamese_ci` (bản UCA cũ hơn) cho đúng thứ tự như `vi_0900_ai_ci` trên 18 tên này.

![Tài liệu MySQL 8.4 mô tả collation utf8mb4_0900_ai_ci và collation theo ngôn ngữ](https://mona.software/wp-content/uploads/2026/10/mysql-84-collation-theo-ngon-ngu-mona.webp)

Phần “Language-Specific Collations” của sách hướng dẫn MySQL 8.4. Nguồn dev.mysql.com, chụp ngày 26/10/2026.

Một cột chỉ có một collation, và ép collation khác vào câu truy vấn làm mất chỉ mục. MONA tạo bảng 50.000 tên với hai cột: `ten` (`vi_0900_ai_ci`, để hiển thị và sắp xếp) và `ten_tim` (`0900_ai_ci`, để tìm). Cả hai cột có chỉ mục (câu `CREATE TABLE` dưới đây xuống dòng cho dễ đọc, còn ba dòng cuối là cột `type`, `key`, `rows` của `EXPLAIN` do một script Python in ra; `rows` là số ước tính nên đổi giữa các lần chạy):

```
CREATE TABLE kh(
  id INT AUTO_INCREMENT PRIMARY KEY,
  ten     VARCHAR(60) COLLATE utf8mb4_vi_0900_ai_ci,
  ten_tim VARCHAR(60) COLLATE utf8mb4_0900_ai_ci,
  INDEX(ten), INDEX(ten_tim)
) DEFAULT CHARSET utf8mb4;

ten_tim = 'Nguyen Van An'                         type=ref    key=ten_tim rows=11
ten = 'Nguyen Van An'                             type=ref    key=ten     rows=1
ten COLLATE utf8mb4_0900_ai_ci = 'Nguyen Van An'  type=index  key=ten     rows=49978
```

EXPLAIN cho `ten_tim` ước tính 11 dòng, còn ép `COLLATE utf8mb4_0900_ai_ci` lên cột `ten` làm MySQL quét 49.978 dòng của chỉ mục. Cột `ten` với chuỗi “Nguyen Van An” không dấu chỉ ra 0 dòng, trong khi “Nguyễn Văn An” ra 11 dòng. Giá phải trả là một cột thứ hai ghi đôi dữ liệu và một chỗ để quên cập nhật khi sửa tên. Cách này chỉ đáng làm khi bảng đủ lớn để việc quét toàn chỉ mục chậm thấy rõ. Bảng nhỏ thì để một cột `utf8mb4_0900_ai_ci` và sắp xếp bằng `ORDER BY ten COLLATE utf8mb4_vi_0900_ai_ci` khi cần.

## Hỏi đáp

### Nên lưu NFC hay NFD?

NFC. UAX #15 ghi rằng W3C khuyến nghị NFC cho nội dung web, và NFC giữ `char_length` đúng với số chữ người đọc thấy (Nguyễn là 6 mã ở dạng NFC, 8 mã ở dạng NFD).

### MariaDB có giống MySQL 8.4 không?

Chưa kiểm. Các collation `utf8mb4_0900_*` và `utf8mb4_vi_0900_*` trong bài là của MySQL 8; trên MariaDB hãy chạy lại bảng đối chiếu ở phần hai bằng tên collation mà bản đang dùng có.

### Có cần NFKC không?

Với tên người và địa chỉ thì NFC là đủ. UAX #15 nói NFKC và NFKD (hai dạng chuẩn hóa tương thích, NFKC có ghép lại) “không được áp mù quáng” cho văn bản bất kỳ vì chúng gộp các kiểu chữ (ví dụ chữ toàn rộng và bán rộng) và có thể đổi nghĩa.

### Vì sao tìm “dang” không ra “Đặng” ở cột tiếng Việt?

Chữ đ (U+0111) không tách ra được, vì vậy tìm bỏ dấu phải nhờ collation coi đ bằng d. Cột `vi_0900_ai_ci` coi hai chữ này là khác nhau, cột `0900_ai_ci` thì coi là bằng.

### Nhập file Excel hay CSV thì chuẩn hóa ở đâu?

Ngay trong script đọc file, trước khi ghi từng ô vào [phần mềm quản lý khách hàng](https://mona.software/phan-mem-quan-ly-khach-hang/) hay bảng dữ liệu khác. MONA chưa kiểm xem `LOAD DATA` có tự chuẩn hóa không; chuẩn hóa trong script là cách đã chạy được. Bài [chuyển dữ liệu từ Excel sang phần mềm quản lý](https://mona.software/chuyen-du-lieu-excel-sang-phan-mem-quan-ly/) nói về cả quy trình nhập. Cột nào đã có dòng NFD thì chạy lại câu `REGEXP` sau mỗi lần nhập để biết file mới có mang NFD vào hay không.

MONA đưa bước kiểm dạng chuẩn hóa này vào [phần mềm viết theo yêu cầu](https://mona.software/viet-phan-mem-theo-yeu-cau/) cho doanh nghiệp nhập tên từ file Excel, form và API. Bảng đang có tên tìm không ra thì gọi hotline 1900 636 648 kèm hai dòng mẫu NFC và NFD.

## Câu hỏi thường gặp

### Nên lưu NFC hay NFD?

NFC. UAX #15 ghi rằng W3C khuyến nghị NFC cho nội dung web, và NFC giữ char_length đúng với số chữ người đọc thấy (Nguyễn là 6 mã ở dạng NFC, 8 mã ở dạng NFD).

### MariaDB có giống MySQL 8.4 không?

Chưa kiểm. Các collation utf8mb4_0900_* và utf8mb4_vi_0900_* trong bài là của MySQL 8; trên MariaDB hãy chạy lại bảng đối chiếu ở phần hai bằng tên collation mà bản đang dùng có.

### Có cần NFKC không?

Với tên người và địa chỉ thì NFC là đủ. UAX #15 nói NFKC và NFKD (hai dạng chuẩn hóa tương thích, NFKC có ghép lại) "không được áp mù quáng" cho văn bản bất kỳ vì chúng gộp các kiểu chữ (ví dụ chữ toàn rộng và bán rộng) và có thể đổi nghĩa.

### Vì sao tìm "dang" không ra "Đặng" ở cột tiếng Việt?

Chữ đ (U+0111) không tách ra được, vì vậy tìm bỏ dấu phải nhờ collation coi đ bằng d. Cột vi_0900_ai_ci coi hai chữ này là khác nhau, cột 0900_ai_ci thì coi là bằng.

### Nhập file Excel hay CSV thì chuẩn hóa ở đâu?

Ngay trong script đọc file, trước khi ghi từng ô vào phần mềm quản lý khách hàng hay bảng dữ liệu khác. MONA chưa kiểm xem LOAD DATA có tự chuẩn hóa không; chuẩn hóa trong script là cách đã chạy được. Bài chuyển dữ liệu từ Excel sang phần mềm quản lý nói về cả quy trình nhập. Cột nào đã có dòng NFD thì chạy lại câu REGEXP sau mỗi lần nhập để biết file mới có mang NFD vào hay không. MONA đưa bước kiểm dạng chuẩn hóa này vào phần mềm viết theo yêu cầu cho doanh nghiệp nhập tên từ file Excel, form và API. Bảng đang có tên tìm không ra thì gọi hotline 1900 636 648 kèm hai dòng mẫu NFC và NFD.
