使用uuid作為數(shù)據(jù)庫(kù)主鍵,被技術(shù)總監(jiān)懟了!

一、前言
在日常開發(fā)中,數(shù)據(jù)庫(kù)中主鍵id的生成方案,主要有三種
數(shù)據(jù)庫(kù)自增ID 采用隨機(jī)數(shù)生成不重復(fù)的ID 采用jdk提供的uuid
對(duì)于這三種方案,我發(fā)現(xiàn)在數(shù)據(jù)量少的情況下,沒(méi)有特別的差異,但是當(dāng)單表的數(shù)據(jù)量達(dá)到百萬(wàn)級(jí)以上時(shí)候,他們的性能有著顯著的區(qū)別,光說(shuō)理論不行,還得看實(shí)際程序測(cè)試,今天就帶著大家一探究竟!
二、程序?qū)嵗?/span>
首先,我們?cè)诒镜財(cái)?shù)據(jù)庫(kù)中創(chuàng)建三張單表tb_uuid_1、tb_uuid_2、tb_uuid_3,同時(shí)設(shè)置tb_uuid_1表的主鍵為自增長(zhǎng)模式,腳本如下:
CREATE TABLE `tb_uuid_1` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主鍵ID自增長(zhǎng)';
CREATE TABLE `tb_uuid_2` (
`id` bigint(20) unsigned NOT NULL,
`name` varchar(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主鍵ID隨機(jī)數(shù)生成';
CREATE TABLE `tb_uuid_3` (
`id` varchar(50) NOT NULL,
`name` varchar(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主鍵采用uuid生成';
下面,我們采用Springboot + mybatis來(lái)實(shí)現(xiàn)插入測(cè)試。
2.1、數(shù)據(jù)庫(kù)自增
以數(shù)據(jù)庫(kù)自增為例,首先編寫好各種實(shí)體、數(shù)據(jù)持久層操作,方便后續(xù)進(jìn)行測(cè)試
/**
* 表實(shí)體
*/
public class UUID1 implements Serializable {
private Long id;
private String name;
//省略set、get
}
/**
* 數(shù)據(jù)持久層操作
*/
public interface UUID1Mapper {
/**
* 自增長(zhǎng)插入
* @param uuid1
*/
@Insert("INSERT INTO tb_uuid_1(name) VALUES(#{name})")
void insert(UUID1 uuid1);
}
/**
* 自增ID,單元測(cè)試
*/
@Test
public void testInsert1(){
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
uuid1Mapper.insert(new UUID1().setName("張三"));
}
long end = System.currentTimeMillis();
System.out.println("花費(fèi)時(shí)間:" + (end - start));
}
2.2、采用隨機(jī)數(shù)生成ID
這里,我們采用twitter的雪花算法來(lái)實(shí)現(xiàn)隨機(jī)數(shù)ID的生成,工具類如下:
public class SnowflakeIdWorker {
private static SnowflakeIdWorker instance = new SnowflakeIdWorker(0,0);
/**
* 開始時(shí)間截 (2015-01-01)
*/
private final long twepoch = 1420041600000L;
/**
* 機(jī)器id所占的位數(shù)
*/
private final long workerIdBits = 5L;
/**
* 數(shù)據(jù)標(biāo)識(shí)id所占的位數(shù)
*/
private final long datacenterIdBits = 5L;
/**
* 支持的最大機(jī)器id,結(jié)果是31 (這個(gè)移位算法可以很快的計(jì)算出幾位二進(jìn)制數(shù)所能表示的最大十進(jìn)制數(shù))
*/
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
/**
* 支持的最大數(shù)據(jù)標(biāo)識(shí)id,結(jié)果是31
*/
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
/**
* 序列在id中占的位數(shù)
*/
private final long sequenceBits = 12L;
/**
* 機(jī)器ID向左移12位
*/
private final long workerIdShift = sequenceBits;
/**
* 數(shù)據(jù)標(biāo)識(shí)id向左移17位(12+5)
*/
private final long datacenterIdShift = sequenceBits + workerIdBits;
/**
* 時(shí)間截向左移22位(5+5+12)
*/
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
/**
* 生成序列的掩碼,這里為4095 (0b111111111111=0xfff=4095)
*/
private final long sequenceMask = -1L ^ (-1L << sequenceBits);
/**
* 工作機(jī)器ID(0~31)
*/
private long workerId;
/**
* 數(shù)據(jù)中心ID(0~31)
*/
private long datacenterId;
/**
* 毫秒內(nèi)序列(0~4095)
*/
private long sequence = 0L;
/**
* 上次生成ID的時(shí)間截
*/
private long lastTimestamp = -1L;
/**
* 構(gòu)造函數(shù)
* @param workerId 工作ID (0~31)
* @param datacenterId 數(shù)據(jù)中心ID (0~31)
*/
public SnowflakeIdWorker(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
/**
* 獲得下一個(gè)ID (該方法是線程安全的)
* @return SnowflakeId
*/
public synchronized long nextId() {
long timestamp = timeGen();
// 如果當(dāng)前時(shí)間小于上一次ID生成的時(shí)間戳,說(shuō)明系統(tǒng)時(shí)鐘回退過(guò)這個(gè)時(shí)候應(yīng)當(dāng)拋出異常
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
// 如果是同一時(shí)間生成的,則進(jìn)行毫秒內(nèi)序列
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
// 毫秒內(nèi)序列溢出
if (sequence == 0) {
//阻塞到下一個(gè)毫秒,獲得新的時(shí)間戳
timestamp = tilNextMillis(lastTimestamp);
}
}
// 時(shí)間戳改變,毫秒內(nèi)序列重置
else {
sequence = 0L;
}
// 上次生成ID的時(shí)間截
lastTimestamp = timestamp;
// 移位并通過(guò)或運(yùn)算拼到一起組成64位的ID
return ((timestamp - twepoch) << timestampLeftShift) //
| (datacenterId << datacenterIdShift) //
| (workerId << workerIdShift) //
| sequence;
}
/**
* 阻塞到下一個(gè)毫秒,直到獲得新的時(shí)間戳
* @param lastTimestamp 上次生成ID的時(shí)間截
* @return 當(dāng)前時(shí)間戳
*/
protected long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 返回以毫秒為單位的當(dāng)前時(shí)間
* @return 當(dāng)前時(shí)間(毫秒)
*/
protected long timeGen() {
return System.currentTimeMillis();
}
public static SnowflakeIdWorker getInstance(){
return instance;
}
public static void main(String[] args) throws InterruptedException {
SnowflakeIdWorker idWorker = SnowflakeIdWorker.getInstance();
for (int i = 0; i < 10; i++) {
long id = idWorker.nextId();
Thread.sleep(1);
System.out.println(id);
}
}
}
其他的操作,與上面類似。
2.3、uuid
同樣的,uuid的生成,我們事先也可以將工具類編寫好:
public class UUIDGenerator {
/**
* 獲取uuid
* @return
*/
public static String getUUID(){
return UUID.randomUUID().toString();
}
}
最后的單元測(cè)試,代碼如下:
@RunWith(SpringRunner.class)
@SpringBootTest()
public class UUID1Test {
private static final Integer MAX_COUNT = 1000000;
@Autowired
private UUID1Mapper uuid1Mapper;
@Autowired
private UUID2Mapper uuid2Mapper;
@Autowired
private UUID3Mapper uuid3Mapper;
/**
* 測(cè)試自增ID耗時(shí)
*/
@Test
public void testInsert1(){
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_COUNT; i++) {
uuid1Mapper.insert(new UUID1().setName("張三"));
}
long end = System.currentTimeMillis();
System.out.println("自增ID,花費(fèi)時(shí)間:" + (end - start));
}
/**
* 測(cè)試采用雪花算法生產(chǎn)的隨機(jī)數(shù)ID耗時(shí)
*/
@Test
public void testInsert2(){
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_COUNT; i++) {
long id = SnowflakeIdWorker.getInstance().nextId();
uuid2Mapper.insert(new UUID2().setId(id).setName("張三"));
}
long end = System.currentTimeMillis();
System.out.println("花費(fèi)時(shí)間:" + (end - start));
}
/**
* 測(cè)試采用UUID生成的ID耗時(shí)
*/
@Test
public void testInsert3(){
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_COUNT; i++) {
String id = UUIDGenerator.getUUID();
uuid3Mapper.insert(new UUID3().setId(id).setName("張三"));
}
long end = System.currentTimeMillis();
System.out.println("花費(fèi)時(shí)間:" + (end - start));
}
}
三、性能測(cè)試
程序環(huán)境搭建完成之后,啥也不說(shuō)了,直接擼起袖子,將單元測(cè)試跑起來(lái)!
首先測(cè)試一下,插入100萬(wàn)數(shù)據(jù)的情況下,三者直接的耗時(shí)結(jié)果如下:




在原有的數(shù)據(jù)量上,我們繼續(xù)插入30萬(wàn)條數(shù)據(jù),三者耗時(shí)結(jié)果如下:

可以看出在數(shù)據(jù)量 100W 左右的時(shí)候,uuid的插入效率墊底,隨著插入的數(shù)據(jù)量增長(zhǎng),uuid 生成的ID插入呈直線下降!
時(shí)間占用量總體效率排名為:自增ID > 雪花算法生成的ID >> uuid生成的ID。
在數(shù)據(jù)量較大的情況下,為什么uuid生成的ID遠(yuǎn)不如自增ID呢?
關(guān)于這點(diǎn),我們可以從 mysql 主鍵存儲(chǔ)的內(nèi)部結(jié)構(gòu)來(lái)進(jìn)行分析。
3.1、自增ID內(nèi)部結(jié)構(gòu)
自增的主鍵的值是順序的,所以 Innodb 把每一條記錄都存儲(chǔ)在一條記錄的后面。

當(dāng)達(dá)到頁(yè)面的最大填充因子時(shí)候(innodb默認(rèn)的最大填充因子是頁(yè)大小的15/16,會(huì)留出1/16的空間留作以后的修改),會(huì)進(jìn)行如下操作:
下一條記錄就會(huì)寫入新的頁(yè)中,一旦數(shù)據(jù)按照這種順序的方式加載,主鍵頁(yè)就會(huì)近乎于順序的記錄填滿,提升了頁(yè)面的最大填充率,不會(huì)有頁(yè)的浪費(fèi) 新插入的行一定會(huì)在原有的最大數(shù)據(jù)行下一行,mysql定位和尋址很快,不會(huì)為計(jì)算新行的位置而做出額外的消耗
3.2、使用uuid的索引內(nèi)部結(jié)構(gòu)
uuid相對(duì)順序的自增id來(lái)說(shuō)是毫無(wú)規(guī)律可言的,新行的值不一定要比之前的主鍵的值要大,所以innodb無(wú)法做到總是把新行插入到索引的最后,而是需要為新行尋找新的合適的位置從而來(lái)分配新的空間。

這個(gè)過(guò)程需要做很多額外的操作,數(shù)據(jù)的毫無(wú)順序會(huì)導(dǎo)致數(shù)據(jù)分布散亂,將會(huì)導(dǎo)致以下的問(wèn)題:
寫入的目標(biāo)頁(yè)很可能已經(jīng)刷新到磁盤上并且從緩存上移除,或者還沒(méi)有被加載到緩存中,innodb在插入之前不得不先找到并從磁盤讀取目標(biāo)頁(yè)到內(nèi)存中,這將導(dǎo)致大量的隨機(jī)IO 因?yàn)閷懭胧莵y序的,innodb不得不頻繁的做頁(yè)分裂操作,以便為新的行分配空間,頁(yè)分裂導(dǎo)致移動(dòng)大量的數(shù)據(jù),一次插入最少需要修改三個(gè)頁(yè)以上 由于頻繁的頁(yè)分裂,頁(yè)會(huì)變得稀疏并被不規(guī)則的填充,最終會(huì)導(dǎo)致數(shù)據(jù)會(huì)有碎片
在把值載入到聚簇索引(innodb默認(rèn)的索引類型)以后,有時(shí)候會(huì)需要做一次OPTIMEIZE TABLE來(lái)重建表并優(yōu)化頁(yè)的填充,這將又需要一定的時(shí)間消耗。
因此,在選擇主鍵ID生成方案的時(shí)候,盡可能別采用uuid的方式來(lái)生成主鍵ID,隨著數(shù)據(jù)量越大,插入性能會(huì)越低!
四、總結(jié)
在實(shí)際使用過(guò)程中,推薦使用主鍵自增ID和雪花算法生成的隨機(jī)ID。
但是使用自增ID也有缺點(diǎn):
別人一旦爬取你的數(shù)據(jù)庫(kù),就可以根據(jù)數(shù)據(jù)庫(kù)的自增id獲取到你的業(yè)務(wù)增長(zhǎng)信息,很容易進(jìn)行數(shù)據(jù)竊取。
其次,對(duì)于高并發(fā)的負(fù)載,innodb在按主鍵進(jìn)行插入的時(shí)候會(huì)造成明顯的鎖爭(zhēng)用,主鍵的上界會(huì)成為爭(zhēng)搶的熱點(diǎn),因?yàn)樗械牟迦攵及l(fā)生在這里,并發(fā)插入會(huì)導(dǎo)致間隙鎖競(jìng)爭(zhēng)。
總結(jié)起來(lái),如果業(yè)務(wù)量小,推薦采用自增ID,如果業(yè)務(wù)量大,推薦采用雪花算法生成的隨機(jī)ID。
本篇文章主要從實(shí)際程序?qū)嵗霭l(fā),討論了三種主鍵ID生成方案的性能差異, 鑒于筆者才疏學(xué)淺,可能也有理解不到位的地方,歡迎網(wǎng)友們批評(píng)指出!
五、參考
1、方志明 - 使用雪花id或uuid作為Mysql主鍵,被老板懟了一頓!
