小伙伴们好,欢迎关注,一起学习,无限进步
以下内容为学习过程中的笔记
集群高并发情况下如何保证分布式唯一全局 Id 生成?
问题
- 为什么需要分布式全局唯一 ID 以及分布式 ID 的业务需求
- 在复杂分布式系统设计中,往往需要对大量的数据和消息进行唯一标识
- 例如在美团点评的金融、支付、餐饮、酒店;猫眼电影等产品的系统中数据日渐增长,对数据分库分表后需要有一个唯一 ID 来标识一条数据或消息;特别一点的如订单、棋手、优惠卷也都需要有唯一 ID 做标识,此时一个能够生成全局唯一 ID 的系统是非常有必要的。
- ID 生成的规则部分硬性要求
- 全局唯一:不能重复的 ID 号,既让是唯一表示,是最基本的要求
- 趋势递增:在 MySQL 的 InnoDB 引擎中使用的是聚簇索引,由于多数 RDBMS 使用 Btree 的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。
- 单调递增:保证下一个 ID 大于上一个 ID,例如事务版本号、IM 增量消息、排序等特殊要求
- 信息安全: 如果 ID 是连续的,恶意用户的爬取工作就非常容易做了,直接按照顺序下载指定 URL 即可;所以在一些应用场景下,需要 ID 无规则不规则下生成
- 含时间戳:能够快速了解分布式 ID 的生成时间
- ID 号生成系统的可用性要求
- 高可用:发一个获取分布式 ID 的请求,服务器就要保证 99.999% 的情况下给我创建一个唯一分布式 ID
- 低延迟:发一个获取分布式的请求,服务器要急速响应
- 高QPS:高并发下 10 万个分布式 ID 同时请求,服务器要顶住且一下子成功创建 10 万个分布式 ID
一般通用方案
- UUID
- 是什么:UUID(Universally Unique Identifier)的标准型式包含 32 个 16 进制数字,以连字号分为五段,形式为 8-4-4-4-12 的 36 个字符,实例:a76b8e3f-cff6-4b49-a3df-c95b343755f1,性能非常高,本地生成,没有网络消耗,如果只考虑唯一性,没有问题,但是入数据库性能比较差
- 为什么无序的 UUID 会导致入库性能变差呢?
- 无序,无法预测他生成主键的顺序,不能生成递增有序的数字。首先分布式 id 一般都会作为主键,但是按照 MySQL 官方推荐主键要尽量越短越好,UUID 每一个都很长,多以不是很推荐
- **主键,ID 作为主键时在特定的环境会存在一些问题。**比如做 DB 主键的场景下,UUID 就非常不适用, MySQL 官方有明确的建议主键要尽量越短越好,36 个字符长度的 UUID 不符合要求
- **索引,B+ 树索引的分裂。**既然分布式 id 是主键,然后主键是包含索引的,然后 MySQL 的索引是通过 B+ 树来实现的,每一次新的 UUID 数据的插入,未来查询优化,都会对索引底层的 B+ 树进行修改,因为 UUID 数据是无序的,所以每一次 UUID 数据的插入都会对主键 B+ 树进行很大的修改,这一点很不好,插入完全无序,不但会导致中间节点产生分裂,也会创造出很多不饱和的节点,这样大大降低了数据库插入的性能。
- 数据库自增主键
- 单机
- 在分布式里面,数据库自增 ID 机制的主要原理是:数据库自增 ID 和 MySQL 数据库的 replace into 实现的。这里的 replace into 跟 insert 功能类似
- 不同点在于:replace into 首先尝试插入数据列表中,如果发现表中已经有次行数据(根据主键或唯一索引判断)则先删除,在插入。否则直接插入新数据。
- replace into 的含义是插入一条记录,如果表中唯一索引的值遇到冲突,则替换老数据。 例如添加:replace insert into user (name) values(“张三”); 查询最新插入id:select last_insert_id();
- 分布式集群
- 数据库自增 ID 适合做分布式 ID 吗?答案是不太合适
- 原因1:系统水平扩展比较困难,比如定义了步长和机器台数后,如果要添加机器该怎么做?假设现在只有一台机器发号是1,2,3,4,5(步长是 1),这个时候需要扩容一台机器,可以这样做,把第二台机器的初始值设置得比第一台超过很多,也还可以接受。加入现在线上有 100 台机器,这时候要扩容很显然这种方式不合适。所以系统水平扩展方案复杂难以实现。
- 原因2:数据库压力还是很大,每次获取 ID 都得读写一次数据库,非常影响性能,不符合分布式 ID 里面得延迟低和要高 QPS 得规则(在高并发下,如果都去数据库里获取 ID,是非常影响性能的)
- 基于 Redis 生成全局 id 策略
- Redis 是单线程的天生保持原子性,可以使用原子性操作 INCR 和 INCRBY 来实现
- 集群分布式
- 在 Redis 集群环境下,同样和 MySQL 一样需要设置不同的增长步长,同时 key 一定要设置有效期
- 可以使用 Redis 集群来获取更高的吞吐量。假如一个集群中有 5 台 Redis。可以初始化每台 Redis 的值分别是 1,2,3,4,5,步长都是 5。(同理增加或减少节点不方便维护,还需要维护一个 Redis 集群,成本太大)
**雪花算法(SnowFlake):**雪花算法是由Twitter公布的分布式主键生成算法,它能够保证不同表的主键的不重复性,以及相同表的主键的有序性。
概述
Twitter 的 SnowFlake 解决分布式 ID 这种需求,最初 Twitter 把存储系统从 MySQL 迁移到 Cassandra(由 FaceBook 开发一套开源分布式 NoSQL 数据库系统)因为 Cassandra 没有顺序 ID 生成机制,所以开发了这样一套全局唯一 ID 生成服务。
Twitter 的分布式雪花算法 SnowFlake,经测试 SnowFlake 每秒能够产生 26 万个自增可排序的 ID
- Twitter 的 SnowFlake 生成的 ID 能够按照时间有序生成
- SnowFlake 算法生成 ID 的结果是一个 64bit 大小的整数,为一个 Long 型(转换成字符串后长度最多 19)
- 分布式系统内不会产生 ID 碰撞(由 datacenter 和 workerId 做区分)并且效率高
分布式系统中,有一些需要使用全局唯一 ID 的场景,生成 ID 的基本要求
- 在分布式环境下必须全局且唯一
- 一般都需要单挑递增,因为一般唯一 ID 都会存到数据库,而 InnoDB 的特性就是将内容存储在主键索引树上的叶子节点,而且是从左往右,递增的,所以考虑到数据库性能,一般生成的 ID 也最好是单调递增。为了防止 ID 冲突可以使用 36 位的 UUID,但是 UUID 有一些缺点,首先是相对较长,另外 UUID 一般都是无序的。
- 可能还会需要无规则,因为如果使用唯一 ID 作为 ID 作为订单号这种,为了不让别人知道一天的订单量是多少,需要这个规则
结构
核心思想:长度共 64 bit(一个long型) 首先是一个符号位,1 bit 标识,由于 long 基本类型在 Java 中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0。 41 bit 时间截(毫秒级),存储的是时间截的差值(当前时间截 - 开始时间截),结果约等于69.73年。 10 bit 作为机器的ID(5个bit是数据中心,5个bit的机器ID,可以部署在1024个节点)。 12 bit 作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID)。
源码
public class SnowFlakeIdWorker { // 工作机器id(0-31) private long workId; // 数据中心id(0-31) private long dataCenterId; // 毫秒内序列(0-4095) private long sequence = 0L; // 上次生成id的时间戳 private long lastTimeStamp = -1L; // 开始时间戳(2022-01-01) private final long twepoch = 1640966400000L; // 机器id所占的位数 private final long workerIdBits = 5L; // 数据标识id所占的位数 private final long dataCenterIdBits = 5L; // 支持的最大机器id,结果是31(这个位移算法可以很快的计算出几位二进制数所能标识的最大十进制位数) private final long maxWorkerId = -1L ^ (-1L << workerIdBits); // 支持的最大数据标识id,结果是31 private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits); // 序列在id中占的位数 private final long sequenceBits = 12L; // 机器id向左移12位 private final long workerIdShift = sequenceBits; // 数据标识id向左移17位(12+5) private final long dataCenterIdShift = sequenceBits + workerIdBits; // 时间戳向左移22位(5+5+22) private final long timeStampLeftShift = sequenceBits + workerIdBits + dataCenterIdBits; // 生成序列的掩码,这里为4095(0b111111111111=0xfff=4095) private final long sequenceMask = -1L ^ (-1L << sequenceBits); /** * 构造方法 * * @param workId 工作id(0-31) * @param dataCenterId 数据中心id(0-31) */ public SnowFlakeIdWorker(long workId, long dataCenterId) { if (workId > maxWorkerId || workId < 0) { throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId)); } if (dataCenterId > maxDataCenterId || dataCenterId < 0) { throw new IllegalArgumentException(String.format("dataCenterId can't be greater than %d or less than 0", maxDataCenterId)); } this.workId = workId; this.dataCenterId = dataCenterId; } /** * 获取限一个id(该方法是线程安全的) * * @return */ public synchronized long nextId() { long timeStamp = timeGen(); // 如果当前时间小于上一次 id 生成的时间戳,说明系统时钟会退过这个时候应当抛出异常 if (timeStamp < lastTimeStamp) { throw new RuntimeException(String.format("Clock moved blackwards. Refusing to generate if for %d milliseconds", lastTimeStamp - timeStamp)); } // 如果是同一时间生成的,则进行毫秒内序列 if (lastTimeStamp == timeStamp) { // 阻塞到下一个毫秒,获得新的时间戳 sequence = (sequence + 1) & sequenceMask; // 毫秒内存序列溢出 if (sequence == 0) { timeStamp = tilNextMillis(lastTimeStamp); } } else { // 时间戳改变 sequence = 0L; } // 上次生成id的时间戳 lastTimeStamp = timeStamp; // 位移并通过或运算拼到一起组成64位的id return ((timeStamp - twepoch) << timeStampLeftShift) | (dataCenterId << dataCenterIdShift) | (workId << workerIdShift) | sequence; } /** * 阻塞到下一毫秒,直接获得新的时间戳 * @param lastTimeStamp 上次生成id的时间戳 * @return 当前时间戳 */ protected long tilNextMillis(long lastTimeStamp){ long timeStamp = timeGen(); while (timeStamp<= lastTimeStamp){ timeStamp = timeGen(); } return timeStamp; } /** * 返回以毫秒位单位的但钱时间 * @return 当前时间毫秒) */ protected long timeGen() { return System.currentTimeMillis(); } // 测试 public static void main(String[] args) { SnowFlakeIdWorker idWorker = new SnowFlakeIdWorker(1,1); for (int i = 0; i < 10; i++) { long id = idWorker.nextId(); System.out.println(id+"\t"+String.valueOf(id).length()); } } }
工程落地经验
hutool-all 工具包地址:https://github.com/dromara/hutool
SpringBoot整合雪花算法
引入依赖
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.7.22</version> </dependency>
生成id相关代码
/** * 分布式id生成工具类(雪花算法) */ @Slf4j @Component public class IdGeneratorSnowFlake { // 机器id private long workerId = 0; // 数据中心id private long dataCenterId = 1; private Snowflake snowflake = IdUtil.createSnowflake(workerId, dataCenterId); @PostConstruct public void init() { try { workerId = NetUtil.ipv4ToLong(NetUtil.getLocalhostStr()); log.info("当前机器的workerId:{}", workerId); } catch (Exception e) { e.printStackTrace(); log.info("当前机器ID失败", e); workerId = NetUtil.getLocalhostStr().hashCode(); log.info("当前机器的workerId:{}", workerId); } } public synchronized long snowFlakeId() { return snowflake.nextId(); } public synchronized long snowFlakeId(long workerId, long dataCenterId) { Snowflake snowflake = IdUtil.createSnowflake(workerId, dataCenterId); return snowflake.nextId(); } // 测试 public static void main(String[] args) { // id生成 System.out.println(new IdGeneratorSnowFlake().snowFlakeId()); // 多线程生成 ExecutorService threadPool = Executors.newFixedThreadPool(5); for (int i = 1; i < 20; i++) { threadPool.submit(()->{ System.out.println(new IdGeneratorSnowFlake().snowFlakeId()); }); } // 关闭线程池 threadPool.shutdown(); } }
优缺点
优点:
毫秒数在高位,自增序列在低位,真个ID都是趋势递增的。
不依赖数据库等第三方系统,以服务的方式部署,稳定性高,生成ID的性能是非常高的。
可以根据自身业务特性分配 bit 位,非常灵活。
缺点:
依赖机器时钟,如果机器始终回拨,会导致重复ID生成。
再单击上是递增的,但是由于设计到分布式环境,每台机器上的时钟不可能完全同步,有时候会出现不是全局递增的情况(次缺点可以认为无所谓,一般分布式ID只要求区是递增,并不会要求递增,90% 的需求都只要求趋势递增)
其他补充
- 百度开源的分布式唯一 ID 生成器 UidGenerator
- Leaf — 美团点评分布式 ID 生成系统