美团leaf改造使用nacos生成workerID

1,541 阅读8分钟

此方案仅支持nacos1.1.4之后的版本

背景描述

在这里插入图片描述 商城秒杀系统需要涉及分布式唯一订单号的生成,市面上主要是百度uid滴滴TinyId美团leaf三种id生成器,经过了解,百度UID和滴滴TinyId均采用数据库方案:大致上是每次去数据库获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。但是由于该方案有个缺点:ID号码不够随机,能够泄露发号数量的信息,不太安全。因此不适合作为订单号的生成方案,所以选择美团Snowflake-leaf生成器来作为分布式ID生成服务。 根据美团文档描述,Snowflake-leaf采用zookeeper的顺序节点来生成每个服务的workerId,但是我做的系统采用的注册中心是nacos,因为ID生成器而维护一个zookeeper集群,成本太高。

看了网上很多资料,没有自己想要的方法,又或者说workerID生成方案我没找到,所以做如下记录。

最终方案

通过查看nacos源码看到,nacos实例类Instance中携带有instanceId字段,出于好奇,经过网上资料查询及翻看了nacos-server注册实例源码得知,在nacos1.1.4版本之后支持自定义instanceId,并可以此作为workerId。

原生instanceId格式:

如果需要实现不重复的生成instanceID作为workerId,需要在实例注册进nacos时携带特定的元数据信息:

在配置文件中加入:
spring.cloud.nacos.discovery.metadata.preserved.instance.id.generator=snowflake

或在手动注册代码中 为实例添加元数据:
Instance instance = new Instance();
instance.setIp(ip);
instance.setPort(Integer.parseInt(port));
// 必须设置ephemeral=false,来保证服务端使用的是严格的一致性协议,否则可能会导致生成的instance id冲突:
instance.setEphemeral(false);
instance.setMetadata(new HashMap<String, String>());
instance.getMetadata().put(PreservedMetadataKeys.INSTANCE_ID_GENERATOR, Constants.SNOWFLAKE_INSTANCE_ID_GENERATOR);
namingService.registerInstance(SERVER_NAME, GROUP_NAME, instance);

如下是nacos-server注册实例步骤:

/**
 * Register new instance.
 *
 * @param request http request
 * @return 'ok' if success
 * @throws Exception any error during register
 */
@CanDistro
@PostMapping
@Secured(parser = NamingResourceParser.class, action = ActionTypes.WRITE)
public String register(HttpServletRequest request) throws Exception {
    
    final String serviceName = WebUtils.required(request, CommonParams.SERVICE_NAME);
    final String namespaceId = WebUtils
            .optional(request, CommonParams.NAMESPACE_ID, Constants.DEFAULT_NAMESPACE_ID);
    /**
	 * 这里是把客户端发送过来的实例信息进行了一遍初始化
	 * 注意:instanceId并不是由客户端指定
	 * 在parseInstance方法中会将原生默认格式的InstanceId赋给Instance
	 */
    final Instance instance = parseInstance(request);	
    //这里就开始了实例注册逻辑
    serviceManager.registerInstance(namespaceId, serviceName, instance);
    return "ok";
}

接着registerInstance方法去追源码可以看到这么一个方法:

public List<Instance> updateIpAddresses(Service service, String action, boolean ephemeral, Instance... ips)
        throws NacosException {

    //省略部分代码

    for (Instance instance : ips) {
        if (!service.getClusterMap().containsKey(instance.getClusterName())) {
            //省略部分代码
        }

		//移除实例if
        if (UtilsAndCommons.UPDATE_INSTANCE_ACTION_REMOVE.equals(action)) {
            instanceMap.remove(instance.getDatumKey());
        } else {
			//这里是非移除实例操作
			/**
			 * ★★★
			 * 重点:这里进行了instanceId的计算 generateInstanceId方法会重新计算instanceId
			 */
            instance.setInstanceId(instance.generateInstanceId(currentInstanceIds));
            instanceMap.put(instance.getDatumKey(), instance);
        }

    }
	//省略部分代码
}

接着查看generateInstanceId方法:

/**
 * Generate instance id.
 *
 * @param currentInstanceIds current instance ids
 * @return new instance id
 */
public String generateInstanceId(Set<String> currentInstanceIds) {
	/**
	 * 简单说一下getInstanceIdGenerator.
	 * 该方法会在当前实例的元数据中查找"preserved.instance.id.generator"为key的数据
	 * 如果有 则返回对应的value 否则返回"simple"
	 */
    String instanceIdGenerator = getInstanceIdGenerator();
	/**
	 * Constants.SNOWFLAKE_INSTANCE_ID_GENERATOR = snowflake
	 * 那么只要你在注册实例时 在实例的元数据信息中添加
	 * 以"preserved.instance.id.generator"为key 以"snowflake"为value的元数据
	 * 就可以生成不重复的instanceId
	 */
    if (Constants.SNOWFLAKE_INSTANCE_ID_GENERATOR.equalsIgnoreCase(instanceIdGenerator)) {
        return generateSnowflakeInstanceId(currentInstanceIds);
    } else {
        return generateInstanceId();
    }
}

snowflake策略生成instanceId代码,其实也是比较简单的:

private String generateSnowflakeInstanceId(Set<String> currentInstanceIds) {
    int id = 0;
	//>>>>不重复id核心代码
    while (currentInstanceIds.contains(String.valueOf(id))) {
        id++;
    }
	//>>>>>
    String idStr = String.valueOf(id);
    currentInstanceIds.add(idStr);
    return idStr;
}

至此instanceId生成完毕。

顺便说一嘴,我是拿了美团leaf的代码进行改造,把zookeeper依赖部分的代码删除,结合nacos去生成的WORKERID,且关闭了nacos自动注册,采用的是手动注册方式,在注册之后拿到instanceId作为wokerID。

SnowflakeNacosHolder.init方法如下

NamingService namingService = NamingFactory.createNamingService(serveAddr);

 String serverInfo = ip.concat(port);
 //手动注册实例
 Instance instance = new Instance();
 instance.setIp(ip);
 instance.setPort(Integer.parseInt(port));
 // 必须设置ephemeral=false,来保证服务端使用的是严格的一致性协议,否则可能会导致生成的instance id冲突:
 instance.setEphemeral(false);//false为持久性节点 true为临时节点
 instance.setMetadata(new HashMap<String, String>());
 //这两个常量类由nacos提供 不需要自己动手写
 instance.getMetadata().put(PreservedMetadataKeys.INSTANCE_ID_GENERATOR, Constants.SNOWFLAKE_INSTANCE_ID_GENERATOR);
 namingService.registerInstance(SERVER_NAME, GROUP_NAME, instance);
 //获取实例列表
 List<Instance> idInstances = namingService.getAllInstances(SERVER_NAME, GROUP_NAME);

 String sIp;
 String sPort;
 String sInfo;
 for (Instance idInstance : idInstances) {
     sIp = idInstance.getIp();
     sPort = String.valueOf(idInstance.getPort());
     //拼接
     sInfo = sIp.concat(sPort);

     if(serverInfo.equals(sInfo)){
         workerID = Integer.valueOf(idInstance.getInstanceId());
         break;
     }
 }

以下是我踩坑的记录:

方案1:redis

前提:redis存放的数据持久化

在redis放一个数,初始化为0,每当一个leaf服务启动都去redis取到该数值并使其自增1。

虽然这样可以保证原子性,在取值的时候同时自增1,很好的规避了并发问题,并且在leaf服务重启后通过redis获取workerid还是不重样的。

但是还是有以下问题:

  • 众所周知,雪花算法10bit的workerID一共可以部署 2^10 = 1024台,也就是workerID取值范围是 0 ~ 1023。当leaf服务多重启几次,redis中存储的值就有可能超过1023了,并且在超过1023后,你还不能很方便的知道哪些workId被占用了。且在服务停止时不能将该数值-1,因为当前服务workerid并不可能是最后一个占用的数字。

在redis中设置一个数组

在redis中初始化一个类型为boolean的长度为1024的数组,默认全部为true。在服务启动时循环数组,得到第一个为true的元素下标并将其设为false,这步操作是原子性的,并在服务停止之前使用@PreDestroy注解标注方法,在服务摧毁之前根据 workerid恢复数组对应的元素为true。

问题:

该方案解决了①中的workerid无法复用的问题,但是在测试下如果服务进程是被kill时无法执行@PreDestroy注解标注方法,即无法恢复false为true。

方案2:nacos

获取nacos所有leaf实例,循环获取下标索引作为workerId

该方案是网上其他同学的方案,实现方法是通过nacos官网提供的SDK中的subscribe方法监听leaf_group组下的所有leaf服务,一旦服务实例状态发生变化,nacos就会通知客户端,此时我们定义的callback会获取leaf_group组下的所有实例并根据本机ip+port去匹配实例数组中的实例,取得本机在实例数组中的下标,以此作为workerId。

问题:

  • 并发问题:
  1. 如果当前有服务A,workerId为0,服务B-1,服务C-2。此时服务A宕机,按照计算方法,服务B的workerId应该由1变0,服务C的workerId由2变1, 但由于网络波动,可能服务B并没有收到nacos的通知而导致workerid还是1,而此时服务C的workerId已经是1了,所以会导致workerid相同,两个服务生成的id重复。

  2. 如果两个新leaf服务同时启动,由于获取实例下标和注册进nacos的操作不是原子性的,很可能导致两个服务得到的实例列表是一样的,在等待nacos感知到新服务已注册进入nacos时的下一次广播这段时间内可能导致计算的workerId(下标)一致,服务生成id重复。

  • 性能问题:只要有一个leaf实例状态发生变化,那么所有在线的leaf服务workerId就需要重新计算,造成的资源浪费严重。

leaf服务注册nacos持久化实例

在leaf项目中配置spring.cloud.nacos.discovery.ephemeral=false,leaf服务注册nacos为持久性实例,并且取消监听代码,直接获取所有实例列表计算下标索引即可。 当前方案可以解决方案①的并发问题和性能问题,但是在新服务第一次启动时还是会遇到获取的实例数组中不包含自身服务而导致的方案1并发问题的第二点问题。

手动配置workerid,将其作为实例元数据一并注册进nacos(备选方案)

这个方案可以解决以上所有问题,适合小集群,但是如果集群服务多时,手动配置成本过高,且在出现workerid冲突时是很难排查出问题的。

手动注册nacos实例

由②可知,只需要解决新服务第一次注册nacos时在获取实例计算workerID这步骤之前就已经注册好实例后再去获取实例列表计算workerID,则不会出现并发第2点的问题。 但是经过实践得知,nacos注册实例顺序是由ip决定,也就是说后注册进nacos的服务,很可能因为ip而显示排序在服务列表的第一位,所以不能使用下标来计算workerid

关闭nacos自动注册:spring.cloud.nacos.discovery.enabled=false 注意:若同时引用nacos-confg包时,需要同时关闭config自动注册

方案3:IP计算

每台机器的IP是唯一的,当我们每台机器都仅部署一台leaf服务时,可以根据IP去计算workerID。

String hostAddress = Inet4Address.getLocalHost().getHostAddress();
int[] ints = StringUtils.toCodePoints(hostAddress);
int sums = 0;
for (int b : ints) {
	sums = sums + b;
}
return (long) (sums % 1024);

但是这种代码还是存在问题:因为workerid不能超过1023,所以对ip转换成byte后得到ascii码总和进行1024取余,还是可能导致workerid重复问题。