技術干貨實戰(2)- 聊一聊分布式系統全局唯一ID的幾種實現方式

作者: 修羅debug
版權聲明:本文為博主原創文章,遵循 CC 4.0 by-sa 版權協議,轉載請附上原文出處鏈接和本聲明。



現如今可謂是微服務、分布式、IoT(物聯網)橫行的時代,作為一名開發者始終還是要保持一定的危機意識,特別是在日常的項目開發中,若是有機會接觸到一些關于微服務、分布式下的應用場景,應當硬著頭皮、排除萬難,主動應承下來 上去大干一場;這期間不管結果如何,積累下來的經驗將會讓自己受益匪淺;而本文要介紹的“分布式全局唯一ID”便是一種典型的分布式應用場景?。?!

話不多說,咱們直接進入正題~~~

說起這個全局唯一ID,你可能會第一時間想到“數據庫的自增主鍵”、“UUID”、“雪花算法”等等,更有甚者,還能說出一些大廠開源的組件,比如滴滴的IDWorker、美團的Leaf等等,沒錯,這些確實是可以實現全局唯一ID的方案,你能想到這些點,那涉獵其實還是挺廣的;


而對于“全局唯一ID/編號/編碼”的應用場景,在現實生活中還是比較多的,比如電商平臺中“訂單系統”的訂單編號,“進銷存系統”中的商品編號、訂單編號,“支付”過程中訂單流水號等等;接下來debug將會總結性的介紹下目前市面上比較流行的“全局唯一ID”的幾種實現方式,并針對分布式場景下的幾種實現方式進行代碼實戰


話不多說,直接進入正題,先貼張思維導圖吧,總結性地概括下目前網上比較流行的幾種方式(當然啦,圖片來源于互聯網哈,因為debug懶得去制作了?。?/span>


結合上圖幾種方式,debug再概括性的介紹下吧:

一、數據庫的自增主鍵

簡介:這一點相信寫過代碼的小伙伴都曉得,主要利用主鍵IDauo_increment特性,每進來一條數據時數據庫自動為其生成當前最大的ID并作為該條記錄的主鍵;

優點:簡單、便捷;

缺點:只能限于單機,嚴重依賴于DB,僅可限于DB相關的業務,可用性還是有點差;


二、批量預生成ID

簡介:DB只存儲當前最大的ID值,每次需要ID時,則按照順序批量生成N個有序的ID列表,并將最大的ID + N

優點:相對于第一種方式性能還是提高了不少;

缺點:只能限于單機,還是仍然得依賴于DB,可用性還是有點差;而且批量生成的ID可能斷層(比如服務掛了然后重啟就可能跳過部分ID,如果服務有多個,將難以保證其有序性)


三、UUID的方式

簡介:通用唯一識別碼,這個估計眾所周知啦,不作過多的介紹了!

優點:簡單,直接 UUID.randomUUID().toString() 即可搞定;

缺點:比較長、占用空間大;無序且不利于索引,在實際項目中不建議使用;特別是在插入數據庫時如果用UUID生成的ID作為主鍵的話,很可能會引起B+樹的不斷重平衡;


四、基于時間戳

簡介:比如按照規則:yyyyMMdd + N位隨機數 或者 yyyyMMddHHmmss + N位隨機數

優點:可行,而且生成的ID編號前半段有序,有一定的業務意義;

缺點:當并發產生的數據量比較大時,那N位隨機數會出現重復的可能(雖然可以通過各種方式去重,比如RedisSet,但代價還是相當高的,因為得不斷的 while判斷是否重復


五、SnowFlake算法

簡介:Twitter開源的一種分布式ID生成算法,結果是一個Long型的64位的ID;其核心思想是將64位劃分為各個段,其中0號位不用,連續41位表示時間戳,連續10位表示工作機器ID,最后12位則表示毫秒級別的序列號,如下圖所示:


優點:可以說是分布式場景下生成全局唯一ID的一種經典算法吧,采用Java生成,對于咱們Java的小伙伴來說可以說是相當接地氣的了;

缺點:目前倒沒發現有啥缺陷,如果硬要說有,那就是“時鐘回播”的問題了,但其實沒啥事的話別亂重置系統時鐘或者亂調系統時鐘則一般是沒啥問題的!如果還說它仍然有缺點的話,那就是它的算法實現邏輯,即nextId()方法里面的代碼還真的挺復雜,一堆位運算 理解起來確實比較消耗腦細胞(除此之外,那就是它最終生成的ID長度有點長啦)!


六、原子操作類AtomicXX

簡介:JUC包下經典的原子操作類,可以基于它生成自增、有序且全局唯一的編號

優點:底層采用CASCompare And Swap)機制實現,并發場景下可以保證“自增”代碼邏輯的安全性;

缺點:依賴于JDK,只適合單機環境


七、RedisINCRBY操作

簡介:熟悉這個命令的應該都知道它是啥意思,不知道的 自己打開redis-cli執行下該命令就可以了!

優點:可行,分布式場景下是適用的;

缺點:基本上沒想到有啥缺陷,如果要挑刺的話,那就是依賴于中間件服務,如果Redis掛掉,那基本上該ID生成服務就不可用了(其實,這有點杠的嫌疑哈,年輕人 不要搞內斗哈 ~ 你不會做Redis集群部署保證其高可用嗎?)


八、基于ZooKeeper的節點版本號生成ID

簡介:這個大家可能有點陌生,其實就是利用ZooKeeper底層樹形節點ZNode(類似于Windows的文件目錄數)的有序性,循環不斷生成其對應的版本號或者節點本身的數據

優點:可行,分布式場景下是適用的;

缺點:基本上沒想到有啥缺陷,跟第七點類似吧,需要保證ZK服務的高可用即可

 

啰里啰嗦介紹了這么多,接下來咱們還是得進入代碼實戰,其中的場景可以暫設定為:生成全局唯一的、數據格式為:yyyyMMddHHmmss + N位的數值碼(N=4或者N=6比較常見),其中要求最終生成的碼全局唯一、有序且最好有一定的業務意義,那廢話少說,咱們直接開干吧!

  

、基于原子操作類AtomicXX

1)需求分析:對于前半部分 yyyyMMddHHmmss 這個還是比較簡單的,基于SimpleDateFormat即可解決(但要注意它本身并非線程安全),而至于后面的 N位數值碼,在這里可以用 AtomicLong 進行實現,假設 N=6,則其初始值可以設定為100000,也就是說在一段時間內可以生成999999個編碼,在秒級并發場景下這應該足夠了;

 要注意的是,當系統運行到一段時間后,如達到999999時需要將其重置回100000,或者也可以通過不定時上線、重啟亦可以達到效果

2為了測試生成的ID/編碼是否全局唯一,我們建了一個簡單的數據庫表qr_code,其DDL如下所示,后續可以通過group bysql語句統計相同的code出現的次數:   

CREATE TABLE `qr_code` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`code` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '編碼',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

緊接著,創建一控制器類QrCodeController,并在其中創建相應的請求方法,用于JMeter壓力測試,如下代碼所示:

@Autowired
private QrCodeMapper qrCodeMapper;

//隨機的后6位商品編號,毫秒級上限為999999,應該是滿足的 (只要間隔一定的頻率重新發布/重//啟應用時,則當前計數器將重置為 100000)
private static AtomicLong atomicLong=new AtomicLong(100000);

@PostMapping("generate/code/v2")
public BaseResponse generateCodeV2(){
BaseResponse response=new BaseResponse(StatusCode.Success);
try {
//方式一:原子操作數(單體應用系統架構)
qrCodeMapper.insertSelective(new QrCode(generateCodeInV1()));
}catch (Exception e){
response=new BaseResponse(StatusCode.Fail.getCode(),e.getMessage());
}
return response;
}
//全局唯一編碼 - 正常情況 - 單體應用系統架構下可用 “原子操作數” 控制并發(加上本身就有//計數功能)
private String generateCodeInV1(){
SimpleDateFormat format=new SimpleDateFormat("yyyyMMddHHmmss");
return format.format(new Date()) + atomicLong.getAndIncrement();
}

之后,將項目運行起來并打開JMeter建立一測試計劃,直接設定1秒內并發線程數為10000,如下圖所示:


完了之后,查看數據庫表,先看下總數會發現一共10000條數據完美進入DB,與此同時執行下下面的SQL查看下是否有相同的code出現2次或者2次以上的,如下圖所示:   

SELECT
`code`,
COUNT(id) AS total
FROM
qr_code
GROUP BY
`code`
HAVING total > 1


OK,此種實現方式基本上就沒啥問題了,但有點要注意的話,這種方式依賴于JDK,只適用于單體應用系統架構,如果是傳統的企業級應用系統需要生成全局唯一的ID/編號,那這種方式應該沒啥問題!

、基于SnowFlake算法

 SnowFlake算法就不作過多介紹了,完整的介紹可以到開源網站github上進行閱覽:https://github.com/souyunku/SnowFlake ,其核心思想在于“分段”,并基于高效的位操作加以實現,感興趣的小伙伴可以去研究研究它的源碼,在此debug就簡單介紹介紹吧:

0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000

64位,第一位為未使用,接下來的41位為毫秒級時間(41位的長度可以使用69),然后是5datacenterId5workerId(10位的長度最多支持部署1024個節點) ,最后12位是毫秒內的計數(12位的計數順序可以支持每個節點每毫秒產生4096ID序號);一共加起來剛好64位,為一個Long(轉換成字符串長度為18)

SnowFlake生成的ID整體上按照時間自增排序,并且整個分布式系統內不會產生ID碰撞(由datacenterworkerId作區分),并且效率較高,據說:snowflake每秒能夠產生26萬個ID;下面簡單進入實戰吧(借助Hutool工具即可):

@Autowired
private QrCodeMapper qrCodeMapper;

//隨機的后6位商品編號,毫秒級上限為999999,應該是滿足的 (只要間隔一定的頻率重新發布/重啟應用時,則當前計數器將重置為 100000)
private static AtomicLong atomicLong=new AtomicLong(100000);

@PostMapping("generate/code/v2")
public BaseResponse generateCodeV2(){
BaseResponse response=new BaseResponse(StatusCode.Success);
try {
//方式二:雪花算法(單體/分布式)
qrCodeMapper.insertSelective(new QrCode(generateCodeInV2()));
}catch (Exception e){
response=new BaseResponse(StatusCode.Fail.getCode(),e.getMessage());
}
return response;
}

private static final Snowflake snowflake=new Snowflake(5,5);

//全局唯一編碼 - 雪花算法
private String generateCodeInV2(){
//為了湊夠20位
return snowflake.nextIdStr()+RandomStringUtils.randomNumeric(1);
}

直接壓測一番,然后看結果吧:



、RedisINCRBY操作

我們仍然假設 N=6,即可以將其初始值設定為99999,然后通過INCRBY命令對應的操作不斷進行 +1 操作;此種方式主要是利用Redis的命令具有原子操作的特性(單線程,但支持并發),因此在分布式高并發的場景下這一方式是頂得住的;

只不過仍然需要設定一個檢測機制,判斷是否已經達到了  999999 ,如果是,則需要將其重置回  99999 (由于前半段的數據格式精確到毫秒ms,因此可能會出現的差錯也是毫秒級別的出錯概率);

如下代碼所示:

@Autowired
private RedisTemplate redisTemplate;

private static final String RedisKeyCode="sb:technology:code:v1";

private static final Long LimitMaxCode=1000L;

private static final Long InitKeyCode=99L;

//每次項目重啟都可以將其重置為初始值
@PostConstruct
public void init(){
redisTemplate.delete(RedisKeyCode);

redisTemplate.opsForValue().set(RedisKeyCode,InitKeyCode);
}

//全局唯一編碼 - Redis:要使用 Redis的 INCRBY命令,需要設置緩存中key的序列化機制為://StringRedisSerializer;
//不然會出現:ERR value is not an integer or out of range
private String generateCodeInV3(){
SimpleDateFormat format=new SimpleDateFormat("yyyyMMddHHmmss");
Long currCode=redisTemplate.opsForValue().increment(RedisKeyCode,1L);
//編碼上限/閾值檢測機制
if (Objects.equals(LimitMaxCode,currCode)){
redisTemplate.opsForValue().set(RedisKeyCode,InitKeyCode);
currCode=redisTemplate.opsForValue().increment(RedisKeyCode,1L);
}
return format.format(new Date()) + currCode;
}

為了壓測方便,debug將其中的N=3,即初始值為99,從100開始計數,最大編碼上限為1000(不能取到),這樣的話,JMeter壓測時QPS設置10000時就會有很多次達到 1000,觸發重置機制,如下圖所示為QPS=10000時的壓測結果:






至此,也完成了此種方式的代碼實戰,感興趣的小伙伴可以擼一擼?。?!   

、基于ZooKeeper的節點版本號生成ID

這種方式的話得需要大致知曉ZooKeeper底層的系統架構和數據的存儲結構,其數據存儲結構可以簡單地理解為“類Windows操作系統的文件目錄結構樹”,即多節點ZNode樹形結構,節點與節點之間串成一路徑Path,以此用于區分、標識存儲的唯一數據;

在這里debug是基于zk本身節點的版本號來構成全局唯一ID、編碼的,話不多說,直接上代碼吧:   

//zookeeper生成全局唯一標志符的方式
private static final String ID_NODE = "/QRCodeV2";

//zk客戶端實例
@Autowired
private CuratorFramework client;

//全局唯一編碼 - zookeeper
private String generateCodeInV4() throws Exception{
if (null == client.checkExists().forPath(ID_NODE)) {
//PERSISTENT(0, false, false) 持久型節點; PERSISTENT_SEQUENTIAL(2, false, true) 持久順序型節點;
//EPHEMERAL(1, true, false) 臨時型節點;EPHEMERAL_SEQUENTIAL(3, true, true) 臨時順序型節點;
client.create().withMode(CreateMode.PERSISTENT).forPath(ID_NODE, new byte[0]);
}

//根據節點的版本號-從0開始遞增的,因此位數也是不斷在變化的(只要path不變)
Stat stat = client.setData().forPath(ID_NODE,new byte[0]);
SimpleDateFormat format=new SimpleDateFormat("yyyyMMddHHmmss");

return format.format(new Date()) + stat.getVersion();
}

其中,要注意的是ZooKeeper客戶端實例CuratorFramework相關屬性的自定義注入與配置,如下所示:

@Configuration
public class ZooKeeperConfig {
private static final String ZK_ADDRESS = "127.0.0.1:2181";

@Bean
public CuratorFramework curatorFramework(){
//如果獲取鏈接失敗,則重試3次,每次間隔2s
RetryPolicy policy=new RetryNTimes(3,2000);
//獲取鏈接到zk服務的客戶端實例
CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
.connectString(ZK_ADDRESS).sessionTimeoutMs(5000).connectionTimeoutMs(10000)
.retryPolicy(policy).build();
//啟動客戶端
curatorFramework.start();
return curatorFramework;
}
}

需要在本地127.0.0.1這里將zookeeper服務開起來,如果是windows環境下的,可以來這里下載:https://www.fightjava.com/web/index/resource/10 ,雙擊bin目錄里面的zkServer.cmd 即可開心的耍起來?。?!

如下所示為最終的壓測結果:



總結:

1代碼下載:關注“程序員實戰基地”微信公眾號,回復“分布式id”,即可獲取代碼下載鏈接   

2至此,我們已經介紹完了N種“全局唯一ID/編碼”實現方式的介紹以及在分布式場景下的代碼實戰實現;具體要選擇哪一種,還是那句老話:視具體的業務場景、服務器配置以及技術人員的技術掌握程度進行抉擇;在本文debug有提供了一種單體下的實現方式、也提供了多種分布式場景下的實現方式(當然啦,既然是分布式,也就適用于單體的場景啦),話不多說,諸位年輕人還是親自上去擼一擼吧!


我是debug,一個相信技術改變生活、技術成就夢想 的攻城獅;如果本文對你有幫助,請關注公眾號,并動動手指收藏、點贊、以及轉發哦?。?!   

關注一下Debug的技術微信公眾號,最新的技術文章、課程以及技術專欄將會第一時間在公眾號發布哦