升级版雪花算法发号器
一、背景
之前写过一篇《双buffer分布式id生成器》,在大部分场景是受用的,但是对于这种发号器模式,存在3个缺点:
- 强依赖业务库
- 重启浪费序列段
- 在瞬时突发流量场景来不及扩容和切换
前两个问题不大,但是作为一个发号器如果应对不了突发流量,那么必定是致命的缺点,也是不太能接受的。那么我们就要考虑设计一种能够应对突发流量的发号器。
二、基于中心化的分布式id生成器
当前的应用架构都推崇分布式多机部署,默认情况下集群中各个节点是无法通信的,也就是说目前比较流行的雪花算法id生成器是单机的,那么在并发量充足的情况下,不同节点在同一个时间一定会生成重复的id。
我们提出两个概念并且思考一个问题:
- 中心化和分布式(也可以理解为去中心化)
- 为什么要分布式
中心化我们暂且简单粗暴的理解为单点部署,所有的业务和功能共享所有的配置项和二方、三方能力,分布式是根据业务模型将大节点拆分成多个独立的单元部署;之所以采用和推崇分布式部署无非是将业务模块拆分和把流量打散的各个业务单元。分布式其实并不能脱离中心化而独立存在,比如rpc服务要统一注册到中心化节点做路由调度。
回到我们的主题,之所以传统雪花算法解决不了分布式多机部署的并发id冲突问题,是因为在集群中每个节点没有办法确定其在集群中的唯一身份,比如雪花算法的机房位相同,那么通过单机算法的出来的机器位id就有可能与集群中其他节点相同,那么就一定会出现两个请求同一时间打到机器id相同的两个节点上,就出现id冲突了。
那么如何解决这个问题,答案就是引入中心化概念,在集群节点启动的时候和中心化节点通信,获得自己的唯一身份,然后填充到雪花算法的机器位。
image.png
升级版雪花算法发号器支持的能力和设计思维:
- 支持db、redis两种中心化节点(后续可考虑支持zk)。
- 利用中心化节点保证分布式集群中每一台机器都有唯一的身份,从而保证雪花算法机器为全局唯一。
- 开闭原则,保证设计实现模式关闭,中心化模式开放。
- 应用启动时利用中心化节点确定每台机器的唯一身份(redis使用lua脚本做incr并且带超时时间,db使用自增主键)
,应用关闭时利用钩子方法回收中心化节点资源(redis带超时可以不处理,db利用启动时生成的主键id删除该条记录)。 - 原始雪花算法机器位031,为了保证基于中心化节点生成的机器id在此范围内(不管是redis还是db,只要启动次数足够,很容易超过31),每台机器从中心节点拿到的id与31取余,保证落在031范围内。
- 为什么要回收中心节点资源?redis的话由于只有一个key,所以问题不大,而db是每台机器启动都生成一条序列记录,那么如果启动次数足够多,项目上线足够久就会导致序列表有大量的记录(短期没影响),占用数据库空间,并且这些记录其实都是一次性的,只在机器启动的时候用到,所以考虑添加钩子方法在应用关闭的时候回收中心节点,也就是删除该记录,可以使用Runtime.addShutdownHook或者spring的DisposableBean接口做处理,当然只有在应用正常方式关闭或者重启才生效,当前如果进程异常退出(kill -9,jvm crashes等)无法执行钩子方法,不回收中心节点,但是问题也不大,应用异常退出的概率不高,并且db序列表留有一部分记录也影响不大。
三、核心设计实现
该发号器核心设计理念是使用注解开启发号能力,并根据用户自己选择的模式启用相应的配置以及bean的注入,给用户留出扩展点自定义实现中心化的数据交互能力,也就是所谓的软件设计模式中的开闭原则,spring相关组件中有大量的应用。
发号器启用时序图:
image.png
发号器初始化与销毁时序图:
image.png
1.EnableIdWorker注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(IdWorkerConfigurationSelector.class)
public @interface EnableIdWorker {
CenterModel mode() default CenterModel.DB;
}
标记开启发号器能力,model属性选择中心化节点方式,可支持DB和redis两种模式,默认是DB
2. IdWorkerConfigurationSelector选择器
@Slf4j
public class IdWorkerConfigurationSelector implements ImportSelector {
public static final String DEFAULT_ADVICE_MODE_ATTRIBUTE_NAME = "mode";
@Override
public String[] selectImports(AnnotationMetadata importingClassMetadata) {
Class<?> annType = EnableIdWorker.class;
Assert.state(annType != null, "Unresolvable type argument for IdWorkerConfigurationSelector");
AnnotationAttributes attributes = AnnotationAttributes.fromMap(importingClassMetadata.getAnnotationAttributes(annType.getName(), false));
if (attributes == null) {
throw new IllegalArgumentException(String.format(
"@%s is not present on importing class '%s' as expected",
annType.getSimpleName(), importingClassMetadata.getClassName()));
}
CenterModel centerModel = attributes.getEnum(getModeAttributeName());
switch (centerModel) {
case DB:
log.info("IdWorkerConfigurationSelector.selectImports use DBIdWorker........");
return new String[] {DBIdWorkerConfig.class.getName()};
case REDIS:
log.info("IdWorkerConfigurationSelector.selectImports use RedisIdWorker........");
return new String[] {RedisIdWorkerConfig.class.getName()};
default:
return null;
}
}
protected String getModeAttributeName() {
return DEFAULT_ADVICE_MODE_ATTRIBUTE_NAME;
}
}
根据EnableIdWorker注解的model属性决定使用哪一种中心化配置
3. 中心化配置类
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
@Slf4j
public class DBIdWorkerConfig {
@Bean("dbIdWorker")
public DBIdWorker dbIdWorker() {
DBIdWorker idWorker = new DBIdWorker();
log.info("DBIdWorkerConfig.dbIdWorker init success....");
return idWorker;
}
}
如果2中选择使用了DB中心化,那么就注入DBIdWorker发号器,对应的redis亦一样
4. 雪花算法抽象
@Slf4j
public abstract class AbstractIdWorker implements InitializingBean, DisposableBean {
/** 起始时间戳(2020-01-01),用于用当前时间戳减去这个时间戳,算出偏移量 **/
private final long startTime = 1577808000000L;
/** 机器id所占的位数 */
private final long workerIdBits = 5L;
protected final long workerIdCount = (1 << workerIdBits) - 1;
/** 工作机器ID(0~31) */
private long workerId;
/**
* 获取中心化机器ID
* @return
*/
protected abstract long getWorkerId();
/**
* 获得下一个ID (该方法是线程安全的)
* @return SnowflakeId
*/
public synchronized long nextId() {
//...
}
@Override
public void afterPropertiesSet() throws Exception {
this.workerId = this.getWorkerId();
log.info("AbstractIdWorker.afterPropertiesSet init workerId success;workerId={}",this.workerId);
}
@Override
public void destroy() throws Exception {
this.doDestroyWorkerId();
}
/**
* 应用关闭时,回收中心化
*/
protected abstract void doDestroyWorkerId();
}
借鸡生蛋原则,基于成熟的雪花算法改造,实现InitializingBean接口在应用启动时与中心化节点交互,计算机器唯一身份,实现DisposableBean接口在应用关闭
或者重启时回收中心化节点
5.具体实现
public class DBIdWorker extends AbstractIdWorker{
private long seq;
@Autowired
@Qualifier("dbSequenceManager")
private ISequenceManager iSequenceManager;
@Override
protected long getWorkerId() {
this.seq = this.iSequenceManager.borrowSeq(null);
return (this.seq % this.workerIdCount);
}
@Override
protected void doDestroyWorkerId() {
this.iSequenceManager.returnSeq(this.seq);
}
}
应用启动时利用db主键自增属性插入序列记录并返回主键,与机器位取余算出集群中机器唯一身份,存储db返回的主键值,在应用关闭的时候删除该序列记录.
以上代码数据框架层内容,不需要使用方编码改造,而此处留了一个拓展点需要用户自己实现,ISequenceManager是序列管理器抽象定义,不同的中心化模式
实现方式不同,但是要遵循db模式需要将实现实现类注册到spring容器并且名称是dbSequenceManager,而redis叫做redisSequenceManager。
四、使用方式
- 引入发号器依赖
pom依赖
- 启动类增加注解
@EnableIdWorker(model=xxx)
- 实现序列管理器
例如DB模式
@Component("dbSequenceManager")
@Slf4j
public class DbSequenceManager implements ISequenceManager<Long> {
@Autowired
private IdWorkerSeqMapper idWorkerSeqMapper;
@Override
public long borrowSeq(Long seq) {
String hostIp = NetUtil.getHostIp();
IdWorkerSeqDO seqDO = new IdWorkerSeqDO();
seqDO.setHostId(hostIp);
seqDO.setCreateTime(new Date());
this.idWorkerSeqMapper.insert(seqDO);
log.info("DbSequenceManager.borrowSeq success,seqId={}",seqDO.getId());
return seqDO.getId();
}
@Override
public void returnSeq(Long seq) {
log.info("DbSequenceManager.returnSeq,seqId={}",seq);
this.idWorkerSeqMapper.deleteByPrimaryKey(seq);
}
}
redis模式
@Component("redisSequenceManager")
@Slf4j
public class RedisSequenceManager extends AbstractRedisSequenceManager {
protected RedisSerializer keySerializer = new StringRedisSerializer();
protected RedisSerializer valueSerializer = new Jackson2JsonRedisSerializer(Object.class);
protected static final long expireTimes = 120 * 60L;
@Autowired
@Qualifier("redisMaster")
protected StringRedisTemplate stringRedisTemplate;
@Override
public long borrowSeq(String seqKey) {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(LUA_BORROW_SCRIPT, Long.class);
List<String> keys = new ArrayList<>(2);
keys.add(seqKey);
Long seq = this.stringRedisTemplate.execute(redisScript
, this.valueSerializer
, this.keySerializer
, keys
, expireTimes);
log.info("RedisSequenceManager.borrowSeq success,seqId={}", seq);
return seq;
}
@Override
public void returnSeq(String seqKey) {
log.info("RedisSequenceManager.returnSeq,seqKey={}", seqKey);
}
}
-
使用发号器
根据选择的中心化模式,在业务类中注入对应的发号器即可
@Autowired
private DBIdWorker dBIdWorker;
或者
@Autowired
private RedisIdWorker redisIdWorker;
总结
1.优点
解决双buffer模式无法应对瞬时突发流量问题
单机近4000并发量
2.缺点
- 生成的id序列业务性差,可读性差,没有yyyMMdd + id + userId直观,并且带有分库分表基因
- 依赖中心化节点
其实我们可以对其再做升级,要保证分布式发号器全局唯一,其实本质在于保证机器位唯一,其他位置可以自定义。