Mongo ObjectId 早就不用机器标识和进程号了

关于 ObjectId 这里就不费舌介绍了,使用过 MongoDB 的同学都应该了解,它是一种标识全局唯一编号的数据类型,由 12 个字节组成,常用来做 mongo 数据记录的主键,它格式化后输出长这样:

ObjectId("507f1f77bcf86cd799439011")

网上有很多介绍 ObjectId 的文章,且在介绍这 12 个字节的含义时,常放出这张图:

image

图中的意思很明显,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 的进程号,倒不如每次都随机生成一个新值。

可见,这是平台层面的架构变动影响了应用层面的设计方案,随着云、容器的继续发展,这样的故事还会继续上演。