1 MySQL全局ID
1.1 前言
系統唯一ID是我們在設計一個系統的時候常常會遇見的問題,也常常為這個問題而糾結。
為什么需要分布式全局唯一ID
以及分布式ID
的業務需求
在復雜分布式系統中,往往需要對大量的數據和消息進行唯一標識,如在美團點評的金融、支付、餐飲、酒店;貓眼電影等產品的系統中數據逐漸增長,對數據庫分庫分表后需要有一個唯一ID來標識一條數據或信息;特別的訂單、騎手、優惠券都需要有唯一ID做標識
此時一個能夠生成全局唯一ID的系統是非常必要的
1.2 ID生成要求
1.2.1 ID生成規則部分硬性要求
ID生成規則部分硬性要求:
- 全局唯一
- 趨勢遞增
在MySQL
的InnoDB
引擎中使用的是聚集索引
,由於多數RDBMS
使用Btree
的數據結構來存儲索引,在主鍵的選擇上面我們應該盡量使用有序的主鍵保證寫入性能 - 單調遞增
保證下一個ID
一定大於
上一個ID,例如事務版本號、IM增量消息、排序等特殊需求 - 信息安全
如果ID是連續,惡意用戶的爬取工作就非常容易做了,直接按照順序下載指定URL
即可,如果是訂單號就危險了,競爭對手可以直接知道我們一天的單量,所以在一些應用場景下,需要ID無規則不規則,讓競爭對手不好猜 - 含時間戳
一樣能夠快速在開發中了解這個分布式ID什么時候生成的
1.2.2 ID號生成系統可用性要求
ID號生成系統可用性要求:
- 高可用
發布一個獲取分布式ID
請求,服務器就要保證99.999%的情況下給我創建一個唯一分布式ID - 低延遲
發一個獲取分布式ID的請求,服務器就要快,極速 - 高QPS
例如並發一口氣10萬個創建分布式ID請求同時殺過來,服務器要頂得住且一下子成功創建10萬個分布式ID
1.3 一般通用解決方案
1.3.1 UUID
1.3.1.1 使用
UUID.randomUUID()
, UUID
的標准型包含32
個16
進制數字,以連字號分為五段
,形式為 8-4-4-4-12
的36字符
,性能非常高,本地生成,沒有網絡消耗。
1.3.1.2 存在問題
-
入數據庫性能差,因為
UUID
是無序的
無序,無法預測他的生成順序,不能生成遞增有序的數字
首先分布式id
一般都會作為主鍵,但是按照mysql
官方推薦主鍵盡量越短越好,UUID
每一個都很長,所以不是很推薦。 -
主鍵,
ID
作為主鍵時,在特定的環境下會存在一些問題
比如做DB
主鍵的場景下,UUID
就非常不適用,MySQL
官方有明確的說明 -
索引,
B+
樹索引的分裂
既然分布式ID
是主鍵,然后主鍵是包含索引的,而mysql
的索引是通過B+樹
來實現的,每一次新的UUID
數據的插入,為了查詢的優化,都會對索引底層的B+
樹進行修改,因為UUID
數據是無序的,所以每一次UUID
數據的插入都會對主鍵的B+
樹進行很大的修改,這一點很不好,插入完全無序,不但會導致一些中間節點產生分裂,也會白白創造出很多不飽和節點
,這樣大大降低了數據庫插入的性能。
UUID只能保證全局唯一性,不滿足后面的趨勢遞增,單調遞增
1.3.2 數據庫自增主鍵
1.3.2.1 單機
在分布式里面,數據庫的自增ID
機制的主要原理是:數據庫自增ID
和mysql
數據庫的replace into
實現的,這里的replace into
跟insert
功能 類似,不同點在於:replace into
首先嘗試插入數據列表中,如果發現表中已經有此行數據(根據主鍵或唯一索引判斷)則先刪除,再插入,否則直接插入新數據。
REPLACE INTO
的含義是插入一條記錄,如果表中唯一索引的值遇到沖突,則替換老數據
REPLACE into t_test(stub) values('b');
select LAST_INSERT_ID();
我們每次插入的時候,發現都會把原來的數據給替換,並且ID也會增加
這就滿足了:遞增性、單調性、唯一性
在分布式情況下,並且並發量不多的情況,可以使用這種方案來解決,獲得一個全局的唯一ID
1.3.2.2 集群分布式集群
那數據庫自增ID機制適合做分布式ID嗎?答案是不太適合
系統水平擴展比較困難,比如定義好步長和機器台數之后,如果要添加機器該怎么辦,假設現在有一台機器發號是:1,2,3,4,5,(步長是1),這個時候需要擴容機器一台,可以這樣做:把第二胎機器的初始值設置得比第一台超過很多,貌似還好,但是假設線上如果有100台機器,這個時候擴容要怎么做,簡直是噩夢,所以系統水平擴展方案復雜難以實現。
數據庫壓力還是很大,每次獲取ID都得讀寫一次數據庫,非常影響性能,不符合分布式ID里面的延遲低
和高QPS
的規則(在高並發下,如果都去數據庫里面獲取ID,那是非常影響性能的)
1.3.2.3 自增Id用完問題
我們知道MySQL
表可以定義一個自增長的id
,如果我們的表沒有指定主鍵字段,那MySQL
會給我們的表創建一個不可見的,長度為6
個自己的row_id
,然后不停地往上加步長,雖然生活中自然數是沒有上限的,但是在計算機里,我們只要定義了表示這個數的字節長度,那么它就有上限,比如在Java
中,int
類型的上限值為 2^31-1 ,即2147483647
。MySQL
無符號整數上限為2^32-1,即4294967295
有兩種情況:
MySQL
自增id
用完后,再次申請id
,得到的值保持不變。插入數據會報主鍵沖突異常MySQL InnoDB
表未指定主鍵時,MySQL
會指定一個row_id
,如果row_id
用完了,則會從頭開始循環。從這點來說還是建議我們創建表的時候指定主鍵的,畢竟使用row_id
會發生覆蓋數據,導致原來的數據丟失,影響數據的可靠性
1.3.3 基於Redis生成全局ID策略
1.3.3.1 單機版
因為Redis
是單線程
,天生保證原子性,可以使用原子操作INCR
和INCRBY
來實現
INCRBY
:設置增長步長
1.3.3.2 集群分布式
注意
:在Redis
集群情況下,同樣和MySQL
一樣需要設置不同的增長步長,同時key
一定要設置有效期,可以使用Redis
集群來獲取更高的吞吐量。
假設一個集群中有5台Redis
,可以初始化每台Redis
的值分別是 1,2,3,4,5
, 然后設置步長都是5
各個Redis
生成的ID為:
A:1 6 11 16 21
B:2 7 12 17 22
C:3 8 13 18 23
D:4 9 14 19 24
E:5 10 15 20 25
但是存在的問題是,就是Redis
集群的維護和保養比較麻煩,配置麻煩。因為要設置單點故障,哨兵值守
但是主要是的問題就是,為了一個ID
,卻需要引入整個Redis
集群,有種殺雞焉用牛刀的感覺
1.3.4 雪花算法
1.3.4.1 定義
Twitter
的分布式自增ID
算法,Snowflake
最初Twitter
把存儲系統從MySQL
遷移到Cassandra
(由Facebook
開發一套開源分布式NoSQL
數據庫系統)因為Cassandra
沒有順序ID
生成機制,所以開發了這樣一套全局唯一ID生成服務。
Twitter
的分布式雪花算法SnowFlake
,經測試SnowFlake每秒可以產生26萬個自增可排序的ID
Snowflake
有以下特點:
SnowFlake
生成ID能夠按照時間
有序生成SnowFlake
算法生成ID的結果是一個64Bit
大小的整數,為一個Long
型(轉換成字符串后長度最多19)- 分布式系統內不會產生
ID碰撞
(由datacenter 和 workerID做區分)並且效率較高
SnowFlake
可以保證:
- 所有生成的
ID
按時間趨勢遞增
- 整個分布式系統內
不會產生重復ID
,因為有datacenterId
和workerId
來做區分
1.3.4.2 結構
雪花算法的幾個核心組成部分
在Java
中64bit
的證書是long類型
,所以在SnowFlake
算法生成的ID
就是long
類存儲的
- 第一部分
二進制中最高位
是符號位
,1表示負數,0表示正數。生成的ID一般都是用整數,所以最高位固定為0
- 第二部分
第二部分是41bit
時間戳位,用來記錄時間戳
,毫秒級
41位
可以表示2^41 -1
個數字
如果只用來表示正整數,可以表示的范圍是:0 - 2^41 -1
,減1是因為可以表示的數值范圍是從0
開始計算的,而不是從1
也就是說41
位可以表示2^41 - 1
毫秒的值,轉換成單位年則是69.73
年 - 第三部分
第三部分為標識位
,5bit
數據中心 ID,5bit
工作機器 ID
可以部署在2^10 = 1024
個節點
5位可以表示的最大正整數是2 ^ 5 = 31
個數字,來表示不同的數據中心 和 機器碼
注意:
這也是最容易出現重復的原因 - 第四部分
12位bit
可以用來表示的正整數是2^12 = 4095
,即可以用0 1 2 … 4094
來表示同一個機器同一個時間戳內產生的4095
個ID序號
1.3.4.3 實現
雪花算法是由scala
算法編寫的,有人使用java實現,
/**
* twitter的snowflake算法 -- java實現
*/
public class SnowFlake {
/**
* 起始的時間戳
*/
private final static long START_STMP = 1480166465631L;
/**
* 每一部分占用的位數
*/
private final static long SEQUENCE_BIT = 12; //序列號占用的位數
private final static long MACHINE_BIT = 5; //機器標識占用的位數
private final static long DATACENTER_BIT = 5;//數據中心占用的位數
/**
* 每一部分的最大值
*/
private final static long MAX_DATACENTER_NUM = -1L ^ (-1L << DATACENTER_BIT);
private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT);
private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT);
/**
* 每一部分向左的位移
*/
private final static long MACHINE_LEFT = SEQUENCE_BIT;
private final static long DATACENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
private final static long TIMESTMP_LEFT = DATACENTER_LEFT + DATACENTER_BIT;
private long datacenterId; //數據中心
private long machineId; //機器標識
private long sequence = 0L; //序列號
private long lastStmp = -1L;//上一次時間戳
public SnowFlake(long datacenterId, long machineId) {
if (datacenterId > MAX_DATACENTER_NUM || datacenterId < 0) {
throw new IllegalArgumentException("datacenterId can't be greater than MAX_DATACENTER_NUM or less than 0");
}
if (machineId > MAX_MACHINE_NUM || machineId < 0) {
throw new IllegalArgumentException("machineId can't be greater than MAX_MACHINE_NUM or less than 0");
}
this.datacenterId = datacenterId;
this.machineId = machineId;
}
/**
* 產生下一個ID
*
* @return
*/
public synchronized long nextId() {
long currStmp = getNewstmp();
if (currStmp < lastStmp) {
throw new RuntimeException("Clock moved backwards. Refusing to generate id");
}
if (currStmp == lastStmp) {
//相同毫秒內,序列號自增
sequence = (sequence + 1) & MAX_SEQUENCE;
//同一毫秒的序列數已經達到最大
if (sequence == 0L) {
currStmp = getNextMill();
}
} else {
//不同毫秒內,序列號置為0
sequence = 0L;
}
lastStmp = currStmp;
return (currStmp - START_STMP) << TIMESTMP_LEFT //時間戳部分
| datacenterId << DATACENTER_LEFT //數據中心部分
| machineId << MACHINE_LEFT //機器標識部分
| sequence; //序列號部分
}
private long getNextMill() {
long mill = getNewstmp();
while (mill <= lastStmp) {
mill = getNewstmp();
}
return mill;
}
private long getNewstmp() {
return System.currentTimeMillis();
}
public static void main(String[] args) {
SnowFlake snowFlake = new SnowFlake(2, 3);
for (int i = 0; i < (1 << 12); i++) {
System.out.println(snowFlake.nextId());
}
}
}
1.3.4.4 SpringBoot整合雪花算法
引入hutool工具類
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.1</version>
</dependency>
整合
/**
* 雪花算法
*/
public class SnowFlakeDemo {
private long workerId = 0;
private long datacenterId = 1;
private Snowflake snowFlake = IdUtil.createSnowflake(workerId, datacenterId);
@PostConstruct
public void init() {
try {
// 將網絡ip轉換成long
workerId = NetUtil.ipv4ToLong(NetUtil.getLocalhostStr());
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 獲取雪花ID
* @return
*/
public synchronized long snowflakeId() {
return this.snowFlake.nextId();
}
public synchronized long snowflakeId(long workerId, long datacenterId) {
Snowflake snowflake = IdUtil.createSnowflake(workerId, datacenterId);
return snowflake.nextId();
}
public static void main(String[] args) {
SnowFlakeDemo snowFlakeDemo = new SnowFlakeDemo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
System.out.println(snowFlakeDemo.snowflakeId());
}, String.valueOf(i)).start();
}
}
}
得到結果
1251350711346790400
1251350711346790402
1251350711346790401
1251350711346790403
1251350711346790405
1251350711346790404
1251350711346790406
1251350711346790407
1251350711350984704
1251350711350984706
1251350711350984705
1251350711350984707
1251350711350984708
1251350711350984709
1251350711350984710
1251350711350984711
1251350711350984712
1251350711355179008
1251350711355179009
1251350711355179010
1.3.4.5 優缺點
- 優點
毫秒數在高維,自增序列在低位,整個ID都是趨勢遞增的
不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的
可以根據自身業務特性分配bit位,非常靈活 - 缺點
依賴機器時鍾,如果機器時鍾回撥,會導致重復ID生成
在單機上是遞增的,但由於涉及到分布式環境,每台機器上的時鍾不可能完全同步,有時候會出現不是全局遞增的情況,此缺點可以認為無所謂,一般分布式ID只要求趨勢遞增,並不會嚴格要求遞增,90%的需求只要求趨勢遞增。
其它補充
為了解決時鍾回撥問題,導致ID重復,后面有人專門提出了解決的方案
UidGenerator
- 百度開源的分布式唯一ID生成器Leaf
- 美團點評分布式ID生成系統