现在你一定会对通过驱动或MongoDB Shell发出命令后究竟发生了什么感到好奇。本节中,我们会掀开“帘子”看看驱动是如何序列化数据并将它传给数据库的。
所有的MongoDB驱动都有三个主要功能。首先,生成MongoDB对象ID,这是存储在所有文档_id
字段里的默认值。其次,驱动会把所有语言特定的文档表述和BSON互相转换,BSON是MongoDB使用的二进制数据格式。前面的例子中,驱动将所有Ruby散列都序列化成了BSON,然后再把数据库返回的BSON反序列化成Ruby散列。
最后一个功能是使用MongoDB的网络协议通过TCP套接字与数据库通信。协议的具体内容超出了我们的讨论范围。但套接字通信的风格很重要,尤其是通过套接字写入时是否要等待响应,本节中我们会探讨这个话题。
3.2.1 对象ID生成
每个MongoDB文档都要求有一个主键,它在每个集合中对于所有文档必须是唯一的,主键存放在文档的_id
字段中。开发者可以随意使用自定义值作为_id
,但如果没有提供该值,就会使用MongoDB对象ID。在向服务器发送文档前,驱动会检查是否提供了_id
字段,如果没有则生成一个适当的对象ID,存储为_id
。
因为MongoDB对象ID是全局唯一的标识符,所以可以安全地在客户端为文档分配ID,不用担心会有重复ID。现在,你已经看到过真实的对象ID了,但可能没有注意到它们是由12个字节构成的。如图3-1所示,这些字节是有特定结构的。
图3-1 MongoDB对象ID格式
最开头的4字节是标准的Unix时间戳,编码了从新纪元开始的秒数。接下来的3字节存储了机器ID,随后则是2字节的进程ID。最后3字节存储了进程局部的计数器,每次生成对象ID计数器都会加1。
使用MongoDB对象ID带来的好处之一是其中包含了时间戳。大多数驱动都允许方便地提取时间戳,从而提供文档的创建时间,精度是最接近的一秒钟。使用Ruby驱动,可以调用对象ID的generation_time
方法来获得ID的创建时间,返回值是Ruby的Time
对象:
irb(main):002:0> id = BSON::ObjectId.new=> BSON::ObjectId(/'4c41e78f238d3b9090000001/'irb(main):003:0> id.generation_time=> Sat Jul 17 17:25:35 UTC 2010
很自然的,我们还可以使用对象ID根据对象的创建时间进行范围查询。举个例子,如果希望查询所有在2010年10月至2010年11月之间创建的文档,可以创建两个对象ID,将它们的时间戳分别编码为那两个时间,然后对_id
发起范围查询。Ruby提供了从任意Time
对象创建对象ID的方法,因此实现这一功能的代码很简单:
oct_id = BSON::ObjectId.from_time(Time.utc(2010, 10, 1))nov_id = BSON::ObjectId.from_time(Time.utc(2010, 11, 1))@users.find({/'_id/' => {/'$gte/' => oct_id, /'$lt/' => nov_id}})
我已经解释了MongoDB对象ID的基本原理及各个字节背后的含义。剩下的就是了解它们是如何编码的,这是下一节的主题,届时我们还将讨论BSON。
3.2.2 BSON
BSON是MongoDB中用来表示文档的二进制格式,它既是存储格式,也是命令格式:所有文档都以BSON格式存储在磁盘上,所有查询和命令都用BSON文档来指定。因此,所有的MongoDB驱动必须能在语言特定的文档表述和BSON之间进行转换。
BSON定义了能在MongoDB中使用的数据类型。知道BSON包含哪些类型,了解它们的编码,这对有效使用MongoDB以及发生性能问题时的诊断都大有好处。
在本书编写时,BSON规范中包含了19种数据类型。这就是说,文档中的每个值为了能存储在MongoDB里,必须要能转换为这19种类型中的一种。BSON类型包含了很多我们所期待的类型:UTF-8字符串、32位和64位整数、双精度浮点数、布尔值、时间戳和UTC 日期时间(datetime)。但是,还有一部分类型是特定于MongoDB的。举例来说,上一节中描述的对象ID格式就有自己的类型;有针对模糊大字段(opaque blob)的二进制类型;如果语言支持的话,MongoDB里甚至还提供了符号类型(symbol type)。
图3-2描述了如何将一个Ruby散列序列化为正确的BSON文档。Ruby文档中包含一个对象ID和一个字符串。在转换为BSON文档后,头部的4字节表明了文档的大小(可以看到此处是38字节)。接下来是两个键值对,每对都由一个表示其类型的字节开头,随后是由null
结尾的字符串表示键名,然后是被存储的值,最后是一个 null
字节表示文档结束。
图3-2 从Ruby转换为BSON
虽然不一定要知道BSON的详情,但经验表明了解BSON对MongoDB开发者是有好处的。举个例子,将对象ID表示成字符串或者BSON对象ID这两种做法都是正确的。因此,以下两个Shell查询并不等价:
db.users.find({_id : ObjectId(/'4c41e78f238d3b9090000001/')});db.users.find({_id : /'4c41e78f238d3b9090000001/'})
其中只有一个查询能匹配_id
字段,这完全取决于users
集合中的文档存储的是BSON对象ID,还是表示ID十六进制值的BSON字符串。1这个例子说明即使只对BSON略知一二,在诊断简单代码问题时都很有帮助。
1. 顺便说一下,如果要保存MongoDB对象ID,应该使用BSON对象ID,而不是字符串。除了遵循对象ID的存储惯例,BSON对象ID还能比字符串节省一半以上的空间。
3.2.3 网络传输
除了创建对象ID以及序列化到BSON,MongoDB驱动还有一项核心功能:与数据库服务器通信。如前文所述,通信是基于TCP套接字的,使用了自定义网络协议。2这个TCP的工作是相当底层的,大多数应用程序开发者对此也并不关心。此处与开发者相关的是要理解驱动何时会等待服务器的响应,何时又能不必等待响应。
2. 一些驱动还支持Unix 域套接字通信
我已经解释过查询是如何工作的,很显然,查询必须要有一个响应。回顾一下,当游标对象的next方法被调用后即会发起一次查询。这时会把查询发给服务器,其响应是一批文档。如果这批文档能满足查询,则不必再和服务器进行通信。但如果查询结果较多,恰好无法全部放进第一个服务器响应中,将会向服务器发送一个所谓的getmore
指令获取下一批查询结果。随着游标的迭代,在查询结束前会连续不断地调用getmore
方法。
上述查询的网络行为并没有什么好让人惊讶的,但说到数据库写操作(插入、更新及删除),默认的行为看起来就不怎么正统了。这是因为在向服务器写数据时,驱动默认不会等待服务器的响应。因此在插入文档时,驱动会向套接字写数据并假设写入是成功的。让这种做法能成为现实的一种策略就是客户端生成对象ID:既然已经有了文档的主键,就没有必要等待服务器返回该主键了。
这种不关心结果的写策略让很多用户如坐针毡;幸运的是,该行为是可配置的。所有的驱动都实现了一个安全写入模式,对所有的写操作(插入、更新及删除)都能开启该模式。在Ruby中,能像这样发起一次安全插入:
@users.insert({/"last_name/" => /"james/"}, :safe => true)
以安全模式写入时,驱动会在插入消息后追加一条特殊的getlasterror
命令。它将做两件事。第一,getlasterror
是一条命令,因此需要和服务器做一次通信,这保证了写操作已经送达服务器。第二,该命令验证了服务器在当前连接中没有抛出任何错误。如果有错误被抛出,驱动会发出一个异常,这一异常能被优雅地处理。我们可以使用安全模式来保证应用程序的关键写操作到达服务器,也可以在期待显式错误时使用安全模式。举例来说,经常要强调值的唯一性。如果正在保存用户数据,我们会维护一个username
字段的唯一性索引。在有重复username
时,该唯一性索引会造成文档插入失败,但要知道插入失败的唯一途径就是使用安全模式。
大多数情况中,慎重的做法是默认开启安全模式。随后,针对一些写入少但要求高吞吐量的应用程序部分可以选择关闭安全模式。要做这种权衡并不容易,还有更多安全模式选项要考虑。在第8章中我们将就此进行更详细的讨论。
目前为止,你了解了驱动是如何工作的,应该感到更舒服了,也许还迫不及待地想要构建一个真实的应用程序。在下一节里,我们会结合所有的知识,使用Ruby驱动来构建一个基本的Twitter监控应用。