NOSQL精粹
学了SpringMVC和MyBatis后在开发一个博客网站练手,然后在存储博文评论和like与unlike功能时选择了NoSQL数据库,虽然实现了功能,但是对于NoSQL还是一知半解,准备花一些时间研究下NoSQL。
原书:NoSQL精粹
为什么用NoSQL
当系统要处理的数据量越来越大时,向单一服务器添加硬件是性价比极低的选项,大多数公司的解决办法都是通过建立集群。
关系型数据库不适合在集群中操作,而NoSQL数据库大多专为集群设计。
第二点就是,关系型数据库需要在设计系统前便设计好数据的存储模式,数据表包含什么字段,以及表与表之间的关系,即用关系——元组的方式对应用中的数据进行建模。而现在的数据越来越繁杂,而且经常会有没有什么固定模式的动态数据,这时关系型数据库不好处理。
NoSQL是啥
NoSQL不是一类明确的数据库软件,而是代表一个时期内(21世纪初)开发出的一部分不使用SQL的数据库,大多是开源的,大多数适用于在集群中运行,使用时无需预先定义模式。
NoSQL存储数据的结构
关系型数据库采用库和表来存储数据,NoSQL数据库大致分为四种存储方式,分别是键值对、文档、列族和图。
聚合(aggregate)
对于键值对、文档和列族,它们都是面向“聚合”的。
我们经常通过各种ORM框架把数据库中的关系——元组转换成如下类型的数据:
class Customer {
private Long id;
private String name;
private List<Order> orders;
}
顾客类中包含他所购买的所有订单,但是在关系——元组组成的关系型数据库中,我们没法把这种数据直接存储在数据库中,我们需要通过建立关系,比如在订单中保存用户的id,比如
顾客表
id | name |
---|---|
1 | yulaoba |
订单表
id | userId | productId | count |
---|---|---|---|
1 | 1 | 1 | 2 |
产品表
id | productName | price |
---|---|---|
1 | 手机 | 2500 |
订单表中的userId
字段说明了id为1的订单是id为1的顾客下的,对于productId
字段也一样,它证明了用户所购买的是id为1的产品,这样ORM框架可以自动帮助我们将由关系——元组组成的数据转换成那种Java对象。
这样做的原因在于,面向关系——元组的模式中的元组只能存储简单的数据类型,无法存储集合等复杂类型,而面向聚合的数据库允许我们直接将这些复杂类型存储到数据库中,我们就可以直接向数据库中插入如下数据(使用JSON作为演示)
{
id: 1,
name: 'yulaoba',
orders: [
{
product: {
productName: '手机',
price: 2500
},
count: 2
}
]
}
这样一个数据就相当于一个聚合,可以看作传统关系型数据库中的一行吧,但它把所有关系都放到这一个聚合本身里了。
聚合的好坏
好的情况
就拿上面的例子来说,如果用户下完订单之后,商品的名字修改了也无所谓,那么就适合使用聚合。比如淘宝的订单系统,你买的时候商品是什么名字,订单列表中就会显示什么名字,后期如果店家更新了名字,你那里也不会改。这时就可以使用聚合,而且没有比使用聚合更加合理的了。
想想如果你购买了一台手机,而商家修改了这个商品,变成了电脑,如果这时使用关系映射,你的订单历史里就多了一台电脑。
而且,面向聚合可以将数据访问的层次降低到最浅,你不用根据一个外键再去发起一次查询,查询其他表。这在个有点在集群中更能体现,因为一个聚合往往在集群中的一个节点上,而如果再发起一次查询,可能就落到集群中的其他节点上。
坏的情况
如果你不仅想要查询用户的订单信息,而还想要针对商品进行一些销售额的统计,那么把所有信息都存储到一个Customer中就不太合适了,这时我们要遍历所有的顾客,再遍历每个用户之中的订单,再遍历订单中的每个产品进行统计,效率很低,这时候需要拆分成三个聚合了
// product
{
id: 1,
productName: '手机',
price: 2500
}
// order
{
id: 1,
productId: 1,
count: 2
}
// customer
{
id:1,
name: 'yulaoba',
orders: [1] // 存储订单id
}
对于其他类型的业务逻辑,我们还可以这样划分聚合,即把订单嵌入到customer中
// product
{
id: 1,
productName: '手机',
price: 2500
}
// customer
{
id:1,
name: 'yulaoba',
orders: [
{
productId: 1,
count: 2
}
]
}
如何划分聚合,没有一个标准答案,根据具体情况分析。要注意的是大部分NoSQL数据库能保证一个聚合内操作的原子性。如果要保证多个聚合上的操作具有原子性,需要在应用层实现。(一些NoSQL数据库貌似支持事务了)
键值数据库和文档数据库
键值数据库的所有操作基于一个主键,每一个聚合里面到底存了啥是不固定的,所以你基本无法通过除了主键之外的任何东西对聚合进行操作。
文档型数据库的聚合中的数据就很有规则,你可以通过主键之外的任何字段进行聚合操作。
最后举个例子,比如这个
{
id: 1,
name: 'yulaoba',
orders: [
{
product: {
productName: '手机',
price: 2500
},
count: 2
}
]
}
对于键值数据库来说,只能通过id来操作,数据库完全不关心聚合里的其他数据的存在,比如name、orders等,也许下一个聚合中有着完全不一样的数据结构,而对于文档数据库,它清晰的知道聚合中包含着name
字段、orders
字段等,所以可以通过它们进行索引,而且一般情况下下一个聚合中也是同样结构的数据。
现在的键值型数据库和文档型数据库的界限越来越不明显,有的键值型数据库也支持通过其它字段来进行操作,而文档型数据库也经常会生成一个主键来用于查询操作。
列族数据库
列族数据库中的KeySpace
(类似关系型数据库中的表)包含了一系列列族
列族是系统设计时预先定义好的,每一个列族包含若干行数据,比如下代表用户资料的列族
每一行有一个主键(RowKey),然后列族中又有若干列,每一列有一一对Name/Value
键值对,和一个时间戳。
图数据库
面向聚合的NoSQL数据库不善于处理关系,图数据库比较善于处理复杂的关系。
图数据库中的数据模型由节点和边组成,图数据库可以编制一些索引用于找到图搜索的起始位置,然后从这些起始位置开始遍历图。
比如找到Anna
和Barbara
都喜欢的东西,需要先找到Anna
和Barbara
,然后再继续向下寻找。
可是图数据库由于总要处理关系,不能够像聚合型数据库那样将关系存在一个聚合中,所以要保证操作的原子性的话,就很难在集群中使用。
无模式
NoSQL数据库有个共同的特点,就是它们都是无模式的。
也就是说你无需在项目编写之初就构建好整个结构,给出整个数据库中的存储模型,用SQL来说,就是你需要在系统创建之初就考虑到数据库和数据表的结构,各表之间的关系以及字段的约束。在NoSQL中你可以随意存放,随意扩展。
模式不是一个坏事,无模式不代表我们就应该使用NoSQL随意放东西,而是要在代码中体现隐含模式,杂乱无章的数据没意义。
物化视图
物化视图的意思不是传统SQL中的视图,而是在数据插入或变动时(或某些其他时间)预先计算查询操作的结果并缓存。
分布式模型
分片(sharding)
分片模式将数据分布在几台数据库服务器上。
这时,应用需要根据某些条件将请求分配到某一个分片上。
比如有三个数据库服务器,A、B和C,将请求的用户的id对3进行求余操作,如果结果为0分配到A服务器,结果为1分配到B服务器,结果为2分配到C服务器。
理想情况下,每台服务器的负载量是相同的。
几条设计原则
- 应该把经常一起访问的数据分配到同一个服务器上,这也是聚合设计的擅长之处。
- 按区域管理,接管用户请求的服务器应该是离用户最近的服务器
缺点
- 如果节点故障,那么分配到该节点上的请求都将无法被服务
主从复制
复制(replication)是除了分片模式外的另一种分布式模型,其中的一个就是主从复制。
主从复制中有一个主节点,若干从节点,当有写入操作时,主节点进行服务,并且把写入结果更新到所有从节点中,保证主从节点的数据一致,读取操作可以由主节点和从节点一起进行服务。
主从复制适合读取操作多,写入操作少的情况。
主节点出错后,可以指派一个从节点作为新的主节点,因为它们数据一致。
主从复制会导致数据的不一致性,即数据被写入主节点,尚未被写入从节点之前就被读取,这时用户读到的就是旧数据。就算能够保证这个阶段的原子性,那么主节点故障时未同步到从节点的数据也需要我们考虑,因为这样会造成数据丢失。
对等复制
对等复制中每个节点的地位都是平等的,没有主从关系,它们都能够处理读写请求,在写请求发生时,处理写请求的节点会把数据同步到其他节点。
不过这并不能解决数据不一致问题,而且可能导致写入冲突,即两个节点同时写入一份数据。
结合分片与复制技术
主从复制与分片技术结合,意味着系统中有多个分片,而每个分片有一个主节点。同一个节点可以作为一个节点的主节点和另一个节点的从节点。
在列族数据库中经常将对等复制和分片技术结合,一个名词是分片因子,是指每一个分片有几个对等复制的数据库,如为3,代表该分片有3个对等复制数据库,这三个数据库中存储着同一份分片数据,当其中一个故障,另外的数据库可以重建它。
一致性
更新一致性
当两个用户同时发起更新,对于单服务器系统,他同时只能处理一个请求,两个更新请求有一个一致的顺序,A先B后或B先A后,所以它可以用悲观的加锁或乐观的CAS来进行更新。分布式系统中,无法保证更新请求的顺序一致,这样如果A和B同时修改一个电话号码,那么分布式节点中会存在不同的电话号码,所以不能简单的使用加锁或CAS。
一个办法就是当系统发现冲突时将两次写操作合并,然后转而提交给用户处理或者系统自行处理,比如当AB两次提交的电话号相同只是格式不同,那么系统可以自动转换为一个统一的格式进行存储,这是领域特定的问题。
采用悲观的加锁方式能保证一致性,但是会降低响应速度。如果能把一份数据的所有写入操作都分配到一个节点上,那么这个问题就不存在了,就回到之前的单服务器下的顺序一致时的处理办法了。(处对等复制外其他都满足这一点)
读取一致性
读取一致性的例子:
A和B是夫妻,它们使用一个账号进行网购
- A发起结账请求,服务器查询A的余额,还有8000,足够结账
- B发起读取余额请求,返回8000
- 服务器继续处理A的结账,从账户扣款5000,余额还有3000
这里注意,A的结账请求中间,B的查询请求插入了,导致B在A已经发起结账请求后读取到了原来的数据。
关系型数据库可以使用ACID事务很轻松的解决这个问题,关键就是把A的结账请求封装到一个事务中,这个操作是原子的,不可被打断,B的读取会在A的操作完成后被处理。
解决办法
- 使用支持ACID事务的NoSQL数据库(如图数据库)
- 将数据放在同一聚合中,面向聚合的数据库保证聚合操作的原子性,上面的示例完全可以
如果必须在不同聚合间操作,那么就会在两个操作之间留下一段时间空档,称作不一致窗口(inconsistency window)。
复制一致性
如果系统中引入复制,那么读取不一致的问题可能加重。
如A与B在不同的城市,他们都想要定C城市中的最后一间房子,而这时D将这个房子订了,由于复制的原因,这个更新传到了A所在城市的服务器上,但还并未传到B所在城市的服务器上,那么A就看到了没有房子,B则看到还剩一个房子。
但是最终更新操作会传递到所有城市的服务器节点上,也就是B最终会看到没有房子的结果,这称为最终一致性(eventually consistent)
会话一致性
会话一致性保证的是在一个会话内,你的写入操作会在下次你的读取操作看到。
因为基于复制的分布式系统可能你写入一条评论的节点和刷新评论的节点不一致,那么你写完评论时可能这个写入操作还没有传播到刷新评论的节点,那你就看不到你发表的评论。
粘性会话是指将会话绑定在某个节点上,这个会话的所有相关操作都由这一节点处理。但它会降低负载均衡器的效能。
如果使用主从复制和粘性会话,并且希望写入操作仍由主节点处理的话,那么可以将写入请求先发送到从节点,然后由从节点转发给主节点,并保持客户端会话一致性。还有一种办法是将写入操作交给主节点,在从节点没收到更新的时间内,由主节点负责该会话的读取操作。
放宽一致性约束
在单服务器系统中,事务是保持一致性约束的工具。但有些系统已经放弃事务了。因为事务隔离级别越高,系统为保持一致性所做的操作越多,系统的响应速度越慢。
CAP定理是说,我们只能满足一致性(Consistency)、可用性(Availability)和分区耐受性(Partition tolerance)其中两个属性。
定理中的一致性就是我们所说的一致性,而可用性是指一个集群中的节点如果没有故障,那么它总能够响应请求,无论请求成功失败(注意这个条件很宽,没有限定故障节点,没有限定读写必须成功),分区耐受性是指当网络故障,集群被分割成无法通信的分区时系统仍能正常工作。
CAP并不是三选二,而是有时我们可以稍微放弃一致性来提升可用性。
比如基于对等复制的系统,被分配到不同服务器上的A和B预定房间,系统要保证一致性,那么当A和B的两个服务器中间的通信连接发生故障,A和B服务器无法通信进行一致性保护,那么系统就无法工作了,这时可用性丧失(因为AB都是正常的,但它们为了保证一致性都无法工作)
如果想要改善可用性,可以用主从复制,比如A的服务器是主节点,它处理写入请求,这时两个服务器的连接出故障了,但是A仍然能订房间,A执行写入后无法发送到B的服务器上,所以B会看到过时的数据,但无法订购房间。一致性损失了,可用性增加了。
放宽持久性约束
比如将用户正在干的事放到内存中,分时间批量写入磁盘,如果系统突然有故障,那么尚未同步持久化到磁盘的数据将丢失。但是在这种情况下丢失了也没啥大事。
未完...