Ký tự vs. Byte: Hiểu UTF-8 và sự khác biệt mã hóa
Trong lập trình và thiết kế cơ sở dữ liệu, hiểu sự khác biệt giữa "số ký tự" và "số byte" là điều thiết yếu. Các ngôn ngữ như tiếng Nhật và tiếng Trung sử dụng ký tự đa byte, trong đó một ký tự hiển thị có thể chiếm nhiều byte. Hiểu sai sự phân biệt này dẫn đến cắt ngắn dữ liệu, lỗi mã hóa và văn bản bị hỏng.
Số byte theo mã hóa
| Mã hóa | ASCII (A-Z, 0-9) | Ký tự CJK | Emoji |
|---|---|---|---|
| UTF-8 | 1 byte | 3 bytes | 4 bytes |
| UTF-16 | 2 bytes | 2 bytes | 4 bytes |
| ASCII | 1 byte | Không hỗ trợ | Không hỗ trợ |
Ví dụ, từ "Hello" là 5 byte trong UTF-8, trong khi một cụm từ tiếng Trung 5 ký tự sẽ là 15 byte trong UTF-8 nhưng chỉ 10 byte trong UTF-16.
Cạm bẫy phổ biến
- Cắt ngắn cơ sở dữ liệu: Mã hóa utf8 cũ của MySQL chỉ hỗ trợ tối đa 3 byte mỗi ký tự, khiến emoji (4 byte) bị lỗi. Luôn sử dụng utf8mb4. Lưu ý rằng MySQL 8.0+ mặc định dùng utf8mb4, nhưng các hệ thống nâng cấp từ 5.7 hoặc cũ hơn vẫn giữ cài đặt cũ
- Giới hạn payload API: Một trường văn bản "1.000 ký tự" trong các ngôn ngữ CJK có thể lên đến 3.000 byte trong UTF-8, có khả năng vượt quá giới hạn kích thước body API. Nếu có mã hóa Base64, kích thước dữ liệu tăng khoảng 33%, làm giảm thêm giới hạn hiệu dụng
- Độ dài chuỗi JavaScript:
String.lengthtrả về đơn vị mã UTF-16, không phải ký tự. Emoji có thể được đếm là 2. Sử dụng[...str].lengthđể đếm số ký tự chính xác - Mở rộng mã hóa URL: Các ký tự không phải ASCII trong URL mở rộng đáng kể - mỗi ký tự CJK trở thành 9 ký tự (%XX%XX%XX) trong mã hóa URL. Với giới hạn URL thực tế khoảng 2.000 ký tự, đường dẫn URL chỉ chứa ký tự CJK đạt giới hạn này ở khoảng 220 ký tự
- Không khớp mã hóa CSV: File CSV UTF-8 mở trong Excel trên Windows hiển thị văn bản lỗi vì Excel giả định mã hóa Shift_JIS cũ khi không có BOM. Thêm BOM UTF-8 (3 byte:
EF BB BF) giải quyết vấn đề này. Lưu ý rằng Excel trên macOS nhận dạng đúng UTF-8 không có BOM, nên đây chủ yếu là vấn đề trên Windows - Giới hạn kích thước truy vấn GraphQL: Nhiều máy chủ GraphQL áp dụng giới hạn kích thước chuỗi truy vấn theo byte. Các truy vấn chứa giá trị biến CJK tiêu thụ khoảng gấp 3 lần byte so với truy vấn chỉ có tiếng Anh, chạm giới hạn độ phức tạp sớm hơn dự kiến
Hành vi độ dài chuỗi theo ngôn ngữ
| Ngôn ngữ | Phương thức độ dài | Trả về | Độ dài của "🎉" | Độ dài của "𠮷" | Đếm ký tự chính xác |
|---|---|---|---|---|---|
| JavaScript | .length | Đơn vị mã UTF-16 | 2 | 2 | [...str].length |
| Python 3 | len() | Điểm mã | 1 | 1 | len(s.encode('utf-8')) cho byte |
| Java | .length() | Đơn vị mã UTF-16 | 2 | 2 | .codePointCount(0, s.length()) |
| Go | len() | Byte | 4 | 4 | utf8.RuneCountInString() |
| Rust | .len() | Byte | 4 | 4 | .chars().count() |
| Swift | .count | Cụm grapheme | 1 | 1 | .utf8.count cho byte |
Thiết kế của Swift đặc biệt đáng chú ý. Thuộc tính .count của nó trả về số cụm grapheme, nên emoji ghép ZWJ như 👨👩👧👦 được đếm chính xác là 1 - xấp xỉ gần nhất với "những gì người dùng nhìn thấy." Đánh đổi là chuỗi Swift không thể được lập chỉ mục trong thời gian O(1); cần duyệt từ đầu.
Rust có cách tiếp cận nghiêm ngặt nhất đối với xử lý chuỗi. Kiểu String được lưu trữ nội bộ dưới dạng chuỗi byte UTF-8, và truy cập chỉ mục như s[0] là lỗi biên dịch. Điều này buộc lập trình viên phải chọn rõ ràng giữa truy cập cấp byte và cấp ký tự - một triết lý thiết kế ngăn ngừa lỗi mã hóa ở cấp ngôn ngữ.
Cách UTF-8 hoạt động
UTF-8 là mã hóa tiêu chuẩn web hiện tại, có khả năng biểu diễn mọi ký tự trong tiêu chuẩn Unicode. Nó sử dụng sơ đồ độ dài thay đổi, phân bổ 1 đến 4 byte mỗi ký tự tùy thuộc vào phạm vi điểm mã. Cốt lõi của thiết kế này là mẫu bit đầu của mỗi byte xác định duy nhất liệu đó là byte bắt đầu hay byte tiếp tục, và ký tự chiếm bao nhiêu byte. Để tìm hiểu sâu về hệ thống mã hóa, sách về mã hóa ký tự cung cấp tài liệu tham khảo có giá trị.
- Ký tự ASCII (chữ cái, chữ số, ký hiệu cơ bản): 1 byte - mẫu bit đầu
0xxxxxxx. 7 bit hiệu dụng, biểu diễn 128 ký tự - Latin mở rộng, Hy Lạp, Cyrillic và các hệ chữ tương tự: 2 byte - bit đầu
110xxxxx 10xxxxxx. 11 bit hiệu dụng, bao phủ U+0080–U+07FF (1.920 ký tự) - Ký tự CJK (tiếng Trung, tiếng Nhật, tiếng Hàn): 3 byte - bit đầu
1110xxxx 10xxxxxx 10xxxxxx. 16 bit hiệu dụng, bao phủ U+0800–U+FFFF (khoảng 63.000 ký tự) - Emoji và ký tự bổ sung: 4 byte - bit đầu
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx. 21 bit hiệu dụng, bao phủ U+10000–U+10FFFF (khoảng 1 triệu ký tự)
Một khía cạnh đặc biệt tinh tế của thiết kế này là thuộc tính "tự đồng bộ". Bạn có thể bắt đầu đọc từ bất kỳ vị trí tùy ý nào trong luồng byte và ngay lập tức xác định ranh giới ký tự bằng cách kiểm tra mẫu bit đầu. Byte tiếp tục luôn bắt đầu bằng 10, giúp phân biệt chúng với byte bắt đầu. Điều này cho phép khôi phục một phần từ dữ liệu bị hỏng và truy cập ngẫu nhiên hiệu quả trong file. UTF-16 thiếu thuộc tính tự đồng bộ này - bắt đầu đọc giữa luồng có nguy cơ hiểu sai nửa đầu và nửa sau của cặp thay thế.
Ưu điểm chính của UTF-8 là tương thích ngược với ASCII. Văn bản tiếng Anh vẫn chính xác 1 byte mỗi ký tự, đó là lý do tại sao các hệ thống dựa trên ASCII hiện có hoạt động liền mạch với UTF-8. Ngoài ra, chuỗi byte UTF-8 sắp xếp theo cùng thứ tự với điểm mã Unicode. Điều này có nghĩa là so sánh đơn giản ở cấp byte tạo ra thứ tự từ điển chính xác, có lợi cho chỉ mục cơ sở dữ liệu và sắp xếp hệ thống file.
Mã hóa cũ: Shift_JIS và EUC-JP
Shift_JIS và EUC-JP được phát triển riêng cho văn bản tiếng Nhật và được sử dụng rộng rãi trong nhiều thập kỷ. Mặc dù việc chuyển đổi sang UTF-8 đang tiến triển tốt, các mã hóa này vẫn xuất hiện trong hệ thống cũ, truyền email và xử lý file CSV.
Shift_JIS có tên từ cách nó "dịch chuyển" giá trị byte vào các vùng không sử dụng của JIS X 0201 (katakana nửa chiều rộng) để cùng tồn tại với ASCII. Tuy nhiên, thiết kế này đã tạo ra "vấn đề 0x5C" khét tiếng: một số kanji có 0x5C (dấu gạch chéo ngược ASCII "\") là byte thứ hai, xung đột với chuỗi thoát C và dấu phân cách đường dẫn file. Các ký tự như 表 (0x955C), 能 (0x945C) và ソ (0x835C) là những ký tự có vấn đề nổi tiếng có thể gây lỗi khi sử dụng trong tên file hoặc đường dẫn - một vấn đề vẫn được báo cáo ngày nay.
EUC-JP được sử dụng rộng rãi trong môi trường UNIX. Vì byte thứ hai của nó luôn nằm trong phạm vi 0xA1–0xFE, nó hoàn toàn tránh được vấn đề 0x5C. Đặc tính an toàn này là một trong những lý do EUC-JP được ưa chuộng trong môi trường UNIX hơn Shift_JIS.
| Tính năng | Shift_JIS | EUC-JP |
|---|---|---|
| Mục đích sử dụng chính | Windows, hệ thống cũ | Môi trường UNIX/Linux |
| Byte mỗi ký tự CJK | 2 byte | 2 byte |
| Hỗ trợ emoji | Không | Không |
| Hỗ trợ đa ngôn ngữ | Chỉ tiếng Nhật | Chỉ tiếng Nhật |
| Khuyến nghị hiện tại | Không còn khuyến nghị (chỉ hệ thống cũ) | Không còn khuyến nghị (chỉ hệ thống cũ) |
Cân nhắc thực tế cho lập trình viên
Sự phân biệt ký tự-byte gây ra vấn đề thực tế trong phát triển hàng ngày. Dưới đây là các tình huống phổ biến nhất cần chú ý.
- Định nghĩa cột cơ sở dữ liệu:
VARCHAR(255)có nghĩa là "255 ký tự" hay "255 byte" phụ thuộc vào DBMS. Trong MySQL với utf8mb4, VARCHAR(255) có nghĩa là 255 ký tự và có thể yêu cầu tới 1.020 byte. Trong cấu hình mặc định của Oracle Database, VARCHAR2(255) có nghĩa là 255 byte, chỉ chứa được khoảng 85 ký tự CJK. PostgreSQL luôn sử dụng ngữ nghĩa dựa trên ký tự, đảm bảo 255 ký tự cho VARCHAR(255). Tham khảo hướng dẫn thiết kế cơ sở dữ liệu để biết khuyến nghị chi tiết theo từng DBMS - Giới hạn kích thước yêu cầu API: Hầu hết API áp dụng giới hạn theo byte, không phải ký tự. Văn bản CJK tiêu thụ khoảng gấp 3 lần byte so với văn bản tiếng Anh cho cùng số ký tự. Tên khóa JSON và chi phí metadata cũng được tính, làm giảm thêm dung lượng ký tự hiệu dụng
- Giới hạn ký tự SMS: Một tin nhắn SMS hỗ trợ 160 ký tự ASCII nhưng chỉ 70 ký tự khi sử dụng Unicode (bắt buộc cho CJK, emoji và hầu hết các hệ chữ không phải Latin). Điều này là do SMS sử dụng mã hóa GSM 7-bit (7 bit × 160 = 1.120 bit) và mã hóa UCS-2 (16 bit × 70 = 1.120 bit) thay thế cho nhau - cả hai đều nằm trong cùng payload vật lý 140 byte
- Ước tính kích thước file: Kích thước file văn bản được xác định bởi số byte, không phải số ký tự. Một tài liệu tiếng Nhật 10.000 ký tự khoảng 30 KB trong UTF-8. Kết thúc dòng CRLF (Windows) thêm byte phụ mỗi dòng so với LF (Unix)
- Cắt ngắn chuỗi: Cắt ngắn theo số byte có thể chia đôi ký tự đa byte giữa chuỗi, tạo ra đầu ra bị hỏng hoặc mojibake. Trong UTF-8, cắt ngắn không hợp lệ có thể được phát hiện bằng cách kiểm tra mẫu bit đầu - nếu byte cuối cùng là byte tiếp tục (10xxxxxx), điểm cắt ngắn nằm giữa ký tự
| Ví dụ văn bản | Ký tự | Byte UTF-8 | Byte UTF-16 | Tỷ lệ Byte/Ký tự (UTF-8) |
|---|---|---|---|---|
| Hello | 5 | 5 | 10 | 1.0 |
| café | 4 | 5 | 8 | 1.25 |
| 日本語 | 3 | 9 | 6 | 3.0 |
| 𠮷野家 | 3 | 10 | 8 | 3.3 |
| 🎉🎊🎈 | 3 | 12 | 12 | 4.0 |
| 👨👩👧👦 (emoji gia đình) | Nhìn thấy 1 | 25 | 22 | — |
"Emoji gia đình" ở hàng cuối là ký tự tổ hợp được tạo bằng cách ghép bốn emoji riêng lẻ với ZWJ (Zero Width Joiner, U+200D). Nó hiển thị như một ký tự đơn nhưng bên trong bao gồm 7 điểm mã (4 emoji + 3 ký tự ZWJ). String.length của JavaScript trả về 11, và [...str].length trả về 7. Đây là ví dụ điển hình về cách "số ký tự trực quan" và "số ký tự nội bộ" có thể khác biệt đáng kể, và nó đại diện cho trường hợp thách thức nhất cho các triển khai đếm ký tự.
Kỹ thuật của kỹ sư có kinh nghiệm
Kỹ sư xử lý vấn đề mã hóa hàng ngày dựa vào bộ kỹ thuật thực tế để tránh các bẫy phổ biến.
- Quy tắc ước lượng "Byte ÷ 3": Để ước tính nhanh số ký tự của văn bản CJK trong UTF-8, chia số byte cho 3. Ví dụ, 3.000 byte văn bản tiếng Nhật là khoảng 1.000 ký tự. Văn bản đa ngôn ngữ sẽ có số ký tự cao hơn một chút. Trong tài liệu kỹ thuật tiếng Nhật điển hình với 20–30% nội dung là ASCII, tỷ lệ byte trên ký tự thực tế khoảng 2,4–2,7
- Luôn sử dụng utf8mb4 trong MySQL: Mã hóa
utf8(utf8mb3) cũ chỉ hỗ trợ tối đa 3 byte mỗi ký tự, nghĩa là emoji không thể được lưu trữ. Luôn chỉ địnhutf8mb4cho dự án mới. Khi di chuyển, lưu ý rằng khóa chỉ mục utf8mb4 tiêu thụ tới 4 byte mỗi ký tự - cột VARCHAR(255) sử dụng 1.020 byte trong giới hạn khóa chỉ mục tối đa 3.072 byte của InnoDB. Xác minh rằng chỉ mục tổ hợp không vượt quá giới hạn này - Cắt ngắn theo số ký tự, không phải byte: Cắt ngắn theo byte có thể chia đôi ký tự đa byte. Trong Python, sử dụng
text[:100](dựa trên ký tự). Trong JavaScript, sử dụng[...text].slice(0, 100).join('')(dựa trên điểm mã). Tuy nhiên, ngay cả cắt ngắn dựa trên điểm mã cũng có thể chia đôi emoji ghép ZWJ giữa chuỗi. Để cắt ngắn nhận biết cụm grapheme hoàn chỉnh, sử dụng APIIntl.Segmentercủa JavaScript - Lưu ý về BOM: BOM UTF-8 (byte order mark,
EF BB BF) là 3 byte ở đầu file. Một số trình phân tích JSON từ chối file có BOM. Sử dụng UTF-8 không có BOM cho file lập trình, nhưng UTF-8 có BOM cho file CSV mở trong Excel. Script shell có BOM ở đầu sẽ không thể thực thi vì shebang (#!/bin/bash) không được nhận dạng - Chú ý cặp thay thế:
String.lengthcủa JavaScript trả về đơn vị mã UTF-16, nên emoji và một số ký tự CJK báo cáo độ dài là 2 thay vì 1. Sử dụng[...str].length(cú pháp spread) để đếm số ký tự chính xác. Biểu thức chính quy cũng yêu cầu cờu- nếu không có nó,/^.$/sẽ không khớp emoji vì chúng được xử lý như hai đơn vị mã riêng biệt - Cạm bẫy phát hiện mã hóa: Phát hiện mã hóa tự động không hoàn toàn chính xác. Văn bản ngắn có tỷ lệ phát hiện sai cao hơn, và Shift_JIS và UTF-8 có các mẫu byte chồng chéo khiến việc phát hiện chính xác khó khăn cho văn bản chỉ vài chục byte. Cách tiếp cận đáng tin cậy là chỉ định rõ ràng mã hóa tại nguồn dữ liệu và truyền nó dưới dạng metadata
Lỗi mã hóa thực tế
Vấn đề mã hóa có thể gây ra lỗi hệ thống quy mô lớn. Dưới đây là các mẫu đã gây ra sự cố sản xuất thực tế.
- Hỏng dữ liệu emoji: Một dịch vụ sử dụng
utf8(giới hạn 3 byte) của MySQL đã gặp hỏng dữ liệu khi người dùng đăng emoji. Di chuyển sangutf8mb4yêu cầu di chuyển cơ sở dữ liệu quy mô lớn và thời gian bảo trì kéo dài. Các thách thức kỹ thuật chính trong quá trình di chuyển bao gồm thời gian khóa bảng, xây dựng lại chỉ mục và quản lý độ trễ sao chép - Đăng ký tên hành chính: Các hệ thống hành chính chỉ hỗ trợ bộ ký tự giới hạn (ví dụ: kanji JIS cấp 1 và 2) không thể đăng ký tên chứa ký tự hiếm hoặc biến thể. Đây là vấn đề lâu dài trong hệ thống CNTT chính phủ Nhật Bản. Cơ quan Kỹ thuật số Nhật Bản đã công bố "Hướng dẫn triển khai môi trường ký tự" năm 2023, khuyến nghị áp dụng Unicode làm tiêu chuẩn cho hệ thống chính phủ
- Lỗi lập chỉ mục công cụ tìm kiếm: Khi header Content-Type của trang web khai báo một mã hóa nhưng nội dung thực tế sử dụng mã hóa khác, công cụ tìm kiếm không thể lập chỉ mục trang chính xác. Ví dụ, khai báo UTF-8 trong header trong khi phục vụ nội dung Shift_JIS dẫn đến đoạn trích tìm kiếm bị lỗi và không xếp hạng cho từ khóa mong muốn
- Vấn đề ghi log tên múi giờ: Các vùng có tên múi giờ không phải ASCII (ví dụ: "日本標準時" trong môi trường tiếng Nhật) có thể gây lỗi phân tích log khi hệ thống thu thập log chỉ mong đợi ASCII. Chuẩn hóa mã hóa log sang UTF-8 và đảm bảo trình phân tích xử lý văn bản đa byte là điều thiết yếu
Sự thật thú vị về mã hóa ký tự
UTF-8 được thiết kế năm 1992 bởi Rob Pike và Ken Thompson. Theo một giai thoại nổi tiếng, sơ đồ ban đầu được phác thảo trên mặt sau của tấm lót đĩa tại một quán ăn ở New Jersey. Vào thời điểm đó, nhiều đề xuất mã hóa Unicode đang cạnh tranh, và yếu tố quyết định trong việc áp dụng UTF-8 là khả năng tương thích với chuỗi kết thúc bằng null của C. Trong UTF-8, byte null (0x00) không bao giờ xuất hiện ngoại trừ dưới dạng ký tự ASCII NUL, nên các hàm C như strlen() và strcpy() hoạt động mà không cần sửa đổi. Nếu không có thuộc tính này, cơ sở mã khổng lồ hiện có của phần mềm C/UNIX sẽ cần phải viết lại, và việc áp dụng sẽ bị trì hoãn đáng kể.
Một sự thật ít được biết đến khác: trong khi hầu hết kanji tiếng Nhật chiếm 3 byte trong UTF-8, một số kanji hiếm trong khối CJK Unified Ideographs Extension B trở đi yêu cầu 4 byte. Ký tự "𠮷" (dạng thay thế của 吉 được sử dụng trong tên chính thức của chuỗi nhà hàng Yoshinoya) là một ký tự 4 byte như vậy. Về mặt kỹ thuật, kanji trong BMP (Basic Multilingual Plane, U+0000–U+FFFF) là 3 byte trong UTF-8, trong khi những ký tự được đặt trong các mặt phẳng bổ sung (U+10000 trở lên) yêu cầu 4 byte. Riêng Extension B chứa khoảng 42.000 ký tự, và nhiều ký tự biến thể được sử dụng trong tên người và tên địa danh nằm trong phạm vi này.
Kết luận
Hiểu sự phân biệt ký tự-byte là nền tảng để xây dựng phần mềm mạnh mẽ. Đối với dự án mới, hãy chuẩn hóa trên UTF-8 (cụ thể là utf8mb4 trong MySQL), luôn cắt ngắn theo số ký tự thay vì số byte, và chuẩn bị các trường hợp kiểm thử bao gồm emoji và ký tự biến thể. Những thực hành này là cách hiệu quả nhất để ngăn ngừa các vấn đề liên quan đến mã hóa trước khi chúng đến môi trường sản xuất. Sử dụng Bộ đếm ký tự để kiểm tra cả số ký tự và số byte cho văn bản của bạn.