分布式数据库的前世今生
当人们一开始使用数据库系统的时候,所有数据都是跑在一台服务器上,即所谓的单机数据库服务器。在企业级应用中,我们会搭建一台应用程序服务器,一般它会被运行在一台服务器或者工作站上,大多数情况下采用 Linux/Unix/Windows 操作系统,也有人把这样的服务器称之为应用程序服务器。顾名思义,他的作用是处理复杂的业务逻辑。但是一点需要注意的是,在这样的构架中,这台应用程序服务器不会存储任何业务数据,也就是说,他只负责逻辑运算,处理用户请求,真正存放数据的地方是前面提到的那台数据库服务器。应用程序服务器将用户的请求转换成数据库语言(通常是 SQL),运行在数据库中,从而进行数据的增删改查。数据库服务器不会对外直接开放,管理人员也不允许直接在数据库层面操作数据。所有的操作都会经过应用程序服务器来完成。应用程序层、数据库层再加上 UI 层,被称为传统的 Web 三层构架。
Replication
随着数据量的增大,技术的不断进步以及需求的增加,安全性、可靠性、容错性、可恢复性等因素被人们考虑进数据库的设计中。于是出现了分布式数据库系统。以前在存储数据的时候,都是采用单体构架模式,及数据全部存储在一台数据库中,一旦数据库出现问题,所有的应用请求都会受到影响。数据库的恢复也是一个令人头疼的问题。有时一次数据库的全恢复会运行几个小时甚至几天的时间。在互联网应用不断普及的今天,业务需求对构架产生了严峻的挑战。没有哪个互联网应用会允许若干小时的宕机时间。分布式数据库的产生,为我们提供了技术上的解决方案。在部署数据库的时候,不用于以前的单体应用,分布式下数据库部署包括多点部署,一套业务应用数据库被分布在多台数据库服务器上,分主从服务器。主服务器处理日常业务请求,从服务器在运行时不断的对主服务器进行备份,当主服务器出现宕机、或者运行不稳定的情况时,从服务器会立刻替换成主服务器,继续对外提供服务。此时,开发运维人员会对出现问题的服务器进行抢修、恢复,之后再把它投入到生产环境中。这样的构架也被称作为高可用构架,它支持了灾难恢复,为业务世界提供了可靠的支持,也是很多企业级应用采用的主流构架之一。需要指出的是,在这样的主从设计中,从数据库常常被设计成只读,主数据库支持读写操作。一般会有一台主数据库连接若干台从数据库。在互联网产品的应用中,人们大多数情况下会对应用服务器请求读操作,这样应用服务器可以把读操作请求分发到若干个从数据库中,这样就避免了主数据库的并发请求次数过高的问题。至于为什么大多数应用都是读操作,你可以想一下在你使用微信或者微博的时候,你是看别人发布的图片多还是自己发布的时候多。当你不断下滑屏幕,刷新朋友圈,这些都是读请求。只有当评论、点赞、分享的时候才会进行写操作。
我们的世界就是这样,当技术为人们解决了现实问题以后,新的需求会层出不穷。智能手机,互联网 +,创业潮的不断兴起,点燃了这样一个拥有几千年文明历史的民族的激情。各种新点子、新概念不断的涌现,谁的手机里没有安装几十个互联网应用,从订餐,快递,到住房,旅游,再到教育,养老,那一个环节没有互联网的支持,没有技术的成分。我们就是生存在这样一个的平凡而又不乏豪情的社会中。许许多多的需求和数据充斥着我们的构架,挑战着我们的存储。
对此,你可能已经想到,前面提到的分布式数据库多点部署是不是会存在大量的瓶颈。比如,在主从数据库结构中,从数据库的内容基本上可以说是主数据库的一份全拷贝,这样的技术称之为Replication
。Replication
在实现主从数据同步时,通常采用Transaction Log
的方式,比如,当一条数据插入到主数据库的时候,主数据库会像Trasaction Log
中插入一条记录来声明这次数据库写纪录的操作。之后,一个Replication Process
会被触发,这个进程会把Transaction Log
中的内容同步到从数据库中。整个过程如下图所示:
对于数据库的扩展来说,通常有两种方法,水平扩展和垂直扩展。
- 垂直扩展:这种扩展方式比较传统,是针对一台服务器进行硬件升级,比如添加强大的 CPU,内存或者添加磁盘空间等等。这种方式的局限性是仅限于单台服务器的扩容,尽可能的增加单台服务器的硬件配置。优点是构架简单,只需要维护单台服务器。
- 水平扩展:这种方式是目前构架上的主流形式,指的是通过增加服务器数量来对系统扩容。在这样的构架下,单台服务器的配置并不会很高,可能是配置比较低、很廉价的 PC,每台机器承载着系统的一个子集,所有机器服务器组成的集群会比单体服务器提供更强大、高效的系统容载量。这样的问题是系统构架会比单体服务器复杂,搭建、维护都要求更高的技术背景。MongoDB 中的 Sharding 正式为了水平扩展而设计的,下面就来挤开 shard 面纱,探讨一下 shard 中不同分片的技术区别以及对数据库系统的影响。
分片 (Shard)
前面提到的 Replication 结构可以保证数据库中的全部数据都会有多分拷贝,数据库的高可用可以保障。但是新的问题是如果要存储大量的数据,不论主从服务器,都需要存储全部数据,这样检索必然会出现性能问题。可以这样讲,Replication
只能算是分布式数据库的第一阶段。主要解决的是数据库高可用,读数据可以水平扩展,部分解决了主数据并发访问量大的问题。但是它并没有解决数据库写操作的分布式需求,此外在数据库查询时也只限制在一台服务器上,并不能支持一次查询多台数据库服务器。我们假设,如果有一种构架,可以实现数据库水平切分,把切分的数据分布存储在不同的服务器上,这样当查询请求发送到数据库时,可以在多台数据库中异步检索符合查询条件的语句,这样不但可以利用多台服务器的 CPU,而且还可以充分利用不同服务器上的 IO,显而易见这样的构架会大大提高查询语句的性能。但是这样的实现却给数据库设计者代码不少麻烦,首先要解决的就是事务(Transaction
),我们知道在进行一次数据库写操作的时候,需要定一个事务操作,这样在操作失败的时候可以回滚到原始状态,那当在分布式数据库的情况下,事务需要跨越多个数据库节点以保持数据的完整性,这给开发者带来不少的麻烦。此外,在关系型数据库中存在大量表关联的情况,分布式的查询操作就会牵扯到大量的数据迁移,显然这必将降低数据库性能。但是,在非关系型数据库中,我们弱化甚至去除了事务和多表关联操作,根据 CAP 理论:在分布式数据库环境中,为了保持构架的扩展性,在分区容错性不变的前提下,我们必须从一致性和可用性中取其一,那么,从这一点上来理解“NoSQL 数据库是为了保证 A 与 P,而牺牲 C”的说法,也是可以讲得通的。同时,根据该理论,业界有一种非常流行的认识,那就是:关系型数据库设计选择了一致性与可用性,NoSQL 数据库设计则不同。其中,HBase
选择了一致性与分区可容忍性,Cassandra
选择了可用性与分区可容忍性。
本文关注于非关系型数据库中分区的技巧和性能,以 MongoDB 为例进行说明,在下面的章节中就围绕这一点展开讨论。
MongoDB 分片原理
MongoDB 中通过 Shard 支持服务器水平扩展,通过 Replication 支持高可用(HA)。这两种技术可以分开来使用,但是在大数据库企业级应用中通常人们会把他们结合在一起使用。
MongoDB Sharding
首先我们简要概述一下分片在 MongoDB 中的工作原理。通过分片这个单词我们可以看出,他的意思是将数据库表中的数据按照一定的边界分成若干组,每一组放到一台 MongoDB 服务器上。拿用户数据举例,比如你有一张数据表存放用户基本信息,可能由于你的应用很受欢迎,短时间内就积攒了上亿个用户,这样当你在这张表上进行查询时通常会耗费比较长的时间,这样这个用户表就称为了你的应用程序的性能瓶颈。很显然的做法是对这张用户表进行拆分,假设用户表中有一个age
年龄字段,我们先做一个简单的拆分操作,按照用户的年龄段把数据放到不同的服务器上,以 20 为一个单位,20 岁以下的用户放到 server1,20 到 40 岁的用户放到 server2,40-60 岁的用户放到 server3,60 岁以上放到 server4,后面我们会讲这样的拆分是否合理。在这个例子中,用户年龄age
就是我们进行Sharding
(切分)的Shard Key
(关于 Shard Key 的选择后面会详细介绍),拆分出来的server1
, server2
, server3
和server4
就是这个集群中的 4 个Shard
(分区)服务器。好,Shard 集群已经有了,并且数据已经拆分完好,当用户进行一次查询请求的时候我们如何向这四个 Shard 服务器发送请求呢?例如:我的查询条件是用户年龄在 18 到 35 岁之间,这样个查询请求应当发送到server1
和server2
,因为他们存储了用户年龄在 40 以下的数据,我们不希望这样的请求发送到另外两台服务器中因为他们并不会返回任何数据结果。此时,另外一个成员就要登场了,mongos
,它可以被称为 Shard 集群中的路由器,就像我们网络环境中使用的路由器一样,它的作用就是讲请求转发到对应的目标服务器中,有了它我们刚才那条查询语句就会正确的转发给server
和server2
,而不会发送到server3
和server4
上。mongos
根据用户年龄(Shard Key)分析查询语句,并把语句发送到相关的 shard 服务器中。除了mongos
和shard
之外,另一个必须的成员是配置服务器,config server
,它存储 Shard 集群中所有其他成员的配置信息,mongos
会到这台config server
查看集群中其他服务器的地址,这是一台不需要太高性能的服务器,因为它不会用来做复杂的查询计算,值得注意的是,在 MongoDB3.4 以后,config server
必须是一个replica set
。理解了上面的例子以后,一个 Shard 集群就可以部署成下图所示的结构:
其中:
- shard: 每一个 Shard 服务器存储数据的一个子集,例如上面的用户表,每一个 Shard 存储一个年龄段的用户数据。
- mongos: 处理来自应用服务器的请求,它是在应用服务器和 Shard 集群之间的一个接口。
- config server: 存储 shard 集群的配置信息,通常部署在一个 replica set 上。
MongoDB Shard 性能分析
环境准备
这样的服务器构架是否合理,或者说是否能够满足数据量不断增长的需求。如果仅仅是通过理论解释恐怕很难服众,我已经信奉理论结合实际的工作方式,所以在我的文章中除了阐述理论之外,一定会有一定的示例为大家验证理论的结果。接下来我们就根据上面的例子做一套本地运行环境。由于 MongoDB 的便捷性,我们可以在任何一台 PC 上搭建这样一个数据库集群环境,并且不限制操作系统类型,任何 Windows/Linux/Mac 的主流版本都可以运行这样的环境。在本文中,我才用 MongoDB3.4 版本。
对于如何创建一个 MongoDB Shard 环境,网上有很多教程和命令供大家选择,创建一个有 3 个 Mongos,每个 Mongos 连接若干个 Shards,再加上 3 个 config server cluster,通常需要 20 几台 MongoDB 服务器。如果一行命令一行命令的打,即便是在非常熟练的情况下,没有半个小时恐怕搭建不出来。不过幸运的是有第三方库帮我们做这个事情,大家可以查看一下mtools
。他是用来创建各种 MongoDB 环境的命令行工具,代码使用python
写的,可以通过pip install
安装到你的环境上。具体的使用方法可以参考https://github.com/rueckstiess/mtools/wiki/mlaunch
。也可以通过https://github.com/zhaoyi0113/mongo-cluster-docker
上面的脚本把环境搭载 Docker 上面。
下面的命令用来在本地创建一个 MongoDB Shard 集群,包含 1 个mongos
路由,3 个shard
replica,每个 replica 有 3 个shard
服务器,3 个config
服务器。这样一共创建 13 个进程。
mlaunch init --replicaset --sharded 3 --nodes 3 --config 3 --hostname localhost --port 38017 --mongos 1
服务器创建好以后我们可以连接到mongos
上看一下 shard 状态,端口是上面制定的 38017。
mongos> sh.status() --- Sharding Status --- ... shards: { "_id" : "shard01", "host" : "shard01/localhost:38018,localhost:38019,localhost:38020", "state" : 1 } { "_id" : "shard02", "host" : "shard02/localhost:38021,localhost:38022,localhost:38023", "state" : 1 } { "_id" : "shard03", "host" : "shard03/localhost:38024,localhost:38025,localhost:38026", "state" : 1 } active mongoses: "3.4.0" : 1 ...
可以看到刚才创建的 shard 服务器已经加入到这台 mongos 中了,这里有 3 个 shard cluster,每个 cluster 包含 3 个 shard 服务器。除此之外,我们并没有看到关于 Shard 更多的信息。这是因为这台服务器集群还没有任何数据,而且也没有进行数据切分。
数据准备
首先是数据的录入,为了分析我们服务器集群的性能,需要准备大量的用户数据,幸运的是mtools
提供了mgenerate
方法供我们使用。他可以根据一个数据模版向 MongoDB 中插入任意条 json 数据。下面的 json 结构是我们在例子中需要使用的数据模版:
{ "user": { "name": { "first": {"$choose": ["Liam", "Aubrey", "Zoey", "Aria", "Ellie", "Natalie", "Zoe", "Audrey", "Claire", "Nora", "Riley", "Leah"] }, "last": {"$choose": ["Smith", "Patel", "Young", "Allen", "Mitchell", "James", "Anderson", "Phillips", "Lee", "Bell", "Parker", "Davis"] } }, "gender": {"$choose": ["female", "male"]}, "age": "$number", "address": { "zip_code": {"$number": [10000, 99999]}, "city": {"$choose": ["Beijing", "ShangHai", "GuangZhou", "ShenZhen"]} }, "created_at": {"$date": ["2010-01-01", "2014-07-24"] } } }
把它保存为一个叫user.json
的文件中,然后使用mgenerate
插入一百条随机数据。随机数据的格式就按照上面json
文件的定义。你可以通过调整 --num
的参数来插入不同数量的 Document。(Link to mgenerate wiki)
mgenerate user.json --num 1000000 --database test --collection users --port 38017
上面的命令会像test
数据库中users
collection 插入一百万条数据。在有些机器上,运行上面的语句可能需要等待一段时间,因为生成一百万条数据是一个比较耗时的操作,之所以生成如此多的数据是方便后面我们分析性能时,可以看到性能的显著差别。当然你也可以只生成十万条数据来进行测试,只要能够在你的机器上看到不同find
语句的执行时间差异就可以。
插入完数据之后,我们想看一下刚刚插入的数据在服务器集群中是如何分配的。通常,可以通过sh.status()
MongoDB shell 命令查看。不过对于一套全新的集群服务器,再没有切分任何 collection 之前,我们是看不到太多有用的信息。不过,可以通过 explain 一条查询语句来看一下数据的分布情况。这里不得不强调一下在进行数据性能分析时一个好的 IDE 对工作效率有多大的影响,我选择 dbKoda 作为 MongoDB 的 IDE 主要原因是他是目前唯一一款对 MongoDB Shell 的完美演绎,对于 MongoDB Shell 命令不太熟悉的开发人员来说尤为重要,幸运的是这款 IDE 还支持 Windows/Mac/Linux 三种平台,基本上覆盖了绝大多数操作系统版本。下面是对刚才建立的一百万条 collection 的一次 find 的 explain 结果。(对于 Explain 的应用,大家可以参考我的另外一片文章:如何通过 MongoDB 自带的 Explain 功能提高检索性能?)
(点击放大图像)
从上图中可以看到,我们插入的一百万条数据全部被分配到了第一个 shard 服务器中,这并不是我们想看到的结果,不要着急,因为我还没有进行数据切分,MongoDB 并不会自动的分配这些数据。下面我们来一点一点分析如何利用 Shard 实现高效的数据查询。
配置 Shard 数据库
环境搭建好并且数据已经准备完毕以后,接下来的事情就是配置数据库并切分数据。方便起见,我们把用户分为三组,20 岁以下(junior),20 到 40 岁(middle)和 40 岁以上(senior),为了节省篇幅,我在这里不过多的介绍如何使用 MongoDB 命令,按照下面的几条命令执行以后,我们的数据会按照用户年龄段拆分成若干个 chunk,并分发到不同的 shard cluster 中。如果对下面的命令不熟悉,可以查看 MongoDB 官方文档关于 Shard Zone/Chunk 的解释。
db.getSiblingDB('test').getCollection('users').createIndex({'user.age':1}) sh.setBalancerState(false) sh.addShardTag('shard01', 'junior') sh.addShardTag('shard02', 'middle') sh.addShardTag('shard03', 'senior') sh.addTagRange('test.users', {'user.age': MinKey}, {'user.age':20}, 'junior') sh.addTagRange('test.users', {'user.age': 21}, {'user.age':40}, 'middle') sh.addTagRange('test.users', {'user.age': 41}, {'user.age': MaxKey}, 'senior') sh.enableSharding('test') sh.shardCollection('test.users', {'user.age':1}) sh.setBalancerState(true)
从上面的命令中可以看出,我们首先要为 Shard Key 创建索引,之后禁止 Balancer 的运行,这么做的原因是不希望在 Shard Collection 的过程中还运行 Balancer。之后将数据按照年龄分成三组,分别标记为junior
, middle
,senior
并把这三组分别分配到三个 Shard 集群中。 之后对 test 库中的 users collection 进行按用户年龄字段的切分操作,如果 Shard collection 成功返回,你会得到下面的输出结果:{ "collectionsharded" : "test.users", "ok" : 1 }
。
关于 Shard 需要注意的几点
- 一旦你对一个 Colleciton 进行了 Shard 操作,你选择的 Shard Key 和它对应的值将成为不可变对象,所以:
- 你无法在为这个 collection 重新选择 Shard Key
- 你不能更新 Shard key 的取值
随后不要忘记,我们还需要将 Balancer 打开:sh.setBalancerState(true)
。刚打开以后运行sh.isBalancerRunning()
应当返回true
,说明 Balancer 服务正在运行,他会调整 Chunk 在不同 Shards 服务器中的分配。一般 Balancer 会运行一段时间,因为他要对分组的数据重新分配到指定的 shard 服务器上,你可以通过sh.isBalancerRunning()
命令查看 Balancer 是否正在运行。现在可以稍事休息一下喝杯咖啡或看看窗外的风景。
为了理解数据如何分布在 3 个 shard 集群中,我们有必要分析一下 chunk 和 zone 的划分,下图是在 dbKoda 上显示 Shard Cluster 统计数据,可以看到数据总共被分成 6 个 chunks,每个 shard 集群存储 2 个 chunk。
(点击放大图像)
对此有些同学会有疑问,为什么我们的数据会被分为 6 个 chunks,而且每个 shard 集群个分配了 2 个 chunk。是谁来保证数据的均匀分配?下面我就给大家解释一下他们的概念以及我们应当如何使用。
Chunk
我们已经知道 MongoDB 是通过 shard key 来对数据进行切分,被切分出来的数据被分配到若干个 chunks 中。一个 chunk 可以被认为是一台 shard 服务器中数据的子集,根据 shard key,每个 chunk 都有上下边界,在我们的例子中,边界值就是用户年龄。chunk 有自己的大小,数据不断插入到 mongos 的过程中,chunk 的大小会发生变化,chunk 的默认大小是 64M。当然 MongoDB 允许你对 chunk 的大小进行设置,你也可以把一个 chunk 切分成若干个小 chunk,或者合并多个 chunk。一般我不建议大家手动操作 chunk 的大小,或者在 mongos 层面切分或合并 chunk,除非真有合适的原因才去这么做。原因是,在数据不断插入到我们的集群中时,mongodb 中的 chunk 大小会发生很大的变化,当一个 chunk 的大小超过了最大值,mongo 会根据 shard key 对 chunk 进行切分,在必要的时候,一个 chunk 可能会被切分成多个小 chunk,大多数情况下这种自动行为已经满足了我们日常的业务需求,无需进行手动操作,另一点原因是当进行 chunk 切分后,直接的结果会导致数据分配的不均匀,此时 balancer 会被调用来进行数据重新分配,很多时候这个操作会运行很长时间,无形中导致了内部结构的负载平衡,因此不建议大家手动拆分。当然,理解 chunk 的分配原理还是有助于大家分析数据库性能的必要条件。我在这里不过多的将如何进行这些操作,有兴趣的读者可以参考 MongoDB 官方文档,上面有比较全面的解释。这里我只强调在进行 chunk 操作的时候,要注意一下几个方面,这些都是影响你 MongoDB 性能的关键因素。
- 如果存在大量体积很小的 chunk,他可以保证你的数据均匀的分布在 shard 集群中但是可能会导致频繁的数据迁移。这将加重 mongos 层面上的操作。
- 大的 chunk 会减少数据迁移,减轻网络负担,降低在 mongos 路由层面上的负载,但弊端是有可能导致数据在 shard 集群中分布的不均匀。
- Balancer 会在数据分配不均匀的时候自动运行,那么 Balancer 是如何决定什么情况下需要进行数据迁移呢?答案是 Migration Thresholds,当 chunk 的数量在不同 shard replica 之间超过一个定值时,balancer 会自动运行,这个定值根据你的 shard 数量不同而不同。
Zones
可以说 chunk 是 MongoDB 在多个 shard 集群中迁移数据的最小单元,有时候数据的分配不会按照我们臆想的方向进行,就拿上面的例子来说,虽然我们选择了用户年龄作为 shard key,但是 MongoDB 并不会按照我们设想的那样来分配数据,如何进行数据分配就是通过 Zones 来实现。Zones 解决了 shard 集群与 shard key 之间的关系,我们可以按照 shard key 对数据进行分组,每一组称之为一个 Zone,之后把 Zone 在分配给不同的 Shard 服务器。一个 Shard 可以存储一个或多个 Zone,前提是 Zone 之间没有数据冲突。Balancer 在运行的时候会把在 Zone 里的 chunk 迁移到关联这个 Zone 的 shard 上。
理解了这些概念以后,我们对数据的分配就有了更清楚的认识。我们对前面提到的问题就有了充分的解释。表面上看,数据的分布貌似均匀,我们执行几个查询语句看看性能怎样。这里再次用到 dbKoda 中的 explain 视图。
(点击放大图像)
上图中查找年龄在 18 周岁以上的用户,根据我们的分组定义,三个 shard 上都有对应的纪录,但是 shard1 对应的年龄组是 20 岁以下,应该包括数量较少的数据,所以在图中 shard 里表里现实的 shard01 返回了 9904 条记录,远远少于其他两个 shard,这也符合我们的数据定义。在上面性能描述中也可以看出,这条语句在 shard01 上面运行的时间也是相对较少的。
再看看下面的例子,如果我们查找 25 周岁以上的用户,得到的结果中并没有出现 shard1 的身影,这也是符合我们的数据分配,因为 shard1 只存储了年龄小于 20 周岁的用户。
(点击放大图像)
你选择的 Shard Key 合适吗?
了解了数据是如何分布的以后,咱们再回过头来看看我们选择的 shard key 是否合理。细心的读者已经发现,上面运行的 explain 结果中存在一个问题,就是 shard3 存储了大量的数据,如果我们看一下每个年龄组的纪录个数,会发现 shard1、shard2、shard3 分别包括 198554, 187975, 593673,显然年龄大于 40 岁的用户占了大多数。这并不是我们希望的结果,因为 shard3 成为了集群中的一个瓶颈,数据库操作语句在 shard3 上运行的速度会大大超过另外两个 shard,这点从上面的 explain 结果中也可以看到,查询语句在 shard3 上的运行时间是另外两个 shard 的两倍以上。更重要的是,随着用户数量的不断增加,数据的分布也会出现显著变化,在系统运行一段时间以后,可能 shard2 的用户数超过 shard3,也有可能 shard1 称为存储数据量最多的服务器。这种数据不平衡是我们不希望看到的。原因在哪里呢?是不是觉得我们选择的用户年龄作为分组条件是一个不太理想的 key。那么什么样的 key 能够保证数据的均匀分布呢?接下来我们分析一下 shard key 的种类。
Ranged Shard Key
我们上面选择的年龄分组就是用的这种 shard key。根据 shard key 的取值,它把数据切分成连续的几个区间。取值相近的纪录会放进同一个 shard 服务器。好处是查询连续取值纪录时,查询效率可以得到保证。当数据库查询语句发送到 mongos 中时,mongos 会很快的找到目标 shard,而且不需要将语句发送到所有的 shard 上,一般只需要少量的 shard 就可以完成查询操作。缺点是不能保证数据的平均分配,在数据插入和修改时会产生比较严重的性能瓶颈。
Hashed Shard Key
于 Ranged Shard Key 对应的一种被称之为 Hashed Shard Key,它采用字段的索引哈希值作为 shard key 的取值,这样做可以保证数据的均匀分布。在 mongos 和各个 shard 集群之间存在一个哈希值计算方法,所有的数据在迁移时都是根据这个方法来计算数据应当被迁移到什么地方。当 mongos 接收到一条语句时,通常他会把这条语句广播到所有的 shard 上去执行。
有了上面的认识,我们如何在 Ranged 和 Shard 之间进行选择呢?下面两个属性是我们选择 shard key 的关键。
Shard Key Cardinality (集)
Cardinality
指的是 shard key 可以取到的不同值的个数。他会影响到 Balancer 的运行,这个值也可以被看做是 Balancer 可以创建的最大 chunk 个数。以我们年龄字段为例,假如一个人的年龄在 100 岁以下,那么这个字段的 cardinality 可以取 100 个不同的值。对于一个唯一的年龄数据,不会出现在不同的 chunk 中。如果你选择的 Shard Key 的 cardinality 很小,比如只有 4 个,那么数据最多会被分发到 4 个不同的 shard 中,这样的结构也不适合服务器的水平扩展,因为不会有数据被分割到第五个 shard 服务器上。
Shard Key Frequency(频率)
Frequency
指的是 shard key 的重复度,也就是对于一个字段,有多少取值相同的纪录。如果大部分数据的 shard key 取值相同,那么存储他们的 chunk 会成为数据库的一个瓶颈。而且,这些 chunk 也变成了不可再切分的 chunk,严重影响了数据库的水平扩展。在这种情况下应当考虑使用组合索引的方式来创建 shard key。所以,尽量选择低频率的字段作为 shard key。
Shard Key Increasing Monotonically (单调增长)
单调增长在这里的意思是在数据被切分以后,新增加的数据会按照其 shard key 取值向 shard 中插入,如果新增的数据的 key 值都是向最大值方向增加,那么这些新的数据会被插入到同一个 shard 服务器上。例如我们前面的用户年龄分组字段,如果系统的新增用户都是年龄大于 40 岁的,那么 shard3 将会存储所有的新增用户,shard3 会成为系统的性能瓶颈。在这种情况下,应当考虑使用 Hashed Shard Key。
重新设计 Shard Key
通过上面的分析我们可以得出结论,前面例子中的用户年龄字段是一个很糟糕的方案。有几个原因:
- 用户的年龄不是固定不变的,由于 shard key 是不可变字段,一旦确定下来以后不能进行修改,所以年龄字段显然不是很合适,毕竟没有年龄永远不增长的用户。
- 一个系统的用户在不同年龄阶段的分布是不一样的,对于像游戏、娱乐方面的应用可能会吸引年轻人多一些。而对于医疗、养生方面也许会有更多老年人关注。从这一点上说,这样的切分也是不恰当的。
- 选择年龄字段并没有考虑到未来用户增长方面带来的问题,有可能在数据切分的时候年龄是均匀分布的,但是系统运行一段时间以后有可能出现不平等的数据分布,这点会给数据维护带来很大的困扰。
那么我们应当如何进行选择呢?看一下用户表的所有属性可以发现,其中有一个created_at
字段,它指的是纪录创建的时间戳。如果采用 Ranged Key 那么在数据增长方向上会出现单调增长
问题,在分析一下发现这个字段重复的纪录不多,他有很高的cardinality
和非常低的频率
,这样 Harded key 就成为了很好的备选方案。
分析完理论以后咱们实践一下看看效果,不幸的是我们并不能修改 shard key,最好的方法就是备份数据,重新创建 shard 集群。创建和数据准备的过程我就不在重复了,你们可以根据前面的例子自己作一遍。
下图中是我新建的一个users
collection,并以created_at
为索引创建了 Hashed Shard Key,注意created_at
必须是一个 hash index 才能成为 hashed shard key。下面是针对用户表的一次查询结果。
(点击放大图像)
从图中可以看到,explain 的结果表示了三个 shard 服务器基本上均匀分布了所有的数据,三个 shard 上执行时间也都基本均匀,在 500 到 700 多毫秒以内。还记得上面的几次查询结果吗?在数据比较多的 shard 上的运行时间在 1 到 2 毫秒。可以看到总的性能得到了显著提高。
选择完美的 Shard Key
在 shard key 的选择方面,我们需要考虑很多因素,有些是技术的,有些是业务层面的。通常来讲应当注意下面几点:
- 所有增删改查语句都可以发送到集群中所有的 shard 服务器中
- 任何操作只需要发送到与其相关的 shard 服务器中,例如一次删除操作不应当发送到没有包括要删除的数据的 shard 服务器上
权衡利弊,实际上没有完美的 shard key,只有选择 shard key 时应当注意和考虑的要素。不会出现一种 shard key 可以满足所有的增删改查操作。你需要从给你的应用场景中抽象出用来选择 shard key 的元素,考量这些要素并作出最后选择,例如:你的应用是处理读操作多还是写操作多?最常用的写操作场景是什么样子的?
小结
在 shard key 的选择方面没有一个统一的方法,要根据具体的需求和数据增长的方向来设计。在我们日常的开发过程中,并不是所有技术问题都应当由技术人员来解决,这个世界是一个业务驱动的时代,而技术主要是为业务服务,我们要提高对需求变化的相应速度。像本文中如何选择 Shard Key 的问题,我觉得并不能单纯的通过技术来考量,更多的是要和业务人员讨论各个数据字段的意义,使用的业务价值以及未来业务的增长点。如果在一开始 shard key 的选择出现错误,那么在接下来的应用过程中想要改变 shard key 是一件极其繁琐的过程。可能你需要备份你的 collection,然后重新创建 shard 服务并恢复数据,这个过程很可能需要运行很长一段时间。在互联网应用的今天,服务器的宕机事件都是以秒为单位计算,很可能错误的 shard key 选择会给你的应用带来灾难性的后果。希望此文能给各位一点启示,在项目初期的设计阶段充分考虑到各方面的因素。
References
- MongoDB Shard: https://docs.mongodb.com/manual/sharding/
- Shard Keys: https://docs.mongodb.com/manual/core/sharding-shard-key/
- Next Generation Databases: NoSQLand Big Data, Guy Harrison:
dbKoda: https://www.dbkoda.com - MongoDB Docker Cluster: https://github.com/zhaoyi0113/mongo-cluster-docker
- CAP theorem: https://en.wikipedia.org/wiki/CAP_theorem
作者简介:
赵翼,毕业于北京理工大学,目前就职于 SouthbankSoftware,从事 NoSQL,MongoDB 方面的开发工作。曾在 GE,ThoughtWorks,元气兔担任项目开发,技术总监等职位,接触过的项目种类繁多,有 Web,Mobile,医疗器械,社交网络,大数据存储等。
感谢蔡芳芳对本文的审校。
评论 1 条评论