Mongo ObjectId 早就不用機器標識和進程號了

  • 2019 年 10 月 4 日
  • 筆記

原文地址: https://blog.wolfogre.com/posts/mongo-objectid-design/

關於 ObjectId 這裡就不費舌介紹了,使用過 MongoDB 的同學都應該了解,它是一種標識全局唯一編號的數據類型,由 12 個位元組組成,常用來做 mongo 數據記錄的主鍵,它格式化後輸出長這樣:

ObjectId("507f1f77bcf86cd799439011")

網上有很多介紹 ObjectId 的文章,且在介紹這 12 個位元組的含義時,常放出這張圖:

圖中的意思很明顯,ObjectId 的組成是:

  • 4 個位元組表示時間戳;
  • 3 個位元組表示機器標識碼;
  • 2 個位元組表示進程號;
  • 3 個位元組表示一個自增的數。

很長一段時間裡,這些都是作為知識存儲在我的記憶里的,此外,我也在 mongo 的民間 golang 驅動里看到過相應的程式碼實現:

// NewObjectId returns a new unique ObjectId.func NewObjectId() ObjectId {	var b [12]byte  	// Timestamp, 4 bytes, big endian  	binary.BigEndian.PutUint32(b[:], uint32(time.Now().Unix()))	// Machine, first 3 bytes of md5(hostname)  	b[4] = machineId[0]  	b[5] = machineId[1]  	b[6] = machineId[2]	// Pid, 2 bytes, specs don't specify endianness, but we use big endian.  	b[7] = byte(processId >> 8)  	b[8] = byte(processId)	// Increment, 3 bytes, big endian  	i := atomic.AddUint32(&objectIdCounter, 1)  	b[9] = byte(i >> 16)  	b[10] = byte(i >> 8)  	b[11] = byte(i)	return ObjectId(b[:])  }

本來一切都順理成章,然而,事情出現了變化。

今天我在看 mongo 的官方 golang 驅動時,發現了奇怪的地方,其中關於生成 ObjectId 的實現是這樣的:

var objectIDCounter = readRandomUint32()var processUnique = processUniqueBytes()// New generates a new ObjectID.func New() ObjectID {	var b [12]byte    	binary.BigEndian.PutUint32(b[0:4], uint32(time.Now().Unix()))	copy(b[4:9], processUnique[:])  	putUint24(b[9:12], atomic.AddUint32(&objectIDCounter, 1))	return b  }// ……func processUniqueBytes() [5]byte {	var b [5]byte  	_, err := io.ReadFull(rand.Reader, b[:])	if err != nil {		panic(fmt.Errorf("cannot initialize objectid package with crypto.rand.Reader: %v", err))  	}	return b  }

是的,你沒有看錯,原本用來存放機器標識碼和進程號的 5 個位元組,現在是被放進了 5 位元組的隨機數。由於官方驅動尚處在開發階段,目前還沒有發布正式版本,我原本以為這只是一個偷懶的寫法。可是當我再去翻看 mongo 官方文檔關於 ObjectId 的解釋時(此時版本為 4.0),卻看到了這個:

a 4-byte value representing the seconds since the Unix epoch, a 5-byte random value, and a 3-byte counter, starting with a random value.

文中明確說了中間的 5 位元組就是一個隨機值,而不是機器標識碼和進程號。

看到這裡,相信你也反應過來了:官方修改了 ObjectId 的設計。

是的,我向前翻看舊版本的文檔,終於在 3.2 版本的解釋中看到了熟悉的描述:

a 4-byte value representing the seconds since the Unix epoch, a 3-byte machine identifier, a 2-byte process id, and a 3-byte counter, starting with a random value.

所以,最終結論是:從 MongoDB 3.4 開始(最早發佈於 2016 年 12 月),ObjectId 的設計被修改了,中間 5 位元組的值由原先的「機器標識碼+進程號」改為單純隨機值。

很遺憾,我沒有找到關於這個修改的官方解釋,甚至連民間解釋也沒找到,網上能找到的所有關於 ObjectId 的文章都還停留在老版本的「機器標識碼+進程號」。

所以,這裡我主觀臆測一下修改 ObjectId 設計的原因,不供參考。

mongo 的 C++ 源碼中,設置 ObjectId 中間 5 個位元組的函數叫 setInstanceUnique,而在官方 golang 驅動中叫 processUnique,字面意思相近,都是說明這個值的作用是「區分不同進程實例」,而這個值具體怎麼實現並沒有什麼要求,所以,使用「機器標識+進程號」來拿區分不同進程實例是可以的,使用互無關聯的隨機數來拿區分不同進程實例也是可以的。

可想而知,「在同一秒內,兩個進程實例產生了相同的 5 位元組隨機數,且剛巧這時候兩個進程的自增計數器的值也是相同的」——這種情況發生的概率實在太低了,完全可以認為不可能發生,所以使用互無關聯的隨機數來拿區分不同進程實例是完全合乎需求的。

那問題來了,為什麼不繼續使用「機器標識+進程號」呢?主觀臆測開始。

問題就在於,機器標識和進程號一定就那麼可靠嗎,尤其在這個物理機鮮見,虛擬機、雲主機、容器橫行的時代?

先說機器標識碼,ObjectId 的機器標識碼是取系統 hostname 哈希值的前幾位,問題來了,想必在座的各位都有干過吧:準備了幾台虛擬機,hostname 都是默認的 localhost,誰都想著這玩意兒能有什麼用,還得刻意給不同機器起不同的 hostname?此外,hostname 在容器、雲主機里一般默認就是隨機數,也不會檢查同一集群里是否有 hostname 重名。

再說進程號,這個問題就更大了,要知道,容器內的進程擁有自己獨立的進程空間,在這個空間里只用它自己這一個進程(以及它的子進程),所以它的進程號永遠都是 1。也就是說,如果某個服務(既可以是 mongo 實例也可以是 mongo 客戶端)是使用容器部署的,無論部署多少個實例,在這個服務上生成的 ObjectId,第八第九個位元組恆為 0000 0001,相當於說這兩個位元組廢了。

綜上,與其使用一個固定值來「區分不同進程實例」,且這個固定值還是人類隨意設置或隨機生成的 hostname 加上一個可能恆為 1 的進程號,倒不如每次都隨機生成一個新值。

可見,這是平台層面的架構變動影響了應用層面的設計方案,隨著雲、容器的繼續發展,這樣的故事還會繼續上演。