就這诲泌?分布式 ID 發(fā)號(hào)器實(shí)戰(zhàn)

img

分布式 ID 需要滿足的條件:

  • 全局唯一:這是最基本的要求,必須保證 ID 是全局唯一的铣鹏。
  • 高性能:低延時(shí)敷扫,不能因?yàn)橐粋€(gè)小小的 ID 生成,影響整個(gè)業(yè)務(wù)響應(yīng)速度诚卸。
  • 高可用:無限接近于100%的可用性葵第。
  • 好接入:遵循拿來主義原則,在系統(tǒng)設(shè)計(jì)和實(shí)現(xiàn)上要盡可能簡單合溺。
  • 趨勢遞增:這個(gè)要看具體業(yè)務(wù)場景卒密,最好要趨勢遞增,一般不嚴(yán)格要求棠赛。

讓我來先捋一捋常見的分布式 ID 的解決方案有哪些哮奇?

1、數(shù)據(jù)庫自增 ID

這是最常見的方式睛约,利用數(shù)據(jù)庫的 auto_increment 自增 ID鼎俘,當(dāng)我們需要一個(gè)ID的時(shí)候,向表中插入一條記錄返回主鍵 ID辩涝。簡單贸伐,代碼也方便,但是數(shù)據(jù)庫本身就存在瓶頸怔揩,DB 單點(diǎn)無法扛住高并發(fā)場景捉邢。

針對數(shù)據(jù)庫單點(diǎn)性能問題,可以做高可用優(yōu)化商膊,設(shè)計(jì)成主從模式集群伏伐,而且要多主,設(shè)置起始數(shù)和增長步長翘狱。

-- MySQL_1 配置:
set @@auto_increment_offset = 1;     -- 起始值
set @@auto_increment_increment = 2;  -- 步長
-- 自增ID分別為:1秘案、3、5潦匈、7阱高、9 ......

-- MySQL_2 配置:
set @@auto_increment_offset = 2;     -- 起始值
set @@auto_increment_increment = 2;  -- 步長
-- 自增ID分別為:2、4茬缩、6赤惊、8、10 ....

但是隨著業(yè)務(wù)不斷增長凰锡,當(dāng)性能再次達(dá)到瓶頸的時(shí)候未舟,想要再擴(kuò)容就太麻煩了圈暗,新增實(shí)例可能還要停機(jī)操作,不利于后續(xù)擴(kuò)容裕膀。

2员串、UUID

UUID 是 Universally Unique Identifier 的縮寫,它是在一定的范圍內(nèi)(從特定的名字空間到全球)唯一的機(jī)器生成的標(biāo)識(shí)符昼扛,UUID 是16字節(jié)128位長的數(shù)字寸齐,通常以36字節(jié)的字符串表示,比如:4D2803E0-8F29-17G3-9B1C-250FE82C4309抄谐。

生成ID性能非常好渺鹦,基本不會(huì)有性能問題,代碼也簡單但是長度過長蛹含,不可讀毅厚,也無法保證趨勢遞增。

3浦箱、雪花算法

雪花算法(Snowflake)是 twitter 公司內(nèi)部分布式項(xiàng)目采用的 ID 生成算法吸耿,開源后廣受國內(nèi)大廠的好評,在該算法影響下各大公司相繼開發(fā)出各具特色的分布式生成器憎茂。

img

組成結(jié)構(gòu):正數(shù)位(占1 bit)+ 時(shí)間戳(占41 bit)+ 機(jī)器 ID(占10 bit)+ 自增值(占12 bit)珍语,總共64 bit 組成的一個(gè) long 類型。

  • 第一個(gè) bit 位(1 bit):Java 中 long 的最高位是符號(hào)位代表正負(fù)竖幔,正數(shù)是0板乙,負(fù)數(shù)是1,一般生成 ID 都為正數(shù)拳氢,所以默認(rèn)為0
  • 時(shí)間戳部分(41 bit):毫秒級(jí)的時(shí)間募逞,不建議存當(dāng)前時(shí)間戳,而是用(當(dāng)前時(shí)間戳 - 固定開始時(shí)間戳)的差值馋评,可以使產(chǎn)生的ID從更小的值開始放接;41位的時(shí)間戳可以使用69年,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年
  • 工作機(jī)器id(10bit):也被叫做 workId留特,這個(gè)可以靈活配置纠脾,機(jī)房或者機(jī)器號(hào)組合都可以,通常被分為 機(jī)器 ID(占5 bit)+ 數(shù)據(jù)中心(占5 bit)
  • 序列號(hào)部分(12bit):自增值支持同一毫秒內(nèi)同一個(gè)節(jié)點(diǎn)可以生成4096個(gè) ID

雪花算法不依賴于數(shù)據(jù)庫蜕青,靈活方便苟蹈,且性能優(yōu)于數(shù)據(jù)庫,ID 按照時(shí)間在單機(jī)上是遞增的右核,但是由于涉及到分布式環(huán)境慧脱,每臺(tái)機(jī)器上的時(shí)鐘不可能完全同步,也許有時(shí)候也會(huì)出現(xiàn)不是全局遞增的情況贺喝。

img

雪花算法好像挺不錯(cuò)的樣子菱鸥,靚仔決定采用這個(gè)方案試下宗兼。

于是一套操作猛如虎,寫個(gè) demo 給領(lǐng)導(dǎo)看下氮采。

img

只能繼續(xù)思考方案了

4殷绍、百度(Uid-Generator)

uid-generator 是基于 Snowflake 算法實(shí)現(xiàn)的,與原始的 snowflake 算法不同在于鹊漠,它支持自定義時(shí)間戳篡帕、工作機(jī)器 ID 和 序列號(hào) 等各部分的位數(shù),而且 uid-generator 中采用用戶自定義 workId 的生成策略贸呢,在應(yīng)用啟動(dòng)時(shí)由數(shù)據(jù)庫分配。

具體不多介紹了拢军,官方地址:https://github.com/baidu/uid-generator

也就是說它依賴于數(shù)據(jù)庫楞陷,并且由于是基于 Snowflake 算法,所以也不可讀茉唉。

5固蛾、美團(tuán)(Leaf)

美團(tuán)的 Leaf 非常全面,即支持號(hào)段模式度陆,也支持 snowflake 模式艾凯。

也不多介紹了,官方地址:https://github.com/Meituan-Dianping/Leaf

號(hào)段模式是基于數(shù)據(jù)庫的懂傀,而 snowflake 模式是依賴于 Zookeeper 的

6趾诗、滴滴(TinyID)

TinyID 是基于數(shù)據(jù)庫號(hào)段算法實(shí)現(xiàn),還提供了 http 和 sdk 兩種方式接入蹬蚁。

文檔很全恃泪,官方地址:https://github.com/didi/tinyid

7、Redis 模式

其原理就是利用 redis 的 incr 命令實(shí)現(xiàn) ID 的原子性自增犀斋,眾所周知贝乎,redis 的性能是非常好的,而且本身就是單線程的叽粹,沒有線程安全問題览效。但是使用 redis 做分布式 id 解決方案,需要考慮持久化問題虫几,不然重啟 redis 過后可能會(huì)導(dǎo)致 id 重復(fù)的問題锤灿,建議采用 RDB + AOF 的持久化方式。

分析到這里持钉,我覺得 Redis 的方式非常適用于目前的場景衡招,公司系統(tǒng)原本就用到了 redis,而且也正是采用的 RDB + AOF 的持久化方式每强,這就非常好接入了始腾,只需少量編碼就能實(shí)現(xiàn)一個(gè)發(fā)號(hào)器功能州刽。

話不多說,直接開始干吧浪箭。

img

本案例基于 Spring Boot 2.5.3 版本

首先在 pom 中引入 redis 依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- lettuce客戶端連接需要這個(gè)依賴 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

application.yml 中配置 redis 連接

spring:
  redis:
    port: 6379
    host: 127.0.0.1
    timeout: 5000
    lettuce:
      pool:
        # 連接池大連接數(shù)(使用負(fù)值表示沒有限制)
        max-active: 8
        # 連接池中的大空閑連接
        max-idle: 8
        # 連接池中的小空閑連接
        min-idle: 0
        # 連接池大阻塞等待時(shí)間(使用負(fù)值表示沒有限制)
        max-wait: 1000
        # 關(guān)閉超時(shí)時(shí)間
        shutdown-timeout: 100

將 RedisTemplate 注入 Spring 容器中

@Configuration
public class RedisConfig{

    @Bean
    @ConditionalOnMissingBean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        // 使用Jackson2JsonRedisSerializer來序列化/反序列化redis的value值
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        // value
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

        // 使用StringRedisSerializer來序列化/反序列化redis的key值
        RedisSerializer<?> redisSerializer = new StringRedisSerializer();
        // key
        redisTemplate.setKeySerializer(redisSerializer);
        redisTemplate.setHashKeySerializer(redisSerializer);

        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

使用 redis 依賴中的 RedisAtomicLong 類來實(shí)現(xiàn) redis 自增序列穗椅,從類名就可以看出它是原子性的。

看一下 RedisAtomicLong 的部分源碼

// RedisAtomicLong 的部分源碼
public class RedisAtomicLong extends Number implements Serializable, BoundKeyOperations<String> {
    private static final long serialVersionUID = 1L;
    //redis 中的 key奶栖,用 volatile 修飾匹表,獲得原子性
    private volatile String key;
    //當(dāng)前的 key-value 對象,根據(jù)傳入的 key 獲取 value 值
    private ValueOperations<String, Long> operations;
    //傳入當(dāng)前 redisTemplate 對象宣鄙,為 RedisTemplate 對象的頂級(jí)接口
    private RedisOperations<String, Long> generalOps;

    public RedisAtomicLong(String redisCounter, RedisConnectionFactory factory) {
        this(redisCounter, (RedisConnectionFactory)factory, (Long)null);
    }
    private RedisAtomicLong(String redisCounter, RedisConnectionFactory factory, Long initialValue) {
        Assert.hasText(redisCounter, "a valid counter name is required");
        Assert.notNull(factory, "a valid factory is required");
        //初始化一個(gè) RedisTemplate 對象
        RedisTemplate<String, Long> redisTemplate = new RedisTemplate();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericToStringSerializer(Long.class));
        redisTemplate.setExposeConnection(true);
        //設(shè)置當(dāng)前的 redis 連接工廠
        redisTemplate.setConnectionFactory(factory);
        redisTemplate.afterPropertiesSet();
        //設(shè)置傳入的 key
        this.key = redisCounter;
        //設(shè)置當(dāng)前的 redisTemplate
        this.generalOps = redisTemplate;
        //獲取當(dāng)前的 key-value 集合
        this.operations = this.generalOps.opsForValue();
        //設(shè)置默認(rèn)值袍镀,如果傳入為 null,則 key 獲取 operations 中的 value冻晤,如果 value 為空苇羡,設(shè)置默認(rèn)值為0
        if (initialValue == null) {
            if (this.operations.get(redisCounter) == null) {
                this.set(0L);
            }
        //不為空則設(shè)置為傳入的值
        } else {
            this.set(initialValue);
        }
    }
    //將傳入 key 的 value + 1并返回
    public long incrementAndGet() {
        return this.operations.increment(this.key, 1L);
    }
} 

看完源碼,我們繼續(xù)自己的編碼

使用 RedisAtomicLong 封裝一個(gè)基礎(chǔ)的 redis 自增序列工具類

// 只封裝了部分方法鼻弧,還可以擴(kuò)展
@Service
public class RedisService {
    @Autowired
    RedisTemplate<String, Object> redisTemplate;

    /**
     * 獲取鏈接工廠
     */
    public RedisConnectionFactory getConnectionFactory() {
        return redisTemplate.getConnectionFactory();
    }

    /**
     * 自增數(shù)
     * @param key
     * @return
     */
    public long increment(String key) {
        RedisAtomicLong redisAtomicLong = new RedisAtomicLong(key, getConnectionFactory());
        return redisAtomicLong.incrementAndGet();
    }

    /**
     * 自增數(shù)(帶過期時(shí)間)
     * @param key
     * @param time
     * @param timeUnit
     * @return
     */
    public long increment(String key, long time, TimeUnit timeUnit) {
        RedisAtomicLong redisAtomicLong = new RedisAtomicLong(key, getConnectionFactory());
        redisAtomicLong.expire(time, timeUnit);
        return redisAtomicLong.incrementAndGet();
    }

    /**
     * 自增數(shù)(帶過期時(shí)間)
     * @param key
     * @param expireAt
     * @return
     */
    public long increment(String key, Instant expireAt) {
        RedisAtomicLong redisAtomicLong = new RedisAtomicLong(key, getConnectionFactory());
        redisAtomicLong.expireAt(expireAt);
        return redisAtomicLong.incrementAndGet();
    }

    /**
     * 自增數(shù)(帶過期時(shí)間和步長)
     * @param key
     * @param increment
     * @param time
     * @param timeUnit
     * @return
     */
    public long increment(String key, int increment, long time, TimeUnit timeUnit) {
        RedisAtomicLong redisAtomicLong = new RedisAtomicLong(key, getConnectionFactory());
        redisAtomicLong.expire(time, timeUnit);
        return redisAtomicLong.incrementAndGet();
    }
}

根據(jù)業(yè)務(wù)需求編寫發(fā)號(hào)器方法

@Service
public class IdGeneratorService {
    @Autowired
    RedisService redisService;

    /**
     * 生成id(每日重置自增序列)
     * 格式:日期 + 6位自增數(shù)
     * 如:20210804000001
     * @param key
     * @param length
     * @return
     */
    public String generateId(String key, Integer length) {
        long num = redisService.increment(key, getEndTime());
        String id = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + String.format("%0" + length + "d", num);
        return id;
    }

    /**
     * 獲取當(dāng)天的結(jié)束時(shí)間
     */
    public Instant getEndTime() {
        LocalDateTime endTime = LocalDateTime.of(LocalDate.now(), LocalTime.MAX);
        return endTime.toInstant(ZoneOffset.ofHours(8));
    }
}

由于業(yè)務(wù)需求设江,需要每天都重置自增序列,所以這里以每天結(jié)束時(shí)間為過期時(shí)間攘轩,這樣第二天又會(huì)從1開始叉存。

測試一下

@SpringBootTest
class IdGeneratorServiceTest {
    @Test
    void generateIdTest() {
        String code = idGeneratorService.generateId("orderId", 6);
        System.out.println(code);
    }
}
// 輸出:20210804000001

6位自增序列每天可以生成將近100w個(gè)編碼,對于大多數(shù)公司度帮,已經(jīng)足夠了歼捏。

經(jīng)過本地環(huán)境測試,開啟10個(gè)線程笨篷,1秒內(nèi)每個(gè)線程10000個(gè)請求甫菠,沒有絲毫壓力。

如果覺得有些場景下連續(xù)的編號(hào)會(huì)泄漏公司的數(shù)據(jù)冕屯,比如訂單量寂诱,那么可以設(shè)置隨機(jī)增長步長,這樣就看不出具體訂單量了安聘。但是會(huì)影響生成的編碼數(shù)量痰洒,可以根據(jù)實(shí)際情況調(diào)整自增序列的位數(shù)。

img

總結(jié)

沒有最好的浴韭,只有最合適的丘喻。在實(shí)際工作中往往都是這樣,需要根據(jù)實(shí)際業(yè)務(wù)需求來選擇最合適的方案念颈。

END

往期推薦

略懂設(shè)計(jì)模式之工廠模式

吳亦凣事件告訴我們泉粉,不懂中間人攻擊會(huì)吃大虧

就這?Spring 事務(wù)失效場景及解決方案

就這?一篇文章讓你讀懂 Spring 事務(wù)

SpringBoot+Redis 實(shí)現(xiàn)消息訂閱發(fā)布

更多精彩推薦嗡靡,請關(guān)注公眾號(hào)【靚仔聊編程】

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末跺撼,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子讨彼,更是在濱河造成了極大的恐慌歉井,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,214評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件哈误,死亡現(xiàn)場離奇詭異哩至,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)蜜自,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評論 2 382
  • 文/潘曉璐 我一進(jìn)店門菩貌,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人重荠,你說我怎么就攤上這事菜谣。” “怎么了晚缩?”我有些...
    開封第一講書人閱讀 152,543評論 0 341
  • 文/不壞的土叔 我叫張陵,是天一觀的道長媳危。 經(jīng)常有香客問我荞彼,道長,這世上最難降的妖魔是什么待笑? 我笑而不...
    開封第一講書人閱讀 55,221評論 1 279
  • 正文 為了忘掉前任鸣皂,我火速辦了婚禮,結(jié)果婚禮上暮蹂,老公的妹妹穿的比我還像新娘寞缝。我一直安慰自己,他們只是感情好仰泻,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,224評論 5 371
  • 文/花漫 我一把揭開白布荆陆。 她就那樣靜靜地躺著,像睡著了一般集侯。 火紅的嫁衣襯著肌膚如雪被啼。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,007評論 1 284
  • 那天棠枉,我揣著相機(jī)與錄音浓体,去河邊找鬼。 笑死辈讶,一個(gè)胖子當(dāng)著我的面吹牛命浴,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 38,313評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼生闲,長吁一口氣:“原來是場噩夢啊……” “哼媳溺!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起跪腹,我...
    開封第一講書人閱讀 36,956評論 0 259
  • 序言:老撾萬榮一對情侶失蹤褂删,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后冲茸,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體屯阀,經(jīng)...
    沈念sama閱讀 43,441評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,925評論 2 323
  • 正文 我和宋清朗相戀三年轴术,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了难衰。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,018評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡逗栽,死狀恐怖盖袭,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情彼宠,我是刑警寧澤鳄虱,帶...
    沈念sama閱讀 33,685評論 4 322
  • 正文 年R本政府宣布,位于F島的核電站凭峡,受9級(jí)特大地震影響拙已,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜摧冀,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,234評論 3 307
  • 文/蒙蒙 一倍踪、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧索昂,春花似錦建车、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至康谆,卻和暖如春凄杯,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背秉宿。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評論 1 261
  • 我被黑心中介騙來泰國打工戒突, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人描睦。 一個(gè)月前我還...
    沈念sama閱讀 45,467評論 2 352
  • 正文 我出身青樓膊存,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子隔崎,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,762評論 2 345

推薦閱讀更多精彩內(nèi)容