MongoDB ObjectId _id的默认生成方式,从pymongo源码解析

  • Post author:
  • Post category:其他




起因

最近发现有的同事在查询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个部分:

    1. 一个4字节的值来标识unix time,精确到秒。所以对应到_id里就是16进制

      606d9fff

      ,转换成10进制为

      1617797119

      ,再转换成unix time就是

      2021-04-07 20:05:19
    2. 由3个字节组成的机器标识(这个需要进一步阅读源码,所以放在后面说)
    3. 有2字节组成的进程号,对应的就是

      eb1a

      ,转换为10进制,对应的进程号就是

      60186



      需要注意这个进程号是mongo client的进程号,例如用python调用pymongo写,

      60186

      对应的就是python的进程号,这里可以用ps命令加以验证。

      如果python程序是通过其他命令例如shell脚本启动的,那么

      60186

      就会对应到父进程(shell)的pid。

      另外可以通过该pid验证,多进程写的场景下数据是从哪个进程产生的,因为同个进程写入mongo时该部分的值是固定的,当然如果程序重启重新分配进程号,那就不一样了。
    4. 最后是由3个字节表示的计数器,是一个

      大于某个随机值

      的值,也是需要看代码,放在后面说

      不过可以知道的是,如果使用类似bulk_write或者insert_many的批次操作,生成的_id的

      时间戳



      机器码



      进程号

      都会是一样的,唯一的区别在于最后这一部分

      计数器

      ,这里可以使用一个简单的小程序批量写入mongo进行验证。
  • 默认的,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

可以看到:

  1. 时间戳生成的时候获取了当前的时间,因为取了int所以精确到秒
  2. 机器码调用的是一个静态成员的值,该值来源于

    _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,取前三个字节。因此可以通过这个大致判定数据来源的机器是否为同一台(碰撞除外)。

  3. 接下来是进程号,通过getpid,余int最大值让其落在int范围中。
  4. 把代码往下搬一下

    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,这也是增加线程锁的目的,保证自增操作的原子性。

    取余也是很直白,为了保证值不会溢出。



结果

简单的看了一眼代码,进行了总结,这里需要进一步讨论的:

  1. ObjectId通过四个部分的数据(

    时间戳



    机器标识



    进程号



    计数器

    )的组合来保证id的唯一性,即使时间戳,机器标识,进程号完全一样,计数器的随机性也会尽可能的避免冲突,所以通过默认方式生成的_id出现碰撞的可能性几乎没有。
  2. ObjectId的值是在客户端就给出来的,尤其是

    时间戳

    属性,这就意味着每一台mongo client自己控制自己的机器时间,在服务端查询的时候通过全局_id来sort并不合理,因为每台机器的时间一方面不完全一致,另一方面例如一台机器的时间落后另一台1h,那么生成的时间戳也会相差一个小时,这个时候全局sort,就会有一部分数据丢失。

    机器标识



    进程号

    的变化也可以对号入座。
  3. 在分布式环境中,尽量不要将默认的_id作为全局id,或是将其作为关键索引。



版权声明:本文为ct2020129原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。