我们暂时将电子商务示例放在一边,来看看数据库、集合与文档的核心细节。其中很多内容涉及了定义、特殊特性和极端情况。如果想知道MongoDB是如何分配数据文件的、文档中严格限制了哪些数据类型、使用固定集合有什么好处,请继续读下去。
4.3.1 数据库
数据库是集合的逻辑与物理分组。本节里,我们会讨论创建与删除数据库的细节。还会深入探讨MongoDB是如何在文件系统上为每个数据库分配空间的。
1. 管理数据库
MongoDB里没有显式创建数据库的方法,在向数据库中的集合写入数据时会自动创建该数据库。看看下面这段Ruby代码:
@connection = Mongo::Connection.new@db = @connection['garden']
假定之前数据库并不存在,在执行这段代码之后仍然不会在磁盘上创建数据库。此处只是实例化了一个Mongo::DB
类的实例。只有在向某个集合写入数据时才会创建数据文件。接下来:
@products = @db['products']@products.save({:name => "Extra Large Wheel Barrow"})
调用products
集合的save
方法时,驱动会告诉MongoDB将产品文档插入到garden.
products
命名空间里。如果该命名空间并不存在,则会进行创建;其中还涉及在磁盘上分配garden
数据库。
要删除数据库,意味着删除其中所有的集合,我们要发出一条特殊的命令。在Ruby里可以这样删除garden
数据库:
@connection.drop_database('garden')
在MongoDB Shell里,可以运行dropDatabase
方法:
use gardendb.dropDatabase;
在删除数据库时要格外小心,因为这个操作是无法撤销的。
2. 数据文件与空间分配
在创建数据库时,MongoDB会在磁盘上分配一组数据文件,所有集合、索引和数据库的其他元数据都保存在这些文件里。数据文件都被放置在启动mongod
时指定的dbpath
里。在未指定dbpath
时,mongod
会把文件全保存在/data/db里。让我们看看在创建了garden
数据库后/data/db目录里的情况:
$ cd /data/db$ls-aldrwxr-xr-x 6 kyle admin 204 Jul 31 15:48 .drwxrwxrwx 7 root admin 238 Jul 31 15:46 ..-rwxr-xr-x 1 kyle admin 67108864 Jul 31 15:47 garden.0-rwxr-xr-x 1 kyle admin 134217728 Jul 31 15:46 garden.1-rwxr-xr-x 1 kyle admin 16777216 Jul 31 15:47 garden.ns-rwxr-xr-x 1 kyle admin 6 Jul 31 15:48 mongod.lock
先来看mongod.lock文件,其中存储了服务器的进程ID。1数据库文件本身是依据所属的数据库命名的。garden.ns是第一个生成的文件。文件扩展名ns表示namespaces,意即命名空间。数据库中的每个集合和索引都有自己的命名空间,每个命名空间的元数据都存放在这个文件里。默认情况下,.ns文件大小固定在16 MB,大约可以存储24 000个命名空间。也就是说数据库中的索引和集合总数不能超过24 000。我们几乎不可能使用这么多集合与索引,但如果真有需要,可以使用--nssize
服务器选项让该文件变得更大一点。
1. 永远不要删除或修改锁定文件,除非是在对非正常关闭的数据库进行恢复。如果在启动mongod时弹出一个与锁定文件有关的错误消息,很有可能是之前没有正常关闭,可能需要初始化一个恢复进程。我们会在第10章里进一步讨论该话题。
除了创建命名空间文件,MongoDB还为集合与索引分配空间,就在以从0开始的整数结尾的文件里。查看目录的文件列表,会看到两个核心数据文件,64 MB的garden.0和128 MB的garden.1。这些文件的初始大小经常会让新用户大吃一惊,但MongoDB倾向于这种预分配的做法,这能让数据尽可能连续存储。如此一来,在查询和更新数据时,这些操作能更靠近一点,而不是分散在磁盘各处。
在向数据库添加数据时,MongoDB会继续分配更多的数据文件。每个新数据文件的大小都是上一个已分配文件的两倍,直到达到预分配文件大小的上限——2 GB,即garden.2会是256 MB,garden.3是512 MB,以此类推。此处基于这样一个假设,如果总数据大小呈恒定速率增长,应该逐渐增加数据文件分配的空间,这是一种相当标准的分配策略。当然,这么做的后果之一就是分配的空间与实际使用的空间之间会存在很大的差距2。
2. 这在空间很宝贵的部署环境下会带来一些问题,针对此类情况,可以组合使用--noprealloc
和--smallfiles
这两个服务器选项。
可以使用stats命令检查已使用空间和已分配空间:
> db.stats{ "collections" : 3, "objects" : 10004, "avgObjSize" : 36.005, "dataSize" : 360192, "storageSize" : 791296, "numExtents" : 7, "indexes" : 1, "indexSize" : 425984, "fileSize" : 201326592, "ok" : 1}
在这个例子里,fileSize
字段标明了为该数据库分配的文件空间的总和,就是简单地把garden
数据库的两个数据文件(garden.0和garden.1)的大小加起来。比较有意思的是dataSize
和storageSize
两者的差值,前者是数据库中BSON对象的实际大小,后者包含了为集合增长预留的额外空间和未分配的已删除空间。3最后,indexSize
的值是数据库索引大小的总合。关注总计索引大小是很重要的,当所有用到的索引都能放入内存时,数据库的性能是最好的。我将在第7章和第10章里介绍排查性能问题的技术时详细讨论这个话题。
3. 严格说来,集合就是每个数据文件里按块分配的空间,这些块称为区段(extent)。storageSize
就是为集合区段所分配空间的总额。
4.3.2 集合
集合是结构上或概念上相似的文档的容器。本节会更详细地描述集合的创建与删除。随后,我会介绍MongoDB特有的固定集合,并给出一些例子,演示核心服务器内部是如何使用集合的。
1. 管理集合
正如在上一节里看到的,在向一个特定命名空间中插入文档时还隐式地创建了集合。但由于存在多种集合类型,MongoDB还提供了创建集合的命令。在Shell中可以执行:
db.createCollection("users")
在创建标准集合时,有选项能指定预先分配多少字节的存储空间。方法如下(但通常没必要这么做):
db.createCollection("users", {size: 20000})
集合名里可以包含数字、字母或.符号,但必须以字母或数字开头。在MongoDB内部,集合名是用它的命名空间名称来标识的,其中包含了它所属的数据库的名称。因此,严格说起来,在往来于核心服务器的消息里引用产品集合时应该用garden.products
。这个完全限定集合名不能超过128个字符。
有时在集合名里包含.符号很有用,它能提供某种虚拟命名空间。举例来说,可以想象有一系列集合使用了下列名称:
products.categoriesproducts.imagesproducts.reviews
请牢记这只是一种组织上的原则,数据库对名字里带有.的集合和其他集合是一视同仁的。我之前已经提到过从集合中删除文档和彻底删除集合了,现在你还需要知道集合是可以重命名的。比如,可以用Shell里的renameCollection
方法重命名产品集合:
db.products.renameCollection("store_products")
2. 固定集合
除了目前为止创建的标准集合,我们还可以创建固定集合(capped collection)。固定集合原本是针对高性能日志场景设计的。它们与标准集合的区别在于其大小是固定的,也就是说,一旦固定集合到达容量上限,后续的插入会覆盖集合中最先插入的文档。在只有最近的数据才有价值的情况下,这种设计免除了用户手工清理集合的烦恼。
要理解如何使用固定集合,可以假设想要追踪访问我们站点的用户的行为。此类行为会包含查看产品、添加到购物车、结账与购买。可以写个脚本来模拟向固定集合记录这些用户行为的日志记录功能。在这个过程里,我们会看到这些集合的一些有趣属性。下面是一个示例。
代码清单4-6 模拟向固定集合中记录用户行为日志
require 'rubygems'require 'mongo' VIEW_PRODUCT = 0ADD_TO_CART = 1CHECKOUT = 2PURCHASE = 3 @con = Mongo::Connection.new@db = @con['garden'] @db.drop_collection("user.actions") @db.create_collection("user.actions", :capped => true, :size => 1024)@actions = @db['user.actions']20.times do |n| doc = { :username => "kbanker", :action_code => rand(4), :time => Time.now.utc, :n => n } @actions.insert(doc)end
首先,使用DB#create_collection
方法4创建一个名为users.actions、
大小为1 KB的固定集合。接下来,插入20个示例日志文档。每个文档都包含用户名、动作代码(存储内容为0~3的整数)和时间戳,还要加入一个不断增加的整数n,这样就能标识出哪个文档过期了。现在从Shell里查询集合:
4. Shell里的等效创建命令是db.createCollection("users.actions", {capped: true, size: 1024})。
> use garden> db.user.actions.count;10
尽管插入了20个文档,但集合里却只有10个文档,查询一下集合内容,你就能知道为什么了:
db.user.actions.find;{ "_id" : ObjectId("4c55f6e0238d3b201000000b"), "username" : "kbanker", "action_code" : 0, "n" : 10, "time" : "Sun Aug 01 2010 18:36:16" }{ "_id" : ObjectId("4c55f6e0238d3b201000000c"), "username" : "kbanker", "action_code" : 4, "n" : 11, "time" : "Sun Aug 01 2010 18:36:16" }{ "_id" : ObjectId("4c55f6e0238d3b201000000d"), "username" : "kbanker", "action_code" : 2, "n" : 12, "time" : "Sun Aug 01 2010 18:36:16" }...
返回的文档是按照插入顺序排列的。仔细观察n的值,很明显,集合中最老的文档是第十个插入的文档,也就是说文档0~9都已经过期了。既然该固定集合最大是1024字节,仅包含10个文档,也就是说每个文档大致是100字节。后面你将看到如何验证这个假设。
在此之前,我要再指出固定集合与标准集合之间的几个不同点。固定集合默认不为_id创建索引,这是为了优化性能,没有索引,插入会更快。如果实在需要_id
索引,可以手动构建索引。在不定义索引的情况下,最好把固定集合当做用于顺序处理的数据结构,而非用于随机查询的数据结构。为此,MongoDB提供了一个特殊的排序操作符,按自然插入顺序5返回集合的文档。之前的查询是按自然顺序正向输出结果的,如果要逆序输出,必须使用$natural
排序操作符:
5. 自然顺序是文档保存在磁盘上的顺序。
> db.user.actions.find.sort({"$natural": -1});
除了按自然顺序排列文档,并放弃索引,固定集合还限制了CRUD操作。比如,不能从固定集合中删除文档,也不能执行任何会增加文档大小的更新操作。6
6. 因为固定集合最早是为日志记录功能而设计的,不需要实现删除或更新文档功能,这些功能会让负责旧文档过期的代码复杂化。去掉这些功能,固定集合获得了设计的简单性和高效性。
3. 系统集合
MongoDB内部对集合的使用方式可以体现它的部分设计思想,system.namespaces
与system. indexes
就属于这些特殊系统集合。前者可以查询到当前数据库中定义的所有命名空间:
> db.system.namespaces.find;{ "name" : "garden.products" }{ "name" : "garden.system.indexes" }{ "name" : "garden.products.$_id_" }{ "name" : "garden.user.actions", "options" : { "create": "user.actions", "capped": true, "size": 1024 } }
后者存储了当前数据库的所有索引定义。要获取garden
数据库的索引,查询该集合即可:
> db.system.indexes.find;{ "name" : "_id_", "ns" : "garden.products", "key":{"_id":1}}
system.namespaces
与system.indexes
都是标准的集合,但MongoDB使用固定集合来做复制。每个副本集的成员都会把所有的写操作记录到一个特殊的oplog.rs固定集合里。从节点顺序读取这个集合的内容,再把这些新操作应用到自己的数据库里。第9章将更详细地讨论这个系统集合。
4.3.3 文档与插入
我们将通过讨论文档及其插入的细节来结束这章。
- 文档序列化、类型和限制
正如上一章中说的那样,所有文档在发送到MongoDB之前都必须序列化成BSON;随后再由驱动将文档从BSON反序列化到语言自己的文档表述。大多数驱动都提供了一个简单的接口,可以进行BSON的序列化和反序列化。我们可能会需要查看发送给服务器的内容,因此了解这部分功能在驱动中是如何实现的会非常有用。举例来说,前文在演示固定集合,我们有理由假设示例文档的大小大约是100字节。可以通过Ruby驱动的BSON序列化器来验证这一假设:
doc={ :_id => BSON::ObjectId.new, :username => "kbanker", :action_code => rand(5), :time => Time.now.utc, :n => 1}bson = BSON::BSON_CODER.serialize(doc)puts "Document #{doc.inspect} takes up #{bson.length} bytes as BSON"
serialize
方法会返回一个字节数组。如果运行上述代码,会得到一个82字节的BSON对象,和我们估计的差不多。如果想要在Shell里检查BSON对象的大小,可以这样做:
> doc = { _id: new ObjectId, username: "kbanker", action_code: Math.ceil(Math.random * 5), time: new Date, n: 1}> Object.bsonsize(doc);82
同样也是82字节。82字节的文档大小和100字节的估计值的差别在于普通集合和文档的开销。
反序列化BSON也很简单,可以尝试运行以下代码:
deserialized_doc = BSON::BSON_CODER.deserialize(bson)puts "Here's our document deserialized from BSON:"puts deserialized_doc.inspect
请注意,不是所有Ruby散列都能被序列化。要正确序列化,键名必须是合法的,每个值都必须能转换为BSON类型。合法的键名由null
结尾的字符串组成,最大长度为255字节。字符串可以包含任意ASCII字符的组合,但有三种情况例外:不能以$
开头,不能包含.字符,除了结尾处外不能包含null
字节。在Ruby里,可以用符号充当散列的键,在序列化时它们会被转换为等效的字符串。
应该慎重选择键名的长度,因为这是存储在文档里面的。这种做法与RDBMS截然不同,RDBMS里列名总是与数据行分开保存的。因此,在使用BSON时,可以用dob
代替date_of_birth
作为键名,这样一来每个文档都能省下10字节。这个数字听起来并不大,但一旦有了10亿个文档,这个更短的键名能帮我们省下将近10 GB的存储空间。但这也不是让你肆意缩短键名长度,请选择一个合适的键名。如果有大量的数据,更“经济”的键名能帮助省下不少空间。
除了合法的键名,文档还必须包含可以序列化为BSON的值。在http://bsonspec.org可以找到一张BSON类型的表格,其中有示例和注解。此处我只会指出一些重点和容易碰到的陷阱。
- 字符串
所有字符串都必须编码为UTF-8,虽然UTF-8就快成为字符编码的行业标准了,但还是有很多地方仍在使用旧的编码。在将数据从遗留系统导入到MongoDB时用户通常会遇到一些问题。解决方案一般是在插入前将内容转换为UTF-8,或者将文本保存为BSON二进制类型。7
7. 顺便说一下,如果你还不太了解字符编码,推荐你读一下Joel Spolsky那篇著名的介绍字符编码的文章,参见http://mng.bz/LVO6。如果你是一名Ruby爱好者,也许还会想读一读James Edward Gray关于Ruby 1.8和1.9字符编码的一系列文章,参见http://mng.bz/wc4J。
- 数字
BSON规定了三种数字类型:double、int
和long
。也就是说BSON可以编码各种IEEE浮点数值,以及各种8字节以内的带符号整数。在动态语言里序列化整数时,驱动会自己决定是将其序列化为int
还是long
。实际上,只有一种常见情况需要显式地决定数字类型,那就是通过JavaScript Shell插入数字数据时。很遗憾,JavaScript天生就支持一种数字类型,即Number
,它等价于IEEE的双精度浮点数。因此,如果希望在Shell里将一个数字保存为整数,需要使用NumberLong
或NumberInt
显式指定。试试下面这段代码:
db.numbers.save({n: 5});db.numbers.save({ n: NumberLong(5) });
这里向numbers
集合添加了两个文档,虽然两个值是一样的,但第一个被保存成了双精度浮点数,第二个则被保存成了长整数。查询所有n
是5
的文档会将这两个文档一并返回:
>db.numbers.find({n: 5});{ "_id" : ObjectId("4c581c98d5bbeb2365a838f9"), "n":5}{ "_id" : ObjectId("4c581c9bd5bbeb2365a838fa"), "n" : NumberLong(5)}
但是可以看到第二个值被标记为长整数。另一种做法是使用特殊的$type
操作符来查询BSON类型。每种BSON类型都由一个从1开始的整数来标识。如果查看http://bsonspec.org上的BSON规范,会看到双精度浮点数是类型1,而64位整数是类型18。所以,可以根据类型来查询集合的值:
> db.numbers.find({n: {$type: 1}});{ "_id" : ObjectId("4c581c98d5bbeb2365a838f9"), "n":5}> db.numbers.find({n: {$type: 18}});{ "_id" : ObjectId("4c581c9bd5bbeb2365a838fa"), "n" : NumberLong(5)}
这也证实了两者在存储上的不同。在生产环境里我们几乎用不上$type
操作符,但在调试时,这是个很棒的工具。
另一个和BSON数字类型有关的问题是其中缺乏对小数的支持。这意味着在MongoDB中保存货币值时需要使用整数类型,并且以美分为单位来保存货币值。
- 日期时间
BSON的日期时间类型是用来存储时间的,用带符号的64位整数来标识Unix epoch8毫秒数,采用的时间格式是UTC(Coordinated Universal Time,协调世界时)。负值代表时间起点之前的毫秒数。
8. Unix epoch是从1970年1月1日午夜开始的协调世界时。
以下是一些使用时的注意事项。首先,如果在JavaScript里创建日期,请牢记JavaScript日期里的月份是从0开始的。也就是说new Date(2011, 5, 11)
创建出的日期对象表示2011年6月11日。其次,如果使用Ruby驱动存储时间数据,BSON序列化器会期待传入一个UTC格式的Ruby Time
对象。其结果就是不能使用包含时区信息的日期类,因为BSON 日期时间无法对它进行编码。
- 自定义类型
如果希望连同时区一起保存时间该怎么办呢?有时候光有基本的BSON类型是不够的。虽然无法创建自定义BSON类型,但可以结合几个不同的原生BSON值,以此创建自己的虚拟类型。举例来说,想要保存时区和时间,可以使用这样一种文档结构,Ruby代码如下:
{:time_with_zone => {:time => Time.utc.now, :zone => "EST" } }
要编写一个能透明处理此类组合表述的应用程序并不复杂。真实情况往往就是这样的。例如,MongoMapper(用Ruby编写的MongoDB对象映射器)允许为任意对象定义to_mongo
和from_mongo
方法,方便此类自定义组合类型的使用。
- 文档大小的限制
MongoDB v2.0中BSON文档的大小被限制在16 MB9。出于两个原因需要增加这个限制,首先是为了防止开发者创建难看的数据模型。虽然在这个限制下仍然会有差劲的数据模型,但16 MB的限制还是有帮助的,尤其是能避免深层次的嵌套,这种嵌套对于MongoDB的新手是个常见的数据建模问题。深层嵌套的文档很难使用,最好能将它们展开到各自不同的集合里。
9. 这个数字在各个服务器版本之间有所不同,而且还在继续增加。要了解正在使用的服务器版本对应的限制值,可以在Shell里运行db.isMaster
,查看maxBsonObjectSize
字段。如果没有这个字段,那么该限制是4 MB(正在使用一个非常古老的MongoDB版本)。
第二个原因与性能有关,在服务器端查询大文档,在将结果发送个客户端之前需要将文档复制到缓冲区里。这个复制动作的代价可能很大,尤其在客户端并不需要整个文档时(这种情况很常见)。10此外,一旦发送之后,就会在网络中传输这些文档,驱动还要对其进行反序列化。如果一次请求大量MB数量级的文档,这笔开销会极大。
10. 在下一章里会看到,可以指定查询返回文档的哪些字段,以此控制响应的大小。如果经常这么做,就可以重新考虑一下你的数据模型了。
结论就是,如果有很大的对象,也许可以将它们拆开,修改其数据模型,使用一到两个额外的集合。如果仅仅存储大的二进制对象,比如图片或视频,这又是另一种情况,附录C里有与处理大型二进制对象相关的内容。
2. 批量插入
在有了正确的文档之后,就该执行插入操作了。第3章里已经讨论了很多与插入相关的细节,包括生成对象ID、网络层上插入是如何实现的,还有安全模式。但还有一个特性值得探讨,那就是批量插入。
所有的驱动都可以一次插入多个文档,这在有很多数据需要插入时非常有用,比如初始化批量导入或者从另一个数据库系统迁移数据时。回想之前向user.actions
集合插入20个文档的例子,如果再去读下代码,会发现每次只插入一个文档。使用下面的代码,事先构造一个40个文档的数组,随后将整个文档数组传递给insert
方法:
docs = (0..40).map do |n| { :username => "kbanker", :action_code => rand(5), :time => Time.now.utc, :n => n }end@col = @db['test.bulk.insert']@ids = @col.insert(docs)puts "Here are the ids from the bulk insert: #{@ids.inspect}"
与单独返回一个对象ID有所不同,批量插入会返回所有插入文档的对象ID数组。用户经常会问,理想的批量插入数量是多少?答案受到太多具体因素的影响,理想的数字范围为10~200。在具体情况中,基准测试的结果是最有价值的。数据库方面唯一的限制是单次插入操作不能超过16 MB上限。经验表明大多数高效的批量插入都远低于该限制。