1.0 简介
对于分布式ID生成策略,之前我的印象里只有twitter开源的snowflake算法,该算法详情参见:snowflake算法思考。后来看了美团技术博客中有写到美团开源了自己的分布式ID服务,还是很好奇,于是把代码拉下来看了一下,这里做一个简单的源码解读,后面也可以给自己一个参考,源码地址下面在最下面。同时,如果在看这篇源码解读博客前,建议还是把美团官方Leaf博客先看一下。
2.0 号段模式
2.1 号段模式解释
号段模式其实就是每次取出一个区间号码,保存到内存中,等这批号码用完了后再从数据库中拿下一批。
2.2 号段模式Leaf如何用
Leaf号段模式实现原理就是在DB中建一张表,记录当前号段的起点和步长,当这个区间的号码都用完时,先更新数据库中起点值为当前使用号段的最大值,然后将这个值取出来作为新的号段的起点。
2.3 Leaf号段模式初始化
号段模式初始化参见SegmentService
构造方法,具体代码如下:
public SegmentService() throws SQLException, InitException {
// 将在leaf.properties文件
Properties properties = PropertyFactory.getProperties();
// 判断配置文件中是否设置为号段模式
boolean flag = Boolean.parseBoolean(properties.getProperty(Constants.LEAF_SEGMENT_ENABLE, "true"));
if (flag) {
// Config dataSource
dataSource = new DruidDataSource();
dataSource.setUrl(properties.getProperty(Constants.LEAF_JDBC_URL));
dataSource.setUsername(properties.getProperty(Constants.LEAF_JDBC_USERNAME));
dataSource.setPassword(properties.getProperty(Constants.LEAF_JDBC_PASSWORD));
dataSource.init();
// 初始化Druid数据源,初始化mybatis SqlSessionFactory
IDAllocDao dao = new IDAllocDaoImpl(dataSource);
// 分布式ID生成核心类
idGen = new SegmentIDGenImpl();
// 依赖注入
((SegmentIDGenImpl) idGen).setDao(dao);
if (idGen.init()) {
logger.info("Segment Service Init Successfully");
} else {
throw new InitException("Segment Service Init Fail");
}
} else {
// 测试类,只能输出0
idGen = new ZeroIDGen();
logger.info("Zero ID Gen Service Init Successfully");
}
}
这里需要明确一个概念Tag,一个leaf服务是可以同时给很多业务同时提供服务的,所以A业务中会有id=1情况,B业务也有id=1的订单,Leaf只是保证同一业务中永远不会出现两个一样的id值。
SegmentIDDaoImpl
类初始化代码如下:
@Override
public boolean init() {
logger.info("Init ...");
// 确保加载到kv后才初始化成功
updateCacheFromDb();
initOK = true;
// 每分钟查一次DB,找到当前表中所有tag,移除已经不用的tag,发现新添加的tag
updateCacheFromDbAtEveryMinute();
return initOK;
}
2.4 Leaf如何取号
Leaf默认情况下使用双缓冲区策略取号,也就采用两个号段,一个主一个备,主号段使用完后备用buffer切换为主,主降级为次,同时更新次号段最大号码值。其实正常情况下一个buffer就好,当一个buffer区号段用完后,重新向DB中取下一个号段即可,但是根据美团官方博客中解释该种模式下会存在两个比较明显的问题:毛刺现象,就是在号被用完后需要到DB中取号,取号分两个阶段,更新最大号码为当前最大号码+步长,然后取更新后的最大号码值为当前buffer的最大号码值,但是如果DB update操作耗时较长,导致分布式ID服务在这个时候响应时间较长。DB在更新时宕机,update失败,服务在MySQL主从切换期间不可用。MySQL主从切换当然是一个相对来说比较快的过程,不过对于每秒海量请求的美团线上服务,这个时延也是不可接受的,于是在主buffer基础上增加一个备用buffer,保证MySQL挂掉的一段时间内,备用buffer可以继续起作用,保证分布式ID服务的高可用。
2.5 Leaf取号原理
这块主要是源码解读,Leaf双buffer核心类SegmentBuffer
,这个类通过一个Segment
数组以及一个currentPos
变量来标示当前正在其作用的段。调用get
方法时,Leaf首先从缓存中拿到当前的SegmentBuffer
对象。
如果取出的SegmentBuffer
未完成初始化,先进行SegmentBuffer
初始化,初始化方式就是从DB中获取到当前号段最大ID值,赋值给当前SegmentBuffer
对象。如果如果取出的SegmentBuffer
已经初始化完成,则表示这个SegmentBuffer
中号已经用完了,Leaf需要根据该步长范围号码消耗时间来判断步长设置的长短,如果步长设置过短,导致应用需频繁去DB中获取下一个最大值,影响应用性能。如果设置过大,可能号码很长时间都用不完,DB在下一次更新最大值号段值与前一次更新设置的步长值相差太大,我个人感觉这个好像也没啥问题吧。下面是官方在实际生产中碰到的问题解释:
号段长度始终是固定的,假如Leaf本来能在DB不可用的情况下,维持10分钟正常工作,那么如果流量增加10倍就只能维持1分钟正常工作了。
号段长度设置的过长,导致缓存中的号段迟迟消耗不完,进而导致更新DB的新号段与前一次下发的号段ID跨度过大。
从SegmentBuffer
中取号代码如下:
public Result getIdFromSegmentBuffer(final SegmentBuffer buffer) {
while (true) {
buffer.rLock().lock();
try {
// 拿到当前正在使用的号段
final Segment segment = buffer.getCurrent();
// 如果下一个buffer还未准备好并且当前号段已经使用了10%,启动SegmentBuffer中线程执行备用buffer初始化
if (!buffer.isNextReady() && (segment.getIdle() < 0.9 * segment.getStep()) && buffer.getThreadRunning().compareAndSet(false, true)) {
service.execute(new Runnable() {
@Override
public void run() {
Segment next = buffer.getSegments()[buffer.nextPos()];
boolean updateOk = false;
try {
// 初始化备用buffer
updateSegmentFromDb(buffer.getKey(), next);
updateOk = true;
logger.info("update segment {} from db {}", buffer.getKey(), next);
} catch (Exception e) {
logger.warn(buffer.getKey() + " updateSegmentFromDb exception", e);
} finally {
if (updateOk) {
// 启用buffer的写锁,对buffer属性进行赋值
buffer.wLock().lock();
// 第一个号段用完后可以切换到第二个号段了
buffer.setNextReady(true);
buffer.getThreadRunning().set(false);
buffer.wLock().unlock();
} else {
buffer.getThreadRunning().set(false);
}
}
}
});
}
// 从主buffer中获取到号码
long value = segment.getValue().getAndIncrement();
if (value < segment.getMax()) {
return new Result(value, Status.SUCCESS);
}
} finally {
buffer.rLock().unlock();
}
// 如果前面获取号码失败,则表示主buffer号码已经用完了,此时需要启用备用buffer进行号段分配。这里做一个类似于CAS的自旋操作,此时一个segmentBuffer已经用完了,下一个号段还未准备好,等下一个号段准备好后
// 进行buffer切换,计数器才能正常工作
waitAndSleep(buffer);
buffer.wLock().lock();
try {
final Segment segment = buffer.getCurrent();
long value = segment.getValue().getAndIncrement();
if (value < segment.getMax()) {
return new Result(value, Status.SUCCESS);
}
if (buffer.isNextReady()) {
buffer.switchPos();
buffer.setNextReady(false);
} else {
logger.error("Both two segments in {} are not ready!", buffer);
return new Result(EXCEPTION_ID_TWO_SEGMENTS_ARE_NULL, Status.EXCEPTION);
}
} finally {
buffer.wLock().unlock();
}
}
主备切换过程Leaf的做法是调用SegmentBuffer#switchPos
方法,具体做法如下:
public int nextPos() {
return (currentPos + 1) % 2;
}
public void switchPos() {
currentPos = nextPos();
}
可以看出就是一个偶数变奇数或者奇数变偶数过程,并没有什么复杂的骚操作。
3.0 总结
至此,Leaf号段模式就解释完了,这里还有基于snowflake算法实现或者zookeeper实现,但是个人感觉基于DB的实现跟可靠,从DB架构层次去做高可用可能比snowflake或zk实现跟成熟。同时,由于Leaf服务部署肯定是多节点,保证节点之间相对递增,如果采用snowflake还需要引入其它号段同步组建,更复杂吧。基于ZK这种ZAB协议实现的结构,可能在写性能上会有很大的障碍,如果多接点频繁进行ZK节点创建与删除,可能ZK会成为最后的瓶颈。
Leaf Github地址: https://github.com/Meituan-Dianping/Leaf
参考地址:
Leaf:美团分布式ID生成服务开源