MongoDB 系列 - ObjectId-- 是如何實現的 “千萬級” 分佈式唯一 ID?

本文開始,先提個問題:“MongoDB ObjectId() 生成的 id 是唯一的嗎?”,答案在文中。

談起分佈式 ID,經常會聊到的一些方案是使用 Twitter 的 Snowflake 算法、UUID、數據庫自增 ID 等。前些時間看了下 MongoDB ObjectId() 的實現原理,也不失爲一種好的實現思路,正如標題所描述的,本文會給大家分享下在 MongoDB 中是如何實現的 “千萬級” 分佈式唯一 ID。

MongoDB 一開始的設計就是用來做爲分佈式數據庫,插入數據時默認使用 _id 做爲主鍵,下面這個 _id 就是 MongoDB 中開源的分佈式系統 ID 算法ObjectId()生成的。

new ObjectId("632c6d93d65f74baeb22a2c9")

關於其組成需要指出一個誤區,網上很多介紹 MongoDB ObjectId() 的文章,都有這樣一段描述:

// 過時的規則,現在已經不用 機器標識 + 進程號
// 一種猜測,現在大多應用容器化,在容器內有獨立的進程空間,它的進程號永遠可能都爲 1,還有創建幾臺虛擬機,其中的 hostname 可能也都爲 localhost
4 字節的時間戳 + 3 個字節機器標識碼 + 2 個字節進程號 + 3 個字節自增數

很長一段時間我也一直這樣認爲,直到前些時間看了源碼之後,發現中間的 3 個字節機器標識碼 + 2 個字節進程號已被替換爲 5 個字節的進程唯一標識,之後翻閱了 MongoDB 官方文檔 描述也確實如此。

// 當前 ObjectId 實現規則
4 字節的時間戳(單位:秒) + 5 個字節的進程唯一標識 + 3 個字節自增數

這個組成規則反映出幾個問題:

實現自定義 UniqueId()

下面讓我們開始實踐,參考 源碼 https://github.com/mongodb/js-bson/blob/HEAD/src/objectid.ts 寫一個最簡化的 ObjectId(),真正理解它的實現原理。編程語言爲 JavaScript,運行環境 Node.js。

實現會用到一些 Node.js 的系統模塊 API 和運算符,每一步都會對用到的知識做一個講解。

初始化

按照它的組成規則,分步實現,首先,創建一個自定義的類,這裏我命名爲 UniqueId,並初始化一個  12 Byte 的 Buffer。

Buffer 是 Node.js 中的一個系統模塊,Buffer.alloc() 按照指定字節數創建一段連續的內存空間,用來處理二進制數據,默認使用 0 進行填充,也可以指定字符進行填充,參見 API Buffer.alloc(size[, fill[, encoding]])。

const kId = Symbol('id');
class UniqueId {
  constructor() {
    this[kId] = UniqueId.generate()
  }
  get id() {
    return this[kId];
  }
  static generate() {
    const buffer = Buffer.alloc(12);
    return buffer;
  }
}

運行之後輸出一個 0 填充的 12 Byte 的 buffer。

(new UniqueId()).id -> <Buffer 00 00 00 00 00 00 00 00 00 00 00 00>

4 Byte 時間戳

Date.now() 獲取當前時間毫秒數,除以 1000 精確到秒,通過 Math.floor() 函數向下取整,取到一個整數。

buffer.writeUInt32BE() 將一個無符號的 32 位整數以高位優先(大端寫入)方式寫入到 buffer 中,32 位在這裏佔用的是 4 Byte,offset 設置爲 0(默認 offset 就是 0),將時間戳寫入到 buffer 的前 4 個字節。

const kId = Symbol('id');
class UniqueId {
  constructor() {
    this[kId] = UniqueId.generate()
  }
  get id() {
    return this[kId];
  }
  static generate() {
    const buffer = Buffer.alloc(12);
    // 4-byte timestamp
+    const time = Math.floor(Date.now() / 1000);
+    buffer.writeUInt32BE(time, 0);
+    return buffer;
  }
}

運行之後可以看到 buffer 的前 4 個字節已被填充,對 Node.js Buffer 模塊不太瞭解的,看到這個結果又迷惑了,buffer 裏面存儲的既不是二進制也不是十進制,到底是啥?

(new UniqueId()).id -> <Buffer 63 2e 90 c0 00 00 00 00 00 00 00 00>

Node.js 中的 buffer 是用來處理二進制數據的,例如下面的 “2e” 二進制爲 00101110,那麼二進制方式在用戶這一側看起來顯然不是很方便,Node.js buffer 中我們所看到的其實是內存實際存儲的值,轉換爲了十六進制表示(00 ~ ff)

記住一點:“計算機底層使用的二進制,如果是用來展示通常是 10 進制,編程用的時候會採用 16 進制,內存地址編碼使用的就是 16 進制。” 內存管理這塊想了解更多可參考這篇文章 爲什麼遞歸會造成棧溢出?探索程序的內存管理!https://github.com/qufei1993/blog/issues/44

如果想取到存進去的時間戳,使用 buffer.readUInt32BE(offset) 方法,默認 offset 爲 0,從 0 位開始讀取前 4 Byte。

5 Byte 進程唯一標識

中間 5 Byte 沒有規定實現方式,保證進程唯一就好,使用 Node.js  系統模塊 crypto 提供的 randomBytes() 方法生成一個長度爲 5 的隨機字節。

+ const crypto = require('crypto');let PROCESS_UNIQUE = null;
const kId = Symbol('id');
class UniqueId {
  constructor() {
    this[kId] = UniqueId.generate()
  }
  get id() {
    return this[kId];
  }
  static generate() {
    const buffer = Buffer.alloc(12);
    // 4-byte timestamp
    const time = Math.floor(Date.now() / 1000);
    buffer.writeUInt32BE(time, 0);
+    // 5-byte process unique
+    if (PROCESS_UNIQUE === null) {
+      PROCESS_UNIQUE = crypto.randomBytes(5);
+    }
+    buffer[4] = PROCESS_UNIQUE[0];
+    buffer[5] = PROCESS_UNIQUE[1];
+    buffer[6] = PROCESS_UNIQUE[2];
+    buffer[7] = PROCESS_UNIQUE[3];
+    buffer[8] = PROCESS_UNIQUE[4];
    return buffer;
  }
}

3 Byte 自增數

最後 3 Byte 爲自增數,是關鍵的一部分,在 1 秒鐘內、進程標識唯一的情況下,一個 ObjectId() 能生成多少個不重複的 ID,由這 3 Byte 決定。

自增數不是簡單的理解爲 0、1、2...  這樣依次生成的,實現步驟爲:

16777215 二進制表示:11111111 11111111 11111111
255(0xff)二進制表示: 00000000 00000000 11111111
與運算結果:      00000000 00000000 11111111
# 與運算是都爲 1 則爲 1,這裏的結果最大是不會超過 255 的
const crypto = require('crypto');
let PROCESS_UNIQUE = null;
const kId = Symbol('id');
class UniqueId {
+ static index = Math.floor(Math.random() * 0xffffff);
  constructor() {
    this[kId] = UniqueId.generate()
  }
  get id() {
    return this[kId];
  }
+ static getInc() {
+  return (UniqueId.index = (UniqueId.index + 1) % 0xffffff);}
  static generate() {
    const buffer = Buffer.alloc(12);
    // 4-byte timestamp
    const time = Math.floor(Date.now() / 1000);
    buffer.writeUInt32BE(time, 0);
    // 5-byte process unique
    if (PROCESS_UNIQUE === null) {
      PROCESS_UNIQUE = crypto.randomBytes(5);
    }
    buffer[4] = PROCESS_UNIQUE[0];
    buffer[5] = PROCESS_UNIQUE[1];
    buffer[6] = PROCESS_UNIQUE[2];
    buffer[7] = PROCESS_UNIQUE[3];
    buffer[8] = PROCESS_UNIQUE[4];
+   // 3-byte counter
+   const inc = UniqueId.getInc();
+   buffer[11] = inc & 0xff;
+   buffer[10] = (inc >> 8) & 0xff;
+   buffer[9] = (inc >> 16) & 0xff;
+   return buffer;
  }
}

以下爲最終的生成結果,可以看到每個字節都被 1 個 16 進制數所填充。

(new UniqueId()).id -> <Buffer 63 33 01 c2 55 58 38 cf e0 be 75 46>

總結

本文從理論到實踐,實現了一個自定義的 UniqueId(),這是一個最簡化的 MongoDB ObjectId() 實現,代碼量也不多,感興趣的可以自己實現一遍,加深理解。

文章開頭提到了一個問題 “MongoDB ObjectId() 生成的 id 是唯一的嗎?” 答案即是 Yes 也是 No,在 1 秒鐘內且進程唯一標識不重複的情況下,根據後 3 Byte 自增數可以得到生成的最大不重複 id 爲 2^24 - 1 = 16777215 個唯一 ID。

最後,留一個問題,爲什麼 MongoDB ObjectId() 可以不用 new 就能生成一個 ID 呢?並且顯示的結果和上面自定義的 UniqueId() 也不一樣,關於 MongoDB ObjectId() 還有很多玩法,下一篇介紹。

console.log(ObjectId());     // 原生 ObjectId 輸出結果:new ObjectId("633304ee48d18c808c6bb23a")
console.log(new UniqueId()); // 自定義 UniqueId 輸出結果:UniqueId { [Symbol(id)]: <Buffer 63 33 04 ee f0 b2 b8 1f c3 15 53 2c> }
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/URuY22b8Hhmjx8iq4LWr1Q