深蓝黑背景封面。左侧是一根横向的 12 字节布局条,按 4、5、3 字节切成三段:青色段标注「4 字节 · 秒级时间戳(Unix 秒)」,紫色段标注「5 字节 · 随机值(机器 + 进程)」,灰蓝段标注「3 字节 · 自增计数器(同秒并发)」。布局条下方三条性质说明:「时间戳打头 → 按 _id 排序 ≈ 按创建时间排序,索引追加写友好」「随机值区分机器与进程 → 多节点各自生成,无需中心协调」「24 位计数器 → 单进程每秒最多 1677 万个 ID 不重复」。顶部小字「12 字节 = 96 位,三段拼接,本地生成」。右侧大标题「MongoDB 的 ObjectID 里装了哪些信息」,副标题「12 个字节:时间、随机与并发的分工」,小注「分布式唯一、大致有序,还不靠中心发号器」。左上标注「面试经典 · 数据库」,右上圆角胶囊标注「Day 02 · 11-29」。 图 1:本文封面,12 字节三段布局与三条性质。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。「ObjectID 由哪些部分组成」表面是背结构,实际考的是你能否从 12 个字节的分工里推出它的两条核心性质:为什么分布式环境下能保证唯一,又为什么天然大致有序。这两条性质决定了它在真实业务里能干什么、不能干什么。

一句话结论:12 字节 = 4 字节秒级时间戳 + 5 字节随机值 + 3 字节自增计数器。

按 MongoDB 官方文档的定义,ObjectId 共 12 字节(96 位),由三段拼接而成。第一段是 4 字节时间戳,记录文档创建时刻距 Unix 纪元的秒数。第二段是 5 字节随机值,每个客户端进程在启动时随机生成一次,用于区分不同的机器与进程;进程重启或主节点切换时会重新生成。第三段是 3 字节自增计数器,进程内每生成一个 ID 就加 1,初始值也是随机的。还有一个容易被忽略的细节:时间戳和计数器按大端序存储(高位字节在前),这与 BSON 里其他数值类型的小端序相反——这个设计正是为了让字典序与时间序一致。

三段各解决一个问题,拼出两条性质。

时间戳打头解决「有序」:ID 的字典序约等于创建时间序,按 _id 排序就近似按时间排序,对索引来说新文档永远追加在末尾,写入友好。随机值解决「分布式」:不同机器、不同进程各自持有不同的随机段,生成 ID 完全在本地完成,不需要任何中心发号器或跨节点协调。计数器解决「同秒并发」:同一进程同一秒内生成的 ID 靠计数器拉开,3 字节即 24 位,上限 2²⁴ = 16,777,216,约每秒 1677 万个——单进程很难打满。三段合起来,就是「零协调成本换分布式唯一 + 大致有序」的经典取舍。

用代码把一个字节串拆开验证(Node.js 22.15.0,node objectid.js):

function parseObjectId(hex) {
  const ts = parseInt(hex.slice(0, 8), 16);        // 前 4 字节:Unix 秒(大端)
  const rand = hex.slice(8, 18);                   // 中 5 字节:随机值
  const cnt = parseInt(hex.slice(18, 24), 16);     // 后 3 字节:计数器
  return { unixSeconds: ts, random: rand, counter: cnt };
}
console.log(parseObjectId('507f1f77bcf86cd799439011'));
console.log('计数器单秒上限 =', (2 ** 24).toLocaleString('en-US'));
const t1 = 1800000000;                                // 任一 Unix 秒
const id1 = t1.toString(16).padStart(8, '0') + '8cd4e507b3' + '9a3b21';
const id2 = (t1 + 60).toString(16).padStart(8, '0') + '8cd4e507b3' + '9a3b21';
console.log('id1 =', id1, '\nid2 =', id2);
console.log('晚 60 秒生成的 ID 字典序更大:', id1 < id2);

实测输出:

{ unixSeconds: 1350508407, random: 'bcf86cd799', counter: 4427793 }
计数器单秒上限 = 16,777,216
id1 = 6b49d2008cd4e507b39a3b21
id2 = 6b49d23c8cd4e507b39a3b21
晚 60 秒生成的 ID 字典序更大: true

随手拿来一个十六进制串,前 8 位翻成十进制就是创建时刻的 Unix 秒数、中间 10 位是随机段、最后 6 位是计数器,结构一目了然;两个相差 60 秒的 ID 直接比较字典序就分出了先后。

深蓝黑背景概念图,顶部小字「概念图 · ObjectID 位布局」,主标题「96 位的分工:前 4 字节定顺序,后 8 字节保唯一」。顶部是一根带十六进制示例的 96 位布局条,按 4、5、3 字节分段:青色段示例「65 f0 3a 12」标注「时间戳 · 32 位」,紫色段示例「8c d4 e5 07 b3」标注「随机值 · 40 位」,灰蓝段示例「9a 3b 21」标注「计数器 · 24 位」。中部三列卡片逐段拆解:时间戳列写「记录文档创建的 Unix 秒」「打头 ⇒ 字典序 ≈ 时间序」「支持按时间做范围查询」;随机值列写「进程启动时随机生成一次」「区分不同机器与进程」「多节点并行生成不冲突」;计数器列写「进程内每生成一个 ID 加 1」「同秒上限 1677 万个」「随机起点,避免被猜测」。下部是一条性质推导链,四个芯片依次为「三段拼接」「本地生成 · 无中心协调」「分布式唯一」「时间戳在前 ⇒ 大致有序」,芯片之间用箭头连接。底部总结条:「取舍:单进程每秒 1677 万的上限够用,才换来零协调的最简实现;更高并发再考虑雪花 ID 等方案」。 图 2:位布局拆解与「唯一 + 有序」性质的推导链。

用对边界,避开三个坑。

第一,时间戳是秒级精度,同一秒内的大量文档只能靠计数器和随机段区分,排序在秒内有细微不确定性,别把 _id 当精确时间戳用,需要精确时间就该单独存字段。第二,「大致有序」依赖各节点时钟大致同步,时钟严重漂移的机器会造出乱序的 ID。第三,计数器上限是单进程每秒约 1677 万,真的触及这个量级的生成并发时,就该考虑雪花 ID 等更宽的方案——这属于主键选型的横向话题,本文不展开。AI 应用里这条知识很日常:会话记录、日志文档的默认主键就是 ObjectID,理解了有序性,才能放心用 _id 做按时间范围查询和「取上一页最后一条的 _id 向下翻页」这种游标分页。

面试现场的答题结构。

先报 4+5+3 的三段布局,再逐段讲职责:时间戳定顺序、随机值保分布、计数器兜同秒并发。然后主动推性质:本地生成、无中心协调,所以分布式唯一;时间戳打头加大端序,所以大致有序、索引追加友好。最后用一个十六进制示例现场拆段收尾,再补一句边界——秒级精度和每秒千万级的计数器上限。答到「这是用计数器上限换零协调实现的取舍」这一层,就把背结构升成了讲设计。你在业务里用 ObjectID 做过范围查询或分页吗?有没有踩过秒级精度的坑?欢迎聊聊。