美團Leaf源碼——號段模式源碼解析

前言

分佈式ID生成策略基本要求就是全局不重複,最好還能遞增,長度較短,性能高,可用性強。關於相關的實現方案有很多,本文着重使用美團開源的分佈式ID生成解決方案——Leaf。

關於Leaf,美團官方的介紹文檔主要如下,強烈建議閱讀文章大致瞭解Leaf的工作流程與原理,這對本文後續的源碼解析有很大的幫助。

  1. Leaf:美團分佈式ID生成服務開源
  2. Leaf——美團點評分佈式ID生成系統

本系列Leaf源碼解析部分按照使用的方式也分爲號段模式和snowflake模式兩篇文章,本文就來着重研究號段模式的源碼實現。

本文的Leaf源碼註釋地址:https://github.com/MrSorrow/Leaf

I. 導入項目

Leaf由Maven構建,源碼地址:https://github.com/Meituan-Dianping/Leaf
首先先Fork官方倉庫到自己的倉庫,我的源碼註釋版:https://github.com/MrSorrow/Leaf
下載源碼,導入IDEA,導入成功依賴下載完成後項目結構大致如下:
項目框架

II. 測試號段模式

「創建數據庫表」

DROP TABLE IF EXISTS `leaf_alloc`;

CREATE TABLE `leaf_alloc` (
  `biz_tag` varchar(128)  NOT NULL DEFAULT '' COMMENT '業務key',
  `max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '當前已經分配了的最大id',
  `step` int(11) NOT NULL COMMENT '初始步長,也是動態調整的最小步長',
  `description` varchar(256)  DEFAULT NULL COMMENT '業務key的描述',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '數據庫維護的更新時間',
  PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

「開啓號段模式」

leaf.name=com.sankuai.leaf.opensource.test
leaf.segment.enable=true
leaf.jdbc.url=jdbc:mysql://localhost:3306/leaf_test?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
leaf.jdbc.username=root
leaf.jdbc.password=1234

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

「測試號段模式」

開啓號段模式並配置好數據庫連接後,點擊啓動 leaf-server 模塊的 LeafServerApplication,將服務跑起來。

瀏覽器輸入http://localhost:8080/api/segment/get/leaf-segment-test來獲取分佈式遞增id;
獲取id
監控號段模式:http://localhost:8080/cache
監控頁面
數據庫表:
數據庫

III. 號段模式源碼解析

正式進入源碼前,再強烈建議閱讀官方的兩篇博客,對Leaf的號段模式工作模式有個大致的理解。

我們從http://localhost:8080/api/segment/get/leaf-segment-test入口來分析。該請求會交由 com.sankuai.inf.leaf.server.LeafController 處理:

@Autowired
SegmentService segmentService;

/**
 * 號段模式獲取id
 * @param key 對應數據庫表的biz_tag
 * @return
 */
@RequestMapping(value = "/api/segment/get/{key}")
public String getSegmentID(@PathVariable("key") String key) {
    // 核心是segmentService的getId方法
    return get(key, segmentService.getId(key));
}

private String get(@PathVariable("key") String key, Result id) {
    Result result;
    if (key == null || key.isEmpty()) {
        throw new NoKeyException();
    }

    result = id;
    if (result.getStatus().equals(Status.EXCEPTION)) {
        throw new LeafServerException(result.toString());
    }
    return String.valueOf(result.getId());
}

可以看到主要是調用 SegmentServicegetId(key) 方法。key 參數其實就是路徑上對應的 leaf-segment-test,也就是數據庫對應的 biz_taggetId(key) 方法返回的是 com.sankuai.inf.leaf.common.Result 對象,封裝了 id 和 狀態 status

public class Result {
    private long id;
    private Status status;

    // getter and setter....
}

public enum  Status {
    SUCCESS,
    EXCEPTION
}

創建SegmentService

我們進入 SegmentService 類中,再調用 getId(key) 方法之前,我們先看一下 SegmentService 類的實例化構造函數邏輯。可以看到:

package com.sankuai.inf.leaf.server;

@Service("SegmentService")
public class SegmentService {
    private Logger logger = LoggerFactory.getLogger(SegmentService.class);
    IDGen idGen;
    DruidDataSource dataSource;

    /**
     * 構造函數,注入單例SegmentService時,完成以下幾件事:
     * 1. 加載leaf.properties配置文件解析配置
     * 2. 創建Druid dataSource
     * 3. 創建IDAllocDao
     * 4. 創建ID生成器實例SegmentIDGenImpl並初始化
     * @throws SQLException
     * @throws InitException
     */
    public SegmentService() throws SQLException, InitException {
        // 1. 加載leaf.properties配置文件
        Properties properties = PropertyFactory.getProperties();
        // 是否開啓號段模式
        boolean flag = Boolean.parseBoolean(properties.getProperty(Constants.LEAF_SEGMENT_ENABLE, "true"));
        if (flag) {
            // 2. 創建Druid 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();

            // 3. 創建Dao
            IDAllocDao dao = new IDAllocDaoImpl(dataSource);

            // 4. 創建ID生成器實例SegmentIDGenImpl
            idGen = new SegmentIDGenImpl();
            ((SegmentIDGenImpl) idGen).setDao(dao);
            // 初始化SegmentIDGenImpl(加載db的tags至內存cache中,並開啓定時同步更新任務)
            if (idGen.init()) {
                logger.info("Segment Service Init Successfully");
            } else {
                throw new InitException("Segment Service Init Fail");
            }
        } else {
            // ZeroIDGen一直返回id=0
            idGen = new ZeroIDGen();
            logger.info("Zero ID Gen Service Init Successfully");
        }
    }

    /**
     * 根據key獲取id
     * @param key
     * @return
     */
    public Result getId(String key) {
        return idGen.get(key);
    }

    /**
     * 獲取號段模式id生成器SegmentIDGenImpl
     * @return
     */
    public SegmentIDGenImpl getIdGen() {
        if (idGen instanceof SegmentIDGenImpl) {
            return (SegmentIDGenImpl) idGen;
        }
        return null;
    }
}

SegmentService 類的構造函數,主要完成以下幾件事:

  1. 加載 leaf.properties 配置文件,並解析配置
  2. 創建 Druid 數據源對象 dataSource
  3. 創建 IDAllocDao 接口實例 IDAllocDaoImpl
  4. 創建ID生成器實例 SegmentIDGenImpl 並初始化

① 解析leaf.properties配置文件

通過 PropertyFactory 讀取了 leaf.properties 配置文件並進行解析。其中所以的key-value配置信息最終封裝爲 Properties 中。

/**
 * 加載leaf.properties配置文件中配置信息
 */
public class PropertyFactory {
    private static final Logger logger = LoggerFactory.getLogger(PropertyFactory.class);
    private static final Properties prop = new Properties();
    static {
        try {
            prop.load(PropertyFactory.class.getClassLoader().getResourceAsStream("leaf.properties"));
            logger.debug("Load leaf.properties successfully!");
        } catch (IOException e) {
            logger.warn("Load Properties Ex", e);
        }
    }
    public static Properties getProperties() {
        return prop;
    }
}

② 手動創建數據源

解析完配置文件後需要判斷是否開啓號段模式:

// 是否開啓號段模式
boolean flag = Boolean.parseBoolean(properties.getProperty(Constants.LEAF_SEGMENT_ENABLE, "true"));
if (flag) {
	// 2. 創建Druid 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();

    ······
    
} else {
    // ZeroIDGen一直返回id=0
    idGen = new ZeroIDGen();
    logger.info("Zero ID Gen Service Init Successfully");
}

如果沒有開啓號段模式,則創建默認返回id爲0的id生成器 ZeroIDGen

public class ZeroIDGen implements IDGen {
    @Override
    public Result get(String key) {
        return new Result(0, Status.SUCCESS);
    }

    @Override
    public boolean init() {
        return true;
    }
}

第二步主要通過配置文件配置的數據庫連接信息,手動創建出數據源 DruidDataSource

③ 創建IDAllocDaoImpl

我們先來查看 IDAllocDao 接口中的方法。

public interface IDAllocDao {
     List<LeafAlloc> getAllLeafAllocs();
     LeafAlloc updateMaxIdAndGetLeafAlloc(String tag);
     LeafAlloc updateMaxIdByCustomStepAndGetLeafAlloc(LeafAlloc leafAlloc);
     List<String> getAllTags();
}

再查看 IDAllocDaoImpl 實現類對應的方法實現。

public class IDAllocDaoImpl implements IDAllocDao {

    SqlSessionFactory sqlSessionFactory;

    public IDAllocDaoImpl(DataSource dataSource) {
        // 手動初始化sqlSessionFactory
        TransactionFactory transactionFactory = new JdbcTransactionFactory();
        Environment environment = new Environment("development", transactionFactory, dataSource);
        Configuration configuration = new Configuration(environment);
        configuration.addMapper(IDAllocMapper.class);
        sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration);
    }

    /**
     * 獲取所有的業務key對應的發號配置
     * @return
     */
    @Override
    public List<LeafAlloc> getAllLeafAllocs() {
        SqlSession sqlSession = sqlSessionFactory.openSession(false);
        try {
            return sqlSession.selectList("com.sankuai.inf.leaf.segment.dao.IDAllocMapper.getAllLeafAllocs");
        } finally {
            sqlSession.close();
        }
    }

    /**
     * 更新數據庫的最大id值,並返回LeafAlloc
     * @param tag
     * @return
     */
    @Override
    public LeafAlloc updateMaxIdAndGetLeafAlloc(String tag) {
        SqlSession sqlSession = sqlSessionFactory.openSession();
        try {
            // 更新tag對應記錄中的max_id,max_id = max_id + step,step爲數據庫中設置的step
            sqlSession.update("com.sankuai.inf.leaf.segment.dao.IDAllocMapper.updateMaxId", tag);
            // 獲取更新完的記錄,封裝成LeafAlloc對象返回
            LeafAlloc result = sqlSession.selectOne("com.sankuai.inf.leaf.segment.dao.IDAllocMapper.getLeafAlloc", tag);
            // 提交事務
            sqlSession.commit();
            return result;
        } finally {
            sqlSession.close();
        }
    }

    /**
     * 依據動態調整的step值,更新DB的最大id值,並返回更新後的記錄
     * @param leafAlloc
     * @return
     */
    @Override
    public LeafAlloc updateMaxIdByCustomStepAndGetLeafAlloc(LeafAlloc leafAlloc) {
        SqlSession sqlSession = sqlSessionFactory.openSession();
        try {
            sqlSession.update("com.sankuai.inf.leaf.segment.dao.IDAllocMapper.updateMaxIdByCustomStep", leafAlloc);
            LeafAlloc result = sqlSession.selectOne("com.sankuai.inf.leaf.segment.dao.IDAllocMapper.getLeafAlloc", leafAlloc.getKey());
            sqlSession.commit();
            return result;
        } finally {
            sqlSession.close();
        }
    }

    /**
     * 從數據庫查詢出所有的biz_tag
     * @return
     */
    @Override
    public List<String> getAllTags() {
        // 設置false,表示手動事務
        SqlSession sqlSession = sqlSessionFactory.openSession(false);
        try {
            return sqlSession.selectList("com.sankuai.inf.leaf.segment.dao.IDAllocMapper.getAllTags");
        } finally {
            sqlSession.close();
        }
    }
}

對於接口的四個方法的作用都有詳細的註釋,讀者大致有個印象,我們後面解析獲取id流程時會繼續詳細查看。比較陌生的應該是方法的返回實體類 LeafAlloc,其他它就是對應着數據庫表。

/**
 * 分配bean,和數據庫表記錄基本對應
 */
public class LeafAlloc {
    private String key;  // 對應biz_tag
    private long maxId;  // 對應最大id
    private int step;    // 對應步長
    private String updateTime;  // 對應更新時間
	// getter and setter
}

我們先來看一下這一步創建 IDAllocDaoImpl 中構造函數的邏輯,可以看到主要是按照使用MyBatis的流程創建出 SqlSessionFactory 對象。

④ 創建並初始化ID生成器

先來查看ID生成器接口:

public interface IDGen {
    /**
     * 獲取指定key下一個id
     * @param key
     * @return
     */
    Result get(String key);

    /**
     * 初始化
     * @return
     */
    boolean init();
}

接口主要包含兩個方法,分別是獲取指定key的下一個id值,和初始化生成器的方法。

該接口的實現類有三個,分別是號段模式、snowflake以及默認一直返回0的生成器。
ID生成器
創建號段模式ID生成器
com.sankuai.inf.leaf.segment.SegmentIDGenImpl 是我們分析整個流程的重點,我們先來簡單的查看其內部幾個重要的成員變量:

/**
 * 號段模式ID生成器
 */
public class SegmentIDGenImpl implements IDGen {

    ·······
    
    /**
     * 最大步長不超過100,0000
     */
    private static final int MAX_STEP = 1000000;
    /**
     * 一個Segment維持時間爲15分鐘
     */
    private static final long SEGMENT_DURATION = 15 * 60 * 1000L;
    /**
     * 線程池,用於執行異步任務,比如異步準備雙buffer中的另一個buffer
     */
    private ExecutorService service = new ThreadPoolExecutor(5, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), new UpdateThreadFactory());
    /**
     * 標記自己是否初始化完畢
     */
    private volatile boolean initOK = false;
    /**
     * cache,存儲所有業務key對應雙buffer號段,所以是基於內存的發號方式
     */
    private Map<String, SegmentBuffer> cache = new ConcurrentHashMap<String, SegmentBuffer>();
    /**
     * 查詢數據庫的dao
     */
    private IDAllocDao dao;
    
    ········
}

cache 是號段模式基於內存發號的關鍵,它是一個key爲數據庫表中不同業務的tag,value是一個 SegmentBuffer 對象,如果閱讀過官方的博客可以知道雙 buffer 優化的事情,這裏的SegmentBuffer 對象就是封裝了兩個 Segment 號段的數據結構。

回到 SegmentService 構造函數的第四步中來,創建 SegmentIDGenImpl 實例時使用的是默認構造函數,緊接着將第三步創建數據庫 dao 注入進 SegmentIDGenImpl 。然後調用生成器的初始化方法。

// 4. 創建ID生成器實例SegmentIDGenImpl
idGen = new SegmentIDGenImpl();
((SegmentIDGenImpl) idGen).setDao(dao);
// 初始化SegmentIDGenImpl(加載db的tags至內存cache中,並開啓定時同步更新任務)
if (idGen.init()) {
    logger.info("Segment Service Init Successfully");
} else {
    throw new InitException("Segment Service Init Fail");
}

初始化號段模式ID生成器

我們查看 SegmentIDGenImpl 的初始化方法邏輯,可以看到主要調用了兩個方法,並且設置了自己的初始化標記爲OK狀態。如果沒有初始化成功,會拋出異常,這在上面代碼可以看出。

@Override
public boolean init() {
    logger.info("Init ...");
    // 確保加載到kv後才初始化成功
    updateCacheFromDb();
    initOK = true;
    // 定時1min同步一次db和cache
    updateCacheFromDbAtEveryMinute();
    return initOK;
}

我們具體來查看 updateCacheFromDb()updateCacheFromDbAtEveryMinute() 方法邏輯。通過方法名其實我們可以推測方法含義是從數據庫中取出數據更新 cache,第二個方法則是一個定時任務,每分鐘都執行一遍第一個方法。我們具體查看一下。

/**
 * 將數據庫表中的tags同步到cache中
 */
private void updateCacheFromDb() {
    logger.info("update cache from db");
    StopWatch sw = new Slf4JStopWatch();
    try {
        // 獲取數據庫表中所有的biz_tag
        List<String> dbTags = dao.getAllTags();
        if (dbTags == null || dbTags.isEmpty()) {
            return;
        }
        // 獲取當前的cache中所有的tag
        List<String> cacheTags = new ArrayList<String>(cache.keySet());
        // 數據庫中的tag
        List<String> insertTags = new ArrayList<String>(dbTags);
        List<String> removeTags = new ArrayList<String>(cacheTags);

        // 下面兩步操作:保證cache和數據庫tags同步
        // 1. cache新增上數據庫表後添加的tags
        // 2. cache刪除掉數據庫表後刪除的tags

        // 1. db中新加的tags灌進cache,並實例化初始對應的SegmentBuffer
        insertTags.removeAll(cacheTags);
        for (String tag : insertTags) {
            SegmentBuffer buffer = new SegmentBuffer();
            buffer.setKey(tag);
            // 零值初始化當前正在使用的Segment號段
            Segment segment = buffer.getCurrent();
            segment.setValue(new AtomicLong(0));
            segment.setMax(0);
            segment.setStep(0);
            cache.put(tag, buffer);
            logger.info("Add tag {} from db to IdCache, SegmentBuffer {}", tag, buffer);
        }
        // 2. cache中已失效的tags從cache刪除
        removeTags.removeAll(dbTags);
        for (String tag : removeTags) {
            cache.remove(tag);
            logger.info("Remove tag {} from IdCache", tag);
        }
    } catch (Exception e) {
        logger.warn("update cache from db exception", e);
    } finally {
        sw.stop("updateCacheFromDb");
    }
}

首先通過dao層查詢出數據庫表中最新的所有的 biz_tag,緊接着就是同步數據庫中的 tags 和內存中的 cache。同步的方式包含兩步操作:

  1. 插入 cache 中不存在但是數據庫新增的 biz_tag ;
  2. 刪除 cache 中仍然存在但是數據庫表中已經刪除的biz_tag

上面這段代碼主要完成的就是這兩步操作,代碼邏輯仔細閱讀還是比較清晰的,配合註釋讀者可以相應理解,不再贅述。
需要額外提及的是 cache 的key我們已經知道是 biz_tag,但value我們僅僅知道是封裝了兩個 Segment 號段的 SegmentBuffer。我們具體來看看 SegmentBuffer 的定義。

/**
 * 雙buffer——雙號段
 * 雙Buffer的方式,保證無論何時DB出現問題,都能有一個Buffer的號段可以正常對外提供服務
 * 只要DB在一個Buffer的下發的週期內恢復,就不會影響整個Leaf的可用性
 */
public class SegmentBuffer {
    private String key; // 數據庫的業務tag
    private Segment[] segments; //雙buffer,雙號段
    private volatile int currentPos; //當前的使用的segment的index
    private volatile boolean nextReady; //下一個segment是否處於可切換狀態
    private volatile boolean initOk; //是否DB數據初始化完成
    private final AtomicBoolean threadRunning; //線程是否在運行中
    private final ReadWriteLock lock; // 讀寫鎖

    private volatile int step;  // 動態調整的step
    private volatile int minStep;  // 最小step
    private volatile long updateTimestamp;  // 更新時間戳

    public SegmentBuffer() {
    	// 創建雙號段,能夠異步準備,並切換
        segments = new Segment[]{new Segment(this), new Segment(this)};
        currentPos = 0;
        nextReady = false;
        initOk = false;
        threadRunning = new AtomicBoolean(false);
        lock = new ReentrantReadWriteLock();
    }

    public int nextPos() {
        return (currentPos + 1) % 2;
    }

    public void switchPos() {
        currentPos = nextPos();
    }
    
    public Lock rLock() {
        return lock.readLock();
    }

    public Lock wLock() {
        return lock.writeLock();
    }
}

可以看見 SegmentBuffer 中包含了一個號段數組,包含兩個 Segment,每一次只用一個,另一個異步的準備好,等到當前號段用完,就可以切換另一個,像Young GC的兩個Survivor區倒來倒去的思想。我們再來看一下號段 Segment 的定義。

/**
 * 號段類
 */
public class Segment {
    /**
     * 內存生成的每一個id號
     */
    private AtomicLong value = new AtomicLong(0);
    /**
     * 當前號段允許的最大id值
     */
    private volatile long max;
    /**
     * 步長,會根據數據庫的step動態調整
     */
    private volatile int step;
    /**
     * 當前號段所屬的SegmentBuffer
     */
    private SegmentBuffer buffer;

    public Segment(SegmentBuffer buffer) {
        this.buffer = buffer;
    }

	/**
     * 獲取號段的剩餘量
     * @return
     */
    public long getIdle() {
        return this.getMax() - getValue().get();
    }
}

value 就是用來產生id值的,它是一個 AtomicLong 類型,多線程下可以利用它的一些原子API操作。max 則代表自己(號段對象)能產生的最大的id值,也就是value的上限,用完了就需要切換號段,自己重新從數據庫獲取下一個號段區間。step 是動態調整的步長,關於動態調整,官方博客也有所解釋,這裏先不贅述。當自己用完了,就需要從數據庫請求新的號段區間,區間大小就是由這個 step 決定的。

介紹完Leaf的號段,雙Buffer數據結構後,我們回過頭查看同步DB到 cache 的邏輯中插入新的 SegmentBuffer 是如何創建的。

for (String tag : insertTags) {
    SegmentBuffer buffer = new SegmentBuffer();
    buffer.setKey(tag);
    // 零值初始化當前正在使用的Segment號段
    Segment segment = buffer.getCurrent();
    segment.setValue(new AtomicLong(0));
    segment.setMax(0);
    segment.setStep(0);
    cache.put(tag, buffer);
}

可以看到對於 SegmentBuffer 我們僅僅設置了key,然後就是依靠 SegmentBuffer 自身的構造函數對其內部成員進行了默認初始化,也可以說是零值初始化。特別注意,此時 SegmentBufferinitOk 標記還是 false,這也說明這個標記其實並不是標記零值初始化是否完成。然後程序接着對0號 Segment 的所有成員進行了零值初始化。

同步完成後,即將數據庫中的所有 tags 記錄加載到內存後,便將ID生成器的初始化標記設置爲 true

我們再來查看 updateCacheFromDbAtEveryMinute() 方法邏輯。

/**
 * 每分鐘同步db到cache
 */
private void updateCacheFromDbAtEveryMinute() {
    ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r);
            t.setName("check-idCache-thread");
            t.setDaemon(true);
            return t;
        }
    });
    service.scheduleWithFixedDelay(new Runnable() {
        @Override
        public void run() {
            updateCacheFromDb();
        }
    }, 60, 60, TimeUnit.SECONDS);
}

可以看到方法中創建了一個定時執行任務線程池,任務就是 updateCacheFromDb(),也就是上面那個方法,定時時間爲60s,也就是1min。

獲取ID

上一小節我們主要是在分析創建 SegmentService 過程中做了哪些事情,總結下來最重要的就是從數據庫表中準備好 cachecache 中包含每個key對應的雙號段,經過第一部分已經零值初始化好雙號段的當前使用號段。接下來我們繼續分析 SegmentServicegetId() 方法,我們的控制層就是通過該方法獲取id的。

/**
 * 根據key獲取id
 * @param key
 * @return
 */
public Result getId(String key) {
    return idGen.get(key);
}

再次分析號段生成器 SegmentIDGenImplget() 方法。

/**
 * 獲取對應key的下一個id值
 * @param key
 * @return
 */
@Override
public Result get(final String key) {
    // 必須在 SegmentIDGenImpl 初始化後執行init()方法
    // 也就是必須將數據庫中的tags加載到內存cache中,並開啓定時同步任務
    if (!initOK) {
        return new Result(EXCEPTION_ID_IDCACHE_INIT_FALSE, Status.EXCEPTION);
    }
    if (cache.containsKey(key)) {
        // 獲取cache中對應的SegmentBuffer,SegmentBuffer中包含雙buffer,兩個號段
        SegmentBuffer buffer = cache.get(key);

        // 雙重判斷,避免多線程重複執行SegmentBuffer的初始化值操作
        // 在get id前檢查是否完成DB數據初始化cache中key對應的的SegmentBuffer(之前只是零值初始化),需要保證線程安全
        if (!buffer.isInitOk()) {
            synchronized (buffer) {
                if (!buffer.isInitOk()) {
                    // DB數據初始化SegmentBuffer
                    try {
                        // 根據數據庫表中key對應的記錄 來初始化SegmentBuffer當前正在使用的Segment
                        updateSegmentFromDb(key, buffer.getCurrent());
                        logger.info("Init buffer. Update leafkey {} {} from db", key, buffer.getCurrent());
                        buffer.setInitOk(true);
                    } catch (Exception e) {
                        logger.warn("Init buffer {} exception", buffer.getCurrent(), e);
                    }
                }
            }
        }

        // SegmentBuffer準備好之後正常就直接從cache中生成id即可
        return getIdFromSegmentBuffer(cache.get(key));
    }

    // cache中不存在對應的key,則返回異常錯誤
    return new Result(EXCEPTION_ID_KEY_NOT_EXISTS, Status.EXCEPTION);
}

首先先從 cache 中獲取 key 對應的 SegmentBuffer,然後判斷 SegmentBuffer 是否是初始化完成,也就是 SegmentBufferinitOk 標記。這裏用了雙重判斷+synchronized 方式確保 SegmentBuffer 只被初始化一次。那麼這裏初始化究竟是指什麼,纔算初始化完成呢?

① 初始化SegmentBuffer

初始化 SegmentBuffer 的核心邏輯就是調用下面這個方法。

// 根據數據庫表中key對應的記錄 來初始化SegmentBuffer當前正在使用的Segment
updateSegmentFromDb(key, buffer.getCurrent());

查看方法名,也可以知道是從數據庫表查詢數據更新號段 Segment,對於號段初始狀態來說,該方法含義可以理解爲初始化 Segment 的值,對於用完的號段來講,可以理解爲從數據庫獲取下一號段值。

所以這裏初始化是指DB數據初始化當前號段,初始化完成就標記 SegmentBufferinitOktrue,也就表明 SegmentBuffer 中有一個號段已經準備完成了。

我們具體查看 updateSegmentFromDb(key, buffer.getCurrent()) 方法:

/**
 * 從數據庫表中讀取數據更新SegmentBuffer中的Segment
 * @param key
 * @param segment
 */
public void updateSegmentFromDb(String key, Segment segment) {
    StopWatch sw = new Slf4JStopWatch();

    /**
     * 1. 先設置SegmentBuffer
     */

    // 獲取Segment號段所屬的SegmentBuffer
    SegmentBuffer buffer = segment.getBuffer();
    LeafAlloc leafAlloc;
    // 如果buffer沒有DB數據初始化(也就是第一次進行DB數據初始化)
    if (!buffer.isInitOk()) {
        // 更新數據庫中key對應記錄的maxId(maxId表示當前分配到的最大id,maxId=maxId+step),並查詢更新後的記錄返回
        leafAlloc = dao.updateMaxIdAndGetLeafAlloc(key);
        // 數據庫初始設置的step賦值給當前buffer的初始step,後面後動態調整
        buffer.setStep(leafAlloc.getStep());
        // leafAlloc中的step爲DB中設置的step,buffer這裏是未進行DB數據初始化的,所以DB中step代表動態調整的最小下限
        buffer.setMinStep(leafAlloc.getStep());
    }
    // 如果buffer的更新時間是0(初始是0,也就是第二次調用updateSegmentFromDb())
    else if (buffer.getUpdateTimestamp() == 0) {
        // 更新數據庫中key對應記錄的maxId(maxId表示當前分配到的最大id,maxId=maxId+step),並查詢更新後的記錄返回
        leafAlloc = dao.updateMaxIdAndGetLeafAlloc(key);
        // 記錄buffer的更新時間
        buffer.setUpdateTimestamp(System.currentTimeMillis());
        // leafAlloc中的step爲DB中的step
        buffer.setMinStep(leafAlloc.getStep());
    }
    // 第三次以及之後的進來 動態設置nextStep
    else {
        // 計算當前更新操作和上一次更新時間差
        long duration = System.currentTimeMillis() - buffer.getUpdateTimestamp();
        int nextStep = buffer.getStep();

        /**
         *  動態調整step
         *  1) duration < 15 分鐘 : step 變爲原來的2倍, 最大爲 MAX_STEP
         *  2) 15分鐘 <= duration < 30分鐘 : nothing
         *  3) duration >= 30 分鐘 : 縮小step, 最小爲DB中配置的step
         *
         *  這樣做的原因是認爲15min一個號段大致滿足需求
         *  如果updateSegmentFromDb()速度頻繁(15min多次),也就是
         *  如果15min這個時間就把step號段用完,爲了降低數據庫訪問頻率,我們可以擴大step大小
         *  相反如果將近30min才把號段內的id用完,則可以縮小step
         */

        // duration < 15 分鐘 : step 變爲原來的2倍. 最大爲 MAX_STEP
        if (duration < SEGMENT_DURATION) {
            if (nextStep * 2 > MAX_STEP) {
                //do nothing
            } else {
                // 步數 * 2
                nextStep = nextStep * 2;
            }
        }
        // 15分鐘 < duration < 30分鐘 : nothing
        else if (duration < SEGMENT_DURATION * 2) {
            //do nothing with nextStep
        }
        // duration > 30 分鐘 : 縮小step ,最小爲DB中配置的步數
        else {
            nextStep = nextStep / 2 >= buffer.getMinStep() ? nextStep / 2 : nextStep;
        }
        logger.info("leafKey[{}], dbStep[{}], duration[{}mins], nextStep[{}]", key, buffer.getStep(), String.format("%.2f",((double)duration / (1000 * 60))), nextStep);

        /**
         * 根據動態調整的nextStep更新數據庫相應的maxId
         */

        // 爲了高效更新記錄,創建一個LeafAlloc,僅設置必要的字段的信息
        LeafAlloc temp = new LeafAlloc();
        temp.setKey(key);
        temp.setStep(nextStep);
        // 根據動態調整的step更新數據庫的maxId
        leafAlloc = dao.updateMaxIdByCustomStepAndGetLeafAlloc(temp);
        // 記錄更新時間
        buffer.setUpdateTimestamp(System.currentTimeMillis());
        // 記錄當前buffer的動態調整的step值
        buffer.setStep(nextStep);
        // leafAlloc的step爲DB中的step,所以DB中的step值代表着下限
        buffer.setMinStep(leafAlloc.getStep());
    }

    /**
     * 2. 準備當前Segment號段
     */

    // 設置Segment號段id的起始值,value就是id(start=max_id-step)
    long value = leafAlloc.getMaxId() - buffer.getStep();
    // must set value before set max(https://github.com/Meituan-Dianping/Leaf/issues/16)
    segment.getValue().set(value);
    segment.setMax(leafAlloc.getMaxId());
    segment.setStep(buffer.getStep());
    sw.stop("updateSegmentFromDb", key + " " + segment);
}

這個函數的邏輯非常重要,還包含了動態調整步長的邏輯。首先,該方法被調用的時機我們需要明確,每當我們需要從數據庫獲取一個號段纔會被調用。方法的第一部分主要先通過數據庫並設置 SegmentBuffer 相關值,第二部分再準備 Segment

第一部分的邏輯按照調用該方法的次數分爲第一次準備號段、第二次準備號段和第三次及之後的準備號段。

  1. 第一次準備號段,也就是 SegmentBuffer 還沒有DB初始化,我們要從數據庫獲取一個號段,記錄 SegmentBuffer 的當前步長、最小步長都是數據庫設置的步長;
  2. 第二次準備號段,也就是雙buffer的異步準備另一個號段 Segment 時,會進入這一邏輯分支。仍然從數據庫獲取一個號段,此時記錄這次獲取下一個號段的時間戳,設置最小步長是數據庫設置的步長;
  3. 之後再次準備號段,首先要動態調整這次申請號段的區間大小,也就是代碼中的 nextStep,調整規則主要跟號段申請頻率有關,具體可以查看註釋以及代碼。計算出動態調整的步長,需要根據新的步長去數據庫申請號段,同時記錄這次獲取號段的時間戳,保存動態調整的步長到 SegmentBuffer,設置最小步長是數據庫設置的步長。

第二部分邏輯主要是準備 Segment 號段,將 Segment 號段的四個成員變量進行新一輪賦值,value 就是 idstart=max_id-step)。

② 從號段中獲取id

SegmentBuffer 和 其中一個號段 Segment 準備好,就可以進行從號段中獲取id。我們具體查看號段ID生成器 SegmentIDGenImplgetIdFromSegmentBuffer() 方法。

/**
 * 從SegmentBuffer生成id返回
 * @param buffer
 * @return
 */
public Result getIdFromSegmentBuffer(final SegmentBuffer buffer) {
    // 自旋獲取id
    while (true) {
        try {
            // 獲取buffer的共享讀鎖,在平時不操作Segment的情況下益於併發
            buffer.rLock().lock();

            // 獲取當前正在使用的Segment
            final Segment segment = buffer.getCurrent();

            // ===============異步準備雙buffer的另一個Segment==============
            // 1. 另一個Segment沒有準備好
            // 2. 當前Segment已經使用超過10%則開始異步準備另一個Segment
            // 3. buffer中的threadRunning字段. 代表是否已經提交線程池運行,是否有其他線程已經開始進行另外號段的初始化工作.使用CAS進行更新保證buffer在任意時刻,只會有一個線程進行異步更新另外一個號段.
            if (!buffer.isNextReady() && (segment.getIdle() < 0.9 * segment.getStep()) && buffer.getThreadRunning().compareAndSet(false, true)) {
                // 線程池異步執行【準備Segment】任務
                service.execute(new Runnable() {
                    @Override
                    public void run() {
                        // 獲得另一個Segment對象
                        Segment next = buffer.getSegments()[buffer.nextPos()];
                        boolean updateOk = false;
                        try {
                            // 從數據庫表中準備Segment
                            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 {
                            // 如果準備成功,則通過獨佔寫鎖設置另一個Segment準備標記OK,threadRunning爲false表示準備完畢
                            if (updateOk) {
                                // 讀寫鎖是不允許線程先獲得讀鎖繼續獲得寫鎖,這裏可以是因爲這一段代碼其實是線程池線程去完成的,不是獲取到讀鎖的線程
                                buffer.wLock().lock();
                                buffer.setNextReady(true);
                                buffer.getThreadRunning().set(false);
                                buffer.wLock().unlock();
                            } else {
                                // 失敗了,則還是沒有準備好,threadRunning恢復false,以便於下次獲取id時重新再異步準備Segment
                                buffer.getThreadRunning().set(false);
                            }
                        }
                    }
                });
            }

            // 原子value++(返回舊值),也就是下一個id,這一步是多線程操作的,每一個線程加1都是原子的,但不一定保證順序性
            long value = segment.getValue().getAndIncrement();
            // 如果獲取到的id小於maxId
            if (value < segment.getMax()) {
                return new Result(value, Status.SUCCESS);
            }
        } finally {
            // 釋放讀鎖
            buffer.rLock().unlock();
        }

        // 等待線程池異步準備號段完畢
        waitAndSleep(buffer);

        // 執行到這裏,說明當前號段已經用完,應該切換另一個Segment號段使用
        try {
            // 獲取獨佔式寫鎖
            buffer.wLock().lock();
            // 獲取當前使用的Segment號段
            final Segment segment = buffer.getCurrent();
            // 重複獲取value, 多線程執行時,Segment可能已經被其他線程切換。再次判斷, 防止重複切換Segment
            long value = segment.getValue().getAndIncrement();
            if (value < segment.getMax()) {
                return new Result(value, Status.SUCCESS);
            }

            // 執行到這裏, 說明其他的線程沒有進行Segment切換,並且當前號段所有號碼用完,需要進行切換Segment
            // 如果準備好另一個Segment,直接切換
            if (buffer.isNextReady()) {
                buffer.switchPos();
                buffer.setNextReady(false);
            }
            // 如果另一個Segment沒有準備好,則返回異常雙buffer全部用完
            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();
        }
    }
}

首先該方法最外層套了一個循環,不斷地嘗試獲取id。整個方法的邏輯大致包含:

  1. 首先獲取共享讀鎖,多個線程能夠同時進來獲取id。如果能夠不需要異步準備雙buffer的另一個 Segment 且分發的id號沒有超出maxId,那麼可以直接返回id號。多個線程併發獲取id號,靠 AtomicLonggetAndIncrement() 原子操作保證不出問題。
  2. 如果需要異步準備另一個 Segment,則將準備任務提交到線程池中進行完成。多線程執行下,要保證只有一個線程去提交任務。這一點是靠 SegmentBuffer 中的 threadRunning 字段實現的。threadRunning 字段用 volatile 修飾保證多線程可見性,其含義代表了異步準備號段任務是否已經提交線程池運行,是否有其他線程已經開始進行另外號段的初始化工作。使用CAS操作進行更新,保證 SegmentBuffer 在任意時刻只會有一個線程進行異步更新另外一個號段。
  3. 如果號段分配的 id 號超出了maxId,則需要進行切換雙buffer的操作。在進行直接切換之前,需要再次判斷是否 id 還大於 maxId,因爲多線程下,號段已經被其他線程切換成功,自己還不知道,所以爲了避免重複切換出錯,需要再次判斷。切換操作爲了保證同一時間只能有一個線程切換,這裏利用了獨佔式的寫鎖。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章