一、分佈式ID
集羣高併發情況下如何保證分佈式唯一全局ID生成?
1. 爲什麼需要分佈式全局唯一ID以及分佈式ID的業務需求?
在複雜的分佈式系統中,往往需要對大量的數據和消息進行唯一標識。如在美團點評的金融、支付、餐飲、酒店等產品的系統中數據日漸增長,對數據分庫分表後需要有一個唯一ID來標識一條數據或消息,此時一個能夠生成全局唯一ID的系統是非常必要的。
2. ID生成規則部分硬性要求
- 全局唯一:不能出現重複的ID號,既然是唯一標識,這是最基本的要求;
- 趨勢遞增:在MySql的InnoDB引擎中使用的是聚集索引,由於多數RDBMS使用B-tree的數據結構來存儲索引數據,在主鍵的選擇上面我們應該儘量使用有序的主鍵保證寫入性能;
- 單調遞增:保證下一個ID一定大於上一個ID,例如事務版本號、IM增量消息、排序等特殊需求;
- 信息安全:如果ID是連續的,惡意用戶的扒取工作就非常容易做了,直接按照順序下載指定URL即可,如果是訂單號就更加危險,競對可以直接知道我們一天的單量;
- 含時間戳:這樣就能夠在開發中快速瞭解分佈式ID的生成時間。
3. ID生成系統的可用性要求
- 高可用:發一個獲取分佈式ID的請求,服務器就要保證99.999%的情況下給我創建一個唯一分佈式ID;
- 低延遲:發一個獲取分佈式ID的請求,服務器就要快,極速;
- 高QPS:假如併發一口氣10萬個創建分佈式ID請求同時發過來,服務器要頂得住且一下子成功創建10萬個分佈式ID。
二、一般通用方案
1. UUID
-
是什麼?UUID的標準形式:包含32個16進制數字,以連字號分爲五段,形式爲8-4-4-12的36個字符。
-
優點:性能非常高,本地生成,沒有網絡消耗。
-
缺點:入數據庫性能差。
爲什麼無序的UUID會導致入庫性能變差呢?
-
1)無序,無法預測它的生成順序,不能生成遞增有序的數字。首先分佈式ID一般都會作爲主鍵,但是MySql官方推薦主鍵要儘量越短越好,UUID每一個都很長,所以不是很推薦。
-
2)主鍵,ID作爲主鍵時在特定的環境下會存在一些問題,比如做DB主鍵的場景下,UUID就非常不適用MySql,官方有明確的建議要儘量越短越好,36個字符長度的UUID不符合要求。
-
3)索引,B+樹索引的分裂。既然分佈式ID是主鍵,然後主鍵是包含索引的,然後MySql的索引是通過b+樹來實現的,每一次新的UUID數據的插入,爲了查詢的優化,都會對索引底層的b+樹進行修改,因爲UUID數據是無序的,所以每一次UUID數據的插入都會對主鍵底層的b+樹進行很大的修改,這一點很不好。插入完全無序,不但會導致一些中間節點產生分裂,也會白白創造出很多不飽和的節點,這樣大大降低了數據庫插入的性能。
如果只是考慮唯一性,UUID是OK的。
2. 數據庫自增主鍵
- 單機應用
在分佈式裏,數據庫的自增ID機制的主要原理是:數據庫自增ID和MySql數據庫的replace into實現的。
replace into 跟 insert 功能類似,不同點在於:replace into首先嚐試插入數據列表中,如果發現表中已經有此行數據(根據主鍵或唯一索引)判斷是否存在,若有則先刪除再插入,否則直接插入新數據。REPLACE INTO的含義是插入一條記錄,如果表中唯一索引的值遇到衝突,則替換老數據。
CREATE TABLE t_test (
id bigint(20) unsigned not null auto_increment primary key,
stub char(1) not null default '',
unique key stub (stub)
)
select * from t_test;
replace into t_test (stub) values('a');
select last_insert_id();
- 集羣分佈式
那數據庫自增ID機制適合作分佈式ID嗎?答案是不太適合的。
1)系統水平擴展比較困難,比如定義好了步長和機器臺數之後,如果要添加機器該怎麼做?假設現在只有一臺機器發號是1到99999,步長是1,這時候需要擴容機器一臺。可以這麼做:把第二臺機器的初始值設置得比第一臺超過很多,貌似還好,現在想象一下如果我們線上有100臺機器,這個時候要擴容該怎麼做?簡直是噩夢,所以系統水平擴展方案複雜難以實現。
2)數據庫壓力還是很大,每次獲取ID都得讀寫一次數據庫,非常影響性能,不符合分佈式ID裏面的延遲低和要高QPS的規則(在高併發下,如果都去數據庫裏獲取ID,那是非常影響性能的)。
3. 基於Redis生成全局ID策略
-
單機應用,因爲Redis是單線程的天生保證原子性,可以使用原子操作INCR和INCRBY來實現。
-
集羣分佈式,可以使用Redis集羣來獲取更高的吞吐量,注意:在Redis集羣情況下,同樣和MySql一樣需要設置不同的增長步長,同時key一定要設置有效期。
假如一個集羣中有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
三、雪花算法 - Snowflake
1. 概述
Twitter的分佈式自增ID算法,經測試snowflake每秒能夠生產26萬個自增可排序的ID
特點:
-
twitter的Snowflake算法生成ID能夠按照時間有序生成;
-
Snowflake算法生成ID的結果是一個64bit大小的整數,爲一個Long型(轉換成字符串後長度最多19);
-
分佈式系統內不會產生ID碰撞並且效率較高。
分佈式系統中,有一些需要使用全局唯一ID的場景,生成ID的基本要求:
-
在分佈式的環境下必須全局且唯一;
-
一般都需要單調遞增,因爲一般唯一ID都會存到數據庫,而Innodb的特性就是將內容存儲在主鍵索引樹上的
葉子節點,而且是從左往右遞增的,所以考慮到數據庫性能,一般生成的ID最好也是單調遞增的。爲了防止ID衝突
可以使用36位的UUID,但是UUID有一些缺點,首先他相對比較長,另外UUID一般是無序的; -
可能還會需要無規則,因爲如果使用唯一ID作爲訂單號這種,爲了不讓別人知道一天的訂單量是多少,就需要無規則。
2. 結構
號段解析:
-
符號位(1 bit):不使用,因爲二進制中最高位是符號位,1表示負數,0表示整數,生成的ID一般都是正數,所以最高位固定爲0;
-
時間戳位(41 bit):用來記錄時間戳,毫秒級。41位可以表示
2^{41}-1
個數字,如果只用來表示正整數(計算機中正數包含0),可以表示的數值範圍是:0至2^{41}-1
,減1是因爲可表示的數值範圍是從0開始算的,而不是1。也就是說41位可以表示2^{41}-1個毫秒的值,轉化成單位年差不多是69年; -
工作進程位(10bit):工作機器ID,用來記錄工作機器ID。可以部署 2^{10} = 1024個節點,包括5位
datacenterId
和5位workerId
。5位(bit)可以表示的最大正整數是2^{5}-1 = 31,即可以用0、1、2、3 … 31這32個數字,來表示不同的datacenterId或workerId; -
序列號位(12bit):序列號,用來記錄同毫秒內產生的不同ID。12位可以表示的最大正整數是2^{12}-1 = 4095,即可以用0、1、2、3 … 4094 這4095個數字,來表示同一機器同一時間截(毫秒)內產生的4095個ID序號
SnowFlake可以保證所有生成的ID按時間趨勢遞增,整個分佈式系統內不會產生重複ID(因爲有 datacenterId 和 workId來做區分)。
3. 源碼
Java版(Hutool):
/**
* Twitter的Snowflake 算法<br>
* 分佈式系統中,有一些需要使用全局唯一ID的場景,有些時候我們希望能使用一種簡單一些的ID,並且希望ID能夠按照時間有序生成。
*
* <p>
* snowflake的結構如下(每部分用-分開):<br>
*
* <pre>
* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
* </pre>
* <p>
* 第一位爲未使用(符號位表示正數),接下來的41位爲毫秒級時間(41位的長度可以使用69年)<br>
* 然後是5位datacenterId和5位workerId(10位的長度最多支持部署1024個節點)<br>
* 最後12位是毫秒內的計數(12位的計數順序號支持每個節點每毫秒產生4096個ID序號)
* <p>
* 並且可以通過生成的id反推出生成時間,datacenterId和workerId
* <p>
* 參考:http://www.cnblogs.com/relucent/p/4955340.html
*
* @author Looly
* @since 3.0.1
*/
public class Snowflake implements Serializable {
private static final long serialVersionUID = 1L;
// 開始時間戳
private final long twepoch;
// 機器標識位數
private final long workerIdBits = 5L;
// 數據中心標識位數
private final long dataCenterIdBits = 5L;
//// 最大支持機器節點數0~31,一共32個
// 最大支持數據中心節點數0~31,一共32個
@SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
@SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits);
// 序列號12位
private final long sequenceBits = 12L;
// 機器節點左移12位
private final long workerIdShift = sequenceBits;
// 數據中心節點左移17位
private final long dataCenterIdShift = sequenceBits + workerIdBits;
// 時間毫秒數左移22位
private final long timestampLeftShift = sequenceBits + workerIdBits + dataCenterIdBits;
@SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
private final long sequenceMask = -1L ^ (-1L << sequenceBits);// 4095
private final long workerId;
private final long dataCenterId;
private final boolean useSystemClock;
private long sequence = 0L;
private long lastTimestamp = -1L;
/**
* 構造
*
* @param workerId 終端ID
* @param dataCenterId 數據中心ID
*/
public Snowflake(long workerId, long dataCenterId) {
this(workerId, dataCenterId, false);
}
/**
* 構造
*
* @param workerId 終端ID
* @param dataCenterId 數據中心ID
* @param isUseSystemClock 是否使用{@link SystemClock} 獲取當前時間戳
*/
public Snowflake(long workerId, long dataCenterId, boolean isUseSystemClock) {
this(null, workerId, dataCenterId, isUseSystemClock);
}
/**
* @param epochDate 初始化時間起點(null表示默認起始日期),後期修改會導致id重複,如果要修改連workerId dataCenterId,慎用
* @param workerId 工作機器節點id
* @param dataCenterId 數據中心id
* @param isUseSystemClock 是否使用{@link SystemClock} 獲取當前時間戳
* @since 5.1.3
*/
public Snowflake(Date epochDate, long workerId, long dataCenterId, boolean isUseSystemClock) {
if (null != epochDate) {
this.twepoch = epochDate.getTime();
} else {
// Thu, 04 Nov 2010 01:42:54 GMT
this.twepoch = 1288834974657L;
}
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than {}} or less than 0", maxWorkerId));
}
if (dataCenterId > maxDataCenterId || dataCenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than {} or less than 0", maxDataCenterId));
}
this.workerId = workerId;
this.dataCenterId = dataCenterId;
this.useSystemClock = isUseSystemClock;
}
/**
* 根據Snowflake的ID,獲取機器id
*
* @param id snowflake算法生成的id
* @return 所屬機器的id
*/
public long getWorkerId(long id) {
return id >> workerIdShift & ~(-1L << workerIdBits);
}
/**
* 根據Snowflake的ID,獲取數據中心id
*
* @param id snowflake算法生成的id
* @return 所屬數據中心
*/
public long getDataCenterId(long id) {
return id >> dataCenterIdShift & ~(-1L << dataCenterIdBits);
}
/**
* 根據Snowflake的ID,獲取生成時間
*
* @param id snowflake算法生成的id
* @return 生成的時間
*/
public long getGenerateDateTime(long id) {
return (id >> timestampLeftShift & ~(-1L << 41L)) + twepoch;
}
/**
* 下一個ID
*
* @return ID
*/
public synchronized long nextId() {
long timestamp = genTime();
if (timestamp < lastTimestamp) {
// 如果服務器時間有問題(時鐘後退) 報錯。
throw new IllegalStateException(String.format("Clock moved backwards. Refusing to generate id for {}ms", (lastTimestamp - timestamp)));
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << timestampLeftShift) | (dataCenterId << dataCenterIdShift) | (workerId << workerIdShift) | sequence;
}
/**
* 下一個ID(字符串形式)
*
* @return ID 字符串形式
*/
public String nextIdStr() {
return Long.toString(nextId());
}
// ------------------------------------------------------------------------------------------------------------------------------------ Private method start
/**
* 循環等待下一個時間
*
* @param lastTimestamp 上次記錄的時間
* @return 下一個時間
*/
private long tilNextMillis(long lastTimestamp) {
long timestamp = genTime();
while (timestamp <= lastTimestamp) {
timestamp = genTime();
}
return timestamp;
}
/**
* 生成時間戳
*
* @return 時間戳
*/
private long genTime() {
return this.useSystemClock ? SystemClock.now() : System.currentTimeMillis();
}
}
4. 工程落地經驗
分佈式整合雪花算法:
IdGenerateInvoker.java
/**
* 分佈式環境下數據庫主鍵的ID生成器
* 實現了對分佈式友好的Snowflake算法,並且會在Spring IOC容器初始化的時候註冊成爲單例Bean.
* 如果需要自定義生成策略,請實現本接口並將其實例註冊到容器中,並在{@link PrimaryKey}的strategy屬性指定.
*
*/
public interface IdGenerateInvoker {
/**
* 獲取下一個永不重複的id.
*
* @return id.
*/
Serializable nextId();
}
SnowFlakeIdGenerateInvoker.java
/**
* ======分佈式數據庫主鍵ID生成器基於SnowFlake算法的實現.
* 本實現類的對象將會在程序啓動的時候自動在Spring IOC容器中註冊爲Bean.
* 如果想在您的持久化過程中由系統自動設置主鍵字段的值,只需要在具體的POJO字段上添加@PrimaryKey註解,詳見{@link PrimaryKey}.
* 其默認打開,並且在未指定具體id生成算法的前提下,它將自動使用SnowFlake算法生成id,在持久化之前自動設置值.
* <p>
* SnowFlake算法的實現依賴於workerId和dataCenterId兩個值的,系統默認使用5L作爲其初始值.
* 如果您想要在分佈式環境使用本算法的實現,建議您在application.yml中配置這2個屬性的值.
*/
@Component
public class SnowFlakeIdGenerateInvoker implements IdGenerateInvoker {
@Autowired
private SnowFlakeConfig snowFlakeConfig;
private Snowflake snowflake;
@PostConstruct
public void init() {
long wokerid = snowFlakeConfig.getWorkerId();
long dataCenterId = snowFlakeConfig.getDataCenterId();
this.snowflake = new Snowflake(wokerid, dataCenterId);
}
@Override
public synchronized Serializable nextId() {
return snowflake.nextId();
}
}
SnowFlakeConfig.java
- 公司workerId使用位長爲8位,取的是機器IP地址後三位;
- dataCenterId使用位長爲兩位,配置在配置文件中。
/**
* 關於SnowFlake的配置.
* WorkerId和DataCenterId的值請保證集羣中的每個節點不相同.
* WorkerId取ip地址最後三位,dataCenterId配置在properties.
*/
@Component
@Validated
@Slf4j
@ConfigurationProperties(prefix = "snowflake")
public class SnowFlakeConfig {
@Value("${spring.application.name}")
private String appName;
private long workerId;
@NotNull
private long dataCenterId;
@PostConstruct
public void init() {
try {
String ipAddr = NetUtil.getHostIpAddr();
log.info("current ip :{}", ipAddr);
if (!StringUtils.isEmpty(ipAddr)) {
String[] ipStep = ipAddr.split("\\.");
this.workerId = Long.valueOf(ipStep[3]);
} else {
log.warn("ip address parse error.");
}
} catch (Exception e) {
throw new RuntimeException(e);
}
log.info("Snowflake:[workerId:{} from ip address tail, dataCenterId:{} from properties] of micro service [{}] has been initialized."
, workerId, dataCenterId, appName == null ? "" : appName.toUpperCase());
}
public long getWorkerId() {
return workerId;
}
public void setWorkerId(long workerId) {
this.workerId = workerId;
}
public long getDataCenterId() {
return dataCenterId;
}
public void setDataCenterId(long dataCenterId) {
this.dataCenterId = dataCenterId;
}
}
application.properties
spring.application.name=ncs-case
# snowflake配置
#snowflake.worker-id=
snowflake.data-center-id=2
5. 雪花算法優缺點
優點:
-
毫秒數在高位,自增序列在低位,整個ID都是趨勢遞增的;
-
不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的;
-
可以根據自身業務特性分配bit位,非常靈活。
缺點:
-
依賴機器時鐘,如果機器時鐘回撥,會導致重複ID生成;
-
在單機上是遞增的,但是由於設計到分佈式環境,每臺機器上的時鐘不可能完全同步,有時候會出現不是全局遞增的情況(此缺點可以認爲無所謂,一般分佈式ID只要求趨勢遞增,並不會嚴格要求遞增,90%的需求都只要求趨勢遞增)。
補充解決時鐘回撥思路:因爲機器的原因會發生時間回撥,我們的雪花算法是強依賴機器時間的,如果時間發生回撥,有可能會生成重複的ID,在我們上面的nextId中用當前時間和上一次的時間進行判斷,如果當前時間小於上一次的時間那麼肯定是發生了回撥,普通的算法會直接拋出異常。這裏我們可以對其進行優化,一般分爲兩個情況:
- 如果時間回撥時間較短,比如配置5ms以內,那麼可以直接等待一定的時間,讓機器時間追上來;
- 如果時間的回撥時間較長,我們不能接受這麼長的阻塞等待,那麼又有兩個策略,直接拒絕,拋出異常,打日誌,或者通知RD時鐘回滾。
四、其他方式
-
百度開源的分佈式唯一ID生成器UidGenerator
-
美團點評分佈式ID生成系統 Leaf