分佈式 id 實現方案,選 leaf 嗎?

本文介紹了分佈式 ID 的幾種實現方式,及其優缺點。最後深入聊聊美團開源的 Leaf 組件,展示了它的實現亮點。

一、引入

1.1 爲什麼需要分佈式 ID

以數據庫爲例,業務數據量不大時,單庫單表完全夠用,或者搞個主從同步、讀寫分離來提高性能。當業務迅速擴張,需要對數據庫進行分庫分表時,ID 生成就不能簡單依靠數據庫表主鍵自增了。因爲這時需要保證數據庫表 ID 全局唯一。

1.2 分佈式 ID 需要滿足的條件

二、分佈式 ID 的實現

2.1 UUID

UUID (Universally Unique IDentifier) 是一個 128 位數字的全球唯一標識,標準型式包含 32 個 16 進制數字,用連字號分爲五段,形式爲 8-4-4-4-12。它生成時用到了網卡地址(即 MAC address)、納秒級時間等。以 Java 語言爲例,生成 UUID 使用java.util.UUID即可,使用非常簡單。

import java.util.UUID;

public class UniqueId {
  public static void main(String[] args) {
    String uuid = UUID.randomUUID().toString();
    // 結果示例:2e55beed-6a65-47f8-b269-00b518d7da6a
    System.out.println(uuid);
    String uuid2 = uuid.replaceAll("-""");
    // 結果示例:2e55beed6a6547f8b26900b518d7da6a
    System.out.println(uuid2);
  }
}

優點:本地生成,性能非常高,使用簡單 缺點:

2.2 數據庫自增 ID

用一個專門的表生成自增 ID,提供給其他表使用。以 MySQL 爲例,創建下面的這張表,當需要一個 ID 時,向表中插入一條記錄返回主鍵id即可。

CREATE TABLE generate_id {
 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
 content CHAR(1) NOT NULL DEFAULT '' COMMENT '無實際意義',
 PRIMARY KEY (id),
} ENGINE=INNODB;

缺點是依賴於數據庫服務,存在單點故障,且性能瓶頸明顯。解決這個不足,通常有兩種方式:一是使用數據庫集羣;二是採用號段模式。

2.2.1 數據庫集羣

還是以 MySQL 爲例,我們可以搭建集羣,提高可用性。給各個節點的auto_increment設置不同的起始值自增步長。假設 MySQL 集羣有 3 個節點,可以做下面的設置,這樣每個節點都能生成唯一 ID。

-- 對節點1
SET @auto_increment_offset = 1; -- 起始值
SET @auto_increment_increment =3; -- 步長

-- 對節點2
SET @auto_increment_offset = 2;
SET @auto_increment_increment =3;

-- 對節點3
SET @auto_increment_offset = 3;
SET @auto_increment_increment =3;

2.2.2 號段模式

號段模式下,一次請求將從數據庫獲取一批自增 ID,減小訪庫次數,降低數據庫讀寫壓力。

CREATE TABLE `segment_id` (
  `biz_tag` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '業務類型',
  `max_id` BIGINT(20) NOT NULL DEFAULT '1' COMMENT '當前最大id',
  `step` INT(11) NOT NULL COMMENT '號段步長',
  `version` INT(20) NOT NULL COMMENT '版本號',
  `update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`biz_tag`)
) ENGINE=INNODB DEFAULT CHARSET=utf8;

比如使用下面的 SQL,當需要 ID 時,先發起查詢,然後更新 max_id,更新成功則表示獲取到新號段[max_id, max_id+step)

UPDATE segment_id SET max_id=max_id+step, VERSION = VERSION + 1 WHERE VERSION = #{version} and biz_tag = #{biz_tag};

2.3 基於 Redis 生成唯一 ID

利用 Redis 的 incr命令,實現 ID 的原子性自增。需注意 Redis 持久化對可靠性的影響。

下面是一個簡單實現。

import org.springframework.data.redis.core.StringRedisTemplate;

public class RedisIdWorker {
  private StringRedisTemplate stringRedisTemplate;

  public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
    this.stringRedisTemplate = stringRedisTemplate;
  }

  public long nextId(String bizTag) {
    return stringRedisTemplate.opsForValue().increment("id:" + bizTag);
  }
}

此外,我們還可以給 ID 加上毫秒級時間戳前綴,即使使用 RDB 持久化,Redis 故障時也不會出現 ID 重複。下面是不考慮時鐘回撥時的一個實現。

public class RedisIdWorker {
  // 開始時間戳
  private static final long BEGIN_TIMESTAMP = 1705221450434L;
  // id位數
  private static final int COUNT_BITS = 24;

  private StringRedisTemplate stringRedisTemplate;

  public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
    this.stringRedisTemplate = stringRedisTemplate;
  }

  public long nextId(String bizTag) {
    // 生成時間戳
    LocalDateTime now = LocalDateTime.now();
    long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
    long timestamp = nowSecond - BEGIN_TIMESTAMP;

    // 2.2.自增長
    long count = stringRedisTemplate.opsForValue().increment("id:" + bizTag);
    return timestamp << COUNT_BITS | count;
  }
}

2.4 雪花算法

雪花算法(SnowFlake)是 Twitter 公司採用並開源的一種算法,能在分佈式系統中產生全局唯一且趨勢遞增的 ID。規則如下:

理論上 snowflake 方案的 QPS 約爲 409.6w/s。網上有不少實現,不再贅述。優點:生成的 ID 趨勢遞增;本地生成且不依賴第三方系統,性能極高。缺點:強依賴於機器時鐘,如果發生時鐘回撥,將導致發號重複或服務不可用。

三、開源組件——美團 Leaf

Leaf 是美團基礎研發平臺推出的一個分佈式 ID 生成服務,具備高可靠、低延遲、全局唯一等特點,支持號段、雪花算法兩種模式。感興趣的小夥伴,可以訪問官網瞭解更多信息。

官網:tech.meituan.com/2019/03/07/… github:github.com/Meituan-Dia…

3.1 號段模式

整體結構如圖,強依賴於數據庫,使用前先創建表。

CREATE DATABASE leaf;
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;

核心接口com.sankuai.inf.leaf.IDGen有三個實現,號段模式實現是SegmentIDGenImpl,有兩大特點:

// key是biz_tag
private Map<String, SegmentBuffer> cache = new ConcurrentHashMap<String, SegmentBuffer>();

public boolean init() {
    logger.info("Init ...");
    // 首次加載所有biz_tag記錄,並更新緩存
    updateCacheFromDb();
    initOK = true;
    // 啓動定時任務,每分鐘更新緩存
    updateCacheFromDbAtEveryMinute();
    return initOK;
}

@Override
public Result get(final String key) {
    if (!initOK) {
        return new Result(EXCEPTION_ID_IDCACHE_INIT_FALSE, Status.EXCEPTION);
    }
    if (cache.containsKey(key)) {
        SegmentBuffer buffer = cache.get(key);
        // 雙重檢查
        if (!buffer.isInitOk()) {
            synchronized (buffer) {
                if (!buffer.isInitOk()) {
                    try {
                        // 當前線程親自加載第一個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);
                    }
                }
            }
        }
        // 獲取ID
        return getIdFromSegmentBuffer(cache.get(key));
    }
    return new Result(EXCEPTION_ID_KEY_NOT_EXISTS, Status.EXCEPTION);
}

3.1.1 雙 buffer

當獲取 ID 的併發很高時,如果在當前號段用完時,纔去數據庫獲取下一個號段,此時耗時將明顯增加。爲此,Leaf 採用了雙 Buffer 異步更新的策略,保證無論何時,都能有一個 Buffer 的號段可以正常對外提供服務

3.1.2 動態調整 Step

假設服務 QPS 爲 Q,號段長度爲 L,號段更新週期爲 T,那麼 Q * T = L。如果 L 固定不變,QPS 增長時,T 會越來越小,即更新庫表越來越頻繁,且 DB 故障時緩存的號段能夠支撐服務正常運行的時間更短了。所以,Leaf 每次更新號段的時候,會根據與上一次更新更新號段的間隔 T 和號段長度 step,來決定這次的號段長度 nextStep:

總之,QPS 越高,號段長度將變大;QPS 越低,號段長度將變小。(有上下限限制)

3.2 雪花算法

美團 Leaf 的雪花算法,ID 構成與前面提到的一致,機器號佔 10 個 bit。使用時需要的配置如下:

leaf.name=unique-id
leaf.snowflake.enable=true
leaf.snowflake.zk.address=192.168.43.105:2181
# 不是服務端口或zk端口,是Leaf在zk上註冊時的端口
leaf.snowflake.port=8870

現在,我們關注以下兩個方面,並從源碼中尋找答案:

3.2.1 workId 分配

當分佈式 ID 服務集羣節點數量較小時,完全可以手動配置 workId。Leaf 中使用 ZooKeeper 發號。代碼實現是com.sankuai.inf.leaf.snowflake.SnowflakeIDGenImpl

// SnowflakeZookeeperHolder中
public boolean init() {
    try {
        CuratorFramework curator = createWithOptions(connectionString, new RetryUntilElapsed(1000, 4), 10000, 6000);
        curator.start();
        Stat stat = curator.checkExists().forPath(PATH_FOREVER);
        if (stat == null) {
            //不存在根節點,機器第一次啓動,創建/snowflake/ip:port-000000000,並上傳數據
            zk_AddressNode = createNode(curator);
            // 將workerID保存到本地文件
            updateLocalWorkerID(workerID);
            //定時上報本機時間給forever節點
            ScheduledUploadData(curator, zk_AddressNode);
            return true;
        } else {
            // ......省略
        } catch(Exception e){
            LOGGER.error("Start node ERROR {}", e);
            try {
                // 異常時(包括zooeeperk故障)從本地文件加載workId
                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;
    }
}


可見,Leaf 依靠 zookeeper 爲各節點生成遞增的 workerId

{
  "ip" : "192.168.43.16",
  "port" : "8870",
  "timestamp" : 1705323905246
}

注意,所有 Leaf 實例,應該配置相同的 leaf.name、leaf.snowflake.zk.address,否則 Leaf 集羣中 workId 將出現重複。

3.2.2 應對時鐘回撥

啓動時檢查

在 Leaf 實例啓動後,會隔 3 秒更新 zookeeper 節點數據,上報當前服務器時間。該時間將被用於 Leaf 啓動時,與機器本地時間進行比較。

假設 Leaf 節點宕機需要重啓,此時將檢查機器本地時間,是否小於 zookeeper 節點保存的時間戳;如果是則說明發生了時鐘回撥,此時拋出異常、啓動失敗。

public boolean init() {
    // ......省略
    if (workerid != null) {
        //有自己的節點,zk_AddressNode=ip:port
        zk_AddressNode = PATH_FOREVER + "/" + realNode.get(listenAddress);
        workerID = workerid;
        // 比較本地時間與zk節點中的時間戳,如果本地時間更小,則拋出異常
        if (!checkInitTimeStamp(curator, zk_AddressNode)) {
            throw new CheckLastTimeException("init timestamp check error,forever node timestamp gt this node time");
        }
        // 啓動定時任務,每隔3秒上報本地時間
        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);
    }
    // ......省略
}

此外,我們在官網(https://tech.meituan.com/2017/04/21/mt-leaf.html)上,看到了下面這張圖。圖中圈出的部分,在源碼中並沒有找到對應實現。猜測,開源版本和美團真正使用的版本間可能存在差異

運行時檢查

Leaf 服務運行中,每生成一個 id,會先比較當前時間與上一個 id 的 timestamp;如果當前時間更小,說明發生了時鐘回撥。回撥小於 5 毫秒時,則計時等待兩倍的回撥時間;如果回撥超過 5 毫秒,則返回負數。

總結

雖然做了一些努力,但是 Leaf 並沒有完全解決時鐘回撥問題

我們看下面兩個場景:

好了,今天就分享到這裏。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/B9rc6Q4aVovIptCFDaWKog