背景:
在复杂分布式系统中,往往需要对大量的数据和消息进行唯一标识。比如支付流水号、订单号、用户编号等等。当数据量大到我们不得不分库分表处理时,如何保证ID的唯一性就成了问题。
解决:
方案一:数据库生成
在单机系统中,我们一般采用数据库自增的方式生成唯一ID主键,显然分布式环境下不适用普通的自增方式。
但是可以设置数据库自增的步长。mysql默认的自增步长为1。也就是:1、2、3、4……顺序下去。假设在分布式系统环境中,我们预先知道需要部署N台数据库实例,那么可以将这N台数据库实例的步长设置为N,然后分别设置每台数据库实例的自增起始点,第一台设置为1,第二台设置为2……第N台设置为N。这样就可以使得每台机器生成的主键ID错开,保证唯一性!
这种方案的优缺点如下:
优点:
- 非常简单,利用现有数据库系统的功能实现,成本小,有DBA专业维护。
缺点:
- 扩展性很差,数据库实例水平扩展比较困难。一开始就得确定好机器数。
方案二:类snowflake方案
这种方案大致来说是一种以划分命名空间(UUID也算,由于比较常见,所以单独分析)来生成ID的一种算法,这种方案把64-bit分别划分成多段,分开来标示机器、时间等,比如在snowflake中的64-bit分别表示如下图(图片来自网络)所示:
41-bit的时间可以表示(1L<<41)/(1000L360024*365)=69年的时间,10-bit机器可以分别表示1024台机器。如果我们对IDC划分有需求,还可以将10-bit分5-bit给IDC,分5-bit给工作机器。这样就可以表示32个IDC,每个IDC下可以有32台机器,可以根据自身需求定义。12个自增序列号可以表示2^12个ID,理论上snowflake方案的QPS约为409.6w/s,这种分配方式可以保证在任何一个IDC的任何一台机器在任意毫秒内生成的ID都是不同的。
这种方式的优缺点是:
优点:
- 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。
- 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的。
- 可以根据自身业务特性分配bit位,非常灵活。
缺点:
- 强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。
方案三:美团Leaf方案
Leaf是美团基础研发平台推出的一个分布式ID生成服务,它以springboot jar的方式提供服务。它支持两种唯一ID生成方式:
- segment
- snowflake
segment:
在使用数据库的方案上,做了如下改变: - 原方案每次获取ID都得读写一次数据库,造成数据库压力大。改为利用proxy server批量获取,每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。 - 各个业务不同的发号需求用biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对biz_tag分库分表就行。
数据库表设计如下:
重要字段说明:biz_tag用来区分业务,max_id表示该biz_tag目前所被分配的ID号段的最大值,step表示每次分配的号段长度。原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step,大致架构如下图所示:
更新号段的sql如下:
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
但是这种模式有一些缺点
- TP999数据波动大,当号段使用完之后还是会卡在更新数据库的I/O上,tg999数据会出现偶尔的尖刺。
- DB宕机会造成整个系统不可用。
- Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,不适用于订单ID等生成场景。容易被爬数据以及猜测出当天的订单量。不是很安全,只能适用于某些数据不是很敏感的场景。
美团对前两条稍作了优化(双Buffer优化):
Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。
为此,我们希望DB取号段的过程能够做到无阻塞,不需要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的TP999指标。
详细实现如下图所示:
采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。
- 每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。
- 每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。
简单来说:原本是一次性取1000条ID,当用完后再去取,但是在这个取期间万一网络不稳定或者其他原因。就很容易导致堵塞。所以优化成:一开始取1000条ID放到segment的第一个buffer中,当用到超过10%,也就是100条时,就异步去数据库取下一段1000条ID。放到segment的第二个buffer里。当第一个buffer用完时就切换到第二个buffer当中。同样当第二个buffer用到10%时,再异步去取1000条放到第一个buffer,依次循环。这样就解决了上面所说的两个问题。
需要注意的是,当leaf发号服务宕机重启后,leaf会直接舍弃当前号段,直接跳到下一号段。
snowflake方案
由于Leaf-segment方案生成的ID不具备随机性,在某些场景,如订单、支付流水等数据下则需要采用snowflake方案。
Leaf-snowflake方案完全沿用snowflake方案的bit位设计,即是“1+41+10+12”的方式组装ID号。对于workerID的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf服务规模较大,动手配置成本太高。所以使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID。Leaf-snowflake是按照下面几个步骤启动的:
- 启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。
- 如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。
- 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。
弱依赖ZooKeeper
除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。这样做到了对三方组件的弱依赖。一定程度上提高了SLA解决时钟问题
因为这种方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的ID号,需要解决时钟回退的问题。
参见上图整个启动流程图
Leaf示例:
1、下载Leaf项目代码:Github地址
2、以独立服务的方式运行Leaf
配置Leaf:
Leaf 提供两种生成的ID的方式(segment(号段)模式和snowflake模式),你可以同时开启两种方式,也可以指定开启某种方式(默认两种方式为关闭状态)。
Leaf Server的配置都在leaf-server/src/main/resources/leaf.properties中
Segment模式
如果使用号段模式,需要建立DB表,并配置leaf.jdbc.url, leaf.jdbc.username, leaf.jdbc.password
如果不想使用该模式配置leaf.segment.enable=false即可。
创建数据表
CREATE DATABASE leaf;
-- 库可以使用自己的,只需后面在leaf.properties文件中指定
CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128) NOT NULL DEFAULT '',
`max_id` bigint(20) NOT NULL DEFAULT '1',
`step` int(11) NOT NULL,
`description` varchar(256) DEFAULT NULL,
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;
insert into leaf_alloc(biz_tag, max_id, step, description) values('leaf-segment-test', 1, 2000, 'Test leaf Segment Mode Get Id');
配置相关数据项
在leaf.properties中配置leaf.jdbc.url, leaf.jdbc.username, leaf.jdbc.password参数
Leaf默认使用的是mysql5.xx版本,如果你用的是mysql8.xx版本,那么还需修改如下:
1、leaf.properties文件增加leaf.jdbc.driver-class-name属性:
leaf.jdbc.driver-class-name=com.mysql.cj.jdbc.Driver
2、父工程中的poem文件中修改mysql依赖版本
<mysql-connector-java.version>8.0.23</mysql-connector-java.version>
3、 com.sankuai.inf.leaf.server.Constants 增加字段
//新增字段
public static final String LEAF_JDBC_DRIVER_CLASS_NAME = "leaf.jdbc.driver-class-name";
4、com.sankuai.inf.leaf.server.service.SegmentService 读取该字段
dataSource.setDriverClassName(properties.getProperty(Constants.LEAF_JDBC_DRIVER_CLASS_NAME));
Snowflake模式
算法取自twitter开源的snowflake算法。
如果不想使用该模式配置leaf.snowflake.enable=false即可。
配置zookeeper地址
在leaf.properties中配置leaf.snowflake.zk.address,配置leaf 服务监听的端口leaf.snowflake.port。
运行Leaf:
打包服务:
git clone git@github.com:Meituan-Dianping/Leaf.git
//按照上面的号段模式在工程里面配置好
cd leaf
mvn clean install -DskipTests
cd leaf-server
运行服务:
注意:首先得先配置好数据库表或者zk地址
mvn方式
mvn spring-boot:run
脚本方式
sh deploy/run.sh
测试:
#segment
curl http://localhost:8080/api/segment/get/leaf-segment-test
#snowflake
curl http://localhost:8080/api/snowflake/get/test
监控页面:
号段模式:http://localhost:8080/cache
当然,为了追求更高的性能,需要通过RPC Server来部署Leaf 服务,那仅需要引入leaf-core的包,把生成ID的API封装到指定的RPC框架中即可。
3、使用leaf-starter注解来启动leaf
打包服务:
git clone git@github.com:Meituan-Dianping/Leaf.git
git checkout feature/spring-boot-starter
cd leaf
mvn clean install -Dmaven.test.skip=true
引入依赖:
<dependency>
<artifactId>leaf-boot-starter</artifactId>
<groupId>com.sankuai.inf.leaf</groupId>
<version>1.0.1-RELEASE</version>
</dependency>
配置leaf.properties到你的classpath下面
leaf.name=com.sankuai.leaf.opensource.test
leaf.segment.enable=false
#leaf.segment.url=
#leaf.segment.username=
#leaf.segment.password=
leaf.snowflake.enable=false
#leaf.snowflake.address=
#leaf.snowflake.port=
利用注解启动leaf,并使用api
//EnableLeafServer 开启leafserver
@SpringBootApplication
@EnableLeafServer
public class LeafdemoApplication {
public static void main(String[] args) {
SpringApplication.run(LeafdemoApplication.class, args);
}
}
//直接使用 spring注入
public class T {
@Autowired
private SegmentService segmentService;
@Autowired
private SnowflakeService snowflakeService;
}
结束: