帶着幾個關注點去研讀源碼
- 算法設計的整體邏輯是什么,核心點是什么?
- 算法是如何達到高並發的?
- 算法的高並發能力極限?
- 既然是生成ID,那么生成的可用量有多大,可用的時間為多少,ID的存儲方式?
- 算法是否有缺陷,如何避免或者改進?
- 算法是否可自由拓展或改造,以契合當前項目需求?
SnowFlake源碼:
/**
* Twitter_Snowflake
* SnowFlake的結構(每部分用-分開):
* 0-00000000000000000000000000000000000000000-00000-00000-000000000000
* 第一部分:
* 1位標識,由於long基本類型在Java中是帶符號的,最高位是符號位,正數是0,負數是1,ID要是正數,最高位是0
* 第二部分:
* 41位毫秒數,不是存儲當前時間的時間截,而是存儲時間截的差值(當前時間截 - 自定義起始時間截),
* 自定義起始時間戳即人為指定的算法起始時間,當前時間即生成ID時的時間戳
* 41位的時間截,可以使用約69年, (1L << 41) / (365 * 24 * 3600 * 1000)≈ 69
* 第三、四部分:
* 10位的數據機器位,可以部署在1024(1L<<10)個節點,包括5位datacenterId(機房)和5位workerId(機器號)
* 第五部分:
* 12位序列,每毫秒可生成序列號數,共4096(1L<<12)個ID序號
* 以上5部分總64bit,即需要一個Long整型來記錄
* SnowFlake的優點:
* - 整體按時間自增排序
* - Long整型ID,存儲高效,檢索高效
* - 分布式系統內無ID碰撞(各分區由datacenterId和workerId來區分)
* - 生成效率高,占用系統資源少,理論每秒可生成1000 * 4096 = 4096000個
* SnowFlake的缺點:
* - 時鍾回撥問題,尤其在高並發中,時鍾回撥可能會生產出重復的ID
*/
public class SnowFlakeIdWorker {
/**
* 指定起始時間戳 (2021-05-21 00:00:00)
*/
private final long twepoch = 1621440000000L;
/**
* 數據中心/機房標識所占bit位數
*/
private final long datacenterIdBits = 5L;
/**
* 機器標識所占bit位數
*/
private final long workerIdBits = 5L;
/**
* 每毫秒下的序列號所占bit位數
*/
private final long sequenceBits = 12L;
/**
* 數據中心掩碼,即最大支持32個機房
*/
private final long maxDatacenterId = ~(-1L << datacenterIdBits);
/**
* 機器掩碼,即最大支持32個機器
*/
private final long maxWorkerId = ~(-1L << workerIdBits);
/**
* 每毫秒序列號的掩碼
*/
private final long sequenceMask = ~(-1L << sequenceBits);
/**
* 機器ID表示的bit在long中位置,需要左移的位數(12)
*/
private final long workerIdShift = sequenceBits;
/**
* 數據中心ID表示的bit在long中的位置,需要左移的位數(12+5)
*/
private final long datacenterIdShift = sequenceBits + workerIdBits;
/**
* 時間截部分需要左移的位數(5+5+12)
*/
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
/**
* 機器ID(0~31)
*/
private long workerId;
/**
* 數據中心ID(0~31)
*/
private long datacenterId;
/**
* 每毫秒內序列(0~4095)
*/
private long sequence = 0L;
/**
* 最后一次生成ID時的時間截
*/
private long lastTimestamp = -1L;
/**
* 構造函數
*
* @param workerId 機器ID (0~31)
* @param datacenterId 數據中心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;
}
/**
* 獲得下一個ID,synchronized同步的,此處必須同步
*
* @return SnowflakeId
*/
public synchronized long nextId() {
long timestamp = timeGen();
// 若當前時間戳小於最后一次生成ID時的時間戳,說明系統時鍾回退過,此時無法保證ID的唯一性,算法拋異常退出
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
// 若當前時間戳等於最后一次生成ID時的時間戳(同一毫秒內),則進行序列號累加
if (lastTimestamp == timestamp) {
// 此操作可獲得的最大值是4095,最小值是0,在溢出時為0
sequence = (sequence + 1) & sequenceMask;
// 毫秒內序列溢出
if (sequence == 0) {
// 阻塞到下一個毫秒,獲得新的時間戳
timestamp = tilNextMillis(lastTimestamp);
}
} else {
// 若當前時間戳大於最后一次生成ID時的時間戳,則序列號需要重置到0
sequence = 0L;
}
// 更新記錄本次時間戳
lastTimestamp = timestamp;
// 位運算,獲得最終的ID並返回
return ((timestamp - twepoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
/**
* 阻塞到下一個毫秒,直到獲得新的時間戳
*
* @param lastTimestamp 上次生成ID的時間截
* @return 當前時間戳
*/
protected long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 返回以毫秒為單位的當前時間戳
*
* @return 當前時間(毫秒)
*/
protected long timeGen() {
return System.currentTimeMillis();
}
/**
* 測試
*/
public static void main(String[] args) {
SnowFlakeIdWorker idWorker = new SnowFlakeIdWorker(0, 0);
for (int i = 0; i < 10; i++) {
long id = idWorker.nextId();
System.out.println(id);
}
}
}
源碼中的注釋分析已經很詳盡,再貼一張直觀的圖看看:
- 1bit保留,最高位有符號標識,Long為正數
- 41bit為時間戳差值,可用時長約69年
- 5bit用於標識機房,取值:0~31
- 5bit用於標識機器,取值:0~31
- 12bit用於區分同一毫秒內請求的序列號,取值:0~4095
在研讀源碼后對一開始幾個問題的回答:
- 算法以時間戳為生成源作為生成ID核心,使用一個Long整型來記錄ID,並對Long的64位進行分割設計為:保留位+時間戳+機房+機器+序列號,其中時間戳為核心,序列號進一步提高了並發量;
- 算法的高並發主要是毫秒級的時間戳和每毫秒的序列化計數,調整這兩個值即可調整並發量;
- 單台機器並發理論值為:4096 * 1000 = 4096000ID/秒;
- 時間維度上,算法的可用時間由41個bit時間戳鎖定了,約69年,極限總量約為:69 * 365 * 24 * 3600 * 4096000 * 1024
- 機器的時鍾回撥可能導致生成重復ID,需要額外手段保持時鍾同步;
- 算法的5個部分中時間戳是核心,時間戳bit可增減,其他部分可合並或重新分割為更多或更少的部分,且不一定非要用一個Long整型來生成ID,在保留時間戳這個核心點之外的部分都可以自由設計,以滿足項目的需求;
對於獲取毫秒時間的效率問題:
// 直接調用原生Java api
System.currentTimeMillis();
查閱資料,一堆的復制粘貼文章說直接這樣調用api在高並發時有性能問題,搜索關鍵詞:“System.currentTimeMillis()的性能問題”,主要的兩個鏈接:
- os層和cpu層的效率分析:http://pzemtsov.github.io/2017/07/23/the-slow-currenttimemillis.html
- Oracle相關問題,及官方回復:https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8185891
簡要概括下Orcale官方的觀點:此問題級別太低,且超出了jvm范圍,慢是os或cpu本身的問題,不在jvm生態內,所以不是bug,問題定級為低,不予處理。
既然官方這樣回復,說明直接調用api的效率在Java本身生態系統內是沒有問題的,那么直接調用即可。
本人在實際項目中的改造案例
項目中的原有的id生成邏輯是這樣的:
- 記錄一個起始long值,作為計數值,每次獲取的時候+1,該值每次記錄到文件,系統啟動時讀取上次記錄的值;
- 把long處理為byte數組,並進行位操作亂序(類似hashcode操作);
- 獲取當前日期,年月日每個部分為一個byte;
- 把兩個byte數組前后拼接並轉為16進制的字符串,總32長度;
舊有邏輯也不知道誰寫的… 反正記錄到文件這個就挺不靠譜,還要多個文件管理,文件不見了又是個問題,現在需求要求不要依賴文件,重新寫一個生成ID效率高且仍然是16進制的32長度的字符串;
基於SnowFlake改造后的ID生成方案:
- 保留時間自增的核心;
- 因為最終要生成16進制的字符串,所以不再局限Long,實際設計為32長度16進制共128bit;
- 考慮一個4位IP地址需要32bit記錄;
- 整個ID設計為:32bit記錄k8s的主機IP + 32bit記錄pod的IP + 52bit時間戳 + 12bit序列號
簡單講就是,保留原有的生成時間戳和序列號的部分,共占64bit,剩余64bit用來記錄兩個IP地址(暫未從項目找到其他固定可用標識了),總計128bit。
即:
- 128bit = 32bit + 32bit + 52bit + 12bit = 8hex + 8hex + 13hex + 3hex = 32hex
改造后的源碼:
/**
* @author zhoujie
* @date 2021/5/17 下午5:10
* @description: 基於SnowFlake改造的32長度hex進制的ID生成方案
* 總128bit長度,32hex長度:
* <p>
* 32bit記錄k8s的主機IP + 32bit記錄pod的IP + 52bit時間戳 + 12bit序列號
* <p>
* 128bit = 32bit + 32bit + 52bit +12bit = 8hex + 8hex + 13hex + 3hex = 32hex
*/
public class SnowFlakeIdUtil {
/**
* 指定起始時間戳 (2021-05-21 00:00:00)
*/
private static final long twepoch = 1420041600000L;
/**
* 每毫秒下的序列號所占bit位數
*/
private static final long sequenceBits = 12L;
/**
* 每毫秒序列號的掩碼
*/
private static final long sequenceMask = ~(-1L << sequenceBits);
/**
* 每毫秒內序列(0~4095)
*/
private static long sequence = 0L;
/**
* 最后一次生成ID時的時間截
*/
private static long lastTimestamp = -1L;
/**
* HOST_IP
*/
private static final String HOST_IP = "222.222.222.222";
/**
* POD_IP
*/
private static final String POD_IP = "111.111.111.111";
/**
* ip的處理后16進制表示的部分
*/
private static String IP_HEX_PART = null;
/**
* 靜態工具類,構造器私有化
*/
private SnowFlakeIdUtil() {
}
/**
* 獲得下一個ID,synchronized同步的,此處必須同步
*
* @return SnowflakeId
*/
public static synchronized long nextId() {
long timestamp = timeGen();
// 若當前時間戳小於最后一次生成ID時的時間戳,說明系統時鍾回退過,此時無法保證ID的唯一性,算法拋異常退出
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
// 若當前時間戳等於最后一次生成ID時的時間戳(同一毫秒內),則進行序列號累加
if (lastTimestamp == timestamp) {
// 此操作可獲得的最大值是4095,最小值是0,在溢出時為0
sequence = (sequence + 1) & sequenceMask;
// 毫秒內序列溢出
if (sequence == 0) {
// 阻塞到下一個毫秒,獲得新的時間戳
timestamp = tilNextMillis(lastTimestamp);
}
} else {
// 若當前時間戳大於最后一次生成ID時的時間戳,則序列號需要重置到0
sequence = 0L;
}
// 更新記錄本次時間戳
lastTimestamp = timestamp;
// 位運算,此處拼接時間戳和序列號一並返回是為了效率,后面處理時還是需要拆開各自處理
return (timestamp - twepoch) << sequenceBits | sequence;
}
/**
* @author zhoujie
* @date 2021/5/17 下午5:15
* @description: 改造后的生成ID方案,生成32長度16進制的ID:
* <p>
* host_ip + pod_ip + 時間戳 + 序列號
* <p>
* 8hex + 8hex + 13hex +3hex
*/
private static String getMsgId() {
long nextId = nextId();
long seq = nextId & sequenceMask;
long unixTime = nextId >> sequenceBits;
StringBuilder msgIdBuffer = new StringBuilder(32);
// 末3位hex為序列號
msgIdBuffer.append(Long.toHexString(seq));
while (msgIdBuffer.length() < 3) {
msgIdBuffer.insert(0, "0");
}
// 中間13位hex為時間戳
msgIdBuffer.insert(0, Long.toHexString(unixTime));
while (msgIdBuffer.length() < 16) {
msgIdBuffer.insert(0, "0");
}
// IP為環境信息,只需要初始化一次
if (IP_HEX_PART == null) {
IP_HEX_PART = ipToHexString(HOST_IP) + ipToHexString(POD_IP);
}
// 前16位為環境相關的兩個IP地址
return msgIdBuffer.insert(0, IP_HEX_PART).toString().toUpperCase();
}
/**
* @return java.lang.String
* @author zhoujie
* @date 2021/5/17 下午5:25
* @param: ip
* @description: 把ip轉為16進制字符串表示
*/
private static String ipToHexString(String ip) {
if (ip == null) {
return "00000000";
}
String[] ipPort = ip.split("\\.");
if (ipPort.length < 4) {
return "00000000";
}
StringBuilder ipBuffer = new StringBuilder(8);
for (String s : ipPort) {
String s1 = Integer.toHexString(Integer.parseInt(s));
ipBuffer.append(s1.length() > 1 ? s1 : "0" + s1);
}
return ipBuffer.toString();
}
/**
* @return java.lang.String
* @author zhoujie
* @date 2021/5/17 下午5:36
* @param: msgId
* @description: 反解析msg獲取信息
*/
private static Map<String, String> parseMsgIdInfo(String msgId) {
if (msgId == null) {
return null;
}
int len = msgId.length();
if (len != 32) {
return null;
}
// 項目用的json對象存儲並返回json字符串對象,簡化import,此處使用map,意會即可
HashMap<String, String> msgIdInfo = new HashMap<>();
msgIdInfo.put("msgId", msgId);
msgIdInfo.put("host_ip", hexStrToIp(msgId.substring(0, 8)));
msgIdInfo.put("pod_ip", hexStrToIp(msgId.substring(8, 16)));
msgIdInfo.put("sequence", new BigInteger(msgId.substring(30), 16).toString());
long timestamp = Long.parseLong(msgId.substring(16, 29), 16) + twepoch;
msgIdInfo.put("dateTime", LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()).toString());
return msgIdInfo;
}
/**
* @return java.lang.String
* @author zhoujie
* @date 2021/5/18 下午3:19
* @param: hexIpStr
* @description: 把16進制的字符串ip轉為常規顯示
*/
private static String hexStrToIp(String hexIpStr) {
int step = 2;
StringBuilder ipBuffer = new StringBuilder(17);
for (int i = 0; i < hexIpStr.length(); i += step) {
String ipPart = hexIpStr.substring(i, i + step);
ipBuffer.append(new BigInteger(ipPart, 16).toString()).append(".");
}
ipBuffer.setLength(ipBuffer.length() - 1);
return ipBuffer.toString();
}
/**
* 阻塞到下一個毫秒,直到獲得新的時間戳
*
* @param lastTimestamp 上次生成ID的時間截
* @return 當前時間戳
*/
protected static long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 返回以毫秒為單位的當前時間戳
*
* @return 當前時間(毫秒)
*/
protected static long timeGen() {
return System.currentTimeMillis();
}
/**
* 測試
*/
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
System.out.println(SnowFlakeIdUtil.getMsgId());
}
String msgId = SnowFlakeIdUtil.getMsgId();
Map<String, String> stringStringMap = parseMsgIdInfo(msgId);
for (Map.Entry<String, String> entry : stringStringMap.entrySet()) {
System.out.println(entry.getKey() + "-" + entry.getValue());
}
}
}
改造后的問題:
- 原有生成是Long類型,現在是String類型,對數據庫索引不友好,但當前項目用作記錄多,暫不涉及搜索;
- 原有僅生成Long並返回,效率很高,現在在原有基礎上增加了一些字符串的處理,對效率略有影響;
改造后生成的是數字字母字符串,所以UUID也能完成這個需求,但是UUID生成的是完全無意義字符,當前對SnowFlake的改造能攜帶有用信息,且在以后可以按需求再改造擴展。
其他分布式ID的解決方案及優劣分析
生成分布式ID的一些要求:
- 全局唯一:最基本要求;
- 高性能:低延遲,生成速度快,不能成為業務瓶頸;
- 高可用:99.99…%可用,9越多越好;
- 易接入:最好是插件式使用,與項目低耦合,易於后續替換/拓展;
- ID友好:一般最好是數字型ID且趨勢遞增,能更好貼合業務屬性,利於項目開發;
常見分布式ID生成解決方案及優缺點:
UUID
優點:
- 簡單,一行代碼搞定,且能保證唯一性,效率很高;
缺點:
- 生成的字符無序無意義;
- 長度偏長,存儲查詢性能不好;
數據庫ID
優點:
- 實現簡單,ID單調遞增,生成及查詢速度快;
- 可設計為號段模式,降低壓力;
缺點:
- 數據庫本身性能將成為生成ID的性能上限;
- 數據庫本身的可用性成為生成ID的可用性;
- 單機數據庫能力有限,集群模式可一定程度提高能力,但缺點仍存在;
Redis生成ID
優點:
- Redis天然的單線程能保證線程安全,生產的ID全局唯一;
- Redis為內存數據庫,效率能保證;
- 依賴於Redis的INCR命令,能高效實現自增ID;
- ID為數字型,數據庫存儲查詢友好效率高;
缺點:
- 需要搭建維護額外的Redis生成ID系統,增加系統復雜度;
- 增加了Redis網絡請求,占用網絡資源,性能比本地生成要慢;
- Redis可能宕機,可用性也低於本地生成;
SnowFlake雪花算法
優點:
- 代碼少且簡單,無需額外依賴,本地生成;
- 生成效率高;
- Long型ID,趨勢遞增,對項目友好;
- 對Long的64bit分段設計,且可根據項目需求重新設計,可塑性高;
缺點:
- 強依賴時間的准確性,時鍾回撥可能生成重復ID;
百度uid-generator,基於SonwFlake的二次開發
優點:
- 使用了Atom原子類計數替換了獲取時間戳,解決了時鍾回撥問題(正常回撥誤差在毫秒級);
- 序列號增加1bit到13bit,共8092個序列號,算是彌補毫秒到秒級的缺失;
- 使用RingBuffer緩存提前生成的ID,提高並發時抗壓能力;
缺點:
- 原有時間戳被替換為秒級且使用的緩存,很早生成的ID可能很久之后才用到,其實問題不大;
美團Leaf,基於SnowFlake的二次開發
優點:
- 早期Leaf的號段+雙buffer模式已經解決了大部分問題;
- 使用zk持久模式順序生成workId,且本地緩存,啟動時先從本地緩存恢復,沒有時請求zk獲取新的workId並緩存;
- 解決時鍾回撥問題:在判斷時間落后時等待2倍的落后差時間重新獲取時間戳,以等待並重新嘗試的方式解決正常NTP(Network Time Protocol)時間同步時的誤差問題,若時間差太多或等待后時間差仍存在則拋異常終止,此時時鍾落后問題需要人工介入解決;
- 初始序列號每次不是從0開始,而是nextInt(100),避免0的情況過多,利於DB的分庫分表;
缺點:
- 號段模式提供的ID可能是不連續的,buffer的存在使得系統的重啟后存在ID的浪費;
滴滴TinyId
優點:
- 與美團的Leaf類似,采用號段的模式生成ID;
- 提供http和rest兩種接入方式;
缺點:
- 號段模式提供的ID可能是不連續的,buffer的存在使得系統的重啟后存在ID的浪費;
總結
綜合以上的分析學習研究,當前分布式ID還是以SnowFlake雪花算法為模板,結合項目實際需求進行設計的方案為最優,大廠都是采用了號段模式或自我改造雪花算法的方案,號段模式可以說是對ID生成本身的拆分--分布式思想;雪花算法因其本身的效率已經足夠高,而其唯一缺陷是時鍾回撥問題,使用前需要關注解決。