運維同事匯入一批大約500萬左右的資料,耗時較久。他使用的是純SQL匯入,主鍵使用的是UUID,因為業務原因沒有使用自增ID。
因為是內網,不能遠端存取。
通過溝通,大致覺得有兩個原因,一是因為UUID作為主鍵,二是表欄位繁多,單行加起來接近10000的長度引起行溢位。
因為是臨時一次性任務,同事沒有做深糾,我在這裡簡單做一個驗證。
版本:5.7.19
引擎:innodb
mysql自帶的UUID()函數簡單方便,不重複。但是它缺點也是眾所周知的。
UUID的返回值通常是隨機的,而InnoDB的表實質是以主鍵組織儲存的索引,插入新的記錄不是順序追加,而會往前插入,造成頁分裂,表的再平衡。在資料量越大的情況,效能影響越嚴重。
主鍵包含在每個二級索引中,過長的主鍵會浪費磁碟和記憶體的空間。
頁分裂
為什麼主鍵ID的順序這麼重要?
mysql innodb引擎資料結構為B+tree,查詢的時候二分查詢,這就要求資料是按順序儲存的。
而UUID是無序的,它作為主鍵在寫入的時候,就可能會頻繁的進行中間插入和頁分裂。頁分裂會造成已有的資料移位,類似於arraylist進行插入資料。
因此表資料越多,插入的效率就會越低。也會使得磁碟空間利用率降低。
除了UUID,其它還有兩種常見的ID生成法。
一是自增id,最簡單效率也最高,但不適合分散式唯一主鍵,和在一些敏感業務如訂單註冊使用者時,可能通過ID值會暴露一些商業祕密。
雪花演演算法
再一個是雪花演演算法,最常見的分散式ID生成演演算法。
可以看到它跟時間戳(毫秒級)和伺服器相關,通過12bit序列號區分同毫秒內產生的不同id。
因此能保證在單臺伺服器上的大致趨勢遞增。 在並行高的情況下不能保證完全遞增,因此需考慮到這一點。
它有時鐘回撥的問題,這裡不展開。
定量驗證
以上只是定性的判斷。
下面做一下定量的測試。
本文驗證自增id,雪花演演算法ID,以及uuid作為主鍵3種情況,往3張空表插入500萬條資料,每個批次10萬,共50個批次。
StopWatch stopWatch = new StopWatch();
stopWatch.start("準備資料階段");
int count = 500 * 10000 ;
List<TUser> list = new ArrayList<>(count);
ExecutorService executorService = Executors.newFixedThreadPool(100);
List<TUser> finalList = list;
Semaphore semaphore = new Semaphore(100);
for (int i = 0; i < count; i++) {
executorService.submit(new Runnable() {
@SneakyThrows
@Override
public void run() {
semaphore.acquire();
TUser user = new TUser();
// 雪花演演算法ID
user.setId(SnowflakeIdUtil.snowflakeId());
user.setName(testService.getRandomName());
// UUID
user.setId(UUID.randomUUID().toString());
finalList.add(user);
semaphore.release();
}
}).get();
}
stopWatch.stop();
System.out.println("開始寫入");
List<Time> times = new ArrayList<>();
stopWatch.start("寫入資料階段");
List<TUser> subList = new ArrayList<>(100000);
for (int i = 1; i <= count; i++) {
TUser user = finalList.get(i-1);
subList.add(user);
if (i % 100000 == 0) {
// finalList.remove(user);
long start = System.currentTimeMillis();
tUserDao.insertBatch(subList);
long time = System.currentTimeMillis() - start;
times.add(new Time(time));
subList.clear();
log.info("寫入一次" + i);
}
}
stopWatch.stop();
System.out.println(stopWatch.prettyPrint());
// 將每個批次耗時列印出來,放入excel生成圖表
times.stream().map(e -> e.getTime()).forEach(System.out::println);
總耗時情況
UU ID:414321ms
雪花ID 167748ms
自增ID 75829ms
將最後每個批次耗時的集合列印出來,放到Excel中,生成圖表。
縱座標表示耗時,單位ms
橫座標表示寫入批次
可以明顯的觀察到,使用UUID作為主鍵,在表資料量在350萬以後,耗時上升極快,效能呈指數級下降。
同時自增ID效率高於雪花演演算法ID,都很穩定。
這是在行資料量極小(<50)的情況下。
在行大小> 8000,在頁16KB至少兩行資料的情況下,也必然會發生行溢位。這裡再看看測試情況。
MySQL單行資料最大能儲存65535位元組的資料,InnoDB的頁為16KB,即16384位元組,當行的實際儲存長度超過16384/2的長度時,就會行溢位。
為什麼是/2,因為頁至少需要儲存兩行資料。
為什麼單頁至少儲存兩行資料,如果為1,B+tree就退化為了連結串列。
當行溢位的時候,會將大欄位資料存放在頁型別為Uncompress BLOB頁中,在當前頁就會對大欄位建立一個參照,類假於二級索引。
所以設定測試表name長度大於大約16384/2的時候,就會發生行溢位現象。
測試程式碼跟上面類似。
再準備3張空表。
欄位型別varchar,長度10000,user.setName()設定實際長度大於8000。
這次只寫入50萬條資料,每批次1萬條資料,共50個批次。
這次uuid在寫入30萬條記錄後,耗時開始明顯上升。
以上是行溢位的情況下,UUID,雪花演演算法,自增ID等 3種ID的寫入效能情況。
下面來測試一下,使用UUID在行溢位和不溢位的兩種情況下寫入效能情況。
再新建兩張表,t_user_uuid_long,此表name欄位長度100000,最終寫入字元長度9995
t_user_uuid_short,此表name欄位長度8000,最終寫入字元長度7995
寫入耗時曲線大致相當,看不出明顯差距。
但是讀的差距非常明顯!
因為t_user_uuid_long 中的name欄位發生了行溢位,。。。。。select的時候需要跨頁提取資料,類似於做了1次回表,而且回表還是跨頁的。
select id,name from t_user_uuid_short order by id asc limit 1000
耗時0.145s
select id,name from t_user_uuid_long order by id asc limit 1000
耗時4.153s,效能相差接近30倍。
以上測試結果只是在測試機上的粗糙測試結果,不是基準測試,只做參考。
測試結果表明,運維同事的慢操作可能受UUID影響 ,但大欄位對寫入的影響並不明顯。
不過大欄位對讀取的效能影響明顯。