一、故事背景
記一次 sql_mode 非嚴格模式下的業務事故排查。當時資料庫沒有開啟 sql_mode 為嚴格模式,並且資料表的編碼是 utf8
,表現為業務側的 Insert SQL
語句執行成功,但是,
查詢表記錄的時候,發現欄位的資料值缺失。示例:寫入一條有特殊字元 ? 的記錄,記錄裡面欄位值在 ? 之後的字元都丟失了。
下面是,開啟了嚴格模式:
問題原因定位到後,解決方案是,在不對資料庫做任何配置調整的前提下,業務邏輯中增加對特殊字元的檢測,過濾掉資料庫不支援的特殊字元,從而杜絕寫入資料表後出現資料缺失的事故。
那麼,哪些字元是 MySQL 不支援的嘞?由此引出本文的探討主題。
二、認識 MySQL UTF8 字符集
我們帶著兩個問題,去調研 MySQL 5.7 版本 UTF8 字符集。
2.1. MySQL 不支援的特殊字元有哪些?
PS: 這裡貼的 MySQL 官方文件也是 5.7。
從文件提取下關鍵資訊:
- 在 MySQL 中 utf8 是 utf8mb3 的別名
- utf8mb3 編碼的每個字元最多三個位元組
示例:特殊字元 ? 特殊字元:
可以觀察到這個字元,需要使用四個位元組編碼,因此這個字元不能被資料庫 utf8mb3 編碼支援。
說點題外話,在 Java 中 String 是 UTF-16
格式的,當我們用滑鼠複製 ?
字元到一個雙引號中時,idea 編輯器,會自動轉換為這樣的格式:
那麼,MySQL 的 utf8mb3 不支援哪些字元 ?
繼續看 MySQL官方文件
可以看到,文件中已經給出了比較明確的描述:
- 僅支援 BMP 字元
- 一個字元的編碼最多三個位元組。
到這裡,你可能又會問是什麼 BMP 字元嘞,Wiki 百科看不懂啊!
在介紹這個問題之前,首先要了解一點基礎知識 Code point
大家應該都認識這張表,ASCLL
包含 128 個 Code point
表示 128 個字元(也就是 0 ~ 127)。
在標準的 Unicode
中容納了 1,114,112 code points,其中前 65,536 個 Code point
(也就是 0 ~ 65535)稱為 Basic Multilingual Plane(縮寫:BMP)
- 檢視一個字元的
Code point
可以使用 charbase.com,示例,檢視大寫字母 A :
- 判斷一個字元是否是 BMP
首先計算出字元的Code point
,然後檢查其範圍,如果在 0 ~ 65535 內,就是 BMP 字元。
2.2. MySQL UTF8 和 標準 UTF-8 編碼是一個概念嗎?
通過上一個問題,我們瞭解到,MySQL 5.7 版本中 UTF8
是 utf8mb3
的別名,utf8mb3
是使用 1 ~ 3 個位元組對 Unicode
字元進行編碼,僅支援 BMP 字元。
在 Wiki 百科裡面對 UTF-8 的定義是:
簡言之:使用 1 ~ 4 個位元組對標準 Unicode 1,112,064 個有效的字元 Code point 進行編碼。
因此,這兩個 utf8
在不同的上下文背景下不是一個概念,很多開發人員包括我,經常在沒有對事物做詳細調研之前,憑藉主觀經驗對事物妄下結論。
三、程式語言最佳實踐
通過上面分析,我們知道問題的背景和原因。下面的給出最佳程式設計實踐,選取前/後端使用的兩門語言:
3.1. 在 Java 語言中檢測字串中的非 BMP 字元
public class Main {
public static void main(String[] args) {
String str = "?方程";
boolean contain = isContainsNonBmpUnicodeCharacter(str);
if (contain) {
System.out.println("The string contains non-BMP Unicode character.");
}
}
private static boolean isContainsNonBmpUnicodeCharacter(String str) {
return str.length() != str.codePointCount(0, str.length());
}
}
3.2.在 Javascript 中檢測字串中非 BMP 字元
function main() {
let str = "?方程";
let contains = isContainsNonBmpUnicodeCharacter(str);
if (contains) {
console.log("The string contains non-BMP Unicode character.");
}
}
function isContainsNonBmpUnicodeCharacter(str) {
return str.split(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g).length != 1;
}