暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

序号生成器

小D学Java 2019-05-26
1813

生成器约束

  • 全局唯一

  • 趋势递增

  • 高可用,高性能

常见实现方式

  • UUID

  • snowflake

  • 数据库自增主键

UUID

形式为32个16进制数字,以-
分为五段,形式如8-4-4-4-12
的36个字符

优点  : 性能好,本地生成,无网络消耗

缺点 : 不易于存储,16字节128位,通常以36长度的字符串表示,


雪花算法

优点 :

  • 毫秒数在高位,自增序列在低位,整个序列是趋势递增的

  • 不依赖数据库等第三方系统,稳定性更高,生成序列的性能非常高

  • 可根据自身业务特性分配bit位,非常灵活

缺点

强依赖机器时钟((记录上一次的记录时间,发生回退告警抛异常))

实现

/**
* Twitter_Snowflake<br>
* SnowFlake的结构如下(每部分用-分开):<br>
* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>
* 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0<br>
* 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
* 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>
* 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId<br>
* 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号<br>
* 加起来刚好64位,为一个Long型。<br>
* SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
*/
public class SnowflakeIdWorker {


/** 开始时间截 (2015-01-01) */
private final long twepoch = 1420041600000L;


/** 机器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+12) */
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;


/** 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095) */
private final long sequenceMask = -1L ^ (-1L << sequenceBits);


/** 工作机器ID(0~31) */
private long workerId;


/** 数据中心ID(0~31) */
private long datacenterId;


/** 毫秒内序列(0~4095) */
private long sequence = 0L;


/** 上次生成ID的时间截 */
private long lastTimestamp = -1L;


/**
* 构造函数
* @param workerId 工作ID (0~31)
* @param datacenterId 数据中心ID (0~31)
*/
public SnowflakeIdWorker(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 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("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}


/**
* 获得下一个ID (该方法是线程安全的)
* @return SnowflakeId
*/
public synchronized long nextId() {
long timestamp = timeGen();


//如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id 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) //
| (workerId << 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(0, 0);
for (int i = 0; i < 100; i++) {
long id = idWorker.nextId();
System.out.println(Long.toBinaryString(id));
System.out.println(id);
}
}
}


数据库自增

优点

  • 实现简单,利用数据库现有功能,成本小

  • 序列号绝对自增

缺点

  • 强依赖数据库,当数据库不可用时整个系统不可用

  • ID生成性能限制在单台DB的读写性能

  • 主从数据库的数据一致性问题可能导致生成重复的id


自设计的分布式id

CREATE TABLE `sequence_type` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增长标识',
`type_id` varchar(32) NOT NULL DEFAULT '' COMMENT '类型标识',
`start` bigint(20) NOT NULL DEFAULT '1' COMMENT '初始值',
`step` bigint(20) NOT NULL DEFAULT '1' COMMENT '步进',
`buffer_size` bigint(20) NOT NULL DEFAULT '100' COMMENT '缓存大小',
`expired_sec` bigint(20) NOT NULL DEFAULT '0' COMMENT '过期时长秒',
`create_time` timestamp NOT NULL DEFAULT '1979-01-01 00:00:00' COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_type_id` (`type_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='序列类型';




CREATE TABLE `sequence` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增长标识',
`type_id` varchar(32) NOT NULL DEFAULT '' COMMENT '类型标识',
`group_key` varchar(32) NOT NULL DEFAULT '' COMMENT '分组标识',
`sequence_no` bigint(20) NOT NULL DEFAULT '0' COMMENT '序号',
`version_number` bigint(20) NOT NULL DEFAULT '0' COMMENT '版本号',
`create_time` timestamp NOT NULL DEFAULT '1979-01-01 00:00:00' COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_type_group` (`type_id`,`group_key`),
KEY `idx_version_number` (`version_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='序列';


属性说明

  • expired_sec
    设置为两天失效

  • type_id
    按照不同需求类型来区分

  • group_key
    来区分相同类型的key,可划分为更细维度,如设置每天/每月/每年,此处设置为每天

数据内容

基于以上的序号生成器 ,我们参考雪花算法使用yyyyMMdd + 序号
来生成趋势递增的id.

表格使用说明


sequence_type
表定义规则,主要属性时起始值,步长,以及在缓存内部操作的最大值buffer_size
,每一次都是针对sequence_no
直接添加buffer_size

sequence
表基于乐观锁(version_number)来记录当前每种特定类型(type_id + group_key
)的序号(sequence_no
),主要算法是insert时捕获重复主键重新update,update时使用版本号更新直至更新成功(update version = version +1 where version = version
)


优化(双Buffer的预分配方案)

上述方案在到数据库重新加载最大值时容易出现延迟问题(如IO问题或网络抖动),因此可使用双Buffer的结构.当一个Buffer的当前值接近最大值时,异步加载下一个最大值,加载完后将当前使用的Buffer替换为新的Buffer


伪代码

不需要选主的原因是多台机都是直接从数据库拿起始值以及缓存最大值的,由唯一索引约束(insert)和乐观锁来保证,而且工单号的主要目的是保证唯一性,若需实现序号的趋势递增而且不能回退则需选主或其它方案来实现分配序号

public Sequence getSequence(String typeId,String groupKey){
Sequence sequence = find(typeId,groupId);
if(sequence == null){
try{
insert(newSequence);
}catch(DuplicateKeyException){
sequence = find(typeId,groupId);
}
}

//设置更新属性
sequence.setSequenceNo(..);
sequence.setVersion(version + 1);

int count = updateByVersion();
if(count == 0){
//乐观锁更新失败,递归来重试
return allow(typeId,groupId);
}
//返回当前开始递增的序号
return sequence;

}


public BufferCount getCounter(String typeId,String groupKey){
//将当前计数器保存在全局变量中
BufferCounter counter = getCurrentCounter();
if(counter.isEmpty()){
SequenceType type = find(typeId);
Sequence sequence = getSequence(typeId,groupId);
BufferCount counter = new BufferCount();
count.setCurrentSeq(sequence.getSequence());
count.setMaxSeqForBuffer(type.getBufferSize());
return counter;
}
return counter;

}


public synchronized long allow(String typeId,String groupKey){
BufferCount counter = getCounter(typeId,groupId);
long currentSeq = counter.getSequence();
counter.setCurrentSeq(currentSeq + counter.getSequenceType().getStep());
return currentSeq;
}


//需声明一个BufferCount来保存type对应的缓存最大值与当前值
public class BufferCount{
private long currentSeq; //当前值
private long maxSeqForBuffer; //缓存最大值,当超过此值时需到数据库重新获取
private Date createTime; //创建时间
private SequenceType sequenceType; //主要用于获取步长

//用来判断缓存是否已用完,是否需到数据库更新当前值以及最大值
public boolean isEmpty(){
return currentSeq >= maxSeqForBuffer;
}

}


微信的消息序号设计

https://dwz.cn/35MESuG7

主要优化 :

  • 由于微信是每个用户使用单独的趋势递增序号的,所以微信使用了相邻用户公用一个缓存最大值的策略来减少重启时从数据库加载缓存值的数据量以及时间

  • 按照用户段进行隔离分set,每个用户段拥有一套单独完整的系统,用于灾难隔离,用于确保一个set故障不影响其它set内的用户

  • 微信的点在于是基于每个用户来单调递增的,那么其需要保证同一时间有且只有一台来为同一个用户分配序号,而上面的工单号是基于系统层面的,主要是为了不重复,而且趋势递增

  • 微信基于单节点序号生成器做了高可用架构,用于在生成器故障时切换生成器


基于Redis的一种单调递增序列号方案

基于redis的incr
原子性操作,使用hash数据结构,保存着序列号当前值以及序列号最大值,利用HINCRBY
的原子性递增,使用mysql来保存更新最大值,redis当到达最大值时更新数据库记录,并且在重启时加载最大值到redis

要点

  • redis来做递增操作

  • mysql保存下一次递增的起始值

  • 使用lua脚本完成值的比较以及设置值
    的原子性操作

  • 重点在于怎么更新redis的起始值以及最大值

方案参考

https://www.zybuluo.com/yishuailuo/note/955188

优点

    基于redis的原子性,性能较好,能实现单调递增

缺点

    强依赖于redis

    设计较为复杂


业界其它常用唯一序号设计

如订单号

滴滴:时间+起点编号+车牌号

淘宝订单:时间戳+用户ID

其他电商:时间戳+下单渠道+用户ID,有的会加上订单第一个商品的ID


美团的Leaf分布式序号设计

https://tech.meituan.com/2017/04/21/mt-leaf.html


文章转载自小D学Java,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论