当前位置: 首页 > 科技观察

MongoDB系列——ObjectId()是如何实现“千万级”分布式唯一ID的?

时间:2023-03-12 19:01:27 科技观察

说到分布式ID,经常被讨论的一些方案是使用Twitter的Snowflake算法,UUID,数据库自增ID。前段时间看了MongoDBObjectId()的实现原理,也是一个不错的实现思路。如标题所述,本文将与大家分享如何在MongoDBID中实现“千万级”分布式唯一。MongoDB最初被设计为分布式数据库。默认情况下,插入数据时使用_id作为主键。下面的_id是由MongoDB中的开源分布式系统ID算法ObjectId()生成的。newObjectId("632c6d93d65f74baeb22a2c9")需要指出对其构成的误解。网上很多介绍MongoDBObjectId()的文章都有这样的描述:4字节时间戳+3字节机器识别码+2字节进程号+3字节自增数我一直这么想很久,直到前段时间看了源码,发现中间3字节的机器识别码+2字节的进程号换成了5字节的进程唯一标识,看了MongoDB官方文档说明,确实是一样的.4字节时间戳(单位:秒)+5字节进程唯一标识+3字节自增数。这个组合规则反映了几个问题:因为前4个字节使用了时间戳,以“秒”为单位结束,一般是递增的,这就是为什么我们有时可以用_id代替创建时间作为排序规则的依据。另外一个问题,如果使用_id作为时间过滤条件怎么办?中间5个字节的随机值是进程的唯一标识,只需要在进程启动后生成一次。说说ObjectId()在某些限定条件下的“唯一性”。最后3个字节为自增数,1个字节等于8位。1秒之内,可以生成Math.pow(2,24)-1=16777215个唯一ID,所以我在文章开头用了“几千万”的描述,这就够了,几乎不可能目前突破这个限制。实现自定义的UniqueId()下面开始实践,参考源码https://img.ydisp.cn/news/20221009/iyhgy0uyf43.ts写一个简化的ObjectId(),真正理解它的实现原理。编程语言为JavaScript,运行环境为Node.js。实现中会用到一些Node.js系统模块API和算子,每一步都会讲解所用到的知识。初始化是根据它的组合规则一步步实现的。首先,创建一个自定义类,这里我将其命名为UniqueId,并初始化一个12ByteBuffer。Buffer是Node.js中的一个系统模块。Buffer.alloc()根据指定的字节数创建一个连续的内存空间来处理二进制数据。默认填0,也可以填指定字符。请参见API缓冲区.alloc(size[,fill[,encoding]])。constkId=Symbol('id');classUniqueId{constructor(){this[kId]=UniqueId.generate()}getid(){返回这个[kId];}staticgenerate(){constbuffer=缓冲区。分配(12);返回缓冲区;}}运行后输出一个0填充的12Byte缓冲区。(newUniqueId()).id->4ByteTimestampDate.now()获取当前时间的毫秒数,除以1000精确到秒,通过数学。floor()函数向下舍入为整数。buffer.writeUInt32BE()将一个无符号的32位整数写入具有高优先级的缓冲区(大端写入)。这里32位占用4个Bytes,偏移量设置为0(默认偏移量为0)。将时间戳写入缓冲区的前4个字节。constkId=Symbol('id');classUniqueId{constructor(){this[kId]=UniqueId.generate()}getid(){返回这个[kId];}staticgenerate(){constbuffer=缓冲区。分配(12);+consttime=Math.floor(Date.now()/1000);+buffer.writeUInt32BE(time,0);+返回缓冲区;}}运行后可以看到buffer的前4个字节已经被填满了。我对Node.jsBuffer模块了解不多。看到这个结果,我又糊涂了。缓冲区中存储的既不是二进制也不是十进制。它是什么?(newUniqueId()).id->Node.js中的buffer是用来处理二进制数据的,例如下面“2e”的二进制值是00101110,那么二进制的方式在用户端显然不是很方便。我们在Node.js缓冲区中看到的实际上是内存中实际存储的值,它被转换成十六进制表示(00~ff)。记住一件事:“计算机底层用的二进制,如果用来显示,一般都是十进制,编程用十六进制,内存地址编码用十六进制”。我想了解更多关于内存管理的知识。你可以参考这篇文章。为什么递归会导致栈溢出?探索程序内存管理!https://github.com/qufei1993/blog/issues/44如果想获取存储的时间戳,使用buffer.readUInt32BE(offset)方法,默认offset为0,从0开始读取前4个Bytes。5Byte进程唯一标识中间的5Byte。没有具体的实现方法,只是保证过程是唯一的。使用Node.js系统模块crypto提供的randomBytes()方法生成一个长度为5的随机字节。+constcrypto=require('crypto');+letPROCESS_UNIQUE=null;constkId=Symbol('id');classUniqueId{构造函数(){this[kId]=UniqueId.generate()}getid(){返回这个[kId];}staticgenerate(){constbuffer=Buffer.alloc(12);consttime=Math.floor(Date.now()/1000);buffer.writeUInt32BE(time,0);++if(PROCESS_UNIQUE===null){+PROCESS_UNIQUE=crypto.randomBytes(5);+}+buffer[4]=PROCESS_UNIQUE[0];+buffer[5]=PROCESS_UNIQUE[1];+缓冲区[6]=PROCESS_UNIQUE[2];+缓冲区[7]=PROCESS_UNIQUE[3];+缓冲区[8]=PROCESS_UNIQUE[4];秒级,当进程ID唯一时,一个ObjectId()能产生多少个唯一ID,就是由这3个Byte决定的。自增数并不是简单的理解为0、1、2……,是顺序生成的。实现步骤为:Math.random()*0xffffff首先生成一个3Byte的随机数作为初始值(这也增加了重复的概率),声明在类的static属性上(相当于UniqueId.index=Math.random()*0xffffff,0xffffff是一个十六进制数,相当于十进制的16777215。每次调用getInc()时初始随机数+1,作为当前随机自增数inc,做完取余运算,这个自增数绝对不会大于16777215.缓冲区中的每个字节都用十六进制表示,一个字节等于8位。用二进制表示的最大数是11111111,转成十六进制是0xff,转成十进制是255。现在我们知道缓冲区中一个字节表示的十进制不能大于255。如果我们要实现一个字节存储的数不能大于255,一种实现是做二进制AND运算。本文也使用了这种方法。例如AND运算的例子:16777215Binaryrepresentation:111111111111111111111111255(0xff)Binaryrepresentation:000000000000000011111111ANDoperationresult:000000000000000011111111在我们目前的实现中,andoperationinrandomincrementand0xffc相当于将inc的最右边8位以二进制方式赋给buffer的最后一个字节(buffer[11]=inc&0xff),同理将inc右移8位与0xff进行AND运算后赋值buffer[10],将inc右移16位,与0xff进行AND运算赋值给buffer[9]。constcrypto=require('crypto');letPROCESS_UNIQUE=null;constkId=Symbol('id');classUniqueId{+staticindex=Math.floor(Math.random()*0xffffff);constructor(){this[kId]=UniqueId.generate()}getid(){returnthis[kId];}}+staticgetInc(){+return(UniqueId.index=(UniqueId.index+1)%0xffffff);+}staticgenerate(){constbuffer=Buffer.alloc(12);//4字节时间戳consttime=Math.floor(Date.now()/1000);buffer.writeUInt32BE(时间,0);//5字节进程唯一if(PROCESS_UNIQUE===null){PROCESS_UNIQUE=crypto.randomBytes(5);}缓冲区[4]=PROCESS_UNIQUE[0];缓冲区[5]=PROCESS_UNIQUE[1];缓冲区[6]=PROCESS_UNIQUE[2];缓冲区[7]=PROCESS_UNIQUE[3];buffer[8]=PROCESS_UNIQUE[4];+//3字节计数器+constinc=UniqueId.getInc();+buffer[11]=inc&0xff;+buffer[10]=(inc>>8)&0xff;+buffer[9]=(inc>>16)&0xff;+returnb受苦;}}下面是最终生成的结果,可以看到每个字节都填充了一个十六进制数(newUniqueId()).id->SummaryFromtheory为了实践,本文实现了一个自定义的UniqueId(),这是MongoDBObjectId()最简化的实现,代码量少。有兴趣的可以自己实现一下,加深理解。文章开头有个问题“MongoDBObjectId()生成的id是否唯一?”答案既是Yes又是No。在1??秒内且进程唯一标识不重复,根据最后3个字节的自增数可以生成最大唯一标识为2^24-1=16777215个唯一标识。最后留个问题,为什么MongoDB的ObjectId()不用new就可以生成ID呢?而且显示的结果和上面自定义的UniqueId()不一样。MongoDBObjectId()的玩法还是很多的,下一篇会介绍。console.log(ObjectId());//原生ObjectId的输出:newObjectId("633304ee48d18c808c6bb23a")console.log(newUniqueId());//自定义UniqueId的输出:UniqueId{[Symbol(id)]: