Plusaber's Blog

  • Home

  • Tags

  • Categories

  • Archives

MongoDB实战

Posted on 2015-12-05 | In Developing | Comments:

基础

概念

文档(document)

文档也就是传统数据库中的行,但是没有确定的键模式,而是由不确定的键值对组成。在MongoDB中文档一般组织和表示为JavaScript的对象形式。

{"greeting" : "Hello, world!", "foo" : 3}

  • 文档中的键/值对是有序的,上面的文档和下面的文档是不同的。
    {"foo" : 3, "greeting" : "Hello, world!}"

  • 文档中的值有几种数据类型,甚至是整个嵌入的文档。

  • 文档的键一般为字符串。
  • 文档不能有重复的键。

集合(collection)

集合是一组文档的集合。
集合是无模式的,也就是一个集合里的文档可以是各式各样的,但是一般根据应用和效率的需要,一般还是把相同类型文档划分为不同集合。

组织集合的一种习惯是使用”.”字符分开的按命名空间划分的子集合。例如,一个带有博客功能的应用可能包含两个集合,分别是blog.posts和blog.authors。这样做的目的只是为组织结构更好些,也就是blog这个集合只是逻辑上的存在,和其子集合没有只是层次上的逻辑关系。

数据库

MongoDB中多个文档组成集合,同样多个集合可以组成数据库。一个MongoDB实例可运行多个数据库。

启动MongoDB

  • Download MongoDB
  • Decompress
  • create directory /data/db with sudo
  • 进入MonogoDB解压目录bin目录,./mongod启动mongoDB本地实例,在没有参数情况下使用默认数据目录/data/db和27017端口。
  • 可以通过访问http://localhost:28017来获取数据库的管理信息。

MongoDB shell

在mongoDB实例启动后,可以执行./mongo启动shell,会自动连接MongoDB服务器。这个shell同时是一个完全的javascript解释器,javascript所有语法都可使用。
默认shell会连接mongodb服务器的test数据库,并将这个数据库连接复制给全局变量db。这个变量是通过shell访问MongoDB的主要入口点。

shell中的基本操作

创建

1
2
3
4
5
6
7
8
9
10
11
 post = {"title" : "My Blog Post",
"content" : "Here's my blog post.","date" : new Date()}

db.blog.insert(post)
db.blog.find()
{
"_id" : ObjectId("5037ee4a1084eb3ffeef7228"),
"title" : "My Blog Post",
"content" : "Here's my blog post.",
"date" : ISODate("2012-08-24T21:12:09.982Z")
}

读取

1
2
3
4
5
6
7
db.blog.findOne()
{
"_id" : ObjectId("5037ee4a1084eb3ffeef7228"),
"title" : "My Blog Post",
"content" : "Here's my blog post.",
"date" : ISODate("2012-08-24T21:12:09.982Z")
}

更新

1
2
3
 post.comments = []
[]
db.blog.update({title : "My Blog Post"}, post)

更新所有键title的值为”My Blog Post”的文档为修改过后的post。

删除

1
db.blog.remove({title : "My Blog Post"})

常用shell命令

可以通过help查看常用命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
help
db.help() help on db methods
db.mycoll.help() help on collection methods
sh.help() sharding helpers
rs.help() replica set helpers
help admin administrative help
help connect connecting to a db help
help keys key shortcuts
help misc misc things to know
help mr mapreduce

show dbs show database names
show collections show collections in current database
show users show users in current database
show profile show most recent system.profile entries with time = 1ms
show logs show the accessible logger names
show log [name] prints out the last segment of log in memory, 'global' is default
use <db_name set current database
db.foo.find() list objects in collection foo
db.foo.find( { a : 1 } ) list objects in foo where a == 1
it result of the last line evaluated; use to further iterate
DBQuery.shellBatchSize = x set default number of items to display on shell
exit quit the mongo shell

另外可以使用db.help()查看数据库级别的命令的帮助,集合的相关帮助可以通过db.foo.help()查看。

另外有个了解函数功能的技巧,就是输入函数但不要输入参数和括号,这样就会现实该函数的JavaScript源代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
 db.foo.update
function ( query , obj , upsert , multi ){
var parsed = this._parseUpdate(query, obj, upsert, multi);
var query = parsed.query;
var obj = parsed.obj;
var upsert = parsed.upsert;
var multi = parsed.multi;
var wc = parsed.wc;

var result = undefined;
var startTime = (typeof(_verboseShell) === 'undefined' ||
!_verboseShell) ? 0 : new Date().getTime();
...

数据类型

基本类型

类型 meaning example
null {“x” : null}
Boolean {“x” : true}
Integer 分为32位和64位 {“x” : NumberInt(“3”)}、{“x” : NumberLong(“3”)}
Double {“x” : 3.14}
String {“x” : “foobar”}
Object ID 对象ID。用于创建文档的 ID。 {“x” : ObjectId()}
Date {“x” : new Date()}
Regular expression 正则表达式类型。用于存储正则表达式(javascript语法)。 {“x” : /foobar/i}
JavaScript Code {“x” : function(){/…/}}
Binary Data 二进制数据。用于存储二进制数据。
Min/Max keys 表示可能的最小/最大值
Arrays 用于将数组或列表或多个值存储为一个键 {“x” : [“a”, 2, 3.14]}
内嵌文档 文档内可以包含别的文档 {“x” : {“foo” : “bar”}} }
  • 数组

如果需要经常对某个数组进行查询,可以其对其构建索引,比如需要快速查询x数组中包含3.14的文档。

1
2
3
4
5
6
7
{
"name" : "John Doe",
"address" : {
"street" : "123 Park Street",
"city" : "Anytown",
"state" : "NY"
} }
  • 内嵌文档

同数组一样,mongoDB能够理解内嵌文档的结构,并能够深入其中构建索引、执行查询,或者更新。可以看出mongoDB的数组组织形式不同于关系型数据库,一般在关系型数据库中,上面的数据需要分开存在两个表中,然后通过第三个表或者外键连接。

mongodb这种内嵌文档会使信息表示更加自然,效率也更好,但是会储存更多的重复数据。

  • _id和ObjectId

MongoDB中存储的文档必须有一个"_id"键。这个键的值可以是任意类型的,默认是个ObjectId对象。在一个集合里,每篇文档都有唯一的"_id"值,来确保集合里面的每个文档都能被唯一标识。

ObjectId是"_id"的默认类型。ObjectId被设计成轻量型的,不同的机器都能用全局唯一的同种方法方便地生成它。这是MongoDB采用ObjectId,,而不是其他常规的方法(如自增主键),因为在多个服务器上同步自动增加主键更加费时费力。

ObjectId使用12字节的存储空间,每个字节两位16位进制数字,是一个24位的字符串。

ObjectId的生成方式与结构

1
|时间戳|机器ID|PID|计数器|

如果插入文档时没有设置"_id"键,系统会自动帮我们创建一个(通常不是有mongodb服务器完成,而是在客户端有驱动程序完成)。

创建、更新及删除文档

插入并保存文档

db.foo.insert({"bar", "baz"})

如果需要插入多个文档,使用批量插入会提高效率,因为以此批量插入只是一个TCP请求,而单个单个文档插入时每个文档插入都需要提交一个TCP请求。批量插入能传递一个由文档构成的数组给数据库。另外批量插入只能对于同一个集合,不能批量插入多个集合。

1
db.foo.batchInsert([{"_id" : 0}, {"_id" : 1}, {"_id" : 2}])

如果只是导入原始数据(如从mysql),可以使用命令行工具,如mongoimport,而不是批量插入。当前MongoDB消息最大长度为48MB.

在执行插入的时候,使用的驱动程序会讲数据转换BSON的形式,然后将其送入数据库。数据库只会检查是否包含_id键并且文档不超过16MB,不做其他检查,就只是简单地的文档原样存入数据库中,所以可以避免注入式攻击。但是一般主流语言的驱动程序都会做一定检查,确认是否是有效文档。

删除文档

1
2
db.users.remove() // 删除users集合中所有文档,但不会删除集合本身
// 原有的索引也会保留

remove函数可以接受一个查询作为可选参数。给定这个参数后,只有符合条件的文档才会被删除。例如,删除mailing.list集合中所有”output”为true的人:

1
db.mailing.list.remove({"opt-out" : true})

删除数据是永久性的,不能撤销,也不能恢复。

删除速度
删除文档通常会很快,如果要删除整个集合,直接删除集合(然后重建索引)会更快

1
2
3
db.bar.remove()  //删除所有文档

db.drop_collection("bar") // 删除集合,比上一种方式要快得多

更新文档

文档存入数据库后,就可以使用update方法来修改。update有两个参数,一个用来查询文档,用来找出要更新的文档;另一个是修改器(modifier)文档,描述对找到的文档做哪些更改。

更新操作是原子的:如果两个更新同时发生,先到达的执行,接着执行另外一个。

文档替换

1
2
3
4
5
6
 {
"_id" : ObjectId("4b2b9f67a1f631733d917a7a"),
"name" : "joe",
"friends" : 32,
"enemies" : 2
}

我们要删除部分字段并更新部分字段,可以执行下面命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
 var joe = db.users.findOne({"name" : "joe"});
joe.relationships = {"friends" : joe.friends, "enemies" : joe.enemies}; {
"friends" : 32,
"enemies" : 2
} joe.username = joe.name;
"joe"
delete joe.friends;
true
delete joe.enemies;
true
delete joe.name;
true
db.users.update({"name" : "joe"}, joe);

注意在一个集合中_id必须唯一的,也就是如果有多个匹配的文档,update的文档会被插入多次,会导致重复_id,导致失败。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 db.people.find()
{"_id" : ObjectId("4b2b9f67a1f631733d917a7b"), "name" : "joe", "age" : 65},
{"_id" : ObjectId("4b2b9f67a1f631733d917a7c"), "name" : "joe", "age" : 20},
{"_id" : ObjectId("4b2b9f67a1f631733d917a7d"), "name" : "joe", "age" : 49},

joe = db.people.findOne({"name" : "joe", "age" : 20});
{
"_id" : ObjectId("4b2b9f67a1f631733d917a7c"),
"name" : "joe",
"age" : 20
}
joe.age++;
db.people.update({"name" : "joe"}, joe);
E11001 duplicate key on update

最好是使用ObjectId指定要更新的文档:

1
db.people.update({"_id" : ObjectId("4b2b9f67a1f631733d917a7c")}, joe)

使用修改器

通常文档只有一部分要更新。利用原子的更新修改器,可以使得这个操作非常快。更新修改器是种特殊的键,用来指定复杂的更新操作,比如调整、增加或删除键,还可能是操作数组或者内嵌文档。

target doc:

1
2
3
4
5
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"url" : "www.example.com",
"pageviews" : 52
}

增加pageviews:

1
2
3
4
5
6
7
8
9
 db.analytics.update({"url" : "www.example.com"},
... {"$inc" : {"pageviews" : 1}})

db.analytics.find()
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"url" : "www.example.com",
"pageviews" : 53
}

$set修改器

$set用来指定一个键的值,如果这个键不存在,则创建它。

1
2
3
4
5
6
7
8
db.users.findOne()
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"name" : "joe",
"age" : 30,
"sex" : "male",
"location" : "Wisconsin"
}

添加一个字段进去:

1
2
3
4
5
6
7
8
9
10
11
12
db.users.update({"_id" : ObjectId("4b253b067525f35f94b60a31")},
... {"$set" : {"favorite book" : "War and Peace"}})

db.users.findOne()
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"name" : "joe",
"age" : 30,
"sex" : "male",
"location" : "Wisconsin",
"favorite book" : "War and Peace"
}

也可以修改键的值,甚至修改键的类型数据:

1
2
3
4
5
6
db.users.update({"name" : "joe"},
... {"$set" : {"favorite book" : "Green Eggs and Ham"}})

db.users.update({"name" : "joe"},
... {"$set" : {"favorite book" :
... ["Cat's Cradle", "Foundation Trilogy", "Ender's Game"]}})

另外可以通过$unset删除一个字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
db.users.update({"name" : "joe"},
... {"$unset" : {"favorite book" : 1}})

db.blog.posts.findOne()
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"title" : "A Blog Post",
"content" : "...",
"author" : {
"name" : "joe",
"email" : "joe@example.com"
}
}

$set可以修改内嵌文档

1
2
3
4
5
6
7
8
9
10
11
12
13
 db.blog.posts.update({"author.name" : "joe"},
... {"$set" : {"author.name" : "joe schmoe"}})

db.blog.posts.findOne()
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"title" : "A Blog Post",
"content" : "...",
"author" : {
"name" : "joe schmoe",
"email" : "joe@example.com"
}
}

增加和减少

$inc修改器用来增加已有键的值,或者在键不存在时创建一个键。

1
2
3
4
5
6
7
8
9
10
11
12
 db.games.insert({"game" : "pinball", "user" : "joe"})

db.games.update({"game" : "pinball", "user" : "joe"},
... {"$inc" : {"score" : 50}})

db.games.findOne()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"game" : "pinball",
"user" : "joe",
"score" : 50
}

数组修改器

$push

数组操作只能用于数组的键上。如果指定的键已经存在,$push会向已有的数组末尾添加一个元素,否则则会创建一个新的数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
 db.blog.posts.findOne()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "..."
}
db.blog.posts.update({"title" : "A blog post"},
... {"$push" : {"comments" :
... {"name" : "joe", "email" : "joe@example.com",
... "content" : "nice post."}}})
db.blog.posts.findOne()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "joe",
"email" : "joe@example.com",
"content" : "nice post."
}
] }

db.blog.posts.update({"title" : "A blog post"},
... {"$push" : {"comments" :
... {"name" : "bob", "email" : "bob@example.com",
... "content" : "good post."}}})
db.blog.posts.findOne()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "joe",
"email" : "joe@example.com",
"content" : "nice post."
},
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
}
] }

如果需要同时添加多个元素,可以使用$each,以数组作为参数.

1
2
db.stock.ticker.update({"_id" : "GOOG"},
... {"$push" : {"hourly" : {"$each" : [562.776, 562.790, 559.123]}}})

如果需要限制数组的大小,我们可以只用$slice,使得数组只保留有top N items(-10指只保留10个元素,参数必须是负值).

1
2
3
4
db.movies.find({"genre" : "horror"},
... {"$push" : {"top10" : {
... "$each" : ["Nightmare on Elm Street", "Saw"],
... "$slice" : -10}}})

如果需要加入数组,并且希望将这个数组中元素排序后再加入:

1
2
3
4
5
6
7
 db.movies.find({"genre" : "horror"},
... {"$push" : {"top10" : {
... "$each" : [{"name" : "Nightmare on Elm Street", "rating" : 6.6},
... {"name" : "Saw", "rating" : 4.3}],
... "$slice" : -10,
... "$sort" : {"rating" : -1}}}})
// 按照rating排序

注意$slice和$sort必须要结合$each使用,不能单独使用。

using arrays as sets

有时我们需要需要判断只有当元素不在该文档的数组里面,才将该元素加入该文档的数组中,则可以在查询文档中使用$ne。

1
2
db.papers.update({"authors cited" : {"$ne" : "Richie"}},
... {$push : {"authors cited" : "Richie"}})

同样可以用$addToSet实现,可以避免重复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
 db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"joe@example.com",
"joe@gmail.com",
"joe@yahoo.com"
] }

db.users.update({"_id" : ObjectId("4b2d75476cc613d5ee930164")},
... {"$addToSet" : {"emails" : "joe@gmail.com"}})
db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"joe@example.com",
"joe@gmail.com",
"joe@yahoo.com",
] }
db.users.update({"_id" : ObjectId("4b2d75476cc613d5ee930164")},
... {"$addToSet" : {"emails" : "joe@hotmail.com"}})
db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"joe@example.com",
"joe@gmail.com",
"joe@yahoo.com",
"joe@hotmail.com"
] }

将$addToSet和$each结合起来,就可以添加多个不同的值,而使用$ne和$push组合则不能实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
db.users.update({"_id" : ObjectId("4b2d75476cc613d5ee930164")}, {"$addToSet" :
... {"emails" : {"$each" :
... ["joe@php.net", "joe@example.com", "joe@python.org"]}}})
db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"joe@example.com",
"joe@gmail.com",
"joe@yahoo.com",
"joe@hotmail.com"
"joe@php.net"
"joe@python.org"
] }

删除数组元素

可以将数组看做一个队列或栈,使用$pop可以移除开头或结尾的一个元素。
{"$pop" : {"key" : 1}}移除末尾一个元素,{"$pop" : {"key" : -1}}移除开头一个元素。

有时我们需要一些要求删除元素,这时可以使用$pull

1
2
3
4
5
6
7
8
9
10
 db.lists.insert({"todo" : ["dishes", "laundry", "dry cleaning"]})
db.lists.update({}, {"$pull" : {"todo" : "laundry"}})

db.lists.find()
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"todo" : [
"dishes",
"dry cleaning"
] }

pulling会移除所有匹配的元素。

数组的定位修改器

如果数组有多个值,而我们只想修改其中一部分,这时我们需要通过位置或者定位操作符$。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
db.blog.posts.findOne()
{
"_id" : ObjectId("4b329a216cc613d5ee930192"),
"content" : "...",
"comments" : [
{
"comment" : "good post",
"author" : "John",
"votes" : 0
},
{
"comment" : "i thought it was too short",
"author" : "Claire",
"votes" : 3
},
{
"comment" : "free watches",
"author" : "Alice",
"votes" : -1
}
]
}
1
2
db.blog.update({"post" : post_id},
... {"$inc" : {"comments.0.votes" : 1}})

在很多情况下,不预先查询文档就不能知道所有修改数组的下标,我们需要$用来定位查询文档已经匹配的元素在数组中的位置,并进行更新。

1
2
3
db.blog.update({"comments.author" : "John"},
... {"$set" : {"comments.$.author" : "Jim"}})
// 56 67

upsert

upsert是一种特殊的更新。如果没有文档符合更新条件,就会以这个条件和更新文档内容为基础创建一个新的文档。如果找到了文档,则正常更新。

1
2
3
4
5
db.users.update({"rep" : 25}, {"$inc" : {"rep" : 3}}, true)
db.users.findOne()
{
"_id" : ObjectId("4b3295f26cc613d5ee93018f"),
"rep" : 28 }

有时有些键在文档插入时需要被设置,但是在随后的修改中不再改变,可以使用$setOnInsert

1
2
3
4
5
6
db.users.update({}, {"$setOnInsert" : {"createdAt" : new Date()}}, true)
db.users.findOne()
{
"_id" : ObjectId("512b8aefae74c67969e404ca"),
"createdAt" : ISODate("2013-02-25T16:01:50.742Z")
}

再执行一遍时,由于文档已经被插入,修改不再执行。

1
2
3
4
5
6
db.users.update({}, {"$setOnInsert" : {"createdAt" : new Date()}}, true) 
db.users.findOne()
{
"_id" : ObjectId("512b8aefae74c67969e404ca"),
"createdAt" : ISODate("2013-02-25T16:01:50.742Z")
}

save

save可以在文档不存在时插入,存在时更新。save只有一个参数:文档。要是这个文档含有_id键,save则会调用upsert(以_id作为匹配参数);否则会调用插入。可以非常方便的使用这个函数在shell中快速修改文档。

1
2
3
4
var x = db.foo.findOne() 
x.num = 42
42
> db.foo.save(x)

更新多个文档

默认情况下,更新只能对符合条件匹配的第一个文档执行操作。要是有多个文档符合条件,其余的文档不会发生变化。如果要使所有匹配的文档都得到更新,可以设置update的第4个参数为true。

1
2
db.users.update({"birthday" : "10/13/1978"},
... {"$set" : {"gift" : "Happy Birthday!"}}, false, true)

可以通过getLastError查看有多少文档被update。

1
2
3
4
5
db.count.update({x : 1}, {$inc : {x : 1}}, false, true) db.runCommand({getLastError : 1})
{
"err" : null, "updatedExisting" : true, "n" : 5,
"ok" : true
}

返回已更新的文档

用getLastError仅能获得有限的信息,并不能反悔已更新的文档。这个可以使用findAndModify命令实现。

findAndModify可以使一系列操作:查询,更新(remove),以原子性操作顺序一起执行完成,而不会发生读取某个值之后该值已经被其他客户端访问,同时满足的文档会被返回,实现在一个操作中返回结果并更新。

1
2
3
4
5
6
7
8
9
10
11
ps = db.runCommand({"findAndModify" : "processes",
... "query" : {"status" : "READY"},
... "sort" : {"priority" : -1},
... "update" : {"$set" : {"status" : "RUNNING"}})
{
"ok" : 1,
"value" : {
"_id" : ObjectId("4b3e7a18005cab32be6291f7"),
"priority" : 1,
"status" : "READY"
} }

注意,是先返回结果,然后再更新,之后再看文档:

1
2
3
4
5
6
db.processes.findOne({"_id" : ps.value._id})
{
"_id" : ObjectId("4b3e7a18005cab32be6291f7"),
"priority" : 1,
"status" : "RUNNING"
}

findAndModify也可以删除文档,也就是返回结果后,不需要更新,而是直接删除文档。

1
2
3
4
ps = db.runCommand({"findAndModify" : "processes", 	"query" : {"status" : "READY"},
"sort" : {"priority" : -1},
"remove" : true}).value
do_something(ps)

findAndModify命令中每个键对应的值如下所示:

键名 意义
findAndModify 字符串,集合名
query 查询文档,用来检索文档的条件
sort 排序结果文件的条件
update 修改器文档,对所找到的文档执行的更新
remove 布尔类型,表示是否删除文档

uodate和remove中有且只能有一个,并且findAndModify只能处理一个文档,不能执行upsert操作,只能更新已有文档。

查询

find

查询返回一个集合中符合条件的子集,其参数也是一个文档,成为query selector,说明选择文档的条件。

空的文档或不指定查询文档会匹配集合所有的内容。

db.c.find()

查询所有age为27的文档:
db.users.find({"age" : 27})

如果需要匹配一个字符串,比如username键值位joe

多个条件AND的关系:

1
2
db.users.find({"username" : "joe"})
db.users.find({"username" : "joe", "age" : 27})

指定返回的键

find可以设置第二个参数来指定想要的键,一般_id键总是会被返回,也可以设置为不返回。

1
2
3
4
5
6
7
8
9
10
11
db.users.find({}, {"username" : 1, "email" : 1})
{
"_id" : ObjectId("4ba0f0dfd22aa494fd523620"),
"username" : "joe",
"email" : "joe@example.com"
}

db.users.find({}, {"username" : 1, "_id" : 0})
{
"username" : "joe",
}

限制

查询文档中的键值对的值必须是常量或者是代码中的变量,但是不能是其他文档中的键的值。

查询条件

前面的查询都是精准匹配,更复杂的有范围,OR,子句,取反等。

查询条件

比较操作符包括$lt, $lte, $gt, $gte,$ne

1
2
3
4
5
6
db.users.find({"age" : {"$gte" : 18, "$lte" : 30}})

start = new Date("01/01/2007")
db.users.find({"registered" : {"$lt" : start}})

db.users.find({"username" : {"$ne" : "joe"}})

OR查询

MongoDB中有两种方式进行OR查询。 1. $in, $nin可以用来匹配一个键的多个值。 2. $or则更加通用,可以完成多个键值的OR查询。

1
2
3
4
db.raffle.find({"ticket_no" : {"$in" : [725, 542, 390]}})
db.users.find({"user_id" : {"$in" : [12345, "joe"]})

db.raffle.find({"ticket_no" : {"$nin" : [725, 542, 390]}})

使用$in, $nin只能实现对于单个键的OR查询,如果需要多个键的OR查询:

1
2
3
db.raffle.find({"$or" : [{"ticket_no" : 725}, {"winner" : true}]})

db.raffle.find({"$or" : [{"ticket_no" : {"$in" : [725, 542, 390]}}, {"winner" : true}]})

$not

$not是元条件语句,可以用在其他任何条件之上。$mod会将查询的值除以第一个给定值,若余数等于第二个给定值则返回该结果。

1
2
3
db.users.find({"id_num" : {"$mod" : [5, 1]}})

db.users.find({"id_num" : {"$not" : {"$mod" : [5, 1]}}})

$not和正则表达式结合会非常有用。

条件句的规则

条件句是内层文档的键,而修改器则是外层文档的键。
一个键可以有多个条件句,但是不能对应多个更新修改器。

特定类型的查询

null

null不仅能匹配键值为null的文档,而且也能匹配不存在该键值的文档。

1
2
3
4
5
6
7
8
9
db.c.find()
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523622"), "y" : 1 }
{ "_id" : ObjectId("4ba0f148d22aa494fd523623"), "y" : 2 }

db.c.find({"z" : null})
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523621"), "y" : null }
{ "_id" : ObjectId("4ba0f0dfd22aa494fd523622"), "y" : 1 }
{ "_id" : ObjectId("4ba0f148d22aa494fd523623"), "y" : 2 }

如果仅仅想要匹配键值为null的文档,既要检查该键的值是否为null,还要通过$exists条件判定键值是否已经存在。

1
db.c.find({"z" : {"$in" : [null], "$exists" : true}})

正则表达式

使用Perl兼容的正则表达式(PCRE)库来匹配正则表达式。

1
db.users.find({"name" : /joe/i})

查询数组

参数只有一个元素时,默认数组中有一个元素匹配就算作匹配。

1
2
db.food.insert({"fruit" : ["apple", "banana", "peach"]})
db.food.find({"fruit" : "banana"})

$all

如果需要通过多个元素来匹配数组,就要使用$all。顺序无关紧要。

1
2
3
4
5
6
7
db.food.insert({"_id" : 1, "fruit" : ["apple", "banana", "peach"]})
db.food.insert({"_id" : 2, "fruit" : ["apple", "kumquat", "orange"]})
db.food.insert({"_id" : 3, "fruit" : ["cherry", "banana", "apple"]})

db.food.find({fruit : {$all : ["apple", "banana"]}})
{"_id" : 1, "fruit" : ["apple", "banana", "peach"]}
{"_id" : 3, "fruit" : ["cherry", "banana", "apple"]}

如果直接以一个完整的数组作为参数,则是完全的精准匹配,包括顺序,元素个数,即是冗余也会被当做不匹配。

1
2
db.food.find({"fruit" : ["apple", "banana"]}) 
% 无法匹配

如果需要匹配指定位置的元素,可以使用key.index语法指定下标(下标从0开始)。

1
db.food.find({"fruit.2" : "peach"})

$size

用来指定查询指定长度的数组。

1
db.food.find({"fruit" : {"$size" : 3}})

一种需求是查询一个长度范围的数组,但是$size并不能与其他查询子句组合(如$gt),我们可以通过在文档中添加一个size键的方式实现,每次插入元素时同时更新size。

$slice

find的第二个参数是可选的,用来指定返回所需要的键。$slice可以指定返回数组的一个子集合。

返回前10条评论:

1
db.blog.posts.findOne(criteria, {"comments" : {"$slice" : 10}})

返回后10条评论:

1
db.blog.posts.findOne(criteria, {"comments" : {"$slice" : -10}})

同时也可以指定偏移量和需要的元素数量:

1
db.blog.posts.findOne(criteria, {"comments" : {"$slice" : [23, 10]}})

使用$slice时,如果没有具体声明其他键的返回需要,默认会返回所有其他的键。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "joe",
"email" : "joe@example.com",
"content" : "nice post."
},
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
}
] }

db.blog.posts.findOne(criteria, {"comments" : {"$slice" : -1}})
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
}
] }

查询内嵌文档

有两种方法可以查询内嵌文档:1. 查询整个文档 2. 只针对其键/值进行查询。

查询整个内嵌文档与普通查询完全相同。

1
2
3
4
5
6
7
8
9
10
{
"name" : {
"first" : "Joe",
"last" : "Schmoe"
},
"age" : 45
}

% 完全匹配整个文档
db.people.find({"name" : {"first" : "Joe", "last" : "Schmoe"}})

如果joe加入一个middle name,上面将无法匹配。更好的方式是针对内嵌文档的特定键值进行查询。

1
db.people.find({"name.first" : "Joe", "name.last" : "Schmoe"})

用.表示深入内嵌文档内部。点表示法也是待插入文档不能包含.的原因。

如果是有一个数组包含若干内嵌文档,我们需要找到有满足某组合条件内嵌文档的文档,上面是无法实现的,因为满足各个组合条件的因素并不在同一个内嵌文档中。这时我们需要使用$elemMatch,对于数组的当个文档,需要实现所有的match条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
db.blog.find()
{
"content" : "...",
"comments" : [
{
"author" : "joe",
"score" : 3,
"comment" : "nice post"
},
{
"author" : "mary",
"score" : 6,
"comment" : "terrible post"
}
]
}

b.blog.find({"comments" : {"author" : "joe", "score" : {"$gte" : 5}}})
% failed

db.blog.find({"comments.author" : "joe", "comments.score" : {"$gte" : 5}})
% failed, because the author criteria could match a different comment
% than the score criteria. That is, it would return the document
% shown above: it would match "author" : "joe" in the first comment
% and "score" : 6 in the second comment.

db.blog.find({"comments" : {"$elemMatch" : {"author" : "joe",
"score" : {"$gte" : 5}}}})
% successful

$where查询

键/值对是很有表现力的查询方式,但是仍不能处理所有情况,这是我们也许需要使用$where,用来指定一些更特殊的条件,它可以执行任意javascript作为查询条件判断的一部分。

如比较文档中的存在两个键值是否相等。

1
2
db.foo.insert({"apple" : 1, "banana" : 6, "peach" : 3})
db.foo.insert({"apple" : 8, "spinach" : 4, "watermelon" : 4})

第二文档存在”spinach”键的值和”watermelon”相等,是符合要求的文档,但是前面的条件句都无法实现这个功能,这时需要使用$where.

1
2
3
4
5
6
7
8
9
10
db.foo.find({"$where" : function () {
for (var current in this) {
for (var other in this) {
if(current != other && this[current] == this[other]) {
return true;
}
}
}
return false;
}}};

对于每个文档(用this表示)都使用函数进行判断,如果函数函数返回true,则这个文档将作为结果set的一个文档。

前面用的是一个函数,也可以用一个字符串来指定$where查询。下面两种表达式是完全等价的:

1
2
db.foo.find("$where" : "this.x + this.y == 10")
db.foo.find("$where" : "function() {return this.x + this.y == 10};")

不是非常必要时,一定要避免使用$where,因为速度要比常规查询慢的多。每篇文档要从BSON转换为javascript对象,然后通过$where的表达式来判断。

游标

数据库使用游标来返回find的执行结果。客户端对游标的实现通常能够对最终结果进行有效的控制。可以限制结果的数量,略过部分就诶过,根据任意方向任意键的组合对结果进行各种排序,或者是执行其他一些功能强大的操作。

1
2
3
for(i=0; i<100; i++) {
db.collection.insert({x : i}); ... }
var cursor = db.collection.find();

这样mongobd不会自动迭代输出所有结果,可以一次查看一条结果。

1
2
3
4
while (cursor.hasNext()) {
obj = cursor.next();
// do stuff ...
}

游标类还实现了迭代器接口,所以可以在foreach循环中使用。

1
2
3
4
5
6
7
var cursor = db.people.find();
cursor.forEach(function(x) {
print(x.name);
... });
adam
matt
zak

当我们调用find的时候,shell并不立即查询数据库,而是等待真正开始要求获得结果的时候才发送查询,这样可以在执行之前可以给查询附加额外的选项。几乎所有游标对象的方法都返回游标本身,这样就可以按任意顺序组成方法链。例如,下面几种表达是等价的:

1
2
3
var cursor = db.foo.find().sort({"x" : 1}).limit(1).skip(10); 
var cursor = db.foo.find().limit(1).sort({"x" : 1}).skip(10);
var cursor = db.foo.find().skip(10).limit(1).sort({"x" : 1});

此时查询还没有被执行,这些函数都只是构造查询。当我们执行

cursor.hasNext()

查询被发往服务器。shell立即获得前100个结果或者前4mb数据(两者中较小者),这样下次调用next或hasNext()就不必再去服务器上去数据。一旦客户端用完第一组结果,shell会再一次请求数据库得到后面的结果。这个过程会一直持续到游标耗尽或者结果全部返回。

limit, skip和sort

1
2
db.c.find().limit(3)
db.c.find().skip(3)

sort用一个文档作为参数:一组键/值对。键对应用于排序的键名,值代表排序的方向,1表示升序,-1表示降序。

1
db.c.find().sort({username : 1, age : -1})

注意有时一个键的值可能是多种类型的,多种类型数据的排序是预先定义好的,从小到大顺序如下:

  1. Minimum value
  2. null
  3. Numbers (integers, longs, doubles)
  4. Strings
  5. Object/document
  6. Array
  7. Binary data
  8. Object ID
  9. Boolean
  10. Date
  11. Timestamp
  12. Regular expression
  13. Maximum value

避免使用skip略过大量结果

skip大量结果速度很很慢,所以要尽量避免。通常可以向文档本身内置查询条件,来避免大的skip,或者利用上次的结果计算下一次查询。

  • 不用skip对结果分页
1
2
3
4
5
6
7
8
9
10
11
12
13
// do not use: slow for large skips
var page1 = db.foo.find(criteria).limit(100)
var page2 = db.foo.find(criteria).skip(100).limit(100)
var page3 = db.foo.find(criteria).skip(200).limit(100)

// use:
var page1 = db.foo.find().sort({"date" : -1}).limit(100)
var latest = null;
// display first page
while (page1.hasNext()) { latest = page1.next(); display(latest);
}
// get next page
var page2 = db.foo.find({"date" : {"$gt" : latest.date}}); page2.sort({"date" : -1}).limit(100);
  • 随机选取文档

最慢的方法是计算文档总数,然后选取一个随机数利用find做一次查询。
另外一种方法是给每个文档添加一个额外的随机键。

高级查询选项

查询分为包装的和普通的两类。

普通查询:
var cursor = db.foo.find({"foo" : "bar"})

有几个选项用于包装查询:
var cursor = db.foo.find({"foo" : "bar"}).sort({"x" : 1})

实际上发送的查询文档不是{"foo" : "bar"},而是将原始查询文档包装在一个更大的文档中{"$query" : {"foo" : "bar"}, "$orderby" : {"x" : 1}}作为发送的查询文档。

查询的各种选项:

  • $maxscan : integer
    Specify the maximum number of documents that should be scanned for the query.
    db.foo.find(criteria)._addSpecial("$maxscan", 20)

This can be useful if you want a query to not to take too long but are not sure how much of a collection will need to be scanned. This will limit your results to whatever was found in the part of the collection that was scanned (i.e., you may miss other documents that match).

  • $min : document
    Start criteria for querying. document must exactly match the keys of an index used for the query. This forces the given index to be used for the query.
    This is used internally and you should generally use "$gt" instead of "$min". You can use "$min" to force the lower bound on an index scan, which may be helpful for complex queries.

  • $max : document
    End criteria for querying. document must exactly match the keys of an index used for the query. This forces the given index to be used for the query.
    If this is used internally, you should generally use "$lt" instead of "$max". You can use "$max" to force bounds on an index scan, which may be helpful for complex queries.

  • $showDiskLoc : true
    Adds a "$diskLoc" field to the results that shows where on disk that particular result lives. For example:

    1
    2
    db.foo.find()._addSpecial('$showDiskLoc',true)
    { "_id" : 0, "$diskLoc" : { "file" : 2, "offset" : 154812592 } } { "_id" : 1, "$diskLoc" : { "file" : 2, "offset" : 154812628 } }

The file number shows which file the document is in. In this case, if we’re using the test database, the document is in test.2. The second field gives the byte offset of each document within the file.

获取一致结果

数据处理通常的一种做法就是先把数据从MongoDB中取出来,然后经过某种变换,,然后存回去。

1
2
3
4
5
6
cursor = db.foo.find();
while (cursor.hasNext()) {
var doc = cursor.next();
doc = process(doc);
db.foo.save(doc);
}

但是如果文档的改动比较大,尤其是增加了很多数量量,会导致预留空间不够,这时mongodb会将文档移动至集合的末尾处。导致的后果是游标往后面移动又会返回已经被处理但是被移动过的文档。

解决方法是对查询进行快照,如果使用了$snapshot选项,查询就是针对不变的集合视图运行的,确保查询的结果就是查询执行的那一刻的一致快照。

索引

索引介绍

如果经常需要基于某个键进行检索,可以对该键建立索引,以提高查询速度。使用explain()可以看到mongodb的执行信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
for (i=0; i<1000000; i++) { ... db.users.insert(
... {
... "i" : i,
"username" : "user"+i,
"age" : Math.floor(Math.random()*120),
"created" : new Date()
}
);
}

db.users.find({username: "user101"}).explain()
{
"cursor" : "BasicCursor",
"nscanned" : 1000000,
"nscannedObjects" : 1000000,
"n" : 1,
"millis" : 721,
"nYields" : 0,
"nChunkSkips" : 0,
"isMultiKey" : false,
"indexOnly" : false,
"indexBounds" : {
}
}

% 建立索引
db.users.ensureIndex({"username" : 1})

db.users.find({"username" : "user101"}).explain()
{
"cursor" : "BtreeCursor username_1",
"nscanned" : 1,
"nscannedObjects" : 1,
"n" : 1,
"millis" : 3,
"nYields" : 0,
"nChunkSkips" : 0,
"isMultiKey" : false,
"indexOnly" : false,
"indexBounds" : {
"username" : [
[
"user101",
"user101"
] ]
} }

建立索引:

1
db.users.ensureIndex({"username" : 1})

-1, 1指名创建索引的方向,如果索引只有一个键,则方向无关紧要。如果有个键则需要考虑索引的方向问题。

如果索引包含N个键,则对于前几个键的查询都会有帮助,比如索引{"a" : 1, "b" : 1, "c" : 1}实际上是有了{“a” : 1}, {“a” : 1, “b” : 1}, {“a” : 1, “b” : 1, “c” : 1}索引,但是没有{“b” : 1}, {“a” : 1, “c” : 1}等索引。

创建索引是每次插入,更新删除时都会产生额外的开销,即使是查询,如果查询要返回集合中一半以上的结果,表扫描效率要高于查索引。

扩展索引

在建立索引时我们需要考虑以下问题:

  • 会做怎样的查询?其中哪些键需要索引?
  • 每个键的索引方向是怎样的?
  • 如何应对扩展?有没有种不同的键的排列可以使常用数据更多的保留在内存中,减少内存交换次数。

假设我们有个集合,保存了用户的状态信息。现在想要查询用户的日期,取出某一用户最近的状态,我们可以创建:

1
db.status.ensureIndex({user : 1, date : -1})

组织形式:

1
2
3
4
5
user 1, 2015_11_11_2
user 1, 2015_11_11_1...and
user 2, 2015_11_11_6
user 2, 2015_11_10_1
...

这样会对用户的日期的查询非常快,但是并不是最好的方式。因为每个用户每天可能有数十条状态更新,如果每个用户的状态索引值都占用类似一页纸一样的空间,但是其中大部分并不是最新值,如果用户数很多,那么内存将无法放下所有索引,发生频繁内存交换。

如果改变索引顺序为{data : -1, user : 1},则数据库可以按照时间将更多用户最近的状态保存在内存中,可以有效减少内存交换。

索引内嵌文档中的键

为内嵌文档的键建立所有那个和位普通的键创建索引没有什么区别。

1
db.blog.ensureIndex({"comments.date" : 1})

内嵌文档的键索引与普通键索引并无差异,两者也可以联合组成符合索引。

为排序创建索引

对于经常需要排序的键,最好建立索引。如果对没有索引的键调用sort,mongodb需要将所有数据提取到内存来做排序,代价非常高且有上限。

索引名称

集合中每个索引都有一个字符串类型的名字,来唯一标识索引,如果没有指定名字,默认情况下会将索引的键名连在一起作为该索引的名字。如果索引的键特别多,就不太合适,可以使用下面的方式:

1
2
db.foo.ensureIndex({"a" : 1, "b" : 1, "c" : 1, ..., "z" : 1},
... {"name" : "alphabet"})

唯一索引

唯一索引可以确保集合的每一个文档的指定键都有唯一值。

1
db.people.ensureIndex({"username" : 1}, {"unique" : true})

消除重复

如果创建唯一索引时已经有数据重复,那么唯一索引的创建会失败。如果希望将所有包含重复值的文档只保留第一个,而删除后面重复的文档,可以使用:

1
db.people.ensureIndex({"username" : 1}, {"unique" : true, "dropDups" : true})

复合唯一索引

创建复合唯一索引时,单个键的值可以相同,只要所有键的组合不同就可以。

explain和hint

索引管理

索引的元信息存储在每个数据库的system.indexes集合中。这是一个保留集合,不能对其插入或者删除文档。操作只能通过ensureIndex或者dropIndexes进行。

system.indexes集合包含每个索引的详细信息,同时system.namespaces集合也包含有索引的名字。在system.namespaces集合中哦该,每个集合至少有两个文档与之对应,集合本身名字,以及其_id唯一索引名字,如果还有其他索引,也会有对应信息在system.namespaces中。

修改索引

后台建立新的索引(否则建立索引期间会阻塞所有索引期间的请求)。

1
db.people.ensureIndex({"username" : 1}, {"background" : true})

删除索引

查看system.indexes找到索引名字,然后删除:

1
2
3
db.people.dropIndex("x_1_y_1")
{ "nIndexesWas" : 3, "ok" : 1 }
% 99 130

可以使用通配符删除所有索引(除了_id索引,这个索引只有删除集合时才会被删除)

另外删除集合也会删除这个集合的所有索引,remove方式不会影响索引。

System Design_大型网站高可用架构

Posted on 2015-11-29 | In System design | Comments:

高可用的网站架构

互联网企业实现高可用架构设计的主要目的是保证服务器硬件故障时服务依然可用,数据依然完整并能够被访问,其主要手段是数据和服务的冗余备份及失效转移。一旦某些服务器宕机,就将服务切换到其他可用的服务器上,如果磁盘损坏,则从备份的磁盘或数据服务器读取数据。

一个典型的网站设计通常遵循如下分层架构:

System_design_c5_1

各层具有相对独立性,上层依赖下层。应用层主要负责业务具体逻辑处理;服务层负责提供可复用的服务;数据层负责数据的存储与访问。中小型网站在具体部署时,通常将应用层和服务层部署在一起,数据层另外部署。

System_design_c5_2

复杂的大型网站会有更细的划分粒度:

System_design_c5_3

大型网站的分层架构和物理服务器的分布式部署使得位于不同层次的服务具有不同的可用性特点。宕机时产生的影响也不同,高可用的解决方案也不同。

位于应用层的服务器通常为了应对高并发的访问请求,会通过负载均衡设备将一组服务器组成一个集群共同对外提供服务,当负载均衡设备通过心跳检测等手段监控到某台应用服务器不可用时,就将其剔除,并将请求分发到其他可用的服务器上。服务层的服务器类似。

数据服务器上存储着数据,为了保证服务器宕机时数据不丢失,数据访问不中断,需要在数据写入时进行数据同步复制,将数据写入多台服务器上,实现数据冗余备份。当数据服务器宕机时,应用程序将访问切换到有备份数据的服务器上。

高可用的应用

应用层主要处理网站应用的业务逻辑,也称业务逻辑层,实现高可用需要的一个特点是应用的无状态性,也就是应用服务器不保存业务的上下文信息,而是仅根据每次请求提交的数据进行相应的业务逻辑处理,多个服务实例(服务器)之间完全对等,请求提交到任意服务器,处理结果都是完全一样的。

通过负载均衡进行无状态服务的失效转移

不保存状态的应用给高可用的架构设计带来了巨大便利,因为所有服务器完全对等,可以将请求转移给任何一台可用的服务器而不会影响结果。

负载均衡实现服务器可用状态实时监控,自动转移失效任务,同时保证每个服务器上的负载相对平均(根据具体性能),不至于超过负载。

System_design_c5_4

应用服务器集群的Session管理

实际上很多业务是有状态的,如电子商务网站购物车等。Web应用将这些多次请求修改使用的上下文对象称为Session,在单机情况下,session可由部署在服务器上的web容器管理(如Jboss)。但在使用负载均衡的集群环境中,请求可能被分发到任何一台服务器上,情况要复杂很多。

集群环境下,session管理主要有一下几种手段:

  • session复制

System_design_c5_5

集群中所有服务器同步session对象。当集群规模很大时,负担很重。

  • Session绑定

session绑定可以利用负载均衡的原地址Hash算法实现,负载均衡总是将来源于同一IP的请求分发到同一台服务器上(或Cookie信息)。这时负载均衡必须工作在HTTP协议层上。Session绑定也称为会话粘滞。

System_design_c5_6

但是不符合系统高可用的要求,某台服务器宕机后,其上的session信息都会丢失。没有得到广泛使用。

  • 利用Cookie记录session

将session记录放在客户端,每次请求服务器时,将session放在请求中发给服务器,服务器处理完请求后再将修改过的session响应发给客户端。

System_design_c5_7

缺点是Cookie大小有限制,传输Cookie会影响性能。还有可能浏览器关闭Cookie支持等情况。但是因为其便利性,仍得到了广泛使用。

  • Session服务器

利用独立部署的Session服务器统一管理Session,服务器每次读写Session时,都访问Session服务器。

System_design_c5_8

实际上是将应用服务器的状态分离,分为无状态的应用服务器和有状态的Session服务器。

高可用的服务

可复用的服务模块为业务产品提供基础公共服务,大型网站中这些服务通常独立分布式部署,被具体应用远程调用。可复用的服务和应用一有那个,也是无状态服务,因此可以使用类似负载均衡的失效转移策略实现高可用的服务。

除此之外,还有一下的几点高可用的服务策略:

  • 分级管理
    运维上将服务器进行分级管理,核心应用和服务使用更好的硬件。同时服务部署上也进行必要的隔离,避免故障的连锁反应,低优先级的服务通过启动不同的线程或者部署在不同的虚拟机上进行隔离,高优先级的服务则需要部署在不同的屋里机上,核心服务和数据甚至需要部署在不同地域的数据中心。

  • 超时设置
    在应用中设置服务调用的超时时间,一旦超时,通信框架就抛出异常,应用程序根据服务调度策略,可以选择继续重试或者转移请求。

  • 异步调用

应用对服务的调用通过消息队列等异步方式完成,避免一个服务失败导致整个应用请求失败的情况。

  • 服务降级

如果网站遇到无法承受高并发访问,为了保证核心应用和功能的正常运行,可以对服务进行降级,一种手段是拒绝低优先级应用的调用,减少服务调用并发数,也就是拒绝服务,另一种是关闭部分不重要的服务,以节约系统开销,也就是关闭服务。

  • 幂等性设计

有时会出现虚假的服务调用失败,比如服务完成超时,但是后面完成了操作,但是应用还是发出了一个新的请求。服务重复调用是无法避免的,因此需要在服务层保证重复调用和调用一次产生的结果相同,即服务具有幂等性。

有些服务天然具有幂等性,但有些不是,比如转账等,这时需要设置交易编号等信息进行服务调用有效性验证,以实现幂等性。

高可用的数据

不同于高可用的应用和服务,由于数据存储服务器上保存的数据不同,当某台服务器宕机时,数据访问请求不能任意切换到集群的其他机器上。

保证数据存储高可用的手段是数据备份和失效转移机制。数据备份是保证数据有多个副本,失效不会导致数据的永久丢失。失效转移保证一个数据副本不可访问时,可以快速切换访问数据的其他副本。

关于缓存的高可用,本质上其不是数据存储服务,缓存服务器宕机引起缓存数据丢失导致服务器负载压力过高应该通过其他手段解决,而不是提高缓存服务本身的高可用。

CAP原理

为了保证数据的高可用,往往需要牺牲另一个重要指标;数据一致性。

高可用的数据有如下几个层面的含义:

  • 数据持久性:即数据不会丢失,需要将数据备份多个副本
  • 数据可访问:在多份数据副本分别存放在不同设备情况下,如果一个损坏,就需要将数据访问切换到另一个设备上。如果这个过程不能很快完成(用户几乎没有感知),那么这段时间数据是不可访问的。
  • 数据一致性:在数据有多个副本情况下,如果网络等出现故障,会导致部分副本更新成功,部分可能会失败,这时副本间的数据就会不一致。

CAP原理任务,一个提供数据服务的存储系统无法同时满足数据一致性(Consistency)、数据可用性(Availibility)、分区耐受性(Patition Tolerance,系统具有跨网络分区的伸缩性)。

System_design_c5_9

在大型网站应用中,数据规模总是快速扩张的,因此可伸缩性即分区耐受性必不可少。规模变大以后,节点失效的可能也会变大,要想保证应用可用,就必须保证数据的可用性。所以在互联网应用中,通常会强化分布式存储系统的可用性和伸缩性,而在一定程度上放弃一致性。尽管如此,系统需要对分布式数据处理系统的数据不一致性进行弥补,以避免数据不正确。

具体数据一致性又可分为如下几点:

  • 数据强一致:各个副本数据在物理存储总是一致的。
  • 数据用户一致:数据在物理存储中的各个部分可能是不一致的,但是用户访问时,通过纠错和校验,可以确定一个一致且正确的数据返回给用户。
  • 数据最终一致:较弱的一致性,物理存储可能是不一致的,用户访问也可能是不一致的,但是系统经过一段时间(较短的时间段)的自我恢复和修正,数据最终会达到一致。

因为难以满足数据强一致性,网站通常会综合成本技术,结合数据监控和纠错功能,使存储系统达到用户一致。

数据备份

备份分为冷备和热备。

冷备的有点是简单和廉价,但是不能保证数据最终一致,因为可能丢失上一次冷备后的更新,同时不能保证数据可用性,因为一般需要较长时间恢复数据。

所以需要热备,热备可以分为两种:异步热备方式和同步热备方式。

异步方式是指多份数据副本的写入操作异步完成,应用程序收到数据服务系统的写操作¥成功响应时,只成功了一份,存储系统会异步地写其他副本(这个过程有可能会失败)。通常先由主存储服务器(Master)写入第一份数据然后响应,然后通过异步线程将写操作数据同步到从存储服务器。

System_design_c5_10

同步方式是指多份数据副本的写入操作同步完成。为了提高性能,通常需要并发的向多个存储服务器写入数据。没有主从之分,方便管理,但是响应速度慢。

System_design_c5_11

关系数据库热备一般是Master-Slave同步机制,另外这一机制也可以通过读写分离改善数据库的性能,写操作只访问Master数据库,读操作只访问Slave数据库。

失效转移

失效转移由下面三部分组成:

  • 失效确认

System_design_c5_12

通常通过心跳检测和应用程序访问失败报告确认服务器是否宕机。对于访问失败报告,系统还需要再发送一次心跳检测进行确认。

  • 访问转移
  • 数据恢复

由于某台服务宕机,存储的副本减少,所以需要重新恢复足够的副本数。

高可用网站的软件质量保证

网站发布

自动化测试

预发布验证

代码控制

自动化发布

灰度发布

网站运行监控

监控数据采集

监控管理

System Design_大型网站高性能架构

Posted on 2015-11-21 | In System design | Comments:

网站性能测试

性能测试指标

  • 响应时间:指应用执行一个请求到相应所需要的时间。
  • 并发数:只系统同时处理请求的数目,最大并发数数值也反映了系统的负载特性。
  • 吞吐量(TPS):指单位时间内系统处理的请求量,体现系统的整体处理能力。在系统并发数由小增大的过程中,系统吞吐量先是逐渐增加,达到一个极限后,随着并发数的增加反而下降,达到系统崩溃点后,系统资源耗尽,吞吐量为0。可以理解为吞吐量是每天通过收费站的车辆数目,并发数是高速公路上的车辆数目。
  • 性能计数器:描述服务器或者操作系统性能的一些数据指标,如System loab, 内存使用,CPU使用..

性能测试方法

  • 性能测试:对系统不断施加压力,验证系统在资源可接受范围内,能否达到性能预期。
  • 负载测试:对系统不断增加并发请求以增加系统压力,直到系统的某项或者性能指标达到安全临界值,如果某种资源已经是饱和状态,这时继续提高压力,系统的吞出量不但不能提高,反而会下降。
  • 压力测试:超过安全负载的情况下,对系统继续施加压力,直到系统崩溃或者不能再处理任何请求,以此获得系统最大压力承受能力。
  • 稳定性测试:在特定硬件、软件、网络环境下,给系统加载一定业务压力,是系统运行一段较长时间,以测试是否稳定。

上面的的增加压力,就是不断增加并发请求数,消耗的系统资源也会变多。

System_design_c4_2

System_design_c4_1

性能测试报告

测试结果报告应该能反映上述性能测试曲线的规律,阅读者可以得到系统性能是否满足设计目标和业务要求、系统最大负载能力、系统最大压力承受能力等重要信息。

System_design_c4_3

性能优化策略

检查请求处理的各个环节的日志,分析哪个环节响应时间不合理;然后检查监控数据,分析影响性能的主要因素是内存、磁盘、网络还是CPU,是代码或架构问题或者是系统资源确实不足。

定位具体原因后,根据具体层次进行优化。

Web前端性能优化

Web前端一般指业务逻辑之前的部分,包括浏览器加载、网站视图模型、图片服务、CDN服务等,主要优化手段有优化浏览器、使用反向代理、CDN等。

浏览器访问优化

  1. 减少HTTP请求,因为每次都要开启线程建立通信链路。如合并CSS, Javascript等。
  2. 使用浏览器缓存。
  3. 启用压缩
  4. 减少Cookie传输

CDN加速

CDN(Content Distribute Network,内容分发网络)的本质仍是一个缓存,将数据缓存在离用户最近的地方。

CDN能偶缓存的一般是静态资源,如图片,文件,视频等。

System_design_c4_4

反向代理

传统代理服务器位于浏览器一侧,代理浏览器将HTTP请求发送到互联网上,而反向代理服务器位于网站机房一侧,代理网站Web服务器接收HTTP请求。

System_design_c4_5

和传统代理服务器可以保护浏览器安全一样,反向代理服务器也具有保护网站安全的作用,来自互联网的访问必须经过反向代理服务器。

除了安全功能,代理服务器也可以通过配置缓存功能加速Web请求。

此外,反向代理还可以实现负载均衡的功能,通过构建负载均衡的应用集群可以提高系统的总体处理能力,进而改善网站高并发下的性能。

应用服务器性能优化

应用服务器就是处理网站业务的服务器,网站的业务代码都部署在这里,是网站开发最复杂、变化最多的地方,主要优化手段有缓存、集群、异步等。

分布式缓存

任何时候,优先考虑使用缓存优化性能。

缓存基本原理

缓存指将数据存储在相对较高访问速度的存储介质中,一方面可以减少访问时间,同时也可能减少计算时间。缓存的本质是一个内存Hash表,网站应用中,数据缓存以一对Key、Value的形式存储在内存Hash表中。

System_design_c4_7

缓存主要用来存放读写比例很高、变化很少的数据,即80%的访问在20%的数据上。通过缓存可以提高数据读取速度,降低存储访问压力。

合理使用缓存

几个要注意的问题:
频发修改的数据
没有热点的访问
数据不一致与脏读
缓存可用性:数据库可能在缓存服务崩溃时由于承受突然增加的压力而宕机,进而导致整个网站不可用,称为缓存雪崩。分布式缓存可以在一定程度上改善缓存的可用性。
缓存预热:在启动缓存后,可能需要较长时间重建缓存数据,可以在缓存系统启动时就准备好热点数据然后直接加载。例如,一些元数据可以全部加载。
缓存穿透:如果因为不恰当的业务、或者恶意攻击持续高并发地请求某个不存在的数据,由于缓存没有保存该数据,所有的请求的都会落到数据库上,会对数据库造成很大的压力。一个简单的对策是将这些不存在的数据也缓存起来,value值设置为null。

分布式缓存架构

分布式缓存指缓存部署在多个服务器组成的集群中,以集群方式提供缓存服务,其架构方式有两种,一种是以JBoss Cache为代表的需要更新同步的分布式缓存,一种是以Memcached为代表的不互相通信的分布式缓存。

JBoss Cache的分布式缓存在集群中所有的服务器中保存相同的缓存数据,当某台服务器有缓存数据更新时,会通知集群中其他机器更新。当集群规模较大时,同步代价非常大,且由于多台服务器存有多份相同的数据,内存利用率不高。

System_design_c4_8

Memcached

Memcached采用一种集中式的缓存集群管理,也就是互不通信的分布式架构模式,缓存与应用分离部署,部署在一组专门的服务器上,应用程序通过一致性Hash等路由算法选择缓存服务器远程访问缓存数据,缓存服务器之间不通信,可以很容易的实现扩容,具有良好的可伸缩性。

System_design_c4_9

简单的通信协议
高性能的网络通信
高效的内存管理
Memcached使用了固定空间分配。将内存空间分为一组Slab,每个slab里又包含一组chunk,同一个slab里的每个chunck的大小是固定的,拥有相同大小chunk的slab被组织在一起,称为slab_class。

System_design_c4_10

互不通信的服务器集群架构:正是这个架构使得集群可以做到几乎无限制的线性伸缩。

异步操作

使用消息队列将调用异步化,可以改善网站的扩展性。同时还可以改善网站系统的性能。

System_design_c4_11

在不使用消息队列的情况下,用户的请求数据直接写入数据库,在高并发的情况下,会对数据库造成巨大的压力,同时也使得相应延迟加剧。使用消息队列后,用户请求在被发送给消息队列后立即返回,响应延迟可以得到有效改善,再由消息队列的合适数量的消费者进程进行处理,进行数据库读写,不必创建超过负载的处理进程。

消息队列具有很好的削峰作用—即通过异步处理,将段时间高并发产生的事务消息存储在消息队列中,从而削平高峰期的并发事务。在电子商务网站促销活动中,合理使用消息队列可以有效抵御冲击。

System_design_c4_12

使用集群

在网站高并发访问场景下,使用负载均衡计数为一个应用构建由多台服务器组成的服务器集群,将并发访问请求分发到多台服务器上处理,避免单一服务器因负载压力过大而响应缓慢,是用户请求具有更好的响应延迟特性。

System_design_c4_13

代码优化

  • 多线程
    假设服务器执行都是相同类型服务,估算公式:
    启动线程数=[任务执行时间/(任务执行时间-IO等待时间)] * CPU内核数
    需要注意线程安全问题。
  • 资源复用
    减少开销大的系统资源创建和销毁,使用单例模式和对象池,线程池。
  • 数据结构和高效算法
  • 垃圾回收
    如在JVM情况下,合理设置Young Generation和Old Generation大小,尽量减少Full GC。根据应用设置合理的回收模式。

存储性能优化

机械硬盘 vs 固体硬盘

B+树 vs LSM树

RAID vs HDFS

System Design_大型网站安全架构

Posted on 2015-11-19 | In System design | Comments:

网站应用攻击与防御

XSS攻击

XSS攻击即夸站点脚本攻击(Cross Site Script),指黑客通过篡改网页,注入恶意HTML脚本,在用户浏览网页时,控制用户浏览器进行恶意操作的一种攻击方式。

常见的XSS攻击类型有两种,一种是反射型,攻击者诱使用户点击一个嵌入恶意脚本的链接,达到攻击的目的。

System_design_c8_1

另一种XSS攻击是持久型XSS攻击,黑客提交含有恶意脚本的请求,保存在被攻击的Web站点的数据库中,用户浏览网页时,恶意脚本被包含在正常页面中,达到攻击的目的。

System_design_c8_2

XSS防攻击的主要方式有如下两种:

消毒

XSS攻击者一般都是通过在请求中嵌入恶意脚本达到攻击目的,这些脚本是一般用户输入中不使用的,如果进行过滤和消毒处理,即对某些html危险字符转义,如”>”转义为”&gt”等,就可以防止大部分攻击。为了避免对不必要的内容转移错误,如”3<5”中的”<”需要进行文本匹配后再转义,如”<img src=”这样的上下文中的”<”才转义。消毒几乎是所有网站最必备的XSS防攻击手段。

HttpOnly

浏览器禁止页面JavaScript访问带有HttpOnly属性的Cookie。HttpOnly并不是直接对抗XSS攻击的,而是防止XSS攻击窃取Cookie。对于存放敏感信息的Cookie,如用户认证信息等,可通过对该Cookie添加HttpOnly属性,避免被攻击脚本窃取。

注入攻击

注入攻击主要有两种形式,SQL注入攻击和OS注入攻击。SQL注入攻击为攻击者在HTTP请求中注入恶意SQL命令(drop table users),服务器用请求构造参数构造数据库SQL命令时,恶意SQL被一起构造,并在数据库执行。

System_design_c8_3

SQL注入攻击需要攻击者对数据库结构有所了解才能进行,攻击者获取数据库表结构信息的手段有如下几种:

网站基于开源软件搭建
错误回显:如果网站开启错误回显,即服务器内部500错误会显示到浏览器上。
盲注:网站关闭错误回显,攻击者根据页面变化情况判断SQL语句的执行情况,据此猜测数据库表结构,难度比较大。

防御SQL注入攻击首先要避免被攻击者猜测到表名等数据库表结构信息,此外还可以采用如下方式:

消毒:和防XSS攻击一样,请求参数消毒是一种比较简单粗暴又有效的手段。通过正则匹配,过滤请求数据中可能注入的SQL,如”drop table”等。

参数绑定:使用预编译手段,绑定参数是最好的防SQL注入方法。目前许多数据访问层框架,如IBatis,Hibernate等,都实现SQL预编译和参数绑定,攻击者的恶意SQL会被当做SQL的参数,而不是SQL命令被执行。

除了SQL注入,攻击者还根据具体应用,注入OS命令、编程语言代码等,利用程序漏洞,达到攻击目的。

CSRF攻击

CSRF(Cross Site Request Forgery,跨站点请求伪造),攻击者通过跨站请求,以合法用户的身份进行非法操作,如转账交易等。CSRF的主要手法是利用跨站请求,在用户不知情的情况下,以用户的身份伪造请求。其核心是利用了浏览器Cookie或服务器Session策略,盗取用户身份。

System_design_c8_4

CSRF的防御手段主要是识别请求者身份。主要有下面几种方法:

表单Token

CSRF是一个伪造用户请求的操作,所以需要构造用户的请求的所有参数才可以。表单Token通过在请求参数中增加随机数的方法来阻止攻击获得所有请求参数:在页面表单中增加一个随机数作为Token,每次响应页面的Token都不相同,从正常页面提交的请求会包含该Token值,而伪造的请求无法获得该值,服务器检查请求参数中Token的值是否存在并且正确以确定请求提交者是否为伪造的。

验证码

验证码是一种更加简单有效的手段,即请求提交时,需要通过用户输入验证码。

Referer check

HTTP请求头的Referer域中记录着请求来源,可通过检查请求来源,验证其是否合法。

其他攻击和漏洞

Error Code
错误回显信息。可以通过配置Web服务器参数,跳转到500页面(HTTP响应码500表示服务器内部错误)到专门的错误页面即可,Web应用常用的MVC框架也有这个功能。

HTML注释:HTML注释会显示在客户端浏览器。

文件上传上传可执行程序以及攻击者获得服务器端命令执行能力。有效的手段是设置上传文件白名单,只允许上传可靠的文件类型。此外还可以修改文件名,使用专门的存储等手段。

路径遍历:攻击者在请求的URL中使用相对路径,遍历系统为开放的目录和文件。防御主要是将JS,CSS等资源文件部署在独立服务器,使用独立域名,其他文件不使用静态URL访问,动态参数最好不包含文件路径信息。

Web应用防火墙

ModSecurity是一个开源的Web应用防火墙,自动探测攻击并保护Web程序,能够同一拦截请求,过滤恶意参数,自动消毒,添加Token等。既可以嵌入到Web应用服务器中,也可以作为一个独立的应用程序启动。

ModSecurity采用处理逻辑与攻击规则集合分离的架构模式。处理逻辑(执行引擎)负责请求和响应的拦截过滤,规则加载执行等功能。而攻击规则集合则负责描述对具体攻击的规则定义、模式识别、防御策略等功能(可以通过文本方式进行描述)。处理逻辑比较稳定,规则集合需要不断针对漏洞进行升级,这时一种可扩展的架构设计。

System_design_c8_5

网站安全漏洞扫描

网络安全漏洞扫描工具是根据内置规则,构造具有攻击性的URL请求,模拟黑客攻击行为,用以发现网站安全漏洞的工具。

信息加密技术及密钥安全管理

网站用户账户密码等敏感信息不能以明文存储,必须要进行加密处理。信息加密技术可分为三类:单项散列加密,对称加密和非对称加密。

单向散列加密

单向散列加密是指通过对不同输入长度的信息进行散列计算,得到固定长度的输出,这个散列计算过程是单向的,即不能对固定长度的输出进行逆计算得到输入信息。

System_design_c8_6

利用单向散列加密的特性,可以进行密码加密保存,即用户注册时的密码不直接保存到数据库,而是进行单向散列加密后将密文存入数据库。用户登录时,进行密码验证,同样计算得到输入密码的密文,并和数据库的密文比较。

System_design_c8_7

这样即使数据库被”拖库”,也不会泄露用户的密码信息。

虽然不能通过算法将单向散列密文反算得到明文,但是由于人们设置密码具有一定模式,因此通过彩虹表(人们常用密码和对应的密文关系表等)等手段可以进行猜测式破解。

为了加强单向散列计算的安全性,还会给散列算法加点盐(salt),salt相当于加密的密钥,增加了破解的难度。

常用的单向散列算法有MD5,SHA等。单向散列算法还有一个特点就是输入的任何微小变化都会导致输出的完全不同,这个特性有时也会被用来生成信息摘要、计算具有高离散程度的随机数等用途。

对称加密

所谓对称加密是指加密和解密使用的密钥是同一个密钥(或者可以互相推算)。

System_design_c8_8

对称加密通常用在信息需要安全交换或存储的场合,如Cookie加密、通信加密等。

对称加密的优点是算法简单,加密解密效率高,适合对大量数据加密。缺点是加密解密使用同一个密钥,远程通信的情况下如何安全的交换密钥是个难题,如果密钥丢失,那么所有的加密信息也就失去安全性。

常用的对称加密算法有DES,RC算法等。对称加密是一种传统加密手段,也是最常用的加密手段,适用于绝大多数需要加密的场合。

非对称加密

不同于对称加密,非对称加密和解密使用的密钥不是同一密钥,其中一个对外公开,被称为公钥,另一个只有所有者直到,称为私钥。使用公钥加密的信息必须要私钥才能解开,反之,用私钥加密的信息只有用公钥才能解开。理论上不可能通过公钥计算获得私钥。

System_design_c8_9

非对称加密技术通常用在信息安全传输、数字签名等场合。

信息发送者A通过公开渠道获得信息接收者B的公钥,对提交信息进行加密,然后发送到B,B得到密文信息后使用私钥解密获得明文信息。即使信息在中途被窃取,没有B的私钥也无法还原明文。

数字签名的过程则相反,签名者用自己的私钥对信息加密,然后发送给对方,接收方用签名者的公钥对信息进行解密,获得明文信息。由于私钥只有签名者拥有,因此该信息必然是由签名者发送的,不可抵赖,具有签名性质。

在实际应用中,常常会混合使用对称加密和非对称加密。先使用非对称加密技术对对称密钥进行传输,然后使用对称加密技术进行信息加密解密与交换。另外可以对同一个数据两次使用非对称加密,可同时实现信息的安全传输和数字签名的目的。

非对称加密的常用算法有RSA算法等。HTTPS传输中浏览器使用的数字证书实质上是经过权威机构认证的非对称加密的公钥。

密钥安全管理

上面的加密算法能否达到安全保密关键的一点是密钥的安全。信息的安全是靠密钥保证的。

改善密钥安全性的手段有两种:

一种是包密钥和算法放在一个独立的服务器上,甚至做出一个专用的硬件设施,对胃提供加密和解密服务,应用系统通过调用这个服务,实现数据的加密解密。优点是安全性高,但是成本较高,系统开销大,甚至可能称为应用瓶颈。

另一种方案是将加密解密算法放在应用系统中,密钥则放在独立服务器中,为了提高密钥的安全性,实际存储时,密钥被切分为数片,加密后分别保存在不同的存储介质中,兼顾密钥安全性有改善了性能。

System_design_c8_10

应用程序调用密钥安全管理系统提供的加密解密服务接口对信息进行加密解密,该接口实现了常用的加密解密算法并根据需求任意扩展。加密解密服务接口则通过密钥服务器的密钥服务取得加密解密密钥,并缓存在本地(定时更新)。而密钥服务器中的密钥则来自多个密钥存储服务器,一个密钥分片后存储在多个服务器中,每个服务器都有专人负责管理。密钥申请者、管理者、安全审核人员通过密钥管理控制台管理更新密钥,没有人能够查看完整的密钥信息。

信息过滤与反垃圾

常用的信息过滤与反垃圾手段有以下几种。

文本匹配

文本匹配主要解决敏感词过滤的问题,进行消毒处理或者拒绝发表。

如果敏感词较少,可以直接使用正则表达式匹配,但是效率较差,不使用高并发量时以及大数据量时。更好的选择是基于Trie树,或则Trie树的变种,如双数组Trie算法,利用两个稀疏数组存储树结构,base数组存储Trie树的节点,check数组进行状态检查。

另一种更简单的实现是通过构造多级Hash表进行文本匹配。过滤树的分支可能比较多,为了提高匹配速度,减少不必要的查找,同一层中相同父节点的字可放在Hash表中。

System_design_c8_11

有时还需要对信息进行降噪预处理,如”笨_%单”。

分类算法

用于自动识别广告信息、垃圾邮件等。

System_design_c8_12

比较简单实用的有贝叶斯分类算法,贝叶斯算法认为特征值之间是独立的,所以也成为朴素贝叶斯算法,然而这个假设很多时候是不成立的,特征值之间具有关联性。通过对贝叶斯算法增加特征值的关联依赖处理,得到TAN算法。更进一步,通过对关联规则的聚类挖掘,可以到ARCS(Association Rule Clustering System)等更强大的算法。由于朴素贝叶斯的简单高效,所以其仍然是很多在线系统的首选。

分类算法除了用于反垃圾,也用于信息自动分类等。

黑名单

黑名单可以通过Hash表实现,简单高效,但是内存消耗较大。在黑名单列表非常大时,如果能够容忍小概率的差错,可以使用布隆过滤器代替Hash表(Bloom filter)。Bloom filter是一种常用的处理海量数据的技术。

System_design_c8_13

System Design_大型网站架构模式及核心要素

Posted on 2015-11-11 | In System design | Comments:

大型网站架构模式

所谓模式就是描述了在我们周围不断重复发生的问题以及该问题解决方案的核心,这样我们可以一次又一次使用该方案而不必重复解决问题。模式的关键在于模式的可重复性,问题与场景的可重复性带来解决方案的可重复使用。

为了解决大型网站面临的高并发访问、海量数据处理、高可靠运行等一系列问题与挑战,许多公司在实践中提出了许多解决方案,以实现网站高性能、高可用、易伸缩、可扩展、安全等技术架构目标。

分层

分层是企业应用系统常用的架构模式,将系统在横向维度切分为几个部分(垂直的分为几层),然后通过上层对下层的依赖和调用组成一个完整的系统,如分割为应用层、服务层、数据层,应用层可细分问u视图层、业务逻辑层。服务层可以细分为数据接口层、逻辑处理层。

不同层可以部署在不同的服务器上,实现低耦合,可伸缩。

分割

水平分割,例如按照功能将系统分割,包装为高内聚低耦合的模块单元,一方面有助于软件的开发和维护,同样便于不同模块的分布式部署,提高网站的并发处理能力和功能扩张能力。

分布式

对于大型网站,分层和分割的一个主要目的是为了切分后的模块便于分布式部署,可通过增加机器提高并发能力。但分布式需要通过网络,会带来性能的影响,多个服务器也意味着机器出现问题的概率变大,降低网站的可用性。另外一个问题是在分布式的环境中保持数据一致性。

几种分布式方案:

  1. 分布式应用和服务:将分层和分割后的应用和服务分布式部署,
  2. 分布式静态资源:将网站的静态资源独立分布式部署。
  3. 分布式数据和存储:分布式数据库,NoSQL等。
  4. 分布式计算

集群

使用分布式虽然已经将分层和分割后的模块独立部署,但是对于用户访问集中的模块,还需要将独立部署的服务器集群化,即多台服务器部署相同服务,通过负载均衡设备共同对外提供服务。通过添加新服务器可以提高服务能力,也就是具有可伸缩性,另外还可以通过失效转移等机制保证可用性。

缓存

  • CDN:内容分发网络,部署在离终端用户最近的网络服务提供商,缓存网站一些静态资源(变化较少的资源)或者热点内容。
  • 反向代理:反向代理属于网站前端架构的一部分,部署在网站的前端,当用户请求到达网站的数据中心时,最先访问的就是反向代理服务器,这里缓存网站的静态和热点资源,无需将请求转发给应用服务器就可以返回给用户。
  • 本地缓存:在应用服务器本地缓存热点数据,减少访问文件和数据库。
  • 分布式缓存:本地缓存一般不够大,另外将数据缓存在一个专门的分布式缓存集群中,通过网络通信访问缓存数据。

异步

降低软件耦合性,前面分层、分割、分布也是同样的目标。通过异步,业务之间的消息传递不是同步调用,而是将一个业务操作分成多个阶段,每个阶段之间通过共享数据的方式异步执行协作。

在单一服务器内部可通过多线程共享内存队列的方式实现异步:在分布式系统中,多个服务器可以通过分布式消息队列实现异步。

异步架构是典型的生产者消费者模式,两者不存在直接调用,只要保持数据结构不变,批次功能实现可以随意变化而不互相影响,这对网站扩展新功能非常便利。另外,使用异步消息队列还有如下特性。

提高系统可用性:消费者服务器发生故障,数据会在消息队列服务器中堆积,生产者可以继续处理业务请求,系统整体无故障。消费者服务器恢复正常后,继续处理消息队列中的数据。

加快网站相应速度:处在业务处理前端的生产者服务器可以在处理完业务请求后,将数据写入消息队列后返回,不需要同步等待消费者服务器处理就可以返回,减少相应延迟。

消除并发访问高峰:用户访问存在突然的高峰,如果同时同步处理所有访问,会导致网站负载过重,响应延迟。使用消息队列将突然增加的访问请求数据放入消息队列中,等待消费者服务器依次处理,就不会对整个网站负载造成太大压力。

冗余

要保证网站的可用性,需要一定程度的服务器冗余运行,数据冗余备份,这样当某台服务器宕机时,可以将其上的服务和数据访问转移到其他机器上。数据库除了冷备份外,为了保证在线业务高可用,也需要对数据库进行主从分离,实时同步实现热备份。

自动化

安全

大型网站核心要素

除了系统的功能需求外,软件架构还需要关注可能、可用性、伸缩性、扩展性、安全性这个5个架构要素,以及平衡这5个要素之间的关系以实现需求和架构目标。

性能

性能是网站的一个重要指标,用户无法忍受一个相应缓慢的网站。

性能的问题无处不在,所以优化的方面也很多:

  • 在浏览器端,可以通过浏览器缓存、页面压缩、减少cookie传输、JavaScript实现可以在本地实现的功能,减少访问次数
  • CDN,反向代理服务器缓存热点文件。
  • 应用服务器端,可以使用服务器本地缓存和分布式缓存,通过缓存在内存的热点数据,减少访问数据库,加快请求处理过程。
  • 异步将用户请求发送到消息队列等待处理,直接返回相应给用户。
  • 在高并发请求的情况下,可以将多台应用服务器组成一个集群共同对外服务,提高整体处理能力。
  • 代码层面,使用合适的数据结构、高效算法,以及多线程、改善内存管理等手段优化性能。
  • 数据库服务器端,索引、索引、sql优化。
  • NoSQL,Hadoop,分布式计算和存储。

衡量网站性能有一系列指标,如相应时间、TPS等,可以测试这些指标确定系统是否达到目标。

可用性

高可用架构设计的前提是必然出现服务器宕机,设计目标是服务器宕机时,服务依然可用。

实现高可用的主要手段是冗余,应用部署在多台服务器上同时提供服务,数据存储在多台服务器上相互备份,任何一台机器宕机不会影响整体服务,也不会导致数据丢失。

对于应用服务器,可以通过负载均衡服务器组成一个集群共同对外提供服务,一台机器宕机时可以将服务转移到其他机器上,前提是服务器不在本地保存服务的会话信息。

对于存储服务器,需要实现实时备份。

伸缩性

所谓伸缩性是指能否通过不断增加机器来缓解上升用户的并发访问压力和不断增长的数据存储需求。加入新的服务器后是否可以能提供和原来的服务器无差别的服务,集群中可容纳的总得服务器数量是否有限制。

对于应用服务器集群,只要服务器上不保存数据,所有服务器都是对等的,通过使用合适的负载均衡就可以向集群中不断加入服务器。

对于缓存服务器集群,加入新的服务其可能会导致缓存路由失效,进而导致集群中大部分缓存数据都无法访问。如果应用严重依赖缓存,可能对网站造成严重影响甚至数据库服务崩溃。需要改进缓存路由算法保证缓存数据的可访问性,一个解决方法是一致性hash算法。

关系数据库虽然支持数据复制,主从热备等机制,但是很难做到大规模集群的可伸缩性,因此关系数据库的集群伸缩方案必须在数据库之外实现,通过路由分区等手段将部署多个数据的服务器组成一个集群。

至于大部分NoSQL数据库产品,由于其就是为海量数据而生,所以对伸缩性支持都非常好,可以做到在较少操作下实现集群规模的线性伸缩。

扩展性

网站的扩展性架构直接关注网站的功能需求。网站快速发展,功能不断扩展,如何设计网站的架构使其能够快速相应需求变化,是网站可扩展的主要目的。

衡量网站架构扩展性的好坏的主要标准就是在网站增加新的业务产品时,是否可以实现对现有产品透明无影响,不需要改动或很少改动既有业务功能就可以上线新功能。不同产品之间是否很少耦合,一个产品改动对其他产品无影响。

网站可伸缩架构的主要手段是事件驱动和分布式服务。

事件驱动架构在网站使用消息队列实现,将用户请求和其他业务时间构造消息发布到消息队列,消息的处理作为消费者从消息队列中获取消息进行处理。通过这种方式将消息产生和消息处理分离开来,可以透明地增加新的消息生产者任务或者新的消息消费者任务。

分布式服务则是将业务和可复用的服务分离开来,通过分布式服务框架调用。新增产品可以调用可复用的服务实现自身的业务逻辑,而对现有产品没有任何影响。可复用服务升级变更时,也可以通过提供多版本服务对应用实现透明升级,不需要强制应用同步变更。

安全性

安全架构目的是保证网站不受恶意访问和攻击,保护网站的重要数据不被窃取。

衡量网站安全架构的标准就是针对现存和潜在的各种攻击和窃密手段,是否有可靠的应对策略。

1234…17
Plusaber

Plusaber

Plusaber's Blog
82 posts
12 categories
22 tags
Links
  • LinkedIn
  • Indeed
  • Baito
  • Kaggle
© 2014 – 2019 Plusaber
Powered by Hexo v3.8.0
|
Theme – NexT.Mist v7.1.1