分散式 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 生成演算法,開源後廣受國內大廠的好評,在該演算法影響下各大公司相繼開發出各具特色的分散式生成器。
組成結構:正數位(佔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 按照時間在單機上是遞增的,但是由於涉及到分散式環境,每臺機器上的時鐘不可能完全同步,也許有時候也會出現不是全域性遞增的情況。
雪花演算法好像挺不錯的樣子,靚仔決定採用這個方案試下。
於是一套操作猛如虎,寫個 demo 給領導看下。
只能繼續思考方案了
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 的持久化方式,這就非常好接入了,只需少量編碼就能實現一個發號器功能。
話不多說,直接開始幹吧。
本案例基於 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個請求,沒有絲毫壓力。
如果覺得有些場景下連續的編號會洩漏公司的資料,比如訂單量,那麼可以設定隨機增長步長,這樣就看不出具體訂單量了。但是會影響生成的編碼數量,可以根據實際情況調整自增序列的位數。
總結
沒有最好的,只有最合適的。在實際工作中往往都是這樣,需要根據實際業務需求來選擇最合適的方案。
END
往期推薦