37 键值存储与数据库

你好,我是七牛云许式伟。

上一讲我们介绍了存储中间件的由来。今天我们就聊一下应用最为广泛的存储中间件:数据库。

数据库的种类

从使用界面(接口)的角度来说,通常我们接触的数据库有以下这些。

使用最为广泛的,是关系型数据库(Relational Database),以 MySQL、Oracle、SQLSever 为代表。

这类数据库把数据每个条目(row)的数据分成多个项目(column),如果某个项目比较复杂,从数据结构角度来说是一个结构体,那么就搞一个新的表(table)来存储它,在主表只存储一个 ID 来引用。

这类数据库的特点是强 schema,每个项目(column)有明确的数据类型。从业务状态的角度看,可以把一个表(table)理解为一个结构体,当遇到结构体里面套结构体,那么就定义一个子表。

第二类是文档型数据库(Document Database),以 MongoDB 为代表。这类数据库把数据每个条目(row)称为文档(document),每个文档用 JSON或其他文档描述格式表示。

当前文档型数据库大部分是无 schema 的,也就是在插入文档时并不对文档的数据格式的有效性进行检查。

这有好有坏。好处是使用门槛低,升级数据格式方便。不好之处在于,质量保障体系弱化,数据可能被弄脏而不自知。可以预见的是,未来也会诞生强 schema 的文档型数据库。

第三类是键值存储(KV Storage),以 Cassandra 为代表。

键值存储从使用的角度来说,可以认为是数据库的特例。数据库往往是允许设定多个索引字段的,而键值存储明确只有唯一索引。

从实现角度来说,键值存储是数据库的基础。每一组数据库的索引,往往背后就是一组键值存储。

事务

无论是何种数据库,都面临一个重大选择:是否支持事务。这是一个艰难选择。从需求角度来说,事务功能非常强大,没道理不去支持。从实现角度来说,事务支持带来极大的负担,尤其是在分布式数据库的场景。

什么是事务?简单来说,事务就是把一系列数据库操作变成原子操作的能力。展开来说,事务的特性我们往往简称为 ACID,详细如下。

如果我们忽略性能要求,事务是很好实现的,只需要用一把能够 Lock/Unlock 整个数据库的大锁就够了。

但这显然不现实,一把大锁下来,整个数据库就废了。从 IOPS(IO 吞吐能力)角度来说,为什么分布式数据库很讨厌事务是很容易理解的:如果没有事务,一次数据库操作很容易根据数据的分区特征快速将操作落到某个分区实例,剩下来的事情就纯粹是一个单机数据库的操作了。

一种常见的事务实现方式是乐观锁。

什么是乐观锁?

常规的锁是先互斥,再修改数据。不管是不是发生了冲突,我们都会先做互斥。

但乐观锁不同,它是先计算出所有修改的数据,然后最后一步统一提交修改。提交时会进行冲突检查,如果没有冲突,也就是说,在我之前没有人提交过新版本,或者虽然有人提交过新版本,但是修改的数据和我所依赖的数据并不相关,那么提交会成功。否则就是发生了冲突,会放弃本次修改。

这意味着,每个数据有可能有多个值。如下:

其中,VER0 对应当前已经提交的值 VAL0,VER1 对应事务1 中修改后的值 VAL1,以此类推。

除了修改后的值外,每个事务还需要记录自己读过哪些数据。不幸的是,它并不是记录读过的 KEY 列表那么简单,而是要记录所有的读条件。

例如,对于 SELECT name, age, address WHERE age>17 这样一个查询,我们不是要记录读过哪些 name、age、address,而是认为我们读过所有 age>17 的条目(row)。

在事务提交的时候,锁住整个数据库(前面修改过程事务间不冲突,所以不需要锁数据库),检查所有记录的读条件,如果这些读条件对应的条目(row)的已提交版本都<=基版本(VER0),那么说明不冲突,于是提交该事务所有的修改并释放锁。

如果事务提交的时候发现和其他已提交事务冲突,则放弃该事务,对所有修改进行回滚(其实是删除该事务产生的版本修改记录)。

到这里我们就可以理解为什么要用乐观锁了:至少它让锁数据库的粒度降到最低,判断冲突的逻辑也都是可预期的行为,这就避免了出现死锁的可能。

我们很容易可以推理得知,在所有并行执行的事务中,必然有一个事务的提交会成功。这样就避免了饥饿(永远都没人可以成功)。

主从结构

一旦我们考虑数据库的业务可用性和数据持久性,我们就需要考虑多副本存储数据。可用性(Availability)关注的是业务是否正常工作,而持久性(Durability)关注的是数据是否会被异常丢失。

当我们数据存在多个副本时,就有数据一致性的问题。因为不同副本的数据可能值不一样,我们到底应该听谁的。

我们的服务同时存在很多并发的请求,这就可能存在客户端 A 希望值是 VALa ,客户端 B 希望值是 VALb 的情况。

解决这个问题的方法之一是采用主从(Master-Slave)结构。主从结构采用的是一主多从模式,所有写操作都发往主(Master),所有从(Slave)都从主这边同步数据修改的操作。

这样,从(Slave)的数据版本只可能因为同步还没有完成,导致版本会比较旧,而不会出现比主(Master)还新的情况。

从(Slave)可以帮主(Master)分担一定的读压力。但是不是所有的读操作都可以被分担。大部分场景的读操作必须要读到最新的数据,否则就可能会出现逻辑错乱。只有那些纯粹用于界面呈现用途,而不是用于逻辑计算的场景,非敏感场景(比如财务场景是敏感场景)下能够接受读的旧版本数据,可以从从节点读。

从(Slave)最重要的是和主(Master)形成了互备关系。在主挂掉的时候,某个从节点可以替代成为新的主节点。这会发生一次选举行为,系统中超过一半的节点需要同意某个节点成为主,那么选举就会通过。

考虑选举的话,意味着集群的节点数为奇数比较好。比如,假设集群有 2 个节点,只有一主一从,那么在主挂掉后,因为只剩下一个节点参与选举,没有超过半数,选举不出新的主节点。

选择谁成为新的主是有讲究的,因为从的数据有可能不是最新的。一旦我们选择没有最新数据的从作为新的主节点,就意味着版本回退,也就意味着发生了数据丢失。

这是不能接受的事情。为了避免版本回退,写操作应该确保至少有一个从节点收到了最新的数据。这样在主挂掉后才可以确保能够选到一个拥有最新数据的节点成为新的主节点。

分布式

多副本让数据库的可用性和持久性有了保障,但是仍然有这样一些问题需要解决:

从目前存储技术的发展看,单台设备的存储量已经可以非常高,所以上面的第二种情况也会很常见。

怎么解决?

分布式。简单说,就是把数据分片存储到多台设备上的分片服务器一起构成一个单副本的数据库。分片的方式常见的有两种:

无论哪个分片方式,都会面临因为扩容缩容导致的重新分片过程。重新分片意味着需要做数据的搬迁。

数据迁移阶段对数据访问的持续有不低的挑战,因为这时候对正在迁移的分片来说,有一部分数据在源节点,一部分数据在目标节点。

在分布式存储领域,有一个著名(CAP)理论。其中,C、A、P 分别代表一个我们要追求的目标。

那么 CAP 理论说的是什么?简单说,就是 C、A、P 三个目标不能兼得,我们只能取其二。

假设我们不会放弃服务的可用性,那么我们决策一个分布式存储基本上在数据一致性(C)和分区容错性(P)之间权衡。

数据一致性(C)的选择基本上是业务特性决定的,业务要求是强一致,我们就不可能用最终一致性模型,相应的,我们只能在分区容错性(P)上去取舍。

结语

今天我们概要讨论了数据库相关的核心话题。我们第一关心的,当然还是使用界面(接口)。从使用界面角度,我们要考虑选择关系型数据库还是文档型数据库,以及是否需要事务特性。

确定了我们要使用什么样的数据库后,接着我们从实现角度,考虑主从结构和分布式方面的特性。

数据库是非常专业并且复杂的领域,限于篇幅我们这里不能展开太多,你如果有兴趣可以参考相关的资料。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊对象存储。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。