文章目录
1. 什么是唯一 ID
分布式 ID 是指在分布式系统中需要生成的全局唯一的标识符。比如在电商、物流等行业,每笔订单都需要一个唯一的订单 ID。通过这个 ID,商家可以跟踪订单的状态,包括下单、支付、发货、签收等环节;用户也可以随时查询自己订单的进度。在金融系统中,每笔交易都有一个唯一的交易 ID。这个 ID 可以用于记录交易的详细信息,如交易时间、交易金额、交易双方等,同时也方便对交易进行审计和对账。总之唯一 ID 在各种场景下用处都比较大,那么如何能够生成一个唯一 ID 呢,有下面几种方案。
2. UUID
首先就是 UUID,UUID 是一种由数字和字母组成的 128 位标识符,通常表示为 36 个字符的字符串,格式为 8-4-4-4-12,关于 UUID 的介绍可以看百度上面的介绍:UUID_百度百科。对于 Java,可以通过 UUID.randomUUID() 来生成 UUID。
public class Main {
public static void main(String[] args) {
System.out.println(UUID.randomUUID());
// 输出结果: f570712a-4e58-4f93-8dce-5f397269d761
}
}
2.1 优点
理论上,UUID 可以保证在全球范围内的唯一性,基本不会出现重复,且不需要依赖外部系统,本地就可以快速生成了,没用网络开销,而且如果需要区别表示各个业务,可以在生成的 UUID 前面加上业务的前缀。
public class Main {
public static void main(String[] args) {
System.out.println(createUUID("业务前缀"));
// 业务前缀-1693999d-ac8f-4780-852b-375a5e3ba22a
}
public static String createUUID(String bizId){
return bizId + "-" + UUID.randomUUID();
}
}
2.2 缺点
- 空间占用: 生成的 UUID 占用 36 个字符,占用的空间太大了。
- ID 没用顺序: 生成的 UUID 没有顺序,一般来说如果业务要用这种 UUID,大多存到数据库也是用来当索引的,这种情况下存到数据库会导致频繁的页分裂,影响数据库性能。
- 安全问题: 基于时间的UUID通过计算当前时间戳、随机数和机器MAC地址得到。由于在算法中使用了MAC地址,这个版本的UUID可以保证在全球范围的唯一性。但与此同时,使用MAC地址会带来安全性问题。
3. 数据库自增 ID
大家知道,数据库是可以设置自增主键的,也就是 MYSQL
的 AUTO_INCREMENT
字段,每次插入数据的时候,id 是数据库帮我们自己填的,这种情况下直接用这个自增 ID 就行了,我们举个例子,首先建一个 biz_increment 表。
DROP TABLE IF EXISTS `biz_increment`;
CREATE TABLE `biz_increment` (
`id` bigint NOT NULL AUTO_INCREMENT,
`biz_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '业务ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
然后设置 entity,dao,mapper,service。
@Data
@AllArgsConstructor
@NoArgsConstructor
public class BizIncrement {
private Long id;
private String bizId;
}
@Mapper
public interface BizIncrementDao {
List<BizIncrement> findAllBizIncrement();
void add(@Param("bizIncrement") BizIncrement bizIncrement);
}
@Service
public interface BizIncrementService {
BizIncrement addBiz(String bizId);
}
@Service
public class BizIncrementServiceImpl implements BizIncrementService {
@Resource
private BizIncrementDao bizIncrementDao;
public BizIncrement addBiz(String bizId){
BizIncrement bizIncrement = new BizIncrement(null, bizId);
bizIncrementDao.add(bizIncrement);
return bizIncrement;
}
}
BizIncrementMapper.xml 配置文件如下。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jianglianghao.boot.demos.dao.BizIncrementDao">
<resultMap id="BaseResultMap" type="com.jianglianghao.boot.demos.entity.BizIncrement">
<id property="id" column="id" jdbcType="INTEGER"/>
<result property="bizId" column="biz_id" jdbcType="BIGINT"/>
</resultMap>
<sql id="Base_Column_List">
id, biz_id
</sql>
<insert id="add" useGeneratedKeys="true" keyProperty="id">
insert into biz_increment(id, biz_id) values (0, #{bizIncrement.bizId})
</insert>
<select id="findAllBizIncrement" resultType="com.jianglianghao.boot.demos.entity.BizIncrement">
select
<include refid="Base_Column_List"/>
from biz_increment
</select>
</mapper>
最后写个单测来测试插入结果。
@SpringBootTest(classes = TestMybatisApplication.class)
class TestMybatisApplicationTests {
@Resource
private BizIncrementService bizIncrementService;
@Test
void contextLoads() {
System.out.println(bizIncrementService.addBiz("订单1"));
System.out.println(bizIncrementService.addBiz("订单2"));
System.out.println(bizIncrementService.addBiz("订单3"));
System.out.println(bizIncrementService.addBiz("订单4"));
}
}
可以看到,最终插入之后 id 会由 MYSQL 给我们自动生成,且是递增的。
3.1 优点
优点就是简单,数据库天然支持的,不需要再额外开发一些复杂的算法,同时生成的 ID 是有序的,方便去优化,比如要范围查询或者分页查询都很方便。
3.2 缺点
不好的一点是依赖数据库,单个数据库情况下存在性能瓶颈,如果数据库出现故障就没办法生成 ID 了,不够既然单节点不行,可以用多节点,但是如果多节点就不得不考虑如何处理数据库生成重复 ID 的问题,比如下面三台机器:
- MYSQL 1
- MYSQL 2
- MYSQL 3
如果每一台机器都从 0 开始递增,这种情况下肯定会有重复的 id,所以刚好能用上数据库的自增 id 和递增步长。
# 全局步长设置
SET GLOBAL auto_increment_increment = 1;
# 全局初始值设置
SET GLOBAL auto_increment_offset = 1;
上面就是给数据库设置步长和初始值的方法,初始值就是从哪个值开始分配,步长就是下一个分配的值是当前的最新 id + 步长,如果需要在会话里面设置,可以用下面这个方法。
-- 会话自增步长
SET SESSION auto_increment_increment = 1;
-- 会话自增初始值
SET SESSION auto_increment_offset = 1;
可以通过这样来配置每一台,这样每一台机器生成的 id 都是不同的了,但是这种还是强依赖 MYSQL,而且只要有一台 MYSQL 挂了,可用的 ID 数就会瞬间少 1/3
,不过这也是一种方法,适合用于数据量大,分布存储的情况,也可以用上集群确保稳定性,不过得考虑好 MSYQL 同步策略,一般用的都是半同步策略,不会等到所有从节点都同步主节点的数据之后再返回,这样性能比较低。
但是这样来配置,集群扩容还是一个问题,比如现在有 3 台机器,现在我要扩容一台就特别困难了,步长和初始值设置都特别麻烦。
4. 利用 redis 来实现自增 id
数据库的方式虽然方便,但是性能还是低,并发比较高的情况下就没办法用,这时候也可以考虑下 Redis,Redis 提供了原子操作 INCR 和 INCRBY,可以实现自增功能。应用程序通过调用这些操作,从 Redis 中获取唯一的 ID。比如下面的例子:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
我们引入 redis 的配置,spring 的和 config 配置文件。
spring:
redis:
database: 0 # Redis数据库索引(默认为0)
host: localhost # Redis服务器地址
port: 6379 # Redis服务器连接端口
timeout: 180000 # 连接超时时间(毫秒)
jedis:
pool:
max-active: 8 # 连接池最大连接数(使用负值表示没有限制)
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
max-idle: 8 # 连接池中的最大空闲连接
min-idle: 0 # 连接池中的最小空闲连接
配置类如下。
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
//为了开发方便,一般直接使用<String,object>
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
//json序列化配置
Jackson2JsonRedisSerializer Jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
Jackson2JsonRedisSerializer.setObjectMapper(om);
//string序列化配置
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
//key采用string的序列化方式
template.setKeySerializer(stringRedisSerializer);
//hash的key采用string的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
//value序列化方式采用jackson
template.setValueSerializer(Jackson2JsonRedisSerializer);
//hash的value序列化方式采用jackson
template.setHashValueSerializer(Jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
然后写一个单测。
@SpringBootTest(classes = TestMybatisApplication.class)
class TestRedisApplicationTests {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Test
void redisIncrement(){
Long increment1 = redisTemplate.opsForValue().increment("biz_id-1");
Long increment2 = redisTemplate.opsForValue().increment("biz_id-1");
Long increment3 = redisTemplate.opsForValue().increment("biz_id-2");
Long increment4 = redisTemplate.opsForValue().increment("biz_id-2");
Long increment5 = redisTemplate.opsForValue().increment("biz_id-3");
Long increment6 = redisTemplate.opsForValue().increment("biz_id-3");
System.out.println(increment1);
System.out.println(increment2);
System.out.println(increment3);
System.out.println(increment4);
System.out.println(increment5);
System.out.println(increment6);
}
}
4.1 优点
可以看到就是输出确实是递增的,Redis 是内存数据库,读写速度快,支持高并发,能够满足大规模系统的 ID 生成需求。而且还有一点好处就是 redis 天然的 key-value 特性能够满足不同业务的递增 id 可以分隔,还是比较符合业务场景的。而且 Redis 内部使用的单线程特性也确保了在执行这些 incr 命令的时候不会有并发问题。
4.2 缺点
还是一样的,如果 Redis 出现故障,将影响 ID 的生成,可能需要搭建 Redis 集群来保证高可用性,但会增加系统的复杂度和成本。同时 Redis 的持久化机制像 AOF 和 RDB 需要根据自己的业务去配置持久化的机制,需要避免每一次操作都写文件才返回,同时也需要避免长时间才持久化一次导致数据丢失的风险变大。
5. 雪花算法
Snowflake 是 Twitter 开源的分布式 ID 生成算法,可以说现在大多数业务用的应该都是这种算法,美团的 Leaf 也是根据这个算法来封装的,下面画个图来介绍下这个算法。
这个方法将 long 类型的 64 位都做了分配,具体如下:
- 最高位的 0,没有用到,正常来说大多数业务都要用的正数的 id,最高位为 0 可以确保生成的是正数,当然如果你业务需要用到负数的 id,那生成之后取个符号就行了,应该也是为了兼容那些无符号 64 位正数这么设计吧。
- 41 位的时间戳,用来记录当前时间的,正常来说 2 41 365 ∗ 24 ∗ 60 ∗ 60 ∗ 1000 ≈ 69.73 \frac{2^{41} }{365 * 24 * 60 * 60 * 1000} \approx 69.73 365∗24∗60∗60∗1000241≈69.73 年,我们可以启动的时候设置一个初始时间,然后获取 id 的时候就用
当前时间 - 初始时间
作为这部分的值,就可以存 69 年了。 - 10 位机器 ID,用来标识一台机器的,10 位可以生成 1024 个机器 ID,我们正常来说每一个业务下面都会通过自己的雪花算法去生成,不然共用一个雪花算法的话,机器 ID 最多 1024,也就是最多之内承受 1024 个业务去生成。
- 12 位序列号,12 位的大小是 4096,也就是说每毫秒内能够承受的请求量是 4096。
下面是一个简单的雪花算法生成类。
public class SnowflakeIdGenerator {
// 起始时间戳,可自定义
private final long startTimeStamp = 1609459200000L;
// 数据中心 ID 所占位数
private final long dataCenterIdBits = 5L;
// 机器 ID 所占位数
private final long workerIdBits = 5L;
// 序列号所占位数
private final long sequenceBits = 12L;
// 数据中心 ID 最大值
private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits);
// 机器 ID 最大值
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
// 序列号最大值
private final long sequenceMask = -1L ^ (-1L << sequenceBits);
// 机器 ID 向左移位数
private final long workerIdShift = sequenceBits;
// 数据中心 ID 向左移位数
private final long dataCenterIdShift = sequenceBits + workerIdBits;
// 时间戳向左移位数
private final long timestampLeftShift = sequenceBits + workerIdBits + dataCenterIdBits;
// 数据中心 ID
private final long dataCenterId;
// 机器 ID
private final long workerId;
// 序列号
private long sequence = 0L;
// 上一次生成 ID 的时间戳
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long dataCenterId, long workerId) {
if (dataCenterId > maxDataCenterId || dataCenterId < 0) {
throw new IllegalArgumentException("Data center ID can't be greater than " + maxDataCenterId + " or less than 0");
}
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException("Worker ID can't be greater than " + maxWorkerId + " or less than 0");
}
this.dataCenterId = dataCenterId;
this.workerId = workerId;
}
public synchronized long nextId() {
// 当前时间
long currentTimestamp = System.currentTimeMillis();
// 如果当前时间小于上一次获取时间, 时钟回退
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards. Refusing to generate id for " + (lastTimestamp - currentTimestamp) + " milliseconds");
}
// 如果还是这一毫秒
if (currentTimestamp == lastTimestamp) {
// 序列号 + 1
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
// 序列号达到最大值,等待下一毫秒
currentTimestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
// 设置上一次生成的时间
lastTimestamp = currentTimestamp;
// 生成 long 类型的雪花算法 ID
return ((currentTimestamp - startTimeStamp) << timestampLeftShift) |
(dataCenterId << dataCenterIdShift) |
(workerId << workerIdShift) |
sequence;
}
private long waitNextMillis(long lastTimestamp) {
// 一直等待到下一毫秒
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
public static void main(String[] args) {
SnowflakeIdGenerator idGenerator = new SnowflakeIdGenerator(1, 1);
long id = idGenerator.nextId();
System.out.println("Generated ID: " + id);
}
}
输出结果:
5.1 优点
首先就是有序性,雪花算法对于同一台机器下生成的 ID 是顺序递增的,比如我们生成 1000 个 id,输出如下所示。
可以看到,生成的 ID 都是顺序递增,这有一个好处就是以这个 id 为索引的时候插入数据时页分裂比较少,性能比较高。而且 ID 完全就是本地生成,不依赖外部系统,生成速度快,能够满足高并发场景的需求。最后就是每一个机器 ID 都可以生成自己的 ID,只需要配置不同的 workerID 就行。
5.2 缺点
依赖系统时钟,如果系统时钟发生回拨,可能会导致生成的 ID 重复,需要进行特殊处理。其次就是雪花算法的 QPS 应该是 4096/ms,如果每 ms 生成的 ID 个数超过了 4096,就是 12 位序列号的总和,那么就需要超前消费。比如当前时间是 1ms,需要生成 4097 个 ID,由于最低位最多承受 4096 个数字,那么在生成第 4097 个 ID 的时候我们就需要提前去到 2ms,但是这种情况应该太少了,400w /s 的 QPS,什么业务才有这种需求。
然后就是 ID 位数限制,41 位时间戳限制了 ID 的使用时长,大约 69 年,不过这个时间应该也够用了。
6. 数据库号段
这种模式也是现在开源的 ID 生成算法里面用的比较多的了,应用程序每次从数据库中获取一个号段(如 1 - 1000),在本地使用完这个号段后,再去数据库获取下一个号段。数据库中会记录当前号段的最大值,每次获取号段时更新该值。由于后面开源项目里面美团的 Leaf 也用了这个算法,所以等到讲源码的时候再来看美团是怎么实现的,这里就不演示了。
6.1 优点
对于这种算法,优点就是不需要每次生成 ID 都访问数据库,降低数据库的压力,性能还不错,且号段内的 ID 是有序的,方便数据库索引优化。
6.2 缺点
缺点就是业务需要根据自己的 QPS 去调整号段大小,号段分配过小会导致频繁访问数据库,性能就不太行,而且如果数据库出现故障,号段分配就会有问题,所以需要考虑主从集群,而涉及到主从集群又要考虑数据库的同步问题,就是使用半同步还是要等到主节点的数据同步到全部从节点才返回结果,这个得考虑。
7. 小结
这篇文章我们就说了生成唯一 ID 的几种方式,后面会逐步介绍百度、美团、滴滴的开源 ID 项目。
如有错误,欢迎指出!!!