技術(shù)干貨實戰(zhàn)(2)- 聊一聊分布式系統(tǒng)全局唯一ID的幾種實現(xiàn)方式

作者: 修羅debug
版權(quán)聲明:本文為博主原創(chuàng)文章,遵循 CC 4.0 by-sa 版權(quán)協(xié)議,轉(zhuǎn)載請附上原文出處鏈接和本聲明。



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

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

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


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


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


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

一、數(shù)據(jù)庫的自增主鍵

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

優(yōu)點:簡單、便捷;

缺點:只能限于單機,嚴重依賴于DB,僅可限于DB相關(guān)的業(yè)務(wù),可用性還是有點差;


二、批量預(yù)生成ID

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

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

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


三、UUID的方式

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

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

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


四、基于時間戳

簡介:比如按照規(guī)則:yyyyMMdd + N位隨機數(shù) 或者 yyyyMMddHHmmss + N位隨機數(shù)

優(yōu)點:可行,而且生成的ID編號前半段有序,有一定的業(yè)務(wù)意義;

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


五、SnowFlake算法

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


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

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


六、原子操作類AtomicXX

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

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

缺點:依賴于JDK,只適合單機環(huán)境


七、RedisINCRBY操作

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

優(yōu)點:可行,分布式場景下是適用的;

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


八、基于ZooKeeper的節(jié)點版本號生成ID

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

優(yōu)點:可行,分布式場景下是適用的;

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

 

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

  

、基于原子操作類AtomicXX

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

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

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

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;

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

@Autowired
private QrCodeMapper qrCodeMapper;

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

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

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


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

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


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

、基于SnowFlake算法

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

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

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

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

@Autowired
private QrCodeMapper qrCodeMapper;

//隨機的后6位商品編號,毫秒級上限為999999,應(yīng)該是滿足的 (只要間隔一定的頻率重新發(fā)布/重啟應(yīng)用時,則當前計數(shù)器將重置為 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);
}

直接壓測一番,然后看結(jié)果吧:



、RedisINCRBY操作

我們?nèi)匀患僭O(shè) N=6,即可以將其初始值設(shè)定為99999,然后通過INCRBY命令對應(yīng)的操作不斷進行 +1 操作;此種方式主要是利用Redis的命令具有原子操作的特性(單線程,但支持并發(fā)),因此在分布式高并發(fā)的場景下這一方式是頂?shù)米〉模?span lang="EN-US">

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

如下代碼所示:

@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命令,需要設(shè)置緩存中key的序列化機制為://StringRedisSerializer;
//不然會出現(xiàn):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開始計數(shù),最大編碼上限為1000(不能取到),這樣的話,JMeter壓測時QPS設(shè)置10000時就會有很多次達到 1000,觸發(fā)重置機制,如下圖所示為QPS=10000時的壓測結(jié)果:






至此,也完成了此種方式的代碼實戰(zhàn),感興趣的小伙伴可以擼一擼?。。?nbsp;  

、基于ZooKeeper的節(jié)點版本號生成ID

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

在這里debug是基于zk本身節(jié)點的版本號來構(gòu)成全局唯一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) 持久型節(jié)點; PERSISTENT_SEQUENTIAL(2, false, true) 持久順序型節(jié)點;
//EPHEMERAL(1, true, false) 臨時型節(jié)點;EPHEMERAL_SEQUENTIAL(3, true, true) 臨時順序型節(jié)點;
client.create().withMode(CreateMode.PERSISTENT).forPath(ID_NODE, new byte[0]);
}

//根據(jù)節(jié)點的版本號-從0開始遞增的,因此位數(shù)也是不斷在變化的(只要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相關(guān)屬性的自定義注入與配置,如下所示:

@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服務(wù)的客戶端實例
CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
.connectString(ZK_ADDRESS).sessionTimeoutMs(5000).connectionTimeoutMs(10000)
.retryPolicy(policy).build();
//啟動客戶端
curatorFramework.start();
return curatorFramework;
}
}

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

如下所示為最終的壓測結(jié)果:



總結(jié):

1代碼下載:關(guān)注“程序員實戰(zhàn)基地”微信公眾號,回復(fù)“分布式id”,即可獲取代碼下載鏈接   

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


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

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