手撸一个“低配版”MongoDB数据库:二、Wire协议介绍
简介
要想实现一个 MongoDB 的服务端,首先需要搞明白客户端与服务端间的底层通信机制。
与 Oracle、MySQL、PostgreSQL 等主流数据库一样,MongoDB 的服务端在与客户端在进行通讯时,也使用了一套自己设计的通讯协议。我们在开发各种基于 MongoDB 的数据库应用时,需要使用 MongoDB 官方或第三方提供的语言适配的 客户端 Driver SDK,通过该协议来与 MongoDB 的 Server端进行交互。MongoDB 官方将该协议命名为“Wire Protocol”,即 Wire协议。
从 MongoDB 诞生到现在的十多年时间里,Wire协议的基本框架一直比较稳定,基本没有什么变化,但协议内部的细节却随着 MongoDB 的逐步成熟,经历了多次较大规模的演进。
本文将对 Wire 协议的定义和演化过程做一个简单的剖析。
协议概述
首先可以看官方文档中对 Wire 协议的概述:
The MongoDB Wire Protocol is a simple socket-based, request-response style protocol. Clients communicate with the database server through a regular TCP/IP socket.
在这一段描述中,可以看到3个关键词:
socket-basedrequest-response styleregular TCP/IP socket
下面分别对这3个关键词的含义和协议的其他特性做一个简单的说明:
Socket-Based
Socket 中文翻译为套接字,它是操作系统中一组用于进程间通信的标准API。根据使用场景的不同,分为两类:
- Network Socket:网络套接字,用于网络中不同主机间通信,操作系统通过它将底层网络协议栈进行抽象和封装,再提供给上层应用程序使用;
- Unix Domain Socket:Unix域套接字,用于同一主机中的不同进程间通信,整个通信机制由系统内核控制和完成。
Socket 最早起源于 1983 年发布的 4.2 BSD Unix,被称为 Berkeley Socket,后来迅速成为网络通信编程的事实标准,并成为 POSIX 规范的标准组件之一。也就是说凡是使用 Socket API 开发的网络通信程序也就具备了较好的平台移植性,可以以非常低的成本在不同操作系统上进行编译和运行。

上图以 TCP/IP 协议族为例,展示了 Socket API在整个通信过程中所处的位置。

上图描述了使用 Socket API 进行通信的基本流程,从图中可以看到无论是客户端还是服务器进程,首先都需要调用一个socket() 函数,该函数用于在操作系统层面创建一个 socket 句柄,句柄创建成功后,就可以使用该句柄来与目标主机进行通信操作了。
以 Linux 系统为例子,其 socket() 函数的定义如下:
1 |
|
调用 socket() 函数时,通过参数指定不同的协议族(domain,例如:IPv4、IPv6、IPX、MPLS等)、套接字类型(type,例如:数据报、字节流等)以及具体的协议(protocol,例如:UDP、TCP等)。
简单说了一下 Socket,再回到 Wire 协议本身,socket-based 说明了,只需要使用标准的Socket API 即可以开发 MongoDB 的客户端或服务器程序。
Request-Response Style
说明 Wire 协议的通信双方采用“请求-应答”式的交互方式。
如下图所示:

一次完整的交互过程,分为两个步骤:
- 客户端向服务端发送一条请求消息(Request),告知需要服务端做什么操作;
- 如果需要做出反馈,服务端向客户端发送一条应答消息(Response),告诉客户端操作的结果。
第2个步骤 Response 并不是必须的,因为有的通信场景本身就是单向的。
如果客户端发出Request消息后,长时间没有收到Response消息,则视为通信异常,需要进行相应的错误处理操作。
根据消息传递的内容大小、操作逻辑的复杂不同,Request 和 Response 消息可以是一条,也可能是多条消息;
Regular TCP/IP Socket
前面对 Socket 做了但简单说明,这里的 Regular TCP/IP Socket 就很好理解了,Wire 协议的数据传输使用的就是标准的 TCP 协议。
只要以如下方式调用 socket() 函数,就可以创建一个 MongoDB的客户端或服务端链接出来。
1 | int listen_fd; |
字节序
网络通信的双方需要传输整数、字符串等各种不同的数据类型,但对一个Socket连接而言,传递的不过是一个个不同的字节而已。因为不同架构的CPU存储整型数据的方式有差别,所以就有一个需要通信双方事先达成约定的地方,如何去传递一个整型的数据,俗称字节序(Byte Ordering)
如下图所示:

通常有两种方式传输一个整型数据:
- 大端模式(Big Endian):地址低位存储值的高位,地址高位存储值的低位,这是最直观便于人类识别的字节序,并且标准的
网络字节序也采用的是大端模式; - 小端模式(Little Endian):地址低位存储值的低位,地址高位存储值的高位,这在逻辑上是最易理解的,并且 x86 体系架构默认就使用的是小端模式。
Wire 协议中对使用的字节序做了相应的说明:
All integers in the MongoDB wire protocol use little-endian byte order: that is, least-significant byte first.
即 Wire 协议使用 小端字节序 传输整型数据,因此在 x86 体系的硬件环境中,就可以省略掉字节序转换的相关操作了,这也间接提高了协议的性能表现;
消息结构
因为客户端和服务器间传输的都是二进制的字节流,通信双方要正确的封装和解析数据,就需要制定一套数据封装的结构标准。
通常 Socket-based 类的通信协议,都会采用“Header + Body”这种两段式的消息结构来封装消息数据。
以 TCP/IP 协议栈为例,一个标准的 IP 数据报格式如下图所示:
一个 IP 数据报由“首部”和“数据部分”两部分组成,首部是对该数据报的说明,而数据部分则是真实需要传递的TCP或UDP协议数据。
Wire协议的消息结构定义,同样采用了这种方式,分为消息头(Header)和消息体(Body)两部分:

消息头(Header)
Wire 协议中,消息头的格式是固定的,长度为16字节,包含了4个32位整型字段,其结构定义如下:
1 | struct MsgHeader { |
以下是各字段的说明:
Message Length
| 序号 | 长度 | 数据类型 | 名称 | 含义 | 说明 |
|---|---|---|---|---|---|
| 1 | 4 | int32 | messageLength | 消息长度 | 包含消息头本身 |
消息长度(MessageLength)字段,保证了消息的接收方,可以正确的接收和解析一条完整的消息。
Request ID 和 Response To
| 序号 | 长度 | 数据类型 | 名称 | 含义 | 说明 |
|---|---|---|---|---|---|
| 2 | 4 | int32 | requestID | 请求ID | 由消息发送方填写 |
| 3 | 4 | int32 | responseTo | 响应ID | Request消息为0,Response消息则为对应Request消息ID |
为了提高数据传输的效率,提高系统性能,通常会在一个 TCP 连接上并发传递多对消息,RequestID和ResponseTo字段,则保证了消息的顺序不会出现错乱
OpCode
| 序号 | 长度 | 数据类型 | 名称 | 含义 | 说明 |
|---|---|---|---|---|---|
| 4 | 4 | int32 | opCode | 操作码 | 操作码的值在协议中有明确的定义,下面会详细说明 |
OpCode 即操作码,是对后续 Body消息体的说明。不同OpCode的消息,往往有着自己独立的消息封装格式,需要消息的接收方根据OpCode做出正确的解析。
详细的操作码(OpCode)如下表:
| OpCode | 名称 | 说明 | 状态 | ||
|---|---|---|---|---|---|
| 1 | OP_REPLY | 回复客户端的应答消息 | Deprecated | ||
| 2001 | OP_UPDATE | Update文档(Document) | Deprecated | 从v2.6 被 update 命令替代 | |
| 2002 | OP_INSERT | Insert新文档(Document) | Deprecated | 从v2.6 被 insert 命令替代 | |
| 2003 | RESERVED | 保留的 | 未使用 | ||
| 2004 | OP_QUERY | 查询一个集合(Collection)中符合条件的文档 | Deprecated | 作废:v5.0 | 仅保留 hello、isMaster等用于连接握手的命令,仍走该操作 |
| 2005 | OP_GET_MORE | 从查询结果中请求更多的数据 | Deprecated | 被 getMore 命令替代 | |
| 2006 | OP_DELETE | Delete文档(Document) | Deprecated | 从v2.6 被 delete 命令替代 | |
| 2007 | OP_KILL_CURSORS | 通知数据库关闭游标 | Deprecated | 被 killCursors 命令替代 | |
| 2010 | OP_COMMAND | Removed | 作废 :v3.6;删除 : v4.2 | ||
| 2011 | OP_COMMANDREPLY | Removed | 作废 :v3.6;删除 : v4.2 | ||
| 2012 | OP_COMPRESSED | 经压缩处理的消息 | 使用中 | 新增 : v3.4 | |
| 2013 | OP_MSG | 使用中 | 新增 : v3.6 |
从MongoDB第一个版本诞生,到目前最新的 5.0 版,Wire协议的发展经历了多个阶段,产生了上表中的这10多个操作码,现在主要使用的是 OP_MSG、OP_COMPRESSED 和 OP_QUERY 这3个操作码,其余的大部分都已作废,并将在未来的版本中删除。
其中要重点说明一下的是OP_COMPRESSED操作码,它表示为了提高传输效率,Body部分的内容是经过了压缩处理的,需要先对消息进行解压缩,然后再对解压后的内容根据OpCode进行处理。
消息体(Body)
每一个不同的OpCode,其消息体的格式都各不相同,当然协议中对每个OpCode的Body,包含了哪些字段,长度,数据类型、含义也都有明确的定义。
因为 OP_INSERT、OP_DELETE、OP_UPDATE 等消息已经作废,所以这里主要说说还在使用中的 OP_MSG、OP_COMPRESSED 和 OP_QUERY 这3个操作码
OP_COMPRESSED
1 | struct OP_COMPRESSED { |
OP_COMPRESSED 是从 MongoDB v3.4版开始新增的一个消息,用于传输经压缩处理后的消息。该消息的出现,在某些场景下,可以极大的节省网络带宽,提高数据传输吞吐量,提高系统的整体性能。
在这个消息中包含了4部分内容:
- 被压缩消息的OpCode:如果缺了这个信息,那么解压后的数据就无法使用了。
- 被压缩消息的长度:可以用来和解压后的数据进行对比,检验数据是否有误;
- 压缩算法的ID:只有知道了使用的压缩算法,才能正确的解压缩数据。
- 消息数据。
这里需要再说一下这个压缩算法ID,该ID是一个8位整型值。目前协议能够识别的压缩算法有:
| ID | 算法名称 | 说明 |
|—|—|—|
| 0 | noop | 用于测试,并不对消息做压缩处理 |
| 1 | snappy |
| 2 | zlib |
| 3 | zstd |
| 4~255 | 保留未用 |
虽然协议中定义了3种受支持的压缩算法,但实际可使用哪种算法还需要服务端来决定。在客户端与服务端建立连接并握手的过程中,服务端会将可使用的压缩算法列表提供给客户端。
OP_QUERY
1 | struct OP_QUERY { |
OP_QUERY 最早被设计来用于“查询操作”。客户端用OP_INSERT、OP_DELETE、OP_UPDATE、OP_QUERY消息提交CRUD操作请求。
后来 OP_QUERY 也被用于传递增删改查之外的其他操作请求,诸如:连接握手、心跳同步、聚合计算、身份认证等。目前这些操作绝大部分都已调整为使用 OP_MSG 消息来传输,OP_QUERY 仅保留了连接握手的 hello、isMaster 命令及身份认证命令的传输,以保持对低版本客户端的向下兼容性。
在MongoDB 5.0 版中 OP_QUERY 已被废弃,在后续的某个版本中也将被删除。
OP_REPLY
1 | struct { |
OP_REPLY 是 OP_QUERY、OP_GET_MORE 这两个操作请求的响应消息,用于封装查询操作的结果数据。
OP_MSG
1 | struct OP_MSG { |
OP_MSG 消息是在 MongoDB 3.6版中增加的一种通用消息结构,用来封装各种操作请求和响应数据,也就是说既可用于Request消息,也可用于Response消息。目前所有的MongoDB操作都可以使用这个消息来传递。
该消息的结构非常简单包括3个部分:
- 消息标记:一个32位整型值,其中的某些bit位被用于作为标记,表示是否包含CRC校验信息、是否允许分多条消息传递操作请求或响应结果;
- 消息内容:消息内容被封装在一个
Section结构中,可以有一个或多个Section - CRC校验和:用于对消息内容的有效性进行检查。
flagBits
flagBits 是一个整数类型,使用位掩码来编码。
前 16 位 (0-15) 是必需的, 并且如果设置了未知的位,解析器将会报错。
后 16 位 (16-31) 是可选的,解析器会忽略未知的设置位。代理服务器和其他消息转发器在转发消息之前必须清除未知可选位的值。
Section
Section 用于进一步对数据进行封装。
每个 Section 以一个长度1字节的 kind 开始,表明后续数据的类型。之后的所有内容构成该 Section 的 Payload。
可用的 Kind 分为两种:
- Kind 0:Body 正文
正文部分被编码为单个 BSON 对象。BSON 对象中的 size 也用作该 Section 的 size。
该 Kind 是命令请求和回复的标准 Body。其中所有顶层字段的名称都必须是唯一的。
- Kind 1:Document 序列
包含多个 Document,由以下内容组成:
| 序号 | 类型 | 说明 |
|---|---|---|
| 1 | int32 | 该Section 的size |
| 2 | C 风格的 String | Document 序列的名称,唯一标识 |
| 3 | 0 或多个 BSON对象 |
