起因
最近发现有的同事在查询mongo的时候,使用_id进行排序,来查找某个时间之后的数据有多少条。一直没有留意_id的生成方式,才知道原来16进制_id是有序的,所以记录研究一下
经过
从python包中找到bson/objectid.py找到ObjectId类,先阅读一下init注释
class ObjectId(object):
"""这部分代码先跳过
"""
def __init__(self, oid=None):
"""Initialize a new ObjectId.
An ObjectId is a 12-byte unique identifier consisting of:
- 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.
By default, ``ObjectId()`` creates a new unique identifier. The
optional parameter `oid` can be an :class:`ObjectId`, or any 12
:class:`bytes` or, in Python 2, any 12-character :class:`str`.
For example, the 12 bytes b'foo-bar-quux' do not follow the ObjectId
specification but they are acceptable input::
>>> ObjectId(b'foo-bar-quux')
ObjectId('666f6f2d6261722d71757578')
`oid` can also be a :class:`unicode` or :class:`str` of 24 hex digits::
>>> ObjectId('0123456789ab0123456789ab')
ObjectId('0123456789ab0123456789ab')
>>>
>>> # A u-prefixed unicode literal:
>>> ObjectId(u'0123456789ab0123456789ab')
ObjectId('0123456789ab0123456789ab')
Raises :class:`~bson.errors.InvalidId` if `oid` is not 12 bytes nor
24 hex digits, or :class:`TypeError` if `oid` is not an accepted type.
:Parameters:
- `oid` (optional): a valid ObjectId.
.. mongodoc:: objectids
"""
"""这部分代码也略过
"""
代码注释已经解释的很直白,我用一个mongo里面保存的_id来做示例:
ObjectId("606d9ffffa091ceb1a68988b")
-
ObjectId是一个12字节的唯一标识,其中包括4个部分:
-
一个4字节的值来标识unix time,精确到秒。所以对应到_id里就是16进制
606d9fff
,转换成10进制为
1617797119
,再转换成unix time就是
2021-04-07 20:05:19
- 由3个字节组成的机器标识(这个需要进一步阅读源码,所以放在后面说)
-
有2字节组成的进程号,对应的就是
eb1a
,转换为10进制,对应的进程号就是
60186
。
需要注意这个进程号是mongo client的进程号,例如用python调用pymongo写,
60186
对应的就是python的进程号,这里可以用ps命令加以验证。
如果python程序是通过其他命令例如shell脚本启动的,那么
60186
就会对应到父进程(shell)的pid。
另外可以通过该pid验证,多进程写的场景下数据是从哪个进程产生的,因为同个进程写入mongo时该部分的值是固定的,当然如果程序重启重新分配进程号,那就不一样了。 -
最后是由3个字节表示的计数器,是一个
大于某个随机值
的值,也是需要看代码,放在后面说
不过可以知道的是,如果使用类似bulk_write或者insert_many的批次操作,生成的_id的
时间戳
,
机器码
,
进程号
都会是一样的,唯一的区别在于最后这一部分
计数器
,这里可以使用一个简单的小程序批量写入mongo进行验证。
-
一个4字节的值来标识unix time,精确到秒。所以对应到_id里就是16进制
-
默认的,ObjectId生成了一个全新的唯一标识,init函数中的可选参数oid相当于可以让开发者自定义ObjectId,oid的类型可为ObjectId类,或者长度为12的bytes类,或者python2中长度为12字符的str类(实际上就是python3中的bytes)。注释中的示例
foo-bar-quux
就是一个长度为12的str,因此可以被转换成长度为24的16进制字符串 -
oid也可以为unicode类型,或者长度为24的16进制的字符串,反正无论是长度为12的str还是长度为24的16进制str,二者都是一致的,因为可以相互转换。
-
记得用b/u前缀来区分bytes还是unicode,因为涉及到encode和decode问题,容易出错。
这也是大部分博客中介绍的ObjectId的组成。
接下来开始阅读代码部分
class ObjectId(object):
"""这部分代码先跳过
"""
def __init__(self, oid=None):
""" 略过一长串注释
"""
if oid is None:
self.__generate()
elif isinstance(oid, bytes) and len(oid) == 12:
self.__id = oid
else:
self.__validate(oid)
这串代码很直白,重点在
__generate
方法中
def __generate(self):
"""Generate a new value for this ObjectId.
"""
# 4 bytes current time,生成时间戳逻辑
oid = struct.pack(">i", int(time.time()))
# 3 bytes machine,生成机器码逻辑
oid += ObjectId._machine_bytes
# 2 bytes pid,生成进程号逻辑
oid += struct.pack(">H", os.getpid() % 0xFFFF)
# 3 bytes inc,生成计数器逻辑
with ObjectId._inc_lock:
oid += struct.pack(">i", ObjectId._inc)[1:4]
ObjectId._inc = (ObjectId._inc + 1) % 0xFFFFFF
self.__id = oid
可以看到:
- 时间戳生成的时候获取了当前的时间,因为取了int所以精确到秒
-
机器码调用的是一个静态成员的值,该值来源于
_machine_bytes()
class ObjectId(object): """A MongoDB ObjectId. """ _inc = random.randint(0, 0xFFFFFF) _inc_lock = threading.Lock() _machine_bytes = _machine_bytes()
def _machine_bytes(): """Get the machine portion of an ObjectId. """ machine_hash = hashlib.md5() if PY3: # gethostname() returns a unicode string in python 3.x # while update() requires a byte string. machine_hash.update(socket.gethostname().encode()) else: # Calling encode() here will fail with non-ascii hostnames machine_hash.update(socket.gethostname()) return machine_hash.digest()[0:3]
代码也是很直白,使用hostname计算md5,取前三个字节。因此可以通过这个大致判定数据来源的机器是否为同一台(碰撞除外)。
- 接下来是进程号,通过getpid,余int最大值让其落在int范围中。
-
把代码往下搬一下
class ObjectId(object): """A MongoDB ObjectId. """ _inc = random.randint(0, 0xFFFFFF) _inc_lock = threading.Lock() """省略 """ def __generate(self): """省略 """ # 3 bytes inc with ObjectId._inc_lock: oid += struct.pack(">i", ObjectId._inc)[1:4] ObjectId._inc = (ObjectId._inc + 1) % 0xFFFFFF self.__id = oid
首先看到的是一个线程锁,这就意味着当多线程同时实例化ObjectId,生成_id的第四部分
计数器
的时候需要进行排队,同一时间只有一个计数器能够生成成功。
再看
_inc = random.randint(0, 0xFFFFFF)
,该变量声明在类中,属于类的静态成员,因此不管类实例化几次,持有的
_inc
都是同一份引用,这就保证了全局唯一性。同时每次生成_inc,_inc就自增1,这也是增加线程锁的目的,保证自增操作的原子性。
取余也是很直白,为了保证值不会溢出。
结果
简单的看了一眼代码,进行了总结,这里需要进一步讨论的:
-
ObjectId通过四个部分的数据(
时间戳
,
机器标识
,
进程号
,
计数器
)的组合来保证id的唯一性,即使时间戳,机器标识,进程号完全一样,计数器的随机性也会尽可能的避免冲突,所以通过默认方式生成的_id出现碰撞的可能性几乎没有。 -
ObjectId的值是在客户端就给出来的,尤其是
时间戳
属性,这就意味着每一台mongo client自己控制自己的机器时间,在服务端查询的时候通过全局_id来sort并不合理,因为每台机器的时间一方面不完全一致,另一方面例如一台机器的时间落后另一台1h,那么生成的时间戳也会相差一个小时,这个时候全局sort,就会有一部分数据丢失。
机器标识
,
进程号
的变化也可以对号入座。 - 在分布式环境中,尽量不要将默认的_id作为全局id,或是将其作为关键索引。