mongodb基础操作及进阶理解

从2012年初开始,公司的一些核心产品准备开始陆续迁移到MongoDB上,我们尝试着从一个小产品开始使用,陆续将其他产品迁入,到13年年底,公司产品在数据库选择上基本实现了NoSQL化,除了一些事务性要求较高(如支付)的模块继续停留在Mysql上,基本上现在大家都会偏向于使用MongoDB。就我个人而言,我觉得一些小项目(如后台管理),或者需求变化极快的项目(现今的大部分中小移动互联网产品),如果对于并发要求不高,没有特别强的事务性,业务相对简单,基本上就是一到两人完成的小应用,mongodb应该是是这类应用的首选数据库,我自己的体验的理由如下:

  • 相对于MySQL等关系型数据库,mongodb更为轻量,安装,使用,部署都轻便得多。

  • mongodb 的驱动写得极为成熟,天然的Bson数据结构,使得存取数据都以Map结构进行交互,数据接口非常方便,不需要额外进行数据转换,开发效率明显提升。较为明显的对比就是:如果使用MySQL,往往需要使用一个第三方ORM框架进行DB层的操作,以及Bean映射和数据转换,mongodb完全不需要ORM,原生驱动已经做得非常棒了。

  • 相对松散的数据库设计模式,使得它能更好的适应快速变化的需求。当然这一点并不是说用mongodb不需要进行严谨的数据库结构设计了,只是说在需求变更涉及到库表修改的时候,不像MySQL那么纠结要先去弄一下表结构,我才敢部署应用。mongodb基本上没有这个痛感。

  • mongodb 现在的最新稳定版是2.4.8,至此,它提供了相对完善的操作API,而且把Aggregation框架加入以后,原来一直头痛的各种统计操作也有了较好的解决方案,现在可以比较放心的说,MySQL能完成的几乎所有事情,mongodb都能完成。

  • mongodb的文档现在真的好的令人发指啊,应该可以算是业界文档的模范了。

这篇文章主要想介绍一下mongodb的一些基本常用的操作,顺便将一些工作中的处理和理解也提出来,希望能称得上是一篇进阶之作。

1. insert,插入数据

insert操作比较简单,mongodb提供了insert, save 方法进行数据插入操作。
insert就是普通插入,如果待插入的数据中未含有key:’_d’,mongodb则会自动生成一个类型为ObjectId,key为’_id’的数据作为该条记录的主键,如果已经含有,则只校验一下’_id’是否存在于集合中,未存在则会插入成功,否则会返回一个错误。
sava 方法会根据待处理的数据中是否含有key:’_id’进行处理,没有包含则插入数据,包含则根据这个_id更新原有数据。
另外,insert方法还可以进行批量操作,只要将需要插入的数据按照数组格式组装传入即可。
基本语法如下:

1
2
3
4
//
db.collection.insert({key:value});
db.collection.insert([{key:value},{key:value}...]);
db.collection.save({key:value});

2. remove,删除数据

remove操作也很简单,只需要把删除条件传入即可。
基本语法:

1
db.collection.remove({key:value});

如果没有传入任何删除条件,则会删除整个集合。

3. update,更新数据

update稍微复杂一些,我们在开发中碰到的关于更新的操作大概有以下三种情况:

  • 普通更新操作(update.$set|$unset)。
  • 原子更新操作(update.$inc)。
  • 阻塞查询更新操作(findAndModify.$set|$inc)。
  • 数组相关更新操作($push|$pull|$addToSet|$pop 等)。

3.1 普通更新操作

首先来说一下update的基本语法:

1
db.collection.update( <query>, <update>, <upsert>, <multi> )

query:更新的查询条件.
update: 更新的数据.
upsert: 当查询条件没有找到数据时是否插入,默认false.
multi:是否更新多条,默认false.
这里需要强调一下的是对于选项【update】的处理,如果是更新全文档,则无需特别处理;如果只更新文档中的几个字段,则需要加”$set”进行处理,不然会将文档覆盖掉,在写数据处理脚本的时候要特别注意这些地方。这里提供一个对于【普通更新操作】的示例:

1
2
3
4
5
6
db.Student.update(
{_id:ObjectId("52e8fce17ee72c8860511af6")},
{"$set":{"name":"jay","status":1}},
false,
true
)

3.2 原子更新操作

mongodb对于自增长的处理是通过$inc来实现的,自增长的过程是原子性的。示例如下:

1
2
3
4
5
6
db.Student.update(
{_id:ObjectId("52e8fce17ee72c8860511af6")},
{"$inc":{"age":3}},
false,
true
)

上面这段代码将Student中的一条记录的age字段自增长了3。
如果在一个update操作中,我既有更新部分数据的需求,又希望对某个字段进行自增长操作,还希望删除某个字段,这里的处理就很简单了:

1
2
3
4
5
6
7
8
9
10
db.Student.update(
{_id:ObjectId("52e8fce17ee72c8860511af6")},
{
"$set":{"name":"jay","status":1}
,"$inc":{"age":3},
,"$unset":{"sex":1}
},
false,
true
)

3.3 阻塞查询更新操作

这里需要提一下mongodb的锁机制了。

3.3.1 MongoDB 使用的锁

MongoDB 使用的是“readers-writer”锁, 可以支持并发但有很大的局限性,当一个读锁存在,许多读操作可以使用这把锁,然而, 当一个写锁的存在,一个单一的写操作会 exclusively 持有该锁,同时其它读,写操作不能使用共享这个锁;举个例子,假设一个集合里有 10 个文档,多个 update 操作不能并发在这个集合上,即使是更新不同的文档。

3.3.2锁的粒度

在2.2版本以前,mongod只有全局锁;在2.2版本开始,大部分读写操作只锁一个库,相对之前版本,这个粒度已经下降,例如如果一个 mongod 实例上有5个库,如果只对一个库中的一个集合执行写操作,那么在写操作过程中,这个库被锁;而其它5个库不影响。相比RDBMS来说,这个粒度已经算很大了!

可以看出,mongodb这种锁机制设计得不是很合理,数据到了一定数量级比较容易出现性能问题,所以要特别注意【更新】和【查询】操作。

我现在的需求是,要在mongodb中获取自增长的Integer类型的主键。利用findAndModify以及mongodb的锁机制可以实现这一需求。findAndModify既是read的操作,又是write的操作,在执行findAndModify时,mongodb会对集合进行writer加锁,其他线程不能进行write操作,操作完毕以后,它同时返回操作后的最新结果,保证read的准确性。这样就保证了每一次只能执行write and read in document的事情。
我们在实践中的设计是这么做的:

  • 设计一个Collection,集合名为AutoIds.插入一条数据:{_id:1}.

  • 实现生成自增长并返回主键逻辑,这里用的是java驱动:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public Integer getNextId(String fieldName) {
    DBCollection autoIdsColl = db.getAutoIdsCollection();
    // _id=1, 确定预先插入的唯一一条记录
    DBObject query = new BasicDBObject("_id", 1);
    // 过滤一下查询的 field
    DBObject fields ={_id:1, fieldName:1};
    // 排序
    DBObject sort = new BasicDBObject("_id", 1);
    // 定义每次自增长幅度为1
    update = new BasicDBObject("$inc", new BasicDBObject(fieldName, 1));
    // 更新并返回
    DBObject obj = autoIdsColl.findAndModify(query, fields, sort, false, update, true, true);
    // 返回此次更新的Id值
    Integer id = (Integer) obj.get(fieldName);
    return id;
    }
  • 由上一步可知,AutoIds只有一条记录,理论上可以无限横向扩展,为多个表维护ID,只需要传递不同的ID的key作为getNextId的参数即可。

相对于关系型数据库,mongodb需要绕这么一大圈确实有点说不过去,而且由于锁机制的欠缺,性能还差了一大截,不过在实际业务中,mongodb自带的ObjectId作为主键其实能解决大部分问题,所以也还算能接受。

3.4 数组更新操作

数组相关的更新操作在大部分情况下和普通更新操作没有啥特别大的区别,无非就是加了几个操作符。但是也有一些棘手的操作,由于不常用,每次弄的时候总是要回过头来翻文档,所以我这里单独提一下。

3.4.1 添加一个子项到数组中
1
2
3
4
5
6
7
8
db.Student.update(
{_id:ObjectId("52e8fce17ee72c8860511af6")},
{
"$push":{"courses":{"name":"Math","code":"001"}}
},
false,
true
)
3.4.2 添加多个子项到数组中
1
2
3
4
5
6
7
8
9
10
11
12
13
db.Student.update(
{_id:ObjectId("52e8fce17ee72c8860511af6")},
{
"$addToSet":{"courses":
{"$each":[ {"name":"Math","code":"001"}
,{"name":"English","code":"002"}
]
}
}
},
false,
true
)

这里的$addToSet会保证带插入的数组中相同子项只会存在一个,重复的子项也只会插入一次。如果业务需求没有这么严谨,也可以用$push代替。

3.4.2 移除指定子项
1
2
3
4
5
6
7
8
db.Student.update(
{_id:ObjectId("52e8fce17ee72c8860511af6")},
{
"$pull":{"courses":{"name":"Math","code":"001"}}
},
false,
true
)
3.4.2 更新数组子项中的某个field

这里要借用占位符 $ 来完成。 先看示例:

1
2
3
4
5
6
7
8
9
10
11
db.Student.update(
{
_id:ObjectId("52e8fce17ee72c8860511af6")
,"courses.code":"001"
},
{
"$set":{"courses.$.name":"MATH"}
},
false,
true
)

这个语句稍微解释一下:
a) 对于更新的查询条件,务必加 【”courses.code”:”001”】这一项,这样才能定位到数组中的具体项。这里我之前有一个疑惑,就是加不加【”courses.code”:”001”】都能查到同一条记录,为啥一定要加呢,主要是为了定位数组中的子项。

b) 有了 a)的解释,【”$set”:{“courses.$.name”:”MATH”}】中的 “$” 的意思就很好理解了,它就是用来定位数组子项当前项的,这两个写法缺一不可。

占位符$的使用在涉及到数组子项的查询也需要用到,后面的章节会说。

4. query 查询

查询操作其实比较简单了,mongodb提供了大量的操作符来做这个事情。之前我也说了mongodb的文档做得非常好,所以一些普通查询操作,直接翻文档吧,里面有语法,实例,非常棒。 链接
这里我就不准备把文档翻译一遍了,我写一下在使用过程中一些必要但是稍微绕了一下的处理。

4.1 优雅实现 between…and

1
2
3
4
5
6
7
db.Student.find({
"time":
{
"$gt":start,
"$lt":end
}
})

这个结构对我的启发就是:我个人认为 $and 基本上是多余的。
之前用$and实现的方式:

1
2
3
4
5
6
db.Student.find({
"$and":[
{"time":{"$gt":start}}
,{"time":{"$lt":end}}
]
})

这样一对比,后者真的笨重而且多余。所以仔细想想,似乎所有的查询条件都不需要通过$and这样通过数组来实现呀,Map结构本来就支持多键存放的嘛。

4.2 ‘like’ 的新样子

1
2
3
4
5
6
db.Student.find({
"name":
{
"$regex":"/abc[dD]{1}/"
}
})

正则表达式来实现like的功能,而且更为强大,唯一需要考虑的就是效率问题。这里顺带也把全文搜索也牵出来了,范围太大了,以后单独讲。

4.3 数组子项的查询,中规中矩的$elemMatch,还是有更方便的写法?

示例:

1
2
3
4
5
db.Student.find({
"courses":{
"$elemMatch":{"code":"001"}
}
});

偶然发现还有一个超级简单的写法:

1
2
3
db.Student.find({
"courses.code":"001"
});

这里很容易引起混淆,到底Student的数据结构是怎么样的?【courses】这个字段类型是Map子文档(map)还是数组子文档(List)呢? 实际上只要它是二者中的任何一种,都可以用上面的写法查询出来。

4.4 根据数组子项查询,希望只返回查询到的数组子项,应该怎么写?

1
2
3
4
5
db.students.find( 
{_id:ObjectId("6718703038737487484498")
, "courses.code": "001"
},
{ "courses.$": 1 })

这里find方法使用了第二个参数,【courses.$】又看到了熟悉的占位符了,这里的作用还是一样,就是定位到query参数中查询到的子项,并只返回这个子项。

其实查询操作还有很多地方没有说到,例如基于位置的查询,全文搜索等。但是只要了解了本文所说的篇幅,日常开发中应该大部分也够了。
查询操作避不开的话题就是效率问题,我会单独写一篇这方面的文章,从索引,锁机制等探讨一下在mongodb中查询和更新等操作需要注意的问题。

综上,基本的操作都说了一下,我觉得还是多翻文档,用多了自然就熟了。