技術(shù)干貨實(shí)戰(zhàn)(2)- 聊一聊分布式系統(tǒng)全局唯一ID的幾種實(shí)現(xiàn)方式
作者:
修羅debug
版權(quán)聲明:本文為博主原創(chuàng)文章,遵循 CC 4.0 by-sa 版權(quán)協(xié)議,轉(zhuǎn)載請(qǐng)附上原文出處鏈接和本聲明。
現(xiàn)如今可謂是微服務(wù)、分布式、IoT(物聯(lián)網(wǎng))橫行的時(shí)代,作為一名開(kāi)發(fā)者始終還是要保持一定的危機(jī)意識(shí),特別是在日常的項(xiàng)目開(kāi)發(fā)中,若是有機(jī)會(huì)接觸到一些關(guān)于微服務(wù)、分布式下的應(yīng)用場(chǎng)景,應(yīng)當(dāng)硬著頭皮、排除萬(wàn)難,主動(dòng)應(yīng)承下來(lái) 上去大干一場(chǎng);這期間不管結(jié)果如何,積累下來(lái)的經(jīng)驗(yàn)將會(huì)讓自己受益匪淺;而本文要介紹的“分布式全局唯一ID”便是一種典型的分布式應(yīng)用場(chǎng)景?。?!
話(huà)不多說(shuō),咱們直接進(jìn)入正題~~~
說(shuō)起這個(gè)全局唯一ID,你可能會(huì)第一時(shí)間想到“數(shù)據(jù)庫(kù)的自增主鍵”、“UUID”、“雪花算法”等等,更有甚者,還能說(shuō)出一些大廠(chǎng)開(kāi)源的組件,比如滴滴的IDWorker、美團(tuán)的Leaf等等,沒(méi)錯(cuò),這些確實(shí)是可以實(shí)現(xiàn)全局唯一ID的方案,你能想到這些點(diǎn),那涉獵其實(shí)還是挺廣的;
而對(duì)于“全局唯一ID/編號(hào)/編碼”的應(yīng)用場(chǎng)景,在現(xiàn)實(shí)生活中還是比較多的,比如電商平臺(tái)中“訂單系統(tǒng)”的訂單編號(hào),“進(jìn)銷(xiāo)存系統(tǒng)”中的商品編號(hào)、訂單編號(hào),“支付”過(guò)程中訂單流水號(hào)等等;接下來(lái)debug將會(huì)總結(jié)性的介紹下目前市面上比較流行的“全局唯一ID”的幾種實(shí)現(xiàn)方式,并針對(duì)分布式場(chǎng)景下的幾種實(shí)現(xiàn)方式進(jìn)行代碼實(shí)戰(zhàn)
話(huà)不多說(shuō),直接進(jìn)入正題,先貼張思維導(dǎo)圖吧,總結(jié)性地概括下目前網(wǎng)上比較流行的幾種方式(當(dāng)然啦,圖片來(lái)源于互聯(lián)網(wǎng)哈,因?yàn)?span style="" lang="EN-US">debug懶得去制作了!)
結(jié)合上圖幾種方式,debug再概括性的介紹下吧:
一、數(shù)據(jù)庫(kù)的自增主鍵
簡(jiǎn)介:這一點(diǎn)相信寫(xiě)過(guò)代碼的小伙伴都曉得,主要利用主鍵ID的auo_increment特性,每進(jìn)來(lái)一條數(shù)據(jù)時(shí)數(shù)據(jù)庫(kù)自動(dòng)為其生成當(dāng)前最大的ID并作為該條記錄的主鍵;
優(yōu)點(diǎn):簡(jiǎn)單、便捷;
缺點(diǎn):只能限于單機(jī),嚴(yán)重依賴(lài)于DB,僅可限于DB相關(guān)的業(yè)務(wù),可用性還是有點(diǎn)差;
二、批量預(yù)生成ID
簡(jiǎn)介:DB只存儲(chǔ)當(dāng)前最大的ID值,每次需要ID時(shí),則按照順序批量生成N個(gè)有序的ID列表,并將最大的ID值 + N
優(yōu)點(diǎn):相對(duì)于第一種方式性能還是提高了不少;
缺點(diǎn):只能限于單機(jī),還是仍然得依賴(lài)于DB,可用性還是有點(diǎn)差;而且批量生成的ID可能斷層(比如服務(wù)掛了然后重啟就可能跳過(guò)部分ID,如果服務(wù)有多個(gè),將難以保證其有序性)
三、UUID的方式
簡(jiǎn)介:通用唯一識(shí)別碼,這個(gè)估計(jì)眾所周知啦,不作過(guò)多的介紹了!
優(yōu)點(diǎn):簡(jiǎn)單,直接 UUID.randomUUID().toString() 即可搞定;
缺點(diǎn):比較長(zhǎng)、占用空間大;無(wú)序且不利于索引,在實(shí)際項(xiàng)目中不建議使用;特別是在插入數(shù)據(jù)庫(kù)時(shí)如果用UUID生成的ID作為主鍵的話(huà),很可能會(huì)引起B+樹(shù)的不斷重平衡;
四、基于時(shí)間戳
簡(jiǎn)介:比如按照規(guī)則:yyyyMMdd + N位隨機(jī)數(shù) 或者 yyyyMMddHHmmss + N位隨機(jī)數(shù)
優(yōu)點(diǎn):可行,而且生成的ID編號(hào)前半段有序,有一定的業(yè)務(wù)意義;
缺點(diǎn):當(dāng)并發(fā)產(chǎn)生的數(shù)據(jù)量比較大時(shí),那N位隨機(jī)數(shù)會(huì)出現(xiàn)重復(fù)的可能(雖然可以通過(guò)各種方式去重,比如Redis的Set,但代價(jià)還是相當(dāng)高的,因?yàn)榈貌粩嗟?span lang="EN-US"> while判斷是否重復(fù)…)
五、SnowFlake算法
簡(jiǎn)介:Twitter開(kāi)源的一種分布式ID生成算法,結(jié)果是一個(gè)Long型的64位的ID;其核心思想是將64位劃分為各個(gè)段,其中0號(hào)位不用,連續(xù)41位表示時(shí)間戳,連續(xù)10位表示工作機(jī)器ID,最后12位則表示毫秒級(jí)別的序列號(hào),如下圖所示:
優(yōu)點(diǎn):可以說(shuō)是分布式場(chǎng)景下生成全局唯一ID的一種經(jīng)典算法吧,采用Java生成,對(duì)于咱們Java的小伙伴來(lái)說(shuō)可以說(shuō)是相當(dāng)接地氣的了;
缺點(diǎn):目前倒沒(méi)發(fā)現(xiàn)有啥缺陷,如果硬要說(shuō)有,那就是“時(shí)鐘回播”的問(wèn)題了,但其實(shí)沒(méi)啥事的話(huà)別亂重置系統(tǒng)時(shí)鐘或者亂調(diào)系統(tǒng)時(shí)鐘則一般是沒(méi)啥問(wèn)題的!如果還說(shuō)它仍然有缺點(diǎn)的話(huà),那就是它的算法實(shí)現(xiàn)邏輯,即nextId()方法里面的代碼還真的挺復(fù)雜,一堆位運(yùn)算 理解起來(lái)確實(shí)比較消耗腦細(xì)胞(除此之外,那就是它最終生成的ID長(zhǎng)度有點(diǎn)長(zhǎng)啦)!
六、原子操作類(lèi)AtomicXX
簡(jiǎn)介:JUC包下經(jīng)典的原子操作類(lèi),可以基于它生成自增、有序且全局唯一的編號(hào)
優(yōu)點(diǎn):底層采用CAS(Compare And Swap)機(jī)制實(shí)現(xiàn),并發(fā)場(chǎng)景下可以保證“自增”代碼邏輯的安全性;
缺點(diǎn):依賴(lài)于JDK,只適合單機(jī)環(huán)境
七、Redis的INCRBY操作
簡(jiǎn)介:熟悉這個(gè)命令的應(yīng)該都知道它是啥意思,不知道的 自己打開(kāi)redis-cli執(zhí)行下該命令就可以了!
優(yōu)點(diǎn):可行,分布式場(chǎng)景下是適用的;
缺點(diǎn):基本上沒(méi)想到有啥缺陷,如果要挑刺的話(huà),那就是依賴(lài)于中間件服務(wù),如果Redis掛掉,那基本上該ID生成服務(wù)就不可用了(其實(shí),這有點(diǎn)杠的嫌疑哈,年輕人 不要搞內(nèi)斗哈 ~ 你不會(huì)做Redis集群部署保證其高可用嗎?)
八、基于ZooKeeper的節(jié)點(diǎn)版本號(hào)生成ID
簡(jiǎn)介:這個(gè)大家可能有點(diǎn)陌生,其實(shí)就是利用ZooKeeper底層樹(shù)形節(jié)點(diǎn)ZNode(類(lèi)似于Windows的文件目錄數(shù))的有序性,循環(huán)不斷生成其對(duì)應(yīng)的版本號(hào)或者節(jié)點(diǎn)本身的數(shù)據(jù)
優(yōu)點(diǎn):可行,分布式場(chǎng)景下是適用的;
缺點(diǎn):基本上沒(méi)想到有啥缺陷,跟第七點(diǎn)類(lèi)似吧,需要保證ZK服務(wù)的高可用即可
啰里啰嗦介紹了這么多,接下來(lái)咱們還是得進(jìn)入代碼實(shí)戰(zhàn),其中的場(chǎng)景可以暫設(shè)定為:生成全局唯一的、數(shù)據(jù)格式為:yyyyMMddHHmmss + N位的數(shù)值碼(N=4或者N=6比較常見(jiàn)),其中要求最終生成的碼全局唯一、有序且最好有一定的業(yè)務(wù)意義,那廢話(huà)少說(shuō),咱們直接開(kāi)干吧!
一、基于原子操作類(lèi)AtomicXX
(1)需求分析:對(duì)于前半部分 yyyyMMddHHmmss 這個(gè)還是比較簡(jiǎn)單的,基于SimpleDateFormat即可解決(但要注意它本身并非線(xiàn)程安全),而至于后面的 N位數(shù)值碼,在這里可以用 AtomicLong 進(jìn)行實(shí)現(xiàn),假設(shè) N=6,則其初始值可以設(shè)定為100000,也就是說(shuō)在一段時(shí)間內(nèi)可以生成999999個(gè)編碼,在秒級(jí)并發(fā)場(chǎng)景下這應(yīng)該足夠了;
要注意的是,當(dāng)系統(tǒng)運(yùn)行到一段時(shí)間后,如達(dá)到999999時(shí)需要將其重置回100000,或者也可以通過(guò)不定時(shí)上線(xiàn)、重啟亦可以達(dá)到效果
(2)為了測(cè)試生成的ID/編碼是否全局唯一,我們建了一個(gè)簡(jiǎn)單的數(shù)據(jù)庫(kù)表qr_code,其DDL如下所示,后續(xù)可以通過(guò)group by等sql語(yǔ)句統(tǒng)計(jì)相同的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)建一控制器類(lèi)QrCodeController,并在其中創(chuàng)建相應(yīng)的請(qǐng)求方法,用于JMeter壓力測(cè)試,如下代碼所示:
@Autowired
private QrCodeMapper qrCodeMapper;
//隨機(jī)的后6位商品編號(hào),毫秒級(jí)上限為999999,應(yīng)該是滿(mǎn)足的 (只要間隔一定的頻率重新發(fā)布/重//啟應(yīng)用時(shí),則當(dāng)前計(jì)數(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ā)(加上本身就有//計(jì)數(shù)功能)
private String generateCodeInV1(){
SimpleDateFormat format=new SimpleDateFormat("yyyyMMddHHmmss");
return format.format(new Date()) + atomicLong.getAndIncrement();
}
之后,將項(xiàng)目運(yùn)行起來(lái)并打開(kāi)JMeter建立一測(cè)試計(jì)劃,直接設(shè)定1秒內(nèi)并發(fā)線(xiàn)程數(shù)為10000,如下圖所示:
完了之后,查看數(shù)據(jù)庫(kù)表,先看下總數(shù)會(huì)發(fā)現(xiàn)一共10000條數(shù)據(jù)完美進(jìn)入DB,與此同時(shí)執(zhí)行下下面的SQL查看下是否有相同的code出現(xiàn)2次或者2次以上的,如下圖所示:
SELECT
`code`,
COUNT(id) AS total
FROM
qr_code
GROUP BY
`code`
HAVING total > 1
OK,此種實(shí)現(xiàn)方式基本上就沒(méi)啥問(wèn)題了,但有點(diǎn)要注意的話(huà),這種方式依賴(lài)于JDK,只適用于單體應(yīng)用系統(tǒng)架構(gòu),如果是傳統(tǒng)的企業(yè)級(jí)應(yīng)用系統(tǒng)需要生成全局唯一的ID/編號(hào),那這種方式應(yīng)該沒(méi)啥問(wèn)題!
二、基于SnowFlake算法
SnowFlake算法就不作過(guò)多介紹了,完整的介紹可以到開(kāi)源網(wǎng)站github上進(jìn)行閱覽:https://github.com/souyunku/SnowFlake ,其核心思想在于“分段”,并基于高效的位操作加以實(shí)現(xiàn),感興趣的小伙伴可以去研究研究它的源碼,在此debug就簡(jiǎn)單介紹介紹吧:
0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
共64位,第一位為未使用,接下來(lái)的41位為毫秒級(jí)時(shí)間(41位的長(zhǎng)度可以使用69年),然后是5位datacenterId和5位workerId(10位的長(zhǎng)度最多支持部署1024個(gè)節(jié)點(diǎn)) ,最后12位是毫秒內(nèi)的計(jì)數(shù)(12位的計(jì)數(shù)順序可以支持每個(gè)節(jié)點(diǎn)每毫秒產(chǎn)生4096個(gè)ID序號(hào));一共加起來(lái)剛好64位,為一個(gè)Long型(轉(zhuǎn)換成字符串長(zhǎng)度為18)
SnowFlake生成的ID整體上按照時(shí)間自增排序,并且整個(gè)分布式系統(tǒng)內(nèi)不會(huì)產(chǎn)生ID碰撞(由datacenter和workerId作區(qū)分),并且效率較高,據(jù)說(shuō):snowflake每秒能夠產(chǎn)生26萬(wàn)個(gè)ID;下面簡(jiǎn)單進(jìn)入實(shí)戰(zhàn)吧(借助Hutool工具即可):
@Autowired
private QrCodeMapper qrCodeMapper;
//隨機(jī)的后6位商品編號(hào),毫秒級(jí)上限為999999,應(yīng)該是滿(mǎn)足的 (只要間隔一定的頻率重新發(fā)布/重啟應(yīng)用時(shí),則當(dāng)前計(jì)數(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);
}
直接壓測(cè)一番,然后看結(jié)果吧:
三、Redis的INCRBY操作
我們?nèi)匀患僭O(shè) N=6,即可以將其初始值設(shè)定為99999,然后通過(guò)INCRBY命令對(duì)應(yīng)的操作不斷進(jìn)行 +1 操作;此種方式主要是利用Redis的命令具有原子操作的特性(單線(xiàn)程,但支持并發(fā)),因此在分布式高并發(fā)的場(chǎng)景下這一方式是頂?shù)米〉模?span lang="EN-US">
只不過(guò)仍然需要設(shè)定一個(gè)檢測(cè)機(jī)制,判斷是否已經(jīng)達(dá)到了 999999 ,如果是,則需要將其重置回 99999 (由于前半段的數(shù)據(jù)格式精確到毫秒ms,因此可能會(huì)出現(xiàn)的差錯(cuò)也是毫秒級(jí)別的出錯(cuò)概率);
如下代碼所示:
@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;
//每次項(xiàng)目重啟都可以將其重置為初始值
@PostConstruct
public void init(){
redisTemplate.delete(RedisKeyCode);
redisTemplate.opsForValue().set(RedisKeyCode,InitKeyCode);
}
//全局唯一編碼 - Redis:要使用 Redis的 INCRBY命令,需要設(shè)置緩存中key的序列化機(jī)制為://StringRedisSerializer;
//不然會(huì)出現(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);
//編碼上限/閾值檢測(cè)機(jī)制
if (Objects.equals(LimitMaxCode,currCode)){
redisTemplate.opsForValue().set(RedisKeyCode,InitKeyCode);
currCode=redisTemplate.opsForValue().increment(RedisKeyCode,1L);
}
return format.format(new Date()) + currCode;
}
為了壓測(cè)方便,debug將其中的N=3,即初始值為99,從100開(kāi)始計(jì)數(shù),最大編碼上限為1000(不能取到),這樣的話(huà),JMeter壓測(cè)時(shí)QPS設(shè)置10000時(shí)就會(huì)有很多次達(dá)到 1000,觸發(fā)重置機(jī)制,如下圖所示為QPS=10000時(shí)的壓測(cè)結(jié)果:
至此,也完成了此種方式的代碼實(shí)戰(zhàn),感興趣的小伙伴可以擼一擼!??!
四、基于ZooKeeper的節(jié)點(diǎn)版本號(hào)生成ID
這種方式的話(huà)得需要大致知曉ZooKeeper底層的系統(tǒng)架構(gòu)和數(shù)據(jù)的存儲(chǔ)結(jié)構(gòu),其數(shù)據(jù)存儲(chǔ)結(jié)構(gòu)可以簡(jiǎn)單地理解為“類(lèi)Windows操作系統(tǒng)的文件目錄結(jié)構(gòu)樹(shù)”,即多節(jié)點(diǎn)ZNode樹(shù)形結(jié)構(gòu),節(jié)點(diǎn)與節(jié)點(diǎn)之間串成一路徑Path,以此用于區(qū)分、標(biāo)識(shí)存儲(chǔ)的唯一數(shù)據(jù);
在這里debug是基于zk本身節(jié)點(diǎn)的版本號(hào)來(lái)構(gòu)成全局唯一ID、編碼的,話(huà)不多說(shuō),直接上代碼吧:
//zookeeper生成全局唯一標(biāo)志符的方式
private static final String ID_NODE = "/QRCodeV2";
//zk客戶(hù)端實(shí)例
@Autowired
private CuratorFramework client;
//全局唯一編碼 - zookeeper
private String generateCodeInV4() throws Exception{
if (null == client.checkExists().forPath(ID_NODE)) {
//PERSISTENT(0, false, false) 持久型節(jié)點(diǎn); PERSISTENT_SEQUENTIAL(2, false, true) 持久順序型節(jié)點(diǎn);
//EPHEMERAL(1, true, false) 臨時(shí)型節(jié)點(diǎn);EPHEMERAL_SEQUENTIAL(3, true, true) 臨時(shí)順序型節(jié)點(diǎn);
client.create().withMode(CreateMode.PERSISTENT).forPath(ID_NODE, new byte[0]);
}
//根據(jù)節(jié)點(diǎn)的版本號(hào)-從0開(kāi)始遞增的,因此位數(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客戶(hù)端實(shí)例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ù)的客戶(hù)端實(shí)例
CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
.connectString(ZK_ADDRESS).sessionTimeoutMs(5000).connectionTimeoutMs(10000)
.retryPolicy(policy).build();
//啟動(dòng)客戶(hù)端
curatorFramework.start();
return curatorFramework;
}
}
需要在本地127.0.0.1這里將zookeeper服務(wù)開(kāi)起來(lái),如果是windows環(huán)境下的,可以來(lái)這里下載:https://www.fightjava.com/web/index/resource/10 ,雙擊bin目錄里面的zkServer.cmd 即可開(kāi)心的耍起來(lái)?。。?span style="" lang="EN-US">
如下所示為最終的壓測(cè)結(jié)果:
總結(jié):
(1)代碼下載:關(guān)注“程序員實(shí)戰(zhàn)基地”微信公眾號(hào),回復(fù)“分布式id”,即可獲取代碼下載鏈接
(2)至此,我們已經(jīng)介紹完了N種“全局唯一ID/編碼”實(shí)現(xiàn)方式的介紹以及在分布式場(chǎng)景下的代碼實(shí)戰(zhàn)實(shí)現(xiàn);具體要選擇哪一種,還是那句老話(huà):視具體的業(yè)務(wù)場(chǎng)景、服務(wù)器配置以及技術(shù)人員的技術(shù)掌握程度進(jìn)行抉擇;在本文debug有提供了一種單體下的實(shí)現(xiàn)方式、也提供了多種分布式場(chǎng)景下的實(shí)現(xiàn)方式(當(dāng)然啦,既然是分布式,也就適用于單體的場(chǎng)景啦),話(huà)不多說(shuō),諸位年輕人還是親自上去擼一擼吧!
我是debug,一個(gè)相信技術(shù)改變生活、技術(shù)成就夢(mèng)想
的攻城獅;如果本文對(duì)你有幫助,請(qǐng)關(guān)注公眾號(hào),并動(dòng)動(dòng)手指收藏、點(diǎn)贊、以及轉(zhuǎn)發(fā)哦?。。?nbsp;