分布式Id生成策略-美团Leaf

之前在做物流相关的项目时候,需要在分布式系统生成运单的id。

1.需求:

1.全局唯一性:不能出现重复的ID。(基本要求)

2.递增:大多数关系型数据库(如 MySQL)使用 B+ 树作为索引结构。如果 ID 是递增的,新数据总是追加到索引的末尾,这样索引的维护成本较低,因为数据库不需要频繁调整树的结构。相反,如果 ID 是随机的,数据插入时可能需要频繁调整索引结构,导致写入性能下降。

3.业务性:对具体的场景的ID要具备业务的特性,比如顺丰运单ID为类似SF1000000000760

4.精简性:某些场景下的ID不宜过长,所以对位数/长度有所限制。

在分布式系统中我们还应该考虑:生成方案能子啊多节点正常工作,能够有一定的解决故障问题,有高可用性,生成ID的速率要快,能够随业务扩展进行水平拓展,比如在分库分表后也能兼容原来的ID。

2.方案

有几个方案可以考虑:

本地的UUID生成 :它的优点的生成速度很快,产生的值也几乎达到不重复的要求,但是产生的ID值比较长,可以达到36个字符,可读性还非常差,而且ID完全随机,没有任何的顺序。因此这个方案不考虑。

依靠数据库自增特性:为不同业务模块建立一张自增表维护递增序列。这种方法比较简单,靠数据库保证自增机制。缺点也很明显,当数据库异常整个系统不可用,而且ID生成的性能瓶颈也限制在单台MYSQL数据库。单台数据库性能问题可以同过部署多态机器,每个机器设置不同初始值,并且步长和机器数相同来优化。但是也带来很多问题,比如扩容很是麻烦。

Redis实现:通过给不同业务设计不同的key,通过INCR命令,对key自增,得到全局唯一并有序的ID。Redis每秒支持10W的读写,所以性能问题得到解决,但是Redis依靠内存,虽然有持久化机制,但是它持久化是先写内存再异步刷盘,遇到没来得及持久化就宕机也是会出大问题的。

雪花算法: 是 Twitter 于 2010 年开源的一种分布式唯一 ID 生成算法,它可以在分布式系统中生成高效、有序的唯一 ID。雪花算法生成的 ID 是一个 64 位的长整型long),在保证唯一性的同时,也确保了生成的 ID 按时间顺序递增。

  1. 全局唯一性:生成的 ID 保证全局唯一,雪花算法结合时间戳、机器 ID 和序列号确保在分布式系统中不会产生重复的 ID。

  2. 高效生成:雪花算法不依赖数据库,因此生成 ID 的过程非常高效,可以在本地的内存中生成,具有极高的性能。每台机器每秒钟可以生成上百万个 ID。

  3. 趋势递增:生成的 ID 是按时间顺序递增的,尤其是基于时间戳的部分,使得 ID 具有递增的特性。这对数据库插入数据时索引的维护非常友好(例如 B+ 树结构索引的维护成本较低)。

  4. 灵活可扩展:通过调整数据中心 ID 和机器 ID 的位数分配,可以根据业务的需要适当扩大集群规模或提升单机 ID 生成的并发能力。

    雪花算法依赖 机器码 来保证不同机器生成的 ID 唯一性。如果在分布式环境中多台机器未能准确区分它们的机器码,可能导致多个机器在同一时间生成相同的 ID,造成 ID 冲突。因此,在分布式系统中,每台服务器、虚拟机或容器必须手动指定一个唯一的机器标识符。**

雪花算法的不足

  • 依赖机器时间:由于 ID 的递增性依赖时间戳,一旦服务器的系统时钟发生回拨,可能会引发 ID 冲突或无法生成 ID 的问题。虽然有一些解决方案(如等待或借助其他算法生成 ID),但还是可能影响生成 ID 的稳定性。

3.美团Leaf

下面就将引入我选取的美团Leaf这个id生成策略。

其源码托管于GitHub:https://github.com/Meituan-Dianping/Leaf

这里有个美团的技术播客,专门介绍了Leaf:https://tech.meituan.com/2017/04/21/mt-leaf.html

目前Leaf覆盖了美团点评公司内部金融、餐饮、外卖、酒店旅游、猫眼电影等众多业务线。在4C8G VM基础上,通过公司RPC方式调用,QPS压测结果近5w/s,TP999 1ms。

Leaf 提供两种生成的ID的方式(segment模式和snowflake模式),我们采用segment模式(号段)来生成运单号。

号段模式

号段模式采用的是基于MySQL数据生成id的,它并不是基于MySQL表中的自增长实现的,因为基于MySQL的自增长方案对于数据库的依赖太大了,性能不好,Leaf的号段模式是基于一张表来实现,每次获取一个号段,生成id时从内存中自增长,当号段用完后再去更新数据库表,如下:

字段说明:

  • biz_tag:业务标签 ,用来区分业务
  • max_id:表示该biz_tag目前所被分配的ID号段的最大值
  • step:表示每次分配的号段长度。如果把step设置得足够大,比如1000 ,那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step
  • description:描述
  • update_time:更新时间

架构图如下:

图片来源:https://tech.meituan.com/2017/04/21/mt-leaf.html

说明:test_tag在**第一台Leaf机器上是1~1000的号段**,当这个号段用完时,会去加载另一个长度为step=1000的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是3001~4000。同时数据库对应的biz_tag这条数据的max_id会从3000被更新成4000,更新号段的SQL语句如下:

java 复制代码
Begin
UPDATE table SET max_id=max_id+step WHERE biz_tag=xxx
SELECT tag, max_id, step FROM table WHERE biz_tag=xxx
Commit

Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间 ,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务。

双buffer优化

Leaf为此做了优化,增加了双buffer优化。

当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的TP999指标。

双buffer原理,来自:https://tech.meituan.com/2017/04/21/mt-leaf.html

采用双buffer的方式,**Leaf服务内部有两个号段缓存区segmen。**当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。

  • 每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS(秒处理事务数)的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。
  • 每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。

4.项目使用

我们只用到了号段的方式,并没有使用雪花方式,所以只需要创建数据库表即可

将其镜像运行:

java 复制代码
docker run \
-d \
-v /hujx/meituan-leaf/leaf.properties:/app/conf/leaf.properties \
--name meituan-leaf \
-p 28838:8080 \
--restart=always \
registry.cn-hangzhou.aliyuncs.com/itheima/meituan-leaf:1.0.1

leaf.properties

leaf.properties 复制代码
leaf.name=leaf-server
leaf.segment.enable=true
leaf.jdbc.url=jdbc:mysql://192.168.150.101:3306/hjx_leaf?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useSSL=false
leaf.jdbc.username=root
leaf.jdbc.password=123

leaf.snowflake.enable=false
#leaf.snowflake.zk.address=
#leaf.snowflake.port=

创建sl_leaf数据库脚本:

sql 复制代码
CREATE TABLE `leaf_alloc` (
  `biz_tag` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '',
  `max_id` bigint NOT NULL DEFAULT '1',
  `step` int NOT NULL,
  `description` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL,
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`biz_tag`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- 插入运单号生成规划数据
INSERT INTO `leaf_alloc` (`biz_tag`, `max_id`, `step`, `description`, `update_time`) VALUES ('transport_order', 1000000000001, 100, 'Test leaf Segment Mode Get Id', '2023-07-07 11:32:16');

封装服务

java 复制代码
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.sl.transport.common.enums.IdEnum;
import com.sl.transport.common.exception.SLException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

/**
 * id服务,用于生成自定义的id
 */
@Service
public class IdService {

    @Value("${sl.id.leaf:}")
    private String leafUrl;

    /**
     * 生成自定义id
     *
     * @param idEnum id配置
     * @return id值
     */
    public String getId(IdEnum idEnum) {
        String idStr = this.doGet(idEnum);
        return idEnum.getPrefix() + idStr;
    }

    private String doGet(IdEnum idEnum) {
        if (StrUtil.isEmpty(this.leafUrl)) {
            throw new SLException("生成id,sl.id.leaf配置不能为空.");
        }
        //访问leaf服务获取id
        String url = StrUtil.format("{}/api/{}/get/{}", this.leafUrl, idEnum.getType(), idEnum.getBiz());
        //设置超时时间为10s
        HttpResponse httpResponse = HttpRequest.get(url)
                .setReadTimeout(10000)
                .execute();
        if (httpResponse.isOk()) {
            return httpResponse.body();
        }
        throw new SLException(StrUtil.format("访问leaf服务出错,leafUrl = {}, idEnum = {}", this.leafUrl, idEnum));
    }

}
java 复制代码
public enum IdEnum implements BaseEnum {

    TRANSPORT_ORDER(1, "运单号", "transport_order", "segment", "SL");

    private Integer code;
    private String value;
    private String biz; //业务名称
    private String type; //类型:自增长(segment),雪花id(snowflake)
    private String prefix;//id前缀

    IdEnum(Integer code, String value, String biz, String type, String prefix) {
        this.code = code;
        this.value = value;
        this.biz = biz;
        this.type = type;
        this.prefix = prefix;
    }

    @Override
    public Integer getCode() {
        return this.code;
    }

    @Override
    public String getValue() {
        return this.value;
    }

    public String getBiz() {
        return biz;
    }

    public String getType() {
        return type;
    }

    public String getPrefix() {
        return prefix;
    }

    @Override
    public String toString() {
        final StringBuffer sb = new StringBuffer("IdEnum{");
        sb.append("code=").append(code);
        sb.append(", value='").append(value).append('\'');
        sb.append(", biz='").append(biz).append('\'');
        sb.append(", type='").append(type).append('\'');
        sb.append(", prefix='").append(prefix).append('\'');
        sb.append('}');
        return sb.toString();
    }
}

使用步骤:

  • 在配置文件中进行配置sl.id.leaf为: 地址:你的服务端口 如:http://192.168.150.101:28838
    pend(", type='").append(type).append(''');
    sb.append(", prefix='").append(prefix).append(''');
    sb.append('}');
    return sb.toString();
    }
    }

使用步骤:

  • 在配置文件中进行配置sl.id.leaf为: 地址:你的服务端口 如:http://192.168.150.101:28838
  • 在Service中注入IdService,调用getId()方法即可,例如:idService.getId(IdEnum.TRANSPORT_ORDER)
相关推荐
只因在人海中多看了你一眼2 小时前
分布式缓存 + 数据存储 + 消息队列知识体系
分布式·缓存
zhixingheyi_tian4 小时前
Spark 之 Aggregate
大数据·分布式·spark
求积分不加C6 小时前
-bash: ./kafka-topics.sh: No such file or directory--解决方案
分布式·kafka
nathan05296 小时前
javaer快速上手kafka
分布式·kafka
谭震鸿9 小时前
Zookeeper集群搭建Centos环境下
分布式·zookeeper·centos
天冬忘忧14 小时前
Kafka 工作流程解析:从 Broker 工作原理、节点的服役、退役、副本的生成到数据存储与读写优化
大数据·分布式·kafka
IT枫斗者19 小时前
如何解决Java EasyExcel 导出报内存溢出
java·服务器·开发语言·网络·分布式·物联网
求积分不加C19 小时前
Kafka怎么发送JAVA对象并在消费者端解析出JAVA对象--示例
java·分布式·kafka·linq
GDDGHS_20 小时前
“Kafka面试攻略:核心问题与高效回答”
分布式·面试·kafka
꧁薄暮꧂21 小时前
kafka中的数据清理策略
数据库·分布式·kafka