MongoDB复制集的主要意义在于实现服务高可用
复制集的现实依赖于两个方面的功能:
复制集在实现高可用的同时,还有以下作用:
一个典型的复制集由3个以上具有投票权的节点组成:
数据是如何复制的
通过选举完成故障恢复
选举的因素
整个集群必须有大多数节点存活
被选举为主节点的节点必须:
复制集的常见选项
复制集节点有以下常见的选配项:
复制集的注意事项
关于硬件:
关于软件:
增加节点不会增加系统写性能
我们通过在一台机器上运行3个实例来搭建一个最简单的复制集。
• 如何启动一个 MongoDB 实例
• 如何将3个 MongoDB 实例搭建成一个复制集
• 如何对复制集运行参数做一些常规调整
1、准备
● 安装最新的 MongoDB 版本
● Windows 系统请事先配置好 MongoDB 可执行文件的环境变量
● Linux 和 Mac 系统请配置 PATH 变量
● 确保有 10GB 以上的硬盘空间
2、创建数据目录
MongoDB 启动时将使用一个数据目录存放所有数据文件。我们将为3个复制集节点创建各自的数据目录。
● Linux/MacOS:
mkdir -p /data/db{1,2,3}
● Windows:
md c:\data\db1
md c:\data\db2
md c:\data\db3
3、准备配置文件
复制集的每个mongod进程应该位于不同的服务器。我们现在在一台机器上运行3个进程,因此要
为它们各自配置:
● 不同的端口。示例中将使用28017/28018/28019
不同的数据目录。示例中将使用:
/data/db1或c:\data\db1
/data/db2或c:\data\db2
/data/db3或c:\data\db3
不同日志文件路径。示例中将使用:
/data/db1/mongod.log或c:\data\db1\mongod.log
/data/db2/mongod.log或c:\data\db2\mongod.log
/data/db3/mongod.log或c:\data\db3\mongod.log
Linux/MacOS
/data/db1/mongod.conf
systemLog:
destination: file
path: /data/db1/mongod.log # log path
logAppend: true
storage:
dbPath: /data/db1 # data directory
net:
bindIp: 0.0.0.0
port: 28017 # port
replication:
replSetName: rs0
processManagement:
fork: true
Windows
c:\data\db1\mongod.conf
systemLog:
destination: file
path: c:\data1\mongod.log # 日志文件路径
logAppend: true
storage:
dbPath: c:\data1 # 数据目录
net:
bindIp: 0.0.0.0
port: 28017 # 端口
replication:
replSetName: rs0
4、启动 MongoDB 进程
Linux/Mac:
mongod -f /data/db1/mongod.conf
mongod -f /data/db2/mongod.conf
mongod -f /data/db3/mongod.conf
注意:如果启用了 SELinux,可能阻止上述进程启动。简单起见请关闭 SELinux。
Windows:
mongod -f c:\data1\mongod.conf
mongod -f c:\data2\mongod.conf
mongod -f c:\data3\mongod.conf
因为 Windows 不支持 fork,以上命令需要在3个不同的窗口执行,执行后不可关闭窗口否则
进程将直接结束。
5、配置复制集
方法1
# mongo --port 28017
> rs.initiate()
> rs.add("centosvm:28018")
> rs.add("centosvm:28019")
注意:此方式hostname 需要能被解析
方法2
# mongo --port 28017
> rs.initiate({_id: "rs0",members: [{_id: 0,host: "localhost:28017"}
,{_id: 1,host: "localhost:28018"}
,{_id: 2,host: "localhost:28019"}]})
6、验证
MongoDB 主节点进行写入
# mongo localhost:28017
> db.test.insert({ a:1 })
> db.test.insert({ a:2 });
MongoDB 从节点进行读
# mongo localhost:28018
> rs.slaveOk()
> db.test.find()
> db.test.find()
writeConcern 决定一个写操作落到多少个节点上才算成功。writeConcern 的取值包括:
• 0:发起写操作,不关心是否成功;
• 1~n(n为集群最大数据节点数):写操作需要被复制到指定节点数才算成功;
• majority:写操作需要被复制到大多数节点上才算成功。
发起写操作的程序将阻塞到写操作到达指定的节点数为止
默认行为
w: “1”
默认的writeConcern,数据写入到Primary就向客户端发送确认
w: “majority”
大多数节点确认模式
w: “all”
全部节点确认模式
j:true
类似于关系数据库中的事务日志。Journaling能够使MongoDB数据库由于意外故障后快速恢复。MongoDB2.4版本后默认开启了Journaling日志功能,mongod实例每次启动时都会检查journal日志文件看是否需要恢复。由于提交journal日志会产生写入阻塞,所以它对写入的操作有性能影响,但对于读没有影响。在生产环境中开启Journaling是很有必要的。
journal 则定义如何才算成功。取值包括:
• true: 写操作落到 journal 文件中才算成功;
• false: 写操作到达内存即算作成功。
对于5个节点的复制集来说,写操作落到多少个节点上才算是安全的?
至少3个节点或者配置majority
在复制集测试writeConcern参数
db.test.insert( {count: 1}, {writeConcern: {w: "majority"}})
db.test.insert( {count: 1}, {writeConcern: {w: 3 }})
db.test.insert( {count: 1}, {writeConcern: {w: 4 }})
配置延迟节点,模拟网络延迟(复制延迟)
conf=rs.conf()
conf.members[2].secondaryDelaySecs = 5 //老版本可能是conf.members[2].slaveDelay = 5
conf.members[2].priority = 0
rs.reconfig(conf)
观察复制延迟下的写入,以及timeout参数
db.test.insert( {count: 1}, {writeConcern: {w: 3}})
db.test.insert( {count: 1}, {writeConcern: {w: 3, wtimeout:3000 }})
注意事项
• 虽然多于半数的 writeConcern 都是安全的,但通常只会设置 majority,因为这是等待写入延迟时间最短的选择;
• 不要设置 writeConcern 等于总节点数,因为一旦有一个节点故障,所有写操作都将失败;
• writeConcern 虽然会增加写操作延迟时间,但并不会显著增加集群压力,因此无论是否等待,写操作最终都会复制到所有节点上。设置 writeConcern 只是让写操作等待复制后再返回而已;
• 应对重要数据应用 {w: “majority”},普通数据可以应用 {w: 1} 以确保最佳性能。
在复制集中,读取数据我们需要关注从哪里读的问题,这个问题是由readPreference 来解决
readPreference 决定使用哪一个节点来满足正在发起的读请求。可选值包括:
primary: 只选择主节点(默认值);
primaryPreferred:优先选择主节点,如果不可用则选择从节点;
secondary:只选择从节点;
secondaryPreferred:优先选择从节点,如果从节点不可用则选择主节点;
nearest:选择最近的节点;
适用场景
primary/primaryPreferred
用户下订单后马上将用户转到订单详情页(查询时效性要求)
secondary/secondaryPreferred
用户查询自己下过的订单(查询历史订单对时效性通常没有太高要求)
secondary
生成报表
报表对时效性要求不高,但资源需求大,可以在从节点单独处理,避免对线上用户造成影响
nearest
将用户上传的图片分发到全世界,让各地用户能够就近读取(每个地区的应用选择最近的节点读取数据)
Tag
readPreference 只能控制使用一类节点。Tag 则可以将节点选择控制到一个或几个节点。考虑以下场景:
• 一个 5 个节点的复制集;
• 3 个节点硬件较好,专用于服务线上客户;
• 2 个节点硬件较差,专用于生成报表;
可以使用 Tag 来达到这样的控制目的:
• 为 3 个较好的节点打上 {purpose: “online”};
• 为 2 个较差的节点打上 {purpose: “analyse”};
• 在线应用读取时指定 online,报表读取时指定analyse。
通过 MongoDB 的连接串参数:
?mongodb://host1:27107,host2:27107,host3:27017/?replicaSet=rs&readPreference=secondary
通过 MongoDB 驱动程序 API:
MongoCollection.withReadPreference(ReadPreference readPref)
Mongo Shell:
db.collection.find({}).readPref( "secondary")
•主节点写入 {x:1}, 观察该条数据在各个节点均可见
db.test.drop()
db.test.insert({x:1})
• 在两个从节点分别执行 db.fsyncLock() 来锁定写入(同步)
db.fsyncLock()
• 主节点写入 {x:2}
db.test.insert({x:2})
主节点上
db.test.find()
• 解除从节点锁定 db.fsyncUnlock()
db.fsyncUnlock()
从节点上我们在看
db.test.find().readPref("secondary")
指定 readPreference 时也应注意高可用问题。例如将 readPreference 指定 primary,则发生故障转移不存在 primary 期间将没有节点可读。如果业务允许,则应选择 primaryPreferred;
在复制集中,读取数据我们需要关注什么样的数据可以读?这个问题是由readConcern来解决
readConcern 决定这个节点上的数据哪些是可读的,类似于关系数据库的隔离级别。可选值包括:
• available:读取所有可用的数据;
• local:读取所有可用且属于当前分片的数据;
• majority:读取在大多数节点上提交完成的数据;
• linearizable:可线性化读取文档;
• snapshot:读取最近快照中的数据;
只读取大多数据节点上都提交了的数据。
以下场景中将一个数据写入一主两从的复制集中。
在各节点上不同的时间通过{readConcern: “majority”} 来读取数据
majority如何实现?
节点上维护多个 x 版本,MVCC 机制
MongoDB 通过维护多个快照来链接不同的版本:
• 每个被大多数节点确认过的版本都将是一个快照;
• 快照持续到没有人使用为止才被删除;
安装 3 节点复制集(一主两从)。
注意配置文件内 server 参数 enableMajorityReadConcern
将复制集中的两个从节点使用 db.fsyncLock() 锁住写入(模拟同步延迟)
db.test.save({"x":1})
db.test.find().readConcern("local")
db.test.find().readConcern("majority")
在某一个从节点上执行 db.fsyncUnlock()
结论:
• 使用 local 参数,则可以直接查询到写入数据
• 使用 majority,只能查询到已经被多数节点确认过的数据
• update 与 remove 与上同理。
MongoDB 中的回滚:
• 写操作到达大多数节点之前都是不安全的,一旦主节点崩溃,而从节还没复制到该次操作,刚才的写操作就丢失了;
• 把一次写操作视为一个事务,从事务的角度,可以认为事务被回滚了。所以从分布式系统的角度来看,事务的提交被提升到了分布式集群的多个节点级别的“提交”,而不再是单个节点上的“提交”。
在可能发生回滚的前提下考虑脏读问题:
• 如果在一次写操作到达大多数节点前读取了这个写操作,然后因为系统故障该操作回滚了,则发生了脏读问题;使用 {readConcern: “majority”} 可以有效避免脏读
{readConcern: “snapshot”} 只在多文档事务中生效。
将一个事务的 readConcern设置为 snapshot,将保证在事务中的读:
• 不出现脏读;
• 不出现不可重复读;
• 不出现幻读。
因为所有的读都将使用同一个快照,直到事务提交为止该快照才被释放。
db.tx.insertMany([{ x: 1 }, { x: 2 }]);
var session = db.getMongo().startSession();
session.startTransaction();
var coll = session.getDatabase('test').getCollection("tx");
coll.updateOne({x: 1}, {$set: {y: 1}});
coll.findOne({x: 1});
db.tx.findOne({x: 1});
session.abortTransaction();
事务内操作 {x:1, y:1}
事务外的操作 {x:1}
var session = db.getMongo().startSession();
session.startTransaction({readConcern: {level: "snapshot"},writeConcern: {w: "majority"}});
var coll = session.getDatabase('test').getCollection("tx");
coll.findOne({x: 1});
db.tx.updateOne({x: 1}, {$set: {y: 1}});
db.tx.findOne({x: 1});
coll.findOne({x: 1});
session.abortTransaction();
注意事项
关于驱动程序:总是选择与所用之 MongoDB 相兼容的驱动程序。
如果使用第三方框架(如 Spring Data),则还需要考虑框架版本与驱动的兼容性。
关于连接对象 MongoClient:使用 MongoClient 对象连接到 MongoDB 实例时总是应该保证它单例,并且在整个生命周期中都从它获取其他操作对象。
关于连接字符串:连接字符串中可以配置大部分连接选项,建议总是在连接字符串中配置这些选项;
// 连接到复制集
mongodb://节点1,节点2,节点3…/database?[options]
// 连接到分片集
mongodb://mongos1,mongos2,mongos3…/database?[options]
在配置集群时使用域名可以为集群变更时提供一层额外的保护。例如需要将集群整体迁移到新网段,直接修改域名解析即可。
另外,MongoDB 提供的 mongodb+srv:// 协议可以提供额外一层的保护。该协议允许通过域名解析得到所有 mongos 或节点的地址,而不是写在连接字符串中。
mongodb+srv://server.example.com/
Record TTL Class Priority Weight Port Target _mongodb._tcp.server.example.com. 86400
IN SRV 0 5 27317 mongodb1.example.com.
_mongodb._tcp.server.example.com. 86400 IN SRV 0 5 27017 mongodb2.example.com.
基于前面提到的原因,驱动已经知晓在不同的 mongos 之间实现负载均衡,而复制集则需要根据节点的角色来选择发送请求的目标。如果在 mongos 或复制集上层部署负载均衡:
• 驱动会无法探测具体哪个节点存活,从而无法完成自动故障恢复;
• 驱动会无法判断游标是在哪个节点创建的,从而遍历游标时出错;
结论:不要在 mongos 或复制集上层放置负载均衡器,让驱动处理负载均衡和自动故障恢复。
● 每一个查询都必须要有对应的索引
● 尽量使用覆盖索引 Covered Indexes (可以避免读数据文件)
● 使用 projection 来减少返回到客户端的的文档的内容
● 在 update 语句里只包括需要更新的字段
● 尽可能使用批量插入来提升写入性能
● 使用TTL自动过期日志类型的数据
● 防止使用太长的字段名(浪费空间)
● 防止使用太深的数组嵌套(超过2层操作比较复杂)
● 不使用中文,标点符号等非拉丁字母作为字段名
尽可能不要计算总页数,特别是数据量大和查询条件不能完整命中索引时。
考虑以下场景:假设集合总共有 1000w 条数据,在没有索引的情况下考虑以下查询:
db.coll.find({x: 100}).limit(50);
db.coll.count({x: 100});
• 前者只需要遍历前 n 条,直到找到 50 条队伍 x=100 的文档即可结束;
• 后者需要遍历完 1000w 条找到所有符合要求的文档才能得到结果。
为了计算总页数而进行的 count() 往往是拖慢页面整体加载速度的原因
避免使用skip/limit形式的分页,特别是数据量大的时候;
**替代方案:**使用查询条件+唯一排序条件;
例如:
第一页:db.posts.find({}).sort({_id: 1}).limit(20);
第二页:db.posts.find({_id: {$gt: <第一页最后一个_id>}}).sort({_id: 1}).limit(20);
第三页:db.posts.find({_id: {$gt: <第二页最后一个_id>}}).sort({_id: 1}).limit(20);
……
使用事务的原则:
• 无论何时,事务的使用总是能避免则避免;
• 模型设计先于事务,尽可能用模型设计规避事务;
• 不要使用过大的事务(尽量控制在 1000 个文档更新以内);
• 当必须使用事务时,尽可能让涉及事务的文档分布在同一个分片上,这将有效地提高效率;