美團Leaf源碼——snowflake模式源碼解析

前言

上一篇文章介紹瞭如何使用Leaf的號段模式生成分佈式全局唯一id,參照下圖我們簡單總結一下。當我們部署Leaf集羣時(圖中是3個),每個節點起初都包含一個雙 buffer,也就是雙號段。當有請求過來時,每個節點都會去數據庫查詢按照初始的DB中的step去更新最大id,從而獲取到一個號段,然後每個節點當第一個號段用到超過10%的時候再異步準備第二個號段。所以按照圖中的理解可以認爲左中右三個節點依次被調用請求test_tag業務對應的id,從而每個節點都獲得到自己的號段,三個節點都按照step=1000去更新maxId,最後maxId表示已經分配最大的id爲3000,接下來三個節點誰先用到超過10%就會再去異步準備另一個號段。等到請求量都達到10%之後,都會準備好雙buffer,然後不斷切換異步準備。
號段模式
這次我們主要討論snowflake模式的使用以及源碼解析。本文的Leaf源碼註釋地址:https://github.com/MrSorrow/Leaf

I. 測試snowflake模式

「安裝ZooKeeper」

這裏選擇Docker的方式快速搭建一個單機版的ZooKeeper,用於整合Leaf框架。

docker pull zookeeper:3.4

firewall-cmd --zone=public --add-port=2181/tcp --permanent
firewall-cmd --zone=public --add-port=2888/tcp --permanent
firewall-cmd --zone=public --add-port=3888/tcp --permanent
firewall-cmd --reload

docker run --name leaf-zookeeper --restart always -p 2181:2181 -e TZ=Asia/Shanghai -d zookeeper:3.4

可能會遇到 WARNING IPv4 forwarding is disabled. Networking will not work 錯誤,解決方法如下:

vi /usr/lib/sysctl.d/00-system.conf
# 添加上一行
net.ipv4.ip_forward=1
# 重啓network服務
systemctl restart network
# 查看
sysctl net.ipv4.ip_forward
# 如果返回爲“net.ipv4.ip_forward = 1”則表示成功了

「開啓snowflake模式」

主要配置好最後的三項,開啓snowflake模式,配置好zookeeper的地址和端口號。

leaf.name=com.sankuai.leaf.opensource.test
# 關閉號段模式
leaf.segment.enable=false
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=true
leaf.snowflake.zk.address=192.168.2.113
leaf.snowflake.port=2181

「啓動測試」

仍然和號段模式啓動一樣,點擊啓動 leaf-server 模塊的 LeafServerApplication,將服務跑起來。
snowflake模式啓動成功
瀏覽器輸入http://localhost:8080/api/snowflake/get/key來獲取分佈式遞增id。或者通過命令行中 curl 方式進行測試。

wangguopingdeMacBook-Air:~ guoping$ curl http://localhost:8080/api/snowflake/get/key1
1128852519460536335
wangguopingdeMacBook-Air:~ guoping$ curl http://localhost:8080/api/snowflake/get/key1
1128852531322028054

wangguopingdeMacBook-Air:~ guoping$ curl http://localhost:8080/api/snowflake/get/key2
1128852655284682815
wangguopingdeMacBook-Air:~ guoping$ curl http://localhost:8080/api/snowflake/get/key2
1128852694715334657

我們將返回的id轉換爲十六進制數,可以確定 workerId 是 0,時間戳也確實在遞增,自增序列卻不是遞增的,這在後面我們研究源碼可以知道當時間戳一致纔會自增,時間戳增大後,自增序列要“清零”重新開始自增。

1128852519460536335=0-00011111010101001111101011000101011001001-0000000000-000000001111
1128852531322028054=0-00011111010101001111101011001010111010101-0000000000-000000010110

1128852655284682815=0-00011111010101001111101100000100101001000-0000000000-000000111111
1128852694715334657=0-00011111010101001111101100010111000000001-0000000000-000000000001

按照snowflake算法的比特分配,我們將上述的id值轉換成對應的形式。圖中有些錯誤,最後自增序列是12位。
snowflake比特分配

III. snowflake模式源碼分析

有了號段模式源碼的分析基礎,我們對於整個項目的結構有了更加清晰的認識。調用snowflake模式下的Leaf服務,依然調用的是 LeafController 下的接口,然後Service層的實現則換成了 SnowflakeService,Service層依賴的ID生成器 IDGen 的實現類則是 SnowflakeIDGenImpl
snowflake模式
下面我們就來從LeafController 下的接口出發。

@Autowired
SnowflakeService snowflakeService;

/**
 * snowflake模式獲取id
 * @param key 隨便定義
 * @return
 */
@RequestMapping(value = "/api/snowflake/get/{key}")
public String getSnowflakeID(@PathVariable("key") String key) {
    return get(key, snowflakeService.getId(key));

}

可以看出核心方法調用的是 SnowflakeServicegetId(key) 方法。

SnowflakeService

/**
 * snowflake模式的service層
 */
@Service("SnowflakeService")
public class SnowflakeService {
    private Logger logger = LoggerFactory.getLogger(SnowflakeService.class);

    /**
     * ID生成器
     */
    IDGen idGen;

    /**
     * 構造函數,注入單例SnowflakeService時,完成以下幾件事:
     * 1. 加載leaf.properties配置文件解析配置
     * 2. 創建snowflake模式ID生成器
     * 3. 初始化ID生成器
     * @throws InitException
     */
    public SnowflakeService() throws InitException {
        // 1. 加載leaf.properties配置文件解析配置
        Properties properties = PropertyFactory.getProperties();
        // 是否開啓snowflake模式
        boolean flag = Boolean.parseBoolean(properties.getProperty(Constants.LEAF_SNOWFLAKE_ENABLE, "true"));
        if (flag) {
            // 2. 創建snowflake模式ID生成器
            String zkAddress = properties.getProperty(Constants.LEAF_SNOWFLAKE_ZK_ADDRESS);
            int port = Integer.parseInt(properties.getProperty(Constants.LEAF_SNOWFLAKE_PORT));
            idGen = new SnowflakeIDGenImpl(zkAddress, port);
            // 3. 初始化ID生成器
            if(idGen.init()) {
                logger.info("Snowflake Service Init Successfully");
            } else {
                throw new InitException("Snowflake Service Init Fail");
            }
        } else {
            // ZeroIDGen一直返回id=0
            idGen = new ZeroIDGen();
            logger.info("Zero ID Gen Service Init Successfully");
        }
    }

    /**
     * 通過ID生成器獲得key對應的id
     * @param key
     * @return
     */
    public Result getId(String key) {
        return idGen.get(key);
    }
}

SnowflakeService 的內容非常簡單,其方法只有一個就是 getId(String key)。成員變量包含一個ID生成器,構造函數主要的目的就是創建並初始化這個ID生成器。

構造函數中,注入單例SnowflakeService時主要完成三件事:

  1. 加載 leaf.properties 配置文件解析配置
  2. 創建snowflake模式ID生成器
  3. 初始化ID生成器

① 創建ID生成器

第一步加載 leaf.properties 配置文件解析zookeeper連接ip與port的相關配置信息就不用多描述了,和號段模式解析數據庫連接配置如出一轍。

然後校驗配置文件中是否打開了snowflake模式,如果打開了則創建 SnowflakeIDGenImpl 類型的ID生成器。我們查看 SnowflakeIDGenImpl 的構造函數。

/**
 * snowflake模式ID生成器
 */
public class SnowflakeIDGenImpl implements IDGen {

	·········
	
    /**
     * 保存該節點的workId
     */
    private long workerId;
    
    /**
     * 是否初始化完成,也就標記着是否get到workID
     */
    public boolean initFlag = false;

    /**
     * zk的端口號
     */
    private int port;

    public SnowflakeIDGenImpl(String zkAddress, int port) {
        this.port = port;
        // 創建SnowflakeZookeeperHolder對象
        SnowflakeZookeeperHolder holder = new SnowflakeZookeeperHolder(Utils.getIp(), String.valueOf(port), zkAddress);
        // 初始化SnowflakeZookeeperHolder對象
        initFlag = holder.init();
        if (initFlag) {
            // 初始化完成後最重要的就是確定了本機器的workerId
            workerId = holder.getWorkerID();
            LOGGER.info("start success use zk workerId-{}", workerId);
        } else {
            // 校驗initFlag是否爲true,不爲true報出Snowflake Id Gen is not init ok錯誤
            Preconditions.checkArgument(initFlag, "Snowflake Id Gen is not init ok");
        }
        // 校驗生成的workID必須在0~1023之間
        Preconditions.checkArgument(workerId >= 0 && workerId <= maxWorkerId, "workerID must gte 0 and lte 1023");
    }

	········
}

可以看到 SnowflakeIDGenImpl 的構造函數的最主要目的就是創建 SnowflakeZookeeperHolder 對象,並調用其 init() 初始化方法,初始化完成後最重要的就是確定了本機器的 workerId。這樣 SnowflakeIDGenImplworkerId 得到了初始化,獲得了本Leaf節點在集羣中的工作機器id號。這樣snowflake算法的三分之一內容就已經搞定了。

爲了顯示 workerId 的獲取的重要性,這裏準備單獨再開一節內容單獨討論。

② 初始化ID生成器

我們查看 SnowflakeIDGenImpl ID生成器的初始化方法:

/**
 * 初始化直接返回爲true
 * @return
 */
@Override
public boolean init() {
    return true;
}

可以看到初始化方法默認返回 true,直接認爲初始化成功,所以創建 SnowflakeService 的整個流程最重要的目的其實就是獲取本機器在集羣中分配得到的 workerId,那麼下面來具體研究 SnowflakeZookeeperHolder 具體是怎麼獲取到 workerId 的。

分配workerId

官方博客說明,分配機器的 workerId 策略:

對於workerID的分配,當服務集羣數量較小的情況下,完全可以手動配置。Leaf服務規模較大,動手配置成本太高。所以使用Zookeeper持久順序節點的特性自動對snowflake節點配置wokerID。

那麼 SnowflakeZookeeperHolder 就是採用ZooKeeper來進行分配機器id的。對於ZooKeeper不太熟悉的朋友可以參考這篇文章。從上面 SnowflakeIDGenImpl 的構造函數流程中可以得知, workerId 的獲取經過兩個步驟即可完成:

  1. 創建 SnowflakeZookeeperHolder 實例;
  2. 調用 SnowflakeZookeeperHolder 的初始化方法。

兩步完成之後, SnowflakeZookeeperHolder 實例中就包含了 workerId

① 創建SnowflakeZookeeperHolder實例

SnowflakeZookeeperHolder 的構造函數主要是保存了幾個關鍵的連接ZooKeeper的信息,還有用於標識自身Leaf節點機器的信息等。

/**
 * 本機ip,用於區分不同的節點
 */
private String ip;
/**
 * zk的端口
 */
private String port;
/**
 *本機ip:port,用於區分zk根節點下不同的節點
 */
private String listenAddress = null;
/**
 * zk的ip地址
 */
private String connectionString;


/**
 * @param ip 本機器的ip地址
 * @param port 連接zk的端口號
 * @param connectionString zk的ip地址
 */
public SnowflakeZookeeperHolder(String ip, String port, String connectionString) {
    this.ip = ip;
    this.port = port;
    this.listenAddress = ip + ":" + port;
    this.connectionString = connectionString;
}

② 初始化SnowflakeZookeeperHolder

/**
 * 初始化方法,包括:
 * 1. 創建zk客戶端連接會話並啓動客戶端
 * 2. 檢查/snowflake/${leaf.name}/forever根節點是否存在
 * 3. 不存在則創建根節點,獲取zk分配的workerId,並寫入本地文件
 * 4. 存在則查詢到持久節點下屬於自己的節點,得到zk分配的workerId,更新本地文件,校驗是否時鐘回撥
 * 5. 如果啓動失敗,就從本地文件中讀取,弱依賴zk
 * @return
 */
public boolean init() {
    try {
        // 1. 創建zk客戶端連接會話並啓動客戶端
        CuratorFramework curator = createWithOptions(connectionString, new RetryUntilElapsed(1000, 4), 10000, 6000);
        // 啓動客戶端
        curator.start();

        // 2. 檢查/snowflake/${leaf.name}/forever根節點是否存在
        Stat stat = curator.checkExists().forPath(PATH_FOREVER);
        // 注意!!!!!這一段邏輯Leaf集羣中只會有一個節點執行一次,所以下面workerId不需要從zk_AddressNode中解析賦值!!!!!
        if (stat == null) {
            // 3. 不存在根節點說明機器是第一次啓動,則創建/snowflake/${leaf.name}/forever/ip:port-000000000,並寫入自身節點標識和時間數據
            zk_AddressNode = createNode(curator);
            LOGGER.info("[New NODE] first register in zk and create node on forever node that endpoint ip-{} port-{} workid-{},create own node on forever node and start SUCCESS ", ip, port, workerID);
            // 在本地緩存workerId,默認是0(因爲此時還沒有從zk獲取到分配的workID,0是成員變量的默認值,這裏可以不從zk_AddressNode解析workerID,直接默認0)
            updateLocalWorkerID(workerID);
            // 定時上報本機時間戳給/snowflake/${leaf.name}/forever根節點
            ScheduledUploadData(curator, zk_AddressNode);
            return true;
        }
        // 4. 存在的話,說明不是第一次啓動leaf應用,zk存在以前的【自身節點標識和時間數據】
        else {
            // 自身節點ip:port->0000001
            Map<String, Integer> nodeMap = Maps.newHashMap();
            // 自身節點ip:port->ip:port-000001
            Map<String, String> realNode = Maps.newHashMap();

            // 存在根節點,先獲取根節點下所有的子節點,檢查是否有屬於自己的節點
            List<String> keys = curator.getChildren().forPath(PATH_FOREVER);
            for (String key : keys) {
                String[] nodeKey = key.split("-");
                realNode.put(nodeKey[0], key);
                nodeMap.put(nodeKey[0], Integer.parseInt(nodeKey[1]));
            }
            // 獲取zk上曾經記錄的workerId,這裏可以看出workerId的分配是依靠zk的自增序列號
            Integer workerid = nodeMap.get(listenAddress);
            if (workerid != null) {
                // 有自己的節點,zk_AddressNode = /snowflake/${leaf.name}/forever+ip:port-0000001
                zk_AddressNode = PATH_FOREVER + "/" + realNode.get(listenAddress);
                // 啓動worker時使用會使用
                workerID = workerid;

                // 檢查該節點當前的系統時間是否在最後一次上報時間之後
                if (!checkInitTimeStamp(curator, zk_AddressNode)) {
                    // 如果不滯後,則啓動失敗
                    throw new CheckLastTimeException("init timestamp check error,forever node timestamp gt this node time");
                }
                // 準備創建臨時節點
                doService(curator);
                // 更新本地緩存的workerID
                updateLocalWorkerID(workerID);
                LOGGER.info("[Old NODE] find forever node have this endpoint ip-{} port-{} workid-{} childnode and start SUCCESS", ip, port, workerID);
            } else {
                // 不存在自己的節點則表示是一個新啓動的節點,則創建持久節點,不需要check時間
                String newNode = createNode(curator);
                zk_AddressNode = newNode;
                String[] nodeKey = newNode.split("-");
                // 獲取到zk分配的id
                workerID = Integer.parseInt(nodeKey[1]);
                doService(curator);
                updateLocalWorkerID(workerID);
                LOGGER.info("[New NODE] can not find node on forever node that endpoint ip-{} port-{} workid-{},create own node on forever node and start SUCCESS ", ip, port, workerID);
            }
        }
    } catch (Exception e) {
        // 5. 如果啓動出錯,則讀取本地緩存的workerID.properties文件中的workId
        LOGGER.error("Start node ERROR {}", e);
        try {
            Properties properties = new Properties();
            properties.load(new FileInputStream(new File(PROP_PATH.replace("{port}", port + ""))));
            workerID = Integer.valueOf(properties.getProperty("workerID"));
            LOGGER.warn("START FAILED ,use local node file properties workerID-{}", workerID);
        } catch (Exception e1) {
            LOGGER.error("Read file error ", e1);
            return false;
        }
    }
    return true;
}

這一段邏輯可以說是非常的核心,其中的註釋基本也達到每行都進行了極爲詳盡的註釋。初始化方法邏輯主要包括:

  1. 創建zk客戶端連接會話並啓動客戶端
  2. 檢查/snowflake/${leaf.name}/forever根節點是否存在
  3. 不存在則創建根節點,獲取zk分配的workerId (其實分配的肯定是0),並寫入本地文件
  4. 存在則查詢到持久節點下屬於自己的節點,得到zk分配的workerId,更新本地文件,校驗是否時鐘回撥
  5. 如果啓動失敗,就從本地文件中讀取,弱依賴zk。

爲了方便理解,我們簡單的繪製出ZooKeeper的節點目錄。假設我們一共部署了三臺Leaf服務用於生成snowflake模式的分佈式id,那麼zookeeper的節點目錄如下。其中 ${leaf.name} 是我們在配置文件中配置的字符串。圖中的都是持久節點,橙色的是持久自動編號節點。節點名稱的定義規則就是Leaf應用的 本機ip地址 + ‘:’ + 端口號 + ‘-’ + 自動編號 構成。那麼其實每有一個Leaf服務器上線註冊到ZooKeeper,自動編號便會加1,這樣自動編號就可以作爲這臺Leaf的 workerId。基於ZooKeeper的分配 workerId 原理就是這樣。
leaf-snowflake模式下zookeeper節點示意圖
如下是真實測試過程中查詢的zk節點:
查看zk的節點
對應於官方博客中提及的根節點 leaf-forever 應該就是指代實際的 /snowflake/${leaf.name}/forever 節點。

整個 init() 方法的邏輯可以用如下的流程圖表示:
初始化方法邏輯
具體的流程可參考代碼詳細閱讀,並不是很難。這裏需要提及的是,官方博客介紹時還涉及到臨時節點 leaf-temporary 相關邏輯,但是源碼中好像並未涉及,具體問題已經在官方倉庫提交問題,參考:https://github.com/Meituan-Dianping/Leaf/issues/40

獲取分佈式id

經過 SnowflakeService 的構造函數,我們已經從ZooKeeper或者本地文件成功獲取到本機的 workerId,接下來就可以利用獲取到的 workerId 按照snowflake算法拼裝出64位的id。

我們從 SnowflakeServiceget() 方法入手。首先,我們注意到該方法是一個 SnowflakeIDGenImpl#synchronized 修飾的同步方法,確保線程安全。

/**
 * 根據key獲取id
 * 這是一個synchronized同步方法,確保原子性,所以sequence就是普通類型的變量值
 * @param key 業務key
 * @return
 */
@Override
public synchronized Result get(String key) {
    /**
     * 生成id號需要的時間戳和序列號
     * 1. 時間戳要求大於等於上一次用的時間戳 (這裏主要解決機器工作時NTP時間回退問題)
     * 2. 序列號在時間戳相等的情況下要遞增,大於的情況下回到起點
     */

    // 獲取當前時間戳,timestamp用於記錄生成id的時間戳
    long timestamp = timeGen();

    // 如果比上一次記錄的時間戳早,也就是NTP造成時間回退了
    if (timestamp < lastTimestamp) {
        long offset = lastTimestamp - timestamp;
        // 如果相差小於5
        if (offset <= 5) {
            try {
                // 等待 2*offset ms就可以喚醒重新嘗試獲取鎖繼續執行
                wait(offset << 1);
                // 重新獲取當前時間戳,理論上這次應該比上一次記錄的時間戳遲了
                timestamp = timeGen();
                // 如果還是早,這絕對有問題的
                if (timestamp < lastTimestamp) {
                    return new Result(-1, Status.EXCEPTION);
                }
            } catch (InterruptedException e) {
                LOGGER.error("wait interrupted");
                return new Result(-2, Status.EXCEPTION);
            }
        }
        // 如果差的比較大,直接返回異常
        else {
            return new Result(-3, Status.EXCEPTION);
        }
    }

    // 如果從上一個邏輯分支產生的timestamp仍然和lastTimestamp相等
    if (lastTimestamp == timestamp) {
        // 自增序列+1然後取後12位的值
        sequence = (sequence + 1) & sequenceMask;
        // seq 爲0的時候表示當前毫秒12位自增序列用完了,應該用下一毫秒時間來區別,否則就重複了
        if (sequence == 0) {
            // 對seq做隨機作爲起始
            sequence = RANDOM.nextInt(100);
            // 生成比lastTimestamp滯後的時間戳,這裏不進行wait,因爲很快就能獲得滯後的毫秒數
            timestamp = tilNextMillis(lastTimestamp);
        }
    } else {
        // 如果是新的ms開始,序列號要重新回到大致的起點
        sequence = RANDOM.nextInt(100);
    }
    // 記錄這次請求id的時間戳,用於下一個請求進行比較
    lastTimestamp = timestamp;

    /**
     * 利用生成的時間戳、序列號和workID組合成id
     */
    long id = ((timestamp - twepoch) << timestampLeftShift) | (workerId << workerIdShift) | sequence;
    return new Result(id, Status.SUCCESS);
}

方法邏輯主要包含兩部分:

  1. 先確定當前的時間戳和自增序列號;
  2. 利用第一步確定好的時間戳、自增序列以及workerId最終拼裝出id。

① 生成時間戳和序列號

時間戳的生成很簡單,就是調用 timeGen() 函數返回系統當前時間戳。

/**
 * 生成時間戳
 * @return
 */
protected long timeGen() {
    return System.currentTimeMillis();
}

併發訪問情況下,很可能同一時間戳下需要下發很多id,此時需要通過自增序列號來進行區分不同的id。如果當前時間戳的所有id全部下發完畢不夠用時,需要調用 tilNextMillis(lastTimestamp) 得到下一個時間戳,重新下發新的id。

/**
 * 自旋生成直到比lastTimestamp之後的當前時間戳
 * @param lastTimestamp
 * @return
 */
protected long tilNextMillis(long lastTimestamp) {
    long timestamp = timeGen();
    while (timestamp <= lastTimestamp) {
        timestamp = timeGen();
    }
    return timestamp;
}

注意,使用新的時間戳時,需要將自增的序列號“清零”。但實際並沒有直接賦值爲0,而是取的是0到100的隨機數 sequence = RANDOM.nextInt(100)官方Issue的解釋如下:

主要是出於BD分表均勻考慮

至於什麼是BD分表,還是寫錯了其實是DB分表,可能最後的自增序列影響到了分表策略吧 ?

理論上這是一個同步方法,也就是多線程併發獲取id變爲了順序獲取的方式,是不會出現當前時間 timestamp 小於 lastTimestamp 的。官方博客給出的解釋是,機器在運行時也可能會進行NTP時間同步,NTP時間同步是指利用網絡時間同步協議(NTP)來同步網絡中各個計算機的時間。所以對於本機器節點而言,可能因此發生時間回退,造成 timestamp 小於 lastTimestamp 的小的情況。對於這種情況的出現,如果回退的時間比較大,那麼直接報錯;如果回退時間叫小,則線程等待一會等到時間追上再繼續服務。

② 拼裝id

/**
 *利用生成的時間戳、序列號和workID組合成id
 */
long id = ((timestamp - twepoch) << timestampLeftShift) | (workerId << workerIdShift) | sequence;
return new Result(id, Status.SUCCESS);

查看用到的幾個變量值分別是什麼:

/**
 * 起始時間戳,用於用當前時間戳減去這個時間戳,算出偏移量
 */
private final long twepoch = 1288834974657L;
/**
 * workID佔用的比特數
 */
private final long workerIdBits = 10L;
/**
 * 最大能夠分配的workerid =1023
 */
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
/**
 * 自增序列號
 */
private final long sequenceBits = 12L;
/**
 * workID左移位數爲自增序列號的位數
 */
private final long workerIdShift = sequenceBits;
/**
 * 時間戳的左移位數爲 自增序列號的位數+workID的位數
 */
private final long timestampLeftShift = sequenceBits + workerIdBits;
/**
 * 後12位都爲1
 */
private final long sequenceMask = -1L ^ (-1L << sequenceBits);

主要是利用位數,進行位運算得到結果。

總結

美團Leaf的號段模式與snowflake模式都可以用於生成分佈式唯一id,其優缺點官方博客也都進行了詳細的介紹。有關兩種模式詳細的源碼分析可以參考博客,推薦有興趣的朋友直接看我詳盡註釋版的源碼,倉庫地址在文章開始之處。如果發現錯誤,可以博客評論或者Github提交問題,一起討論。

請參考代碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章