就這?分散式 ID 發號器實戰

靚仔聊程式設計發表於2021-08-14

img

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

  • 全域性唯一:這是最基本的要求,必須保證 ID 是全域性唯一的。
  • 高效能:低延時,不能因為一個小小的 ID 生成,影響整個業務響應速度。
  • 高可用:無限接近於100%的可用性。
  • 好接入:遵循拿來主義原則,在系統設計和實現上要儘可能簡單。
  • 趨勢遞增:這個要看具體業務場景,最好要趨勢遞增,一般不嚴格要求。

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

1、資料庫自增 ID

這是最常見的方式,利用資料庫的 auto_increment 自增 ID,當我們需要一個ID的時候,向表中插入一條記錄返回主鍵 ID。簡單,程式碼也方便,但是資料庫本身就存在瓶頸,DB 單點無法扛住高併發場景。

針對資料庫單點效能問題,可以做高可用優化,設計成主從模式叢集,而且要多主,設定起始數和增長步長。

-- 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 ....

但是隨著業務不斷增長,當效能再次達到瓶頸的時候,想要再擴容就太麻煩了,新增例項可能還要停機操作,不利於後續擴容。

2、UUID

UUID 是 Universally Unique Identifier 的縮寫,它是在一定的範圍內(從特定的名字空間到全球)唯一的機器生成的識別符號,UUID 是16位元組128位長的數字,通常以36位元組的字串表示,比如:4D2803E0-8F29-17G3-9B1C-250FE82C4309。

生成ID效能非常好,基本不會有效能問題,程式碼也簡單但是長度過長,不可讀,也無法保證趨勢遞增。

3、雪花演算法

雪花演算法(Snowflake)是 twitter 公司內部分散式專案採用的 ID 生成演算法,開源後廣受國內大廠的好評,在該演算法影響下各大公司相繼開發出各具特色的分散式生成器。

img

組成結構:正數位(佔1 bit)+ 時間戳(佔41 bit)+ 機器 ID(佔10 bit)+ 自增值(佔12 bit),總共64 bit 組成的一個 long 型別。

  • 第一個 bit 位(1 bit):Java 中 long 的最高位是符號位代表正負,正數是0,負數是1,一般生成 ID 都為正數,所以預設為0
  • 時間戳部分(41 bit):毫秒級的時間,不建議存當前時間戳,而是用(當前時間戳 - 固定開始時間戳)的差值,可以使產生的ID從更小的值開始;41位的時間戳可以使用69年,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年
  • 工作機器id(10bit):也被叫做 workId,這個可以靈活配置,機房或者機器號組合都可以,通常被分為 機器 ID(佔5 bit)+ 資料中心(佔5 bit)
  • 序列號部分(12bit):自增值支援同一毫秒內同一個節點可以生成4096個 ID

雪花演算法不依賴於資料庫,靈活方便,且效能優於資料庫,ID 按照時間在單機上是遞增的,但是由於涉及到分散式環境,每臺機器上的時鐘不可能完全同步,也許有時候也會出現不是全域性遞增的情況。

img

雪花演算法好像挺不錯的樣子,靚仔決定採用這個方案試下。

於是一套操作猛如虎,寫個 demo 給領導看下。

img

只能繼續思考方案了

4、百度(Uid-Generator)

uid-generator 是基於 Snowflake 演算法實現的,與原始的 snowflake 演算法不同在於,它支援自定義時間戳、工作機器 ID 和 序列號 等各部分的位數,而且 uid-generator 中採用使用者自定義 workId 的生成策略,在應用啟動時由資料庫分配。

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

也就是說它依賴於資料庫,並且由於是基於 Snowflake 演算法,所以也不可讀。

5、美團(Leaf)

美團的 Leaf 非常全面,即支援號段模式,也支援 snowflake 模式。

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

號段模式是基於資料庫的,而 snowflake 模式是依賴於 Zookeeper 的

6、滴滴(TinyID)

TinyID 是基於資料庫號段演算法實現,還提供了 http 和 sdk 兩種方式接入。

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

7、Redis 模式

其原理就是利用 redis 的 incr 命令實現 ID 的原子性自增,眾所周知,redis 的效能是非常好的,而且本身就是單執行緒的,沒有執行緒安全問題。但是使用 redis 做分散式 id 解決方案,需要考慮持久化問題,不然重啟 redis 過後可能會導致 id 重複的問題,建議採用 RDB + AOF 的持久化方式。

分析到這裡,我覺得 Redis 的方式非常適用於目前的場景,公司系統原本就用到了 redis,而且也正是採用的 RDB + AOF 的持久化方式,這就非常好接入了,只需少量編碼就能實現一個發號器功能。

話不多說,直接開始幹吧。

img

本案例基於 Spring Boot 2.5.3 版本

首先在 pom 中引入 redis 依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- lettuce客戶端連線需要這個依賴 -->
<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:
        # 連線池大連線數(使用負值表示沒有限制)
        max-active: 8
        # 連線池中的大空閒連線
        max-idle: 8
        # 連線池中的小空閒連線
        min-idle: 0
        # 連線池大阻塞等待時間(使用負值表示沒有限制)
        max-wait: 1000
        # 關閉超時時間
        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 類來實現 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;
    //當前的 key-value 物件,根據傳入的 key 獲取 value 值
    private ValueOperations<String, Long> operations;
    //傳入當前 redisTemplate 物件,為 RedisTemplate 物件的頂級介面
    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");
        //初始化一個 RedisTemplate 物件
        RedisTemplate<String, Long> redisTemplate = new RedisTemplate();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericToStringSerializer(Long.class));
        redisTemplate.setExposeConnection(true);
        //設定當前的 redis 連線工廠
        redisTemplate.setConnectionFactory(factory);
        redisTemplate.afterPropertiesSet();
        //設定傳入的 key
        this.key = redisCounter;
        //設定當前的 redisTemplate
        this.generalOps = redisTemplate;
        //獲取當前的 key-value 集合
        this.operations = this.generalOps.opsForValue();
        //設定預設值,如果傳入為 null,則 key 獲取 operations 中的 value,如果 value 為空,設定預設值為0
        if (initialValue == null) {
            if (this.operations.get(redisCounter) == null) {
                this.set(0L);
            }
        //不為空則設定為傳入的值
        } else {
            this.set(initialValue);
        }
    }
    //將傳入 key 的 value + 1並返回
    public long incrementAndGet() {
        return this.operations.increment(this.key, 1L);
    }
} 

看完原始碼,我們繼續自己的編碼

使用 RedisAtomicLong 封裝一個基礎的 redis 自增序列工具類

// 只封裝了部分方法,還可以擴充套件
@Service
public class RedisService {
    @Autowired
    RedisTemplate<String, Object> redisTemplate;

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

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

    /**
     * 自增數(帶過期時間)
     * @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();
    }

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

    /**
     * 自增數(帶過期時間和步長)
     * @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();
    }
}

根據業務需求編寫發號器方法

@Service
public class IdGeneratorService {
    @Autowired
    RedisService redisService;

    /**
     * 生成id(每日重置自增序列)
     * 格式:日期 + 6位自增數
     * 如: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;
    }

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

由於業務需求,需要每天都重置自增序列,所以這裡以每天結束時間為過期時間,這樣第二天又會從1開始。

測試一下

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

6位自增序列每天可以生成將近100w個編碼,對於大多數公司,已經足夠了。

經過本地環境測試,開啟10個執行緒,1秒內每個執行緒10000個請求,沒有絲毫壓力。

如果覺得有些場景下連續的編號會洩漏公司的資料,比如訂單量,那麼可以設定隨機增長步長,這樣就看不出具體訂單量了。但是會影響生成的編碼數量,可以根據實際情況調整自增序列的位數。

img

總結

沒有最好的,只有最合適的。在實際工作中往往都是這樣,需要根據實際業務需求來選擇最合適的方案。

END

往期推薦

略懂設計模式之工廠模式

吳亦凣事件告訴我們,不懂中間人攻擊會吃大虧

就這?Spring 事務失效場景及解決方案

就這?一篇文章讓你讀懂 Spring 事務

SpringBoot+Redis 實現訊息訂閱釋出

相關文章