手记

一文熟知存储 – 从磁盘到文件,到数据库,到分布式环境集中式存储,再到分布式数据库

相信我,认真读完之后,你是业余数据库选手里靠前的,至少不至于民科。

可以记住我的博客:zouzhiquan.com

0.前言

正如各种软文推送的,我们现在是信息的时代,而信息从人类社会诞生就存在了,之所以把现在这个时代称为信息时代,完全是因为计算机把信息的传递、存储、处理效率提升到了一个前所未有的量级,这也是互联网发展突飞猛进的根本原因,我们现在的手机、各种APP 都是基于这个关键原因诞生的。

如果有兴趣可以想想,如果计算机所带来的“信息传递、存储、处理”效率,每个方面再进一步提升,会有哪些行业会受到影响,会催生什么形态的产品,这可能就是下一个大的风口了,接下来从技术的角度看一下,信息:存储、传递、处理的发展。

信息传递,直白点就是计算机网络,这个可以参照上一篇文章《一文读懂网络,文章巨长,但很详细》

信息存储,信息的持久化动作,说直白点就是“不丢”,之前是书本,现在是磁盘。这个就是本文要说的重点,信息时代信息是怎么存储的,有怎样的演进过程。

信息处理,数据计算的效率,主要依赖于CPU的计算效率、CPU的充分使用、数据组织结构和处理算法的效率,这个之后来说。其中网络/存储都会涉及一定的信息处理(就统一归属到信息传递、信息存储域了),这里的信息处理效率单纯指数据的计算/分析效率。

本文会按照这个顺序进行叙述:

整体内容概要:

1.信息的持久化动作

信息持久化动作的效率主要是存储介质的效率,外加信息组织形式的效率。存储介质的效率决定了整体效率的上限和下限,而信息组织形式的效率决定了在上下限之间的发挥空间。

1.1 存储介质

信息的持久化形式是信息记录的关键效率,最开始脑子记,再后来变成了篆刻和打绳结,到后来书写书本记录,到现在计算机系统中的磁盘记录,要想存储的量大、持久化动作的效率高,跟存储介质是密切相关的。

1.2 信息的管理形式

对于数据的组织形式,最初信息极少,基本不需要组织。出现书本之后,信息密度开始上升,出了索引、书籍管理等形式。现在磁盘存储是由01表示信息,然后在此之上解码出对应的人类可读的文字信息,使用一些数据结构来组织信息,单机是有空间极限的,会采用分布式的形式来横向的扩展存储。

2.磁盘读写 – 文件读写 – 数据库系统操作

对于信息的存储站在不同的实现角度,面向的对象是不同的:

如果站在硬件开发的角度,面向的磁盘介质的读写;如果做操作系统的落地,面向的是磁盘应用的读写;如果是操作系统使用的角度,是文件的读写;如果站在应用开发的角度,是数据库系统的操作。

2.1 磁盘读写

2.1.1 磁盘

磁盘是指利用磁记录数据的存储器,存储介质是可以存储正负极的磁性材料,用正负极来代表0/1。目前市场上的硬盘分为两类,机械磁盘、固态磁盘。固态磁盘要比机械磁盘快的多,但是为什么机械磁盘一直没被淘汰?核心原因是快的程度远没有超过价格差异,而且机械磁盘的速度对于当前的数据读写的部分场景仍然够用。如果专门做存储系统,不差钱,那就直接固态磁盘。
两者最主要的差异是固态磁盘完全是基于电信号,而机械硬盘是机械结构 + 电信号的结合。

2.1.1.1 机械磁盘

机械词盘是一个个原子存储单位构成了扇面,多个扇面构成了磁道,然后磁道构成盘面,然后一个个盘面构成了一个磁盘,把磁盘横切就构成了柱面。然后每个盘面都有一个磁头,用磁头寻找磁道,然后不同盘面并发找,先定位磁道,然后找到对应的扇区,就完成了对应信息的读取。(看不到实物的可以回想一下早年的光盘类比一下)
机械硬盘读取的时间、信息传递的时间,完全是电信号,基本可以忽略不计,真正花时间的是寻道时间和旋转时间,其中寻道时间要大于旋转时间。

这就是机械硬盘随机读写和顺序读写速度能差那么多,核心原因就是顺序读写寻址时间小,想要提高性能,首先减少磁盘读写的次数,另外顺序读写。

2.1.1.2 固态磁盘

固态磁盘基于闪存实现,内部是块装的存储结构,基本存储单元是浮栅晶体管,通过充放电子,来对晶体管进行写入和擦除。然后闪存分为NOR型和NAND型。NOR型闪存芯片具有可靠性高、随机读取速度快的优势,但擦除和编程速度较慢,容量小。NAND闪存容量大,按页进行读写,容量大,适合进行数据存储。

固态磁盘并不是完美的,耐用性相对机械磁盘要差一些,会存在比如写入寿命上限、比特反转等问题。并且由于实现的差异性,固态磁盘读写不均衡比机械磁盘要高。而且浮栅晶体管这东西受温度的影响比较大。

2.2 文件读写

如果每次都直接编程访问磁盘很显然不太合适,所以操作系统帮你包了一层,我们可以直接访问操作系统,然后操作系统帮你访问磁盘。在linux中为了使用上的方便,抽象出了文件的概念,并把这些可操作的东西都看作了文件(一切皆文件),除磁盘以外,还有网络链接这些。

2.2.1 文件读写原理

在Linux进行文件读写时,可以直接透过系统调用、或者透过函数库使用系统调用直达操作系统,然后到达虚拟文件系统(VFS),然后文件系统去分发调用不同的存储结构,比如基于磁盘的XFS、Ext4,还有管理网络链接的NFS,又或是基于内存的/proc、/sys 文件系统。

通过VFS完成对应文件系统的调用,这里磁盘读写可能就会进行XFS的调用,然后进行磁盘的读取,读到的数据会存放在内核缓冲区,然后IO线程将对应的数据从内核缓冲区中读出放到用户态的内存中,然后完成对应的更改之后,再从用户态内存透过虚拟文件系统写入内核缓冲,内核缓冲刷入磁盘。

注意,这里的文件写入可能会提前返回,并非系统调用完成之后就一定写入成功了。

fsync 会写到磁盘写入成功返回,但是大部分磁盘为了性能,会带有内置写入缓冲,也就是hardware cache,如果进入cache但未刷入磁盘,此时断电数据还是会丢的。这种情况发生的概率极小,目前普遍认为写入hardware就近似不会丢,用更贵的硬件有更好的效果。

fflush 写入内核缓冲就认为成功,如果此时断电,丢的数据量级、丢失的概率都比较高。

mmap 虽然不是一个写入动作,但会把用户态内存和内核缓冲建立一个映射,避免了内核/用户态切换,可以通过操作用户态内存中的对象,操作内核缓冲,省的显式调用flush。

各语言常用的write函数基本都是调用fflush,而且较多的语言实现应用层可能还会有一层缓冲,如果断电的话,丢失的概率和量级要更高。如果想要文件持久化的稳,就fsync吧。

2.2.2 读写IO

IO部分,同样的可以分为同步/异步(是不是同一个线程/进程处理IO操作),阻塞非阻塞(当前线程处理时,无可读/写是否会被挂起),这部分同《一文熟知网络,文章巨长,但是很详细》完全一致。

除了这部分基础的IO分类,还有点民科的分类,还会根据有无读写缓冲分为缓冲IO、非缓冲IO,根据是否跳过页缓存,分为直接IO(直接访问文件系统)和非直接IO(透过页缓存)

2.2.3 详解文件系统

2.2.3.1 磁盘 & 文件系统 & 操作系统的关系

由于磁盘是个物理结构,缺少整体的管理机制,所以针对这个方面,磁盘被划分为几个逻辑单元,每个单元可以被独立管理,每个单元被称为“分区”,然后分区的分配信息存放于分区表中,Linux下,使用fdisk命令进行磁盘分区(这里会分配文件系统的类型)

并且,磁盘再进行文件系统格式化的时候,会分出来 3 个区:Superblockinode blocksdata blocks

Superblock: 存放整个文件系统的元信息,inode blocks: 存放inode,data blocks: 存放数据块。

分区是磁盘挂载的第一步,区分好分区,并确定文件系统类型之后,再注册到操作系统中,这是磁盘挂载的第二步。

在linux中,使用目录树来进行管理,直白的就是以根目录为入口,向下呈现分枝状的一种文件结构。linux必须能够将根文件系统挂载到根目录上,当根目录挂载完成之后,我们可以将其它文件系统挂载于树形结构各种挂载点上。根结构下的任何目录都可以作为挂载点,挂载点实际上就是linux中的磁盘文件系统的入口目录。

2.2.3.2 内部实现

磁盘的最小的操作单位是扇区,所以一个文件最小的操作单位就应该等于扇区的大小,也就是512字节,但由于一个个扇区操作效率比较低,所以会一次性多个扇区,多个扇区就构成了“块”结构,所以文件的最小操作单位就变成了“块”大小。

每个文件还有自己的一些元数据信息(创建人、时间、size等),就单独起了个结构来记录,也就是inode。

所以一个文件 = 数据块 + inode

还有一个结构:dentry,记录了文件名字、inode 指针以及与其他 dentry 的关联关系,dentry 构成了整个文件目录树。

首先我们通过目录相关dentry,找到了对应的文件,然后透过inode指针操作对应的文件,然后读写数据块。

最常用的是ZFS,支持Pool存储-动态扩容、基于copyOnWrite的事务文件系统、ARC 缓存等等

2.2.3.3 文件描述符

linux中,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行I/O操作的系统调用都会通过文件描述符。

文件描述符、文件、进程间的关系:

  • 每个文件描述符会与一个打开的文件相对应;
  • 不同的文件描述符也可能指向同一个文件;
  • 相同的文件可以被不同的进程打开,也可以在同一个进程被多次打开;

我们进行文件操作时,其实就是持有文件描述透过系统调用进行文件读写,一个进程能持有的文件描述符数量通常是1024个,不过这个值可改。

另外,linux文件被并发修改时,会有问题的,出现错数据。

2.3 数据库系统

文件存储满足了早期应用的大多数场景,比如说结构化文本、展示文本文件、图片、音频存储、源码文件等等,但随着应用的复杂度逐渐上升,早期相对完整及简单的文件结构已经逐渐不满足应用场景了。在数据量极大、数据类型众多、数据用途千差万别的情况下,我们面临的常常是修改若干文件中其中一个文件,其中的一段数据或者一块数据。

所以我们开始写代码尝试组织各种文件,然后用一些数据结构来组织数据,并相对通用的方法读写其中的“子数据”。导致在这个场景下出现了大量的相似的 文件操作&数据结构操作代码,而且随着数据量指数级膨胀,大部分程序员已经无法编写高效的程序了。

这个时候,数据库系统就孕育而生了,专门去做数据的组织与存储,以技术中间件的形式向大众提供结构化数据的存储&操作服务,隐藏了文件管理&数据操作的复杂度。

这些中间件就是我们现在所熟知的数据库系统(应用),数据库按照数据结构的差异可以大致分为:

关系型数据库,比如常用的mysql、oracle
图数据库,比如Neo4j
文档数据库,比如MongoDB
键值数据库,比如常见的对象存储redis、levelDB
宽列数据库,比如HBase

本文要说的主要是关系型数据库,键值数据库几年前写过一篇,可以大体看看《谈一谈若干的K-V NoSQL应用:LevelDB、Redis、Tair、RockesDB》

关于其他类型的数据库,这块会在后面《看这一大锅中间件》(之后会更新链接)进行简介

接下来详细看看,关于关系型数据的应用落地。

3.关系型数据库系统详解

关系型数据库的核心能力:定义数据、操作数据。所有的关系型数据库都是围绕这两点进行打磨精进的,我用的最多的是mysql及mysql 相关的变形应用,就站在mysql的角度看关系型数据库的落地吧,大致实现都相似,但是某些技术点的具体差异比较大。

关系型数据库还挺多的,比如oracle、db2、SQL server等等,有兴趣大家可以去了解下~

3.1 一个数据库应该有哪几部分构成

一个mysql 应用通常由连接层、SQL层、存储引擎层构成。

连接层负责mysql的连接处理,及相关的鉴权等功能,内部通常会维护一个TCP连接池。

SQL层,具有一个SQL解析器,进行SQL语句的处理,包含词法分析、语法分析等操作,根据语法树生成对应的执行计划。SQL优化部分就发生在这里,对应的SQL缓存(键值缓存)这里在这一层发生的。

下一层是引擎层,负责具体的执行计划的处理者,核心文件/数据的处理都发生在这一层,可以按照当前场景的需要选择不同的数据库引擎,拿mysql来说,常见的有Innodb、MyISAM。

3.2 如何组织&存储数据

3.2.1 文件存储结构 – mysql 文件结构

mysql 里面有这么几类文件:

  • 数据目录:存储所有数据库对象和数据文件的根目录,然后再数据目录下每个数据库有一个字目录,里面包含了该数据库的数据文件
  • 表结构文件:这个文件里存储了每个数据库中表的定义和结构信息,这些文件以 .frm结尾
  • 数据文件:真证存储数据的文件,以.ibd结尾(innodb中)
  • 日志文件:用户记录逻辑操作记录(binlog)、物理变更记录(redolog)、现场日志(undolog)、错误日志(errorlog)、慢查询日志(slow query log)
  • 配属文件:存放mysql 相关的配置信息,这部分信息通常会直接夹在到内存中,不太会直接读写。my.conf
  • 临时文件:主要保存临时数据和中间结果,通常在临时文件夹中
zouzhiquan@MacMini-Pro data % pwd
/usr/local/mysql/data

zouzhiquan@MacMini data % ls
#ib_16384_0.dblwr       auto.cnf                binlog.000045           ca-key.pem              client-key.pem          ib_logfile1             mysql                   mysqld.local.pid        public_key.pem          sys                     undo_002
#ib_16384_1.dblwr       binlog.000043           binlog.000046           ca.pem                  ib_buffer_pool          ibdata1                 mysql.ibd               performance_schema      server-cert.pem         test
#innodb_temp            binlog.000044           binlog.index            client-cert.pem         ib_logfile0             ibtmp1                  mysqld.local.err        private_key.pem         server-key.pem          undo_001

zouzhiquan@MacMini-Pro data %

zouzhiquan@MacMini data % ls -l sys
total 88
-rw-r-----  1 zouzhiquan  _mysql  114688 10 26  2020 sys_config.ibd

3.2.2 数据组织结构 – 页、区、段、表空间

上面看到的是mysql的“物理”文件存储结构,接下来看下mysql 内部是如何对于数据进行组织的。
mysql 中基本的操作单位是页,1页默认16K(操作系统一次读取的大小),16k实际上是四个磁盘页,一个磁盘页16k,读的时候会预读3页 + 目标页,也就变成了16K。

数据库对于数据的基本存储就是用页来存储的,页的种类有很多,比如数据页用来存数,undo页 来存undo日志,常见的还有系统页、事务数据页、插入缓冲位图页、插入缓冲空闲列表页、未压缩的二进制大对象页、压缩的二进制大对象页。

然后页构成了区,一个区通常有64个连续的页,并且相邻的页构成一个双向链表,便于顺序访问,一个区的大小是100 * 16k 也就是1m,剧透下,有没发现,这个就是两次写,写缓冲的大小。

若干个区构成了段,段中的区是随机分布的,段是mysql对于磁盘的分配单位,我们创建一个表、索引的时候就会创建一些段结构来存储具体的数据信息,比如表段、索引段

然后表空间是一个逻辑空间,一个表一个表空间,或者多个表共用同一个表空间,拿独立表空间来说,一个表空间了就包含了具体的数据段、索引段等,表空间是最高的逻辑结构,主要用于具体的管理职责,可以看作是一个管理结构。

3.2.3 逻辑数据结构 – B+树

我们感知到的数据是一行一行的,而这些行就存放于页中,我们的操作对象是行,数据库的处理对象是页。

行记录的格式通常有四种:Compact,Redundant,Dynamic,Compressed,压缩方法不同而已,常用的5.7默认是Dynamic,之后都是Compact。

如果要找某一行该怎么找呢?最直观的方式是直接一行行找,在mysql 内部就是一页页的查,然后业内遍历每一行,很显然效率比较低,如果按照非主键查询算法是O(n)的,并且没法把所有页都灌进内存来避免IO。

3.2.3.1 主键索引

为了解决这个问题,引入了索引结构(也是一种页),先找到对应的索引,然后按照索引找到对应的数据页,在进行数据页访问,并且由于索引的量级、和大小都比数据页小的多,可以进行缓存。

这样就做到了1、2次IO就可以完成数据的访问,多级-索引页 + 数据页 就构成了整个查询树(b+树)

整个过程就变成:

访问最高层的索引页,找到下一级的索引页,以此类推,查到最底层的根节点之后,再访问数据页,执行数据访问,和行记录修改,修改的过程中打log,写缓冲,然后后台线程把脏页刷回磁盘。

索引页不一定是全缓存的哈,索引本身也很大,但是“常用”的索引页会因为缓存策略被拉入缓存,innodb_buffer_pool_size 就是这个缓存的大小,某几行比较热,大概率数据页常驻内存,某些段比较热,大概率索引页常驻内存。这个参数决定了查询的IO次数,也就导致这个参数变成了mysql 最大的性能影响参数。

3.2.3.2 为什么选择 B+树

顺序遍历为啥不行?

这个主要跟数据量强相关,理论上没啥问题,数据量小,就是完全遍历也没啥问题,数据量稍大点就用树结构去组织数据页(比如多路平衡搜索树)。

十万级数据,可能顺序查就扛不住了,而大概率我们要面对的是千万级的数据。所以开始考虑索引结构,效率最高的Hash结构,然后就是树,但是Hash不支持范围查询,这点很头疼,所以对于这种SQL数据库就用树来做索引了。

索引结构为什么用B+树呢,其他树不行?
索引的作用是快速定位数据页,并且尽可能少的IO产生。所以索引页要尽可能的存放更多信息,然后以这个为基准去看各种树。

二叉树
数据量的增大必然导致高度的快速增加,对那种逐渐增大的数据查询相当于链表查询,效率低下,显然这个不适合作为大量数据存储的基础结构。

二叉平衡树
同样数据量的增大必然导致高度的快速增加,每次插入数据索引的变更成本太高了,不合适。二叉树都有深度这个问题

m阶b树
平衡的多路搜索树,要求根节点至少有两个子节点,除根节点以外的所有节点(不包括失败节点)至少有[m/2]个子节点,所有的搜索路径一样长。由于索引不重复,数据节点可以理解为是根叶都有。
m阶b树比较擅长随机检索,但是如果想要顺序搜索就比较难了。

b+树
就是在b树上做了一点变更,允许索引冗余,并且索引中只存放索引信息,具体的数据放在叶子结点中,这样就做到了索引信息足够小,一页索引能存更多的索引信息,并且继承了b树的优势点,搜索路径均衡,面对巨大数据量级是还能足够扁平,搜索效率较高。并且把叶子结点组成了双向链表,能够顺序查找。

3.2.3.3 非聚集索引

非聚集索引又称辅助索引,同聚集索引的差异是,是否直接索引到数据节点。

比如主键索引就是聚集索引,而非主键唯一索引虽然能1:1定位到数据,但是不直接索引到数据节点,所以是非聚集索引。

mysql InnoDB中的非聚集索引的实现同主键索引基本类似,同样也是b+树结构,差异点在于,叶子结点存储的是主键值,所以查找过程基本是先走非主键索引,找到对应的主键值,然后使用对应的主键值去查找对应的数据页,然后找到对应的行数据。(很显然会有一次回表动作,再走一次查询过程)

非聚集索引的查找过程基于左匹配原则(比较、模糊匹配会中断匹配),所以写sql 语句的时候要注意下,不过新版本的mysql已经能够对于where 条件根据索引顺序进行优化。(值匹配也是左匹配(左前缀))

肯定会有人好奇,为什么存的是主键值,而不是直接指向具体的数据页。因为往表里插入数据是可能会导致主索引结构发生变化,会导致数据地址发生变化,也就要求需要把每一个索引都要同步更新,成本较大,尤其是在非主键索引较多的情况下。

但这里同样要区分场景,myisam就是直接指向的数据页,核心还是事务操作/查询 的占比情况。

然后mysql InnoDB的插入缓冲除了为了插入性能考虑,还有一个就是为了解决非聚集索引插入的性能问题,因为非聚集索引并不是像主键那样有顺序性,插入过程完全是随机读写,所以需要的时间比较多,如果同步写,性能会差很多。

当插入一个新的索引时,首先会在insert buffer中查找对应的索引页,如果存在则插入;不存在则初始化一个新的索引页。通常多个插入缓存能够被合并为一个操作再与辅助索引页合并,所以大大提高了性能。

但是能主键查就主键插,回表过程开销整体来说比较大。

3.2.4 锁

mysql 中常见的锁有:表锁、页锁、行锁、间隙锁、临键锁、还有意向锁等,可以大致分为共享锁和排他锁。锁粒度从大到小:表锁、页锁、行锁(记录锁、间隙锁、临键锁)

行锁都是基于索引实现的,直接锁索引段,然后按照属性加共享或排他,除了主键索引会上锁,辅助索引也会。

这种情况会死锁
where index = 1 and primary_key = 2;

where primary_key = 2 and index = 1;

死锁的情况,有非常多,我踩的最多的就是插入意向锁导致的各种死锁。
gap 锁和插入意向锁(本质也是个间隙锁):比如5.7里面 大量的insert on duplicate key update会导致死锁,还有类似的select for update,没有就插入,有就更新(像不像余额的初始化动作,很危险哦)

死锁写代码的时候完全避免可能对水平要求有点高,上线前模拟真实流量压测吧,然后一个个报,对着死锁日志,对着锁表挨着看吧,之前做账务的经历,可太精彩了。

3.3 OLAP & OLTP

OLAP 在线分析系统,OLTP在线事务系统,分析和事务处理面临的数据操作差异是相当大的,同时对应的数据库的底层实现差异也较大。

OLAP 侧重于分析,使用场景更多的大量的查询功能,并且是超大规模查询和计算。
OLTP 侧重于数据的记录&变更,增删改查相对均衡,对实时性要求通常比较高。

对于我们日常的场景来看,在线的用户行为操作,比如支付行为、发个朋友圈之类的都是OLTP,比如你刷朋友圈时给你插了一条广告,这条广告计算得出的过程就是OLAP。

本文侧重要说的OLTP,除了常规的增删改查,还要看看更复杂的改动 “事务操作”,事务的四大特性,想必大家已经烂熟于心了:原子性、隔离性、持久性、一致性,前三个是手段,最后一个是目的。事务的本质就是利用原子性、隔离性、持久性实现数据的一致性保障。

这个一致性说的直白点,就是计算机系统里面的数据和客观世界的预期变化保持一致,保持客观规律和事实。

看懂所谓的一致性,然后在这几个特性上增加一个去中心化操作,防止篡改,这不就是去中心化存储了嘛。

扯远了,本文要说的就是我们常见的数据库,比如说mysql InnoDB是怎么落地事务的,怎么实现 原子性、隔离性、持久性的。

3.3.1 几大log:binlog、redolog、undolog

binlog 是个二进制log,保存了所有的数据库逻辑操作日志,数据库层面,于数据库存储引擎无关,包含了每条语句执行的时间、所消耗资源、还有相关的事务信息,常用于主从同步。
-刷入binlog 文件,有这么几个选项:

    sync_binlog=0 的时候,表示每次提交事务都只 写入文件系统的page cache,不 fsync;
    sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
    sync_binlog=N(N>1) 的时候,表示每次提交事务都写入文件系统的page cache,但累积 N 个事务后才 fsync。(如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志)

binlog 详尽可参照:MySQL Binlog 源码入门

redolog 保存了具体的数据变更,记录了具体是哪个数据页,偏移量是多少,进行了什么修改,偏物理层面的修改。比如:xx 表空间,xx 页,xx 位置,xx 值
-刷入redolog 文件,有这么几个选项:

    innodb_flush_log_at_trx_commit=0 -- 以固定间隔将缓冲中的数据写入内核缓冲,并调用一次 fsync刷入磁盘,系统崩溃可能丢失最大1秒的数据
    innodb_flush_log_at_trx_commit=1 -- 默认值,每次事务提交时调用 fsync,这种方式即使系统崩溃也不会丢失任何数据,但是因为每次提交都写入磁盘,IO的性能较差
    innodb_flush_log_at_trx_commit=2 -- 每次事务提交都将数据写入内核缓冲,但仅在固定间隔调用一次 fsync 强制刷新高速缓冲,安全性高于配置为 0

redolog详尽可参照:

undolog 记录了数据更新前的版本,用于数据回滚是快速回退到上一版本的数据,可以把undolog 看成另一份数据。
undolog的结构相对复杂可参照:

3.3.2 看一下事务的执行过程

0.生成一个事务id
1.读待修改页(缓冲池中有就读缓冲,没有就读磁盘)
2.执行修改
3.写入数据页BufferPool(数据缓冲)
4.旧值写入undolog
5.写入redo log缓冲
-刷入redolog 文件(prepare阶段)
6.写入binlog 缓冲
-刷入binlog 文件
7.刷入redolog 文件(commit)

围绕这个过程来看一下具体的实现原理。

3.3.3 持久性实现

持久性说白了就是不丢,前面提到过,从应用程序到系统调用,再到磁盘的写入,都是存在丢失可能的。
如果直接fsync、关掉hardware缓冲,性能会劣化到极差。要一点性能我们就没法儿去保证单次写入的的可靠性。所以需要一种机制来保证数据不丢,同时能稍微兼顾一下性能。

mysql InnoDB 采用的持久性方式很大众化,先写log,再写数据文件,也就经常听到的WAL(write ahead log),这是几乎所有有持久化诉求应用采用的方式,一种经典的crash-safe 策略。

mysql 保存的这份日志是redo log。redo log分为两部分,日志缓冲和日志文件。整体的过程会先写binlog缓冲,然后再写入redolog 缓冲,再根据对应的策略在这个过程中或者过程外将redo log刷入磁盘,然后整体操作完成,redolog刷入提交。

整个过程保证了事务提交时,redolog 一定先刷入磁盘,redolog 写入成功,就认定事务操作成功。对啦,redolog 是循环写的哈,不是个纯流水。

整个redolog的写入过程可以被划分为prepare、commit,做了一个两阶段提交。

3.3.3.1 为什么需要两阶段

为什么这么实现呢,首先WAL机制保证了数据不丢。那为什么还需要两阶段提交,这种XA事务存在的意义是什么呢?这不是分布式事务的协议吗,不少人看到这儿都会有这个疑问。
其实很简单,一次数据提交,要保证两个文件的一致性,这不就是分布式数据节点嘛,只不过没有网络IO发生而已,原理上是相通的。

前面提到过binlog、redolog各有职责,两者就实践意义上来看都是不可或缺的,并且两者具有一致性要求,任何一个少了都会诱发一些问题,比如少了binlog主从一致会受影响;少了redolog事务就无法重做等等。那不用两阶段可以吗,不可以,因为出现故障时会很麻烦。

假如只写一次,不做两阶段提交,这两种功能的日志有一定概率发生不一致。
假如先写redolog,redolog写成功了,redolog是相对完整的,binlog还未写入,此时宕机,binlog就少了一些,这就导致从库丢失更新。
假如先写binlog,只有binlog是相对完整的,但是binlog这种逻辑操作日志里并没有记录具体的事务检查点,恢复不了。

而实现了两阶段,可以等两者都完成写入之后(到达一致),才真正的commit。宕机恢复时,到达commit状态的可以直接提交。prepare状态的才去检查binlog,binlog缺失就回滚,binlog完整就直接提交。

这里的WAL和2PC思想很重要,分布式事务也是这么玩的,后面会具体分析哈。

3.3.3.2 为什么这么实现

主要对于性能的考量,前面提到过,磁盘写入是一个重消耗操作,对性能影响极大,而且磁盘顺序写、磁盘随机写 性能差异很大,出于各种功能,我们要写数据文件(若干次随机IO)、binlog(少量顺序IO)、redolog(少量顺序IO)、undolog(少量顺序IO),如果每次都fsync强制刷盘,性能会差到难以想象,一次sql 操作几十毫秒、甚至上秒肯定是不可能接受的。

所以把redolog做成了轻量级记录,只记录变更点,并且磁盘顺序写,性能较好,redolog 写成功了,然后后台线程异步刷磁盘。保证持久性的同时,兼顾了性能。并提供缓冲机制,并且根据具体的场景选择不同的可靠性配置。

对于binlog,同样提供了缓冲机制,可以选择不同的模式,来平衡redolog、binlog的一致性问题。

3.3.3.3 宕机时怎么恢复

InnoDB 为 redo log 记录了序列号,这被称为 LSN(Log Sequence Number),可以理解为偏移量,越新的日志 LSN 越大。InnoDB 用检查点(checkpoint_lsn)标记未被刷脏页的 redolog 数据从这里开始,用 lsn 指示下一个应该被写入日志的位置。

Recover过程:故障恢复包含三个阶段:Analysis,Redo和Undo。

Analysis阶段的任务主要是利用checkpoint及redolog中的信息确认后续Redo和Undo阶段的操作范围,通过redolog修正checkpoint中记录的脏页集合信息,并用其中涉及最小的LSN位置作为下一步Redo的开始位置RedoLSN。同时修正checkpoint中记录的活跃事务集合(未提交事务),作为Undo过程的回滚对象;

Redo阶段从Analysis获得的RedoLSN出发,重放所有的Log中的Redo内容,注意这里也包含了未Commit事务;

Undo阶段对所有未提交事务利用Undo信息进行回滚,通过Log的PrevLSN可以顺序找到事务所有需要回滚的修改。

3.3.3.4 有redolog 为什么还需要数据页两次写?

两次写主要是针对Innodb 对于WAL设计的引入,通常来说理论当中的WAL(write ahead log),WAL的原理通常是没问题的,先写日志再写数据文件,但是innodb中的WAL实现实际上是存在问题的,这里存在一个叫做部分写的问题,通常来说数据页是16k(前面提到过),但是磁盘的页大小通常是4k的(读16K、写4K),这就存在问题了,容易出现部分失效的情况,redo log + 旧页是无法重演数据的,所以引入了两次写。

先写入两次写buffer,然后刷到共享表空间的两次写缓冲区、最后再刷数据文件。

1:脏页刷新阶段
    1-1 把要刷盘的脏页memcpy到内存中的double write buffer(2M大小,2个区)
    1-2 每次1M连续写进共享表空间磁盘文件,然后fsync,double writebuffer落盘完成。由于是顺序磁盘写,所以double write对性能的损耗肯定不会double,据说对性能的影响预计在5%-10%。
    1-3 将double writebuffer中的数据离散的写进各个表空间。

2:崩溃回复recovery阶段
     A. 如果在1-1的系统崩溃,此时内存中的数据都没了,但是没关系,redolog已经落盘,也没有partial page问题,数据重新演算。
     B. 如果在1-2的时候系统崩溃,内存数据丢失,跟1-1同样处理,如果只是写磁盘出现问题,直接重新覆盖,写共享表空间。
     C. 如果在1-3的时候系统崩溃,恢复时,直接使用2-1步骤进行恢复。

整体来看Innodb使用redolog+double write来保障数据的写入可靠,实现事务的持久性。某种视角来看异步刷新脏页中doublewrite,一定程度实际充当了物理日志的功能。

3.3.4 原子性实现

原子性是指要么全做,要么一点不做,不存在中间状态,可能早年人们认为原子是不可再分的,所以起了这么个名字吧,叫不可分割性更形象吧。前面提到的undolog就是来做这个事儿的,改数据之前记录一下改之前的值。

有了undolog我们就有能力将数据恢复到事务开启时的版本,同时undolog的持久化也需要redolog来做保障。

3.3.5 隔离性实现

大家常背的八股,事务的隔离性:“读未提交”、“读已提交”、“可重复读”、“串行化”,这几种隔离级别实际上描述的就是并发修改的粒度,主要用来解决并发修改下,脏读、不可重复读、幻读、丢失更新(一类、二类)等问题。

隔离性要解决的核心就是并发控制的问题,也就是保证并发执行的事务在某一个隔离级别上能够正确的执行。

关于并发控制如果想要更深入的了解,可参照:

如果想了解隔离级别的发展史,可参照

3.3.5.1 并发修改下的问题

脏读是指读出了其他事务正在修改的中间值,而不是最终值

不可重复读,一个事务内多次读取数值不同,会有新提交的事务结果被读到。

幻读,其他事务新插入的数据被读到了。

一类丢失更新,事务撤销时,会导致之后执行的事务被覆盖,导致丢失。(可以理解为版本控制的指定快照回滚)

二类丢失更新,两个事务同时开启,但提交延迟较大,较早提交的事务,会被之后提交的事务给覆盖。(经典的并发下的竟态条件)

3.3.5.2 MVCC机制

接下来看下,这些数据并发修改的控制是怎么实现的。从程序实现角度,并发下要解决问题,首先想到的是不要并发,要么串行,要么互不干涉,数据操作的这个场景下,互不干涉是不可能了,那就可以直接串行操作,让有冲突的操作串行化。

但是挂锁串行太粗暴了,对于冲突的情况,毫无并发度可言,为了性能,能不能无锁化处理,事务间互不影响。

毕竟很多时候我们只需要读一个“有效的快照”即可,可重复读、读已提交、读未提交 这些就是有效的定义,怎么做呢?

mysql innodb提供了MVCC机制(多版本并发控制)来实现读已提交、可重复读两种级别。

MVCC本质上就是创建数据的一致性视图,让读操作切到这个一致性视图上:

读未提交:和MVCC没啥关系,就是不创建视图,直接读行记录
读已提交:在SQL执行开始的时候记录下已经提交的版本视图,每次读这个视图
可重复读:在事务开始的时候记录下已经提交的版本视图,事务内读这个视图
串行化:和MVCC没啥关系

举个例子:最常用的可重复读模式

1: 当一个事务开启的时候,会向系统申请一个新事务id
2: 此时,可能还有多个正在进行的其他事务没有提交,因此在瞬时时刻,是有多个活跃的未提交事务id
3: 将这些未提交的事务id组成一个数组,数组里面最小的事务id记录为低水位,当前系统创建过的事务id的最大值+1记录为高水位
4: 这个数组array 和 高水位,就组成了“一致性视图”。

其他的模式类似,隔离级别决定的就是视图创建的时机,拿到视图之后,接下来开始判断

如果版本号小于“低水位”,说明事务已经提交,可见;
如果版本号大于“高水位”,说明这行数据的这个事务id版本是在快照后产生的,不可见;
如果版本号在事务数组array中,说明这个事务还没提交,不可见;
如果版本号不在事务数组array中,且低于高水位、大于低水位,说明这个事务已经提交,可见;
自己的事务id中的任何变化,都是可见的;

3.3.5.3 回头看问题

读未提交、串行化,这个不需要任何机制支持,本来就是这样的。

读已提交,可以直接读取MVCC sql执行时创建的一致性视图,每次读到的就是已提交的数据。

可重复读,可以直接读取MVCC 事务开启时创建的一致性视图,每次读到的就是已提交的数据。

MVCC把脏读、不可重复读都已经解决了,而在事务的回滚机制中,一类更新丢失不允许发生。剩下的还有幻读和第二类更新丢失,记住一点,MVCC是实现读已提交、可重复读机制的,讨论的幻读、更新丢失等问题都是在这两种模式之下进行讨论。

3.3.5.4 幻读问题的解决

首先幻读这个问题是指可重复读级别下,当前读,读出其他事务新提交的数据的。如果是读快照(事务开启时的一致性数据视图)是出现不了幻读现象的。

可重复读级别下,幻读问题是通过next-key lock(可粗暴的理解为行锁 + 间隙锁)来解决的,不同的版本实现都有差异。

当InnoDB扫描索引记录的时候,会首先对索引记录加上行锁(Record Lock),再对索引记录两边的间隙加上间隙锁(Gap Lock)。加上间隙锁之后,其他事务就不能在这个间隙修改或者插入记录。

当然啦,串行化级别下,当然没有幻读问题。

3.3.5.5 第二类更新丢失

第二类更新丢失更多是事务间的协同能力,本质上是一个多线程问题,最佳的方式是根据业务的需求来上锁,使其串行化,而mysql 也是这么来做的。

在可重复读场景下,读的都是一致性视图的快照数据,事务开启时读的是相同值,然后进行更改,理论上会发生后提交事务覆盖先提交事务的情况。但是如果你去实际实验一下,不难发现,没有出现所谓的丢失更新呀。

核心原因是,mysql对于当前读场景,会进行上锁动作,相当于在可重复读场景下,让这几个事务串行化了

网上各种blog 各种搬运各种copy,一多半都是错误信息,不要被迷惑哈,mysql可重复读之下,对于同一行记录的原地更新是没有问题的,不存在更新丢失,每次设置值都会去上锁的。

举个例子:有id、balance 两个字段,表名是t_user_balance;

-- 事务1
begin;
update t_user_balance set balance = balance + 1 where id = 1;
commit;

-- 事务2
begin;
update t_user_balance set balance = balance + 1 where id = 1;
commit;

两个一起执行,会对行记录挂行锁,事务二会被夯住,只有事务1提交后,事务2才能执行

如果用的是快照读,那没法了,会出现第二类更新丢失

-- 事务1
begin;
select * from t_user_balance where id = 1; // 行锁
// 然后利用上面的查询结果,写入
update t_user_balance set balance = ${cal_result} where id = 1;
commit;

-- 事务2
begin;
update t_user_balance set balance = balance + 1 where id = 1;
commit;

两个一起执行,事务2先提交,事务1会对于事务2更改前的值进行写入,那怎么办呢,像mysql 一样,变成当前读。

begin;
select * from t_user_balance where id = 1 for update; // 行锁
// 然后利用上面的查询结果,写入
update t_user_balance set balance = ${cal_result} where id = 1;
commit;

-- 事务2
begin;
update t_user_balance set balance = balance + 1 where id = 1;
commit;

这个事儿怎么说呢,mysql 对于MVCC实现有点瑕疵,PostgreSQL、Oracle这些应该对于可重复读场景直接做了校验,如果数据发生了变更,自动检测更新丢失,会直接报错扔出来,而不是上锁。

mysql InnoDB 并不是严格意义上的MVCC,也就是说不会直接存储多个版本的数据,而是所有更改操作利用行锁做并发控制,这样对某一行的更新操作是串行化的,然后用Undo log记录串行化的结果。当快照读的时候,利用undolog重建需要读取版本的数据,从而实现读写并发。

3.4 尝试阶段性总结下

尝试总结下,说了这么多实现和原理,那对我们使用mysql、或者实现和mysql类似功能的应用,有啥帮助没,看到这里,可以尝试回忆总结下。

  • 如果要有持久性保证,那就WAL,但是要注意部分写问题。
  • 如果要控“分布”的数据节点,经典的2PC是一个不错的解决方案。
  • 要做原子性,就要支持完整且可靠的回滚机制,记录修改前的版本,并且对于这份记录做持久性保障。
  • 要做事务动作,持久性(解决不丢)、原子性(支持回滚)、隔离性(并发下的控制) 缺一不可。
  • 并发问题不好解了,要么不要并发,直接串行(上锁),或者以串行的方式去并发(数据不共享)。
  • 牺牲可靠性,使用缓冲批次读写,对于吞吐的提升真的很大,规避IO时,相当好用。
  • 顺序读写、随机读写性能差异很大,尤其是在大量IO的场景下,整体的差异会更大,WAL 中日志顺序写,数据文件异步写,然后写入加缓冲,性能比次次fsync要快太多太多。
  • 要写好where,得对索引门清,能用主键查的就用主键。
  • 尽可能避免null,会导致索引、索引统计、值比较很麻烦,然后要做索引,区分度最好高一些。
  • 范围查询会导致索引失效,所以优先使用 = in等操作,避免失效,记住最左。
  • 优化索引是能修改当前的,就不要新增新的索引,会增加维护成本和速度(索引页会多,插入缓冲的消费也会更慢),并且需要更大的空间。
  • 推荐使用联合索引,减少辅助索引的数量,一次辅助索引查找就找到主键值。
  • where 子句中对字段进行表达式操作或者函数操作,都会导致引擎放弃使用索引而进行全表扫描

4.分布式环境下的数据库系统

看完单机的mysql 应该已经十分清楚怎么能够保证数据不丢、不错,计算机的数据存储和操作能跟我们的预期保持一致,但是单机是有极限的,无论是由于CPU算力上限和内存上限导致的网络极限、磁盘IO极限,又或者磁盘的大小上限,每一个极限都是制约单机性能的因素。哪怕我们把软件层面能做的事儿拉满了,异步批处理缓冲、缓存、并发度拉满,单机还是有着一个较低的上限。

在面临上限的存在,由于数据存储是个状态型应用,导致无法像无状态应用一样做集群处理。比如nginx,可以堆机器,请求落到哪台机器上都是可以的,比如执行计算任务,带着输入放在哪台机器上算也都是一样的,而状态型应用要求只能把流量放在存在目标数据的机器上。

那如果每个机器都放一样的数据,然后把流量散到每台机器上呢?结果就是:需要保证每台机器的数据是完全一样的,才能保证整个系统的一致性。最终的结果必然导致每台机器抗整个集群的流量(实际操作 + 一致性流量),单机极限仍然是系统的极限,并且还浪费了大量人力物力。 ps:这其实就是多活的方式,但是多活其实是做了时空差,才不会演化成上面说的最坏的情况,换个角度不难发现,多活是为了容灾和性能,而不是为了突破极限

4.1 拆流量

要突破极限,最直接的方法就是拆流量,把流量拆到每一台机器上,有两种策略,四个思路:

逻辑拆分:

按照数据的横向和纵向分别进行拆分。

  • 水平拆分:按照流量来源做拆分,比如说用户维度拆,不同流量来源的数据落到不同的机器上。
  • 垂直拆分:相同流量来源,但不同功能的数据落到不同的机器上。

牺牲一致性拆分:
做读写分离,按照读写,然后按照各自特性做复制,做拆分。

  • 热点复制:同一份数据复制多份做分布节点,牺牲数据一致性,数据复制后组集群、挂缓存、挂缓存集群(短时间不一致可接受,最终一致就好)构成低数据一致性读数据源,然后强一致性读仍然走源数据,这种拆分方式,主要是读场景
  • 热点拆分:把热点数据拆分多份,或者多通道缓冲,通常是计算场景,根据场景强行容忍短暂的不一致,做sharding和batch。主要是写场景

通常来说这几步是一个递进过程,首先是水平拆,水平拆完,垂直拆,垂直拆完,按照一致性做拆分,面临热点大于极限时,根据场景,要么拒绝,要么牺牲一致性,继续拆。对于各种有状态应用的极限应对思路往往都是这样。

下面,详细来看

4.1.1 水平拆分

按照流量来源做拆分,比如说用户维度拆,不同流量来源的数据落到不同的机器上。每台机器上的流量和数据量都降低了很多,整个集群能扛住大流量了,并且由于数据的减少,读写的性能也更好了。

就具体实践,我们可以在一堆mysql 实例前面挂一个接入层,然后按照一定的规则,稳定散列到每一台机器上。如果出现不均衡的情况,可以强制干预某些大流量源进行进一步的散列。

关于数据分布的方法可以参照浅谈分布式存储系统数据分布方法

4.1.2 垂直拆分

水平拆分通常能解决大部分问题,但是某些情况下水平拆也解决不了问题。由于单行数据过大,受限于mysql的实现原因,表的极限、库的极限会提前到来。

很多时候我们的诉求只是改一大行中的某几个字段,但是对于mysql而言,操作的是整个数据页。如果行数据过大,会导致一页内能存放的行数过少,必然导致磁盘IO次数增多(索引页多 + 数据页多),而IO多带来的就是响应时间很长,也就导致单机能抗的吞吐变小。牺牲性能,但由于单机吞吐太小,机器数过多不可接受;保性能,一台机器少存点行,机器数过多也不可接受。而且就算硬堆机器,本质上是在浪费,mysql 并没有发挥最大的性能。

数据库里表越拆越多,带来的是文件数量的大规模增加,而单机的文件描述符是有上限的。并且数据库的单库的维护成本也会直线上升。而且单数据库的能处理的链接也是有上限的,水平拆完之后,流量依旧可能很大,但是单表已经足够窄了。

总结来看:

1:让流量只影响到最小的操作单元是最佳的性能处理思路,所以需要按照功能用途进行数据拆分,那就别让无关列无关表 一起被操作、一起受影响了,这样才能发挥最佳性能,提升吞吐

2:水平拆分后,哪怕就极少的数据,流量仍然可能很大,可以按照功能维度进行流量的进一步拆离,也就意味着把不同功能的数据拆分

3:并且出于容灾、维护、故障屏蔽等方面的考虑,做功能隔离,也有着较大的拆分倾向。

4:再加上康威定律导致的组织上的分工,按照功能拆分也是种必然的趋势;

垂直拆分的意义就很突出了,按照功能字段-拆表,然后组成新的表,突破表极限;按照功能表-拆库,组成新的库,突破单库极限;并且以此发挥数据库的最佳性能。上层应用层按照功能做路由,拆到不同的数据源,拆分到不同的表。

4.1.3 热点复制

热点问题很头疼,原则上要保持一致性,由于有单点极限存在,热点问题是解不了的。但是为了可用性,我们可以牺牲一致性,做数据分布,换取有效吞吐上限的增加(可以理解为可用性的极限),这就是经典CAP理论的思路。所有面临有效吞吐难以提升的场景,不妨都试试这个思路,把数据拆分或者复制为不同的数据节点(牺牲整体数据的一致性)。

流量的一致性要求是不同的,有的要求低、有的要求高。比如说一些评论信息、用户个人介绍、当前展示的库存等等,对于一致性要求并没有那么高,最终是没问题的就够了。而像账户余额、商品扣减时的库存、计费数据这些则要求丁点儿不错。

当完成流量的一致性分类之后,不难发现,读流量更多是低一致性要求的,写流量和写时读是高一致流量的。那么我们首先可以做读写分离。

用热点数据复制的方式,将读流量处理的极限突破掉,低一致性要求读复制节点,高一致性要求读源节点。

比如mysql,可以按照这些将流量进行拆分,同样的把数据复制多份,其中一份为强一致基准(主),其他的数据节点来复制这份数据(从)。让一致性要求低的流量指向从库,强一致的流量指向主库。

ps:主从的机制,不单纯是为了突破极限,很多时候是为了容灾和故障恢复。

如果还扛不住,对于一致性再进一步牺牲,进一步数据复制,比如从库上面挂缓存。这样理论上就把往往是流量大头的一致性要求低的流量给分出来了。

4.1.4 热点拆分

牺牲读一致性将流量拆分完成之后,还是有一类问题是解不了的,这就是我们常说的热点数据写入问题,常见的场景比如:库存、收款账户。

对于热点写入数据,我们还能拆吗?理论不能(会打破时间上的一致性,写入成功、但未生效),但是没有办法的情况下,就适当的牺牲逻辑上的一致性,将源数据拆分为多个子节点或者缓冲节点,子节点共同构成逻辑节点,子节点操作时只关心内部状态。

对于同一时刻,子节点规集得到的逻辑数据节点与父节点是不一致的,但是父节点过一段时间总能达到逻辑数据节点的状态,整体来看是最终一致的。但是基于中间的软状态,会导致某些决策上的不一致。

ps:因为要保证整体逻辑上的一致性,当前读本质上属于写流量,不可能去读父节点当前数据,所以当前读应该也被拆分到具体的子节点中。

就拿库存来说,看两种常见的实现方案: 把一份库存数据拆成多份,然后按照一定的策略流到不同的子库存上,可能会导致检查库存时无库存,但是实际还有。

把库存拆成一个个的令牌,然后预先分发给一台令牌下发server(纯内存),或者直接下发给每台业务Server,同样对于单个请求逻辑看来是有一致性问题的。

或者给库存的写入动作挂个通道,异步批处理,相当于绕过了热点极限,异步通知用户中没中。

比如商家收款: 把商家账户拆分为多个子户,拆分热点流量,子户规集至主户,牺牲主余额实时状态下的一致性。

把付款流量做缓冲,批量入户,高性能可靠写存储换取更高的写极限,同样牺牲主余额的一致性。

更甚者,只记流水数据,高性能可靠写换取更高的写极限,异步对账,日结即可。

我们日常实践的过程中,如果水平、垂直拆分完,流量还是过大,还是难以解决,不妨试试这种方式。

需要注意的是:在牺牲一致性时,要根据具体的场景进行单方面的牺牲,比如库存能少发、但不能多发,再比如余额能牺牲一定时效性,但不允许错

ps:对于某些场景,可以直接牺牲可用性的,比如抽奖直接不中奖,秒杀直接无库存,背后其实可能直接随机数过滤掉大部分流量。但是对于某些场景无法去拒绝流量、无法伤害用户体验,那不妨试试上面的思路,只要保证数据逻辑上整体的最终一致就好,单个请求的一致性就牺牲吧。

4.2 关于分布式环境下,一致性的保障思路

前面提到了如果使用分布式的方式突破单机极限,但是如果数据做分布了,我们怎么去保证数据的一致性呢,前面提到的最终一致、强一致、CAP又是些什么。

4.2.1 一致性只能是强一致嘛

我们前面提到了什么是数据一致性:“计算机系统中的数据与客观世界的预期变化保持一致”,而这个一致性通常由原子性、隔离性、持久性来保障,由于就一份数据,依赖于事务机制变更完成是一瞬时的,是一个时间点而非时间段。

但是数据分布之后,对于逻辑数据的一致性保证就比较复杂了,数据变更往往是一个时间段。就现实情况来看,这个保持一致性的时间长度并没有很强的界定,比如保持一瞬间到达就是结果数据,还是5分钟之后是结果数据,还是只要我用这个数的时候就是当前的结果数据。

我们可以根据时间这个维度,把一致性划分为:线性一致(强一致) 最终一致 顺序一致

  • 线性一致就是变更完成的瞬时完成变化,以提交为临界点,之前是修改前的,之后是修改后的,在任意时刻分布下的所有的数据节点总是一致的。
  • 最终一致是允许在变更的时间跨度内有中间状态的存在,相当于线性一致变更的瞬间是一个时间段,时间段内观察到的是中间状态,完成之后观察到的一致的。
  • 顺序一致是指一次数据的更改是顺序的,总能按照更改顺序读到每个节点最后一次修改的数据。

还有一些其他的基于以上演变后的分类,比如说顺序一致变种一下就是因果一致(只对必要的读顺序一致),读写一致就是对自己线性一致,对其他最终一致。

这些分布式下的数据一致性适用于各种不同的场景。

4.2.2 CAP & BASE

CAP 是指分区容错(因为通信机制导致出现多个子网络,重点是互不知死活)、可用性、数据一致性只能取其二。

分布式环境下,分区容错是必须的。首先分布式下协同机制(比如网络)一定是不稳定的,如果不容错(不允许有分区发生,但是分区一定会发生),就只能不分布,只有单节点能解决问题。

剩下的就是选可用性和数据一致性,发生网络分区时,就代表有节点不可达,要么拒绝请求(牺牲可用性),要么不可达的节点不操作(牺牲一致性),别无他法。

如果有可靠的协同机制,那CAP就能同时满足了?可靠的协同机制就代表不会有分区发生,多个数据节点本质就是一个数据节点,有没有分区容错已经不重要了,AC就能同时满足。比如同一磁盘页内的两行数据(协同:磁头的一次刷入),一次写入,要么全成功、要么全失败,肯定是一致的,并且可用性跟一致性没有互斥关系,肯定是能共存的。

另外,分区容错,容的错是指的是部分错误:Partition Tolerance:System continues to work despite message loss or partial failure.

这其实意味着不只是网络分区,就算单机内,数据呈分布态,本质上也会有CAP问题,我们也可以用CAP和BASE理论去解决问题。

可以回忆一下,同一数据变更,redolog、binlog需要都记录,如果其中一方挂了需要决策还要不要继续。比如binlog因为某些原因不可达了,是不是也要做决策是拒绝请求牺牲可用性,还是容忍binlog丢失产生不一致,这就代表了有互斥关系发生。

并且进行决策,mysql选择一致性之后,还需要2PC机制来进一步保证已经写入的redolog不会直接生效,来保障整体的数据一致性。2PC只是保证了在选择一致性时,保障数据的一致性,而不是让CA不互斥了,过程中该决策CA,还是要决策的。

而对于mysql内部的不同行(不同数据页)的修改虽然是分布的,但是redolog写入(单节点数据)保证了其一定能操作成功,也就是完全可以把两行数据的两次落盘看作一次修改,也就是协同机制是绝对可靠的,不存在分区问题。

就使用上来看,数据对于单机mysql来看是单节点的,本身就是强一致的,成功是一致、失败也是一致,单就使用上可用性跟一致性没有互斥关系,是CA的,不需要使用者决策什么。

BASE理论是在CAP之上提出:一致性和可用性并非只能干脆的选择,可以绕点弯路,稍微平衡下,比如分区发生时,选择容忍丢失保证基础可用性(基本可用),但是存在中间状态(软状态),但网络分区最终会恢复,然后通过一定手段达成一致(最终一致)

上面这句话可以这么粗暴的理解哈。‘

4.2.3 共识算法

上面提到了各种一致性的定义,还有相应的分布式环境下,可用性、一致性、分区容错性之间的关系。很多人对这里CAP中的一致性有疑惑,是说跟客观世界保持一致,还是说所有的分布节点都是都一样。

首先,这两者并不冲突,更像是既要又要,只有每个节点对于变化都是一致的,也就是每个节点的变化跟客观时间的变化预期保持一致,才能保证整个系统数据的一致性。

而这里促进一致的过程,指的就是共识。

共识的定义:共识是指在分布式系统中,通过节点之间的相互通信和协调,使得网络中的各个节点能够就某个值或状态达成一致的过程。共识的目标是确保网络中的所有节点对某个事实的认同,并保证数据的一致性和可信度。

通俗点来说,分布式一致性,通过共识算法是让每个数据节点均发生变化,到达一致状态,并且每个节点的变化跟客观世界的变化保持一致,最终的结果就是分布式系统变化完,跟单节点时一致,并且跟现实世界保持一致

分布式一致性 = 单节点一致性 + 一致性变化

共识算法是一个促成分布节点一致的算法,常见的有:Paxos算法、Raft算法、ZAB算法等等。

放个观点,能让分布式系统保证强一致的算法只有Paxos算法。

4.3 逻辑拆分下的数据一致性保障

当我们把数据按照水平、垂直切分之后,数据节点被分布了,没法像单机一样直接本地事务来保证数据的强一致不与可用性发生冲突。比如余额转账不同的行记录需要保持一致,比如商品库存数据和订单数据需要保持一致等。

按照CAP理论,一致性、可用性就要被决策。我们选择可以选择CP或者AP,或者以BASE理论选择AP 容忍软状态,通过各种手段到达最终一致性。

4.3.1 强一致策略 — 刚性事务

对于分布式环境下实现强一致是不准确的,我们只能无限逼近强一致。原理同本地事务相对类似,同样要实现原子性、隔离性、持久性来保证数据的一致性。

分布式事务主要也是建立在本地事务之上的,在本地事务的基础上,增加协调多个本地事务的能力,让整个分布式数据修改的动作能够实现原子性、持久性、隔离性,而整个的协调机制就是全局的事务管理器。可以回忆一下本地事务的协调机制和执行过程。

持久性基本是完全依赖于本地事务实现;隔离性可以粗暴的理解为一个本地事务的写如果依赖其他事务的数据,则透过其他事务进行读,看作是在一个事务内部;原子性方面除了实现本地操作的原子性,还要保证多个数据节点要么全做、要么全不做(单机建立在undolog之上,而分布式建立在rollback机制上)

而如果尝试进行抽象,就得到了经典的DTP模型,一个分布式事务的落地,至少需要应用程序、事务管理器、资源管理器(本地事务支持者,就是我们的数据库)、通讯管理器。

4.3.1 XA 事务

接下来如果选择CP(分区容错 + 一致性),看基于DTP模型落地的其中一种实现 — XA事务。分布式事务的关键是事务管理器,XA事务的事务管理器是基于XA协议落地的。

4.3.1.1 2PC

XA协议默认使用两阶段(2PC)提交来实现不同本地事务的之间资源的提交和回滚,把本地事务分为准备阶段、提交阶段两部分,具体过程是:

事务管理器向所有本地事务发送prepare请求并等待响应。
本地事务接受到prepare消息之后,正常执行事务动作,记录redolog/undolog 这些,写完第一阶段之后返回,代表所有数据ready。
如果有参与者事务都成功,那就像所有本地事务提交commit请求。
如果有任何一方prepare失败或者超时了,就全局回滚事务,向所有本地事务提交rollback请求。

2PC的实现机制很理想,是建立在网络没有问题的情况下及事务管理器不故障的前提下的,2PC是没问题的,有一方异常,就牺牲可用性拒绝请求。但由于决策者完全依赖于事务管理器,如果事务管理器挂了,参与者就一致等着了,完全夯死,而且事务管理器是单点的,一旦故障,整个系统就全挂了。

再有就是如果commit消息丢失,如果是rollback还好,要是commit就出现数据不一致了。

在单机数据分布式问题也是一样的,前面提到的mysql 2PC来写binlog、redolog在同步链路里,prepare之后,因为某些原因夯住了,redolog就得等着;commit失败时,也会产生不一致。只不过能够检查binlog和redolog来搞成一致的。并且单机上这些问题发生的可能性近乎认为不可能了,2PC在相对可靠的通信环境、协调稳定的情况下,是没有任何问题的。

但是99%的应用都是分布在网络环境中的,通信一定是不可靠的,管理器自身故障或者跟管理器链接的故障,一定会发生。2PC的瑕疵就被放大了。不过可以使用共识协议Paxos或Raft保证协调者的高可用来解决单点故障,但是网络问题,着实难解。

4.3.1.2 3PC

于是3PC就出现了,主要就是解决单点问题、同步阻塞问题的,把prepare、commit变成了canCommit、preCommit、doCommit三个阶段,并且引入了超时机制。

canCommit用来做类似于2PC的prepare数据准备阶段,preCommit是事务的信息的写入(redolog、undolog等),然后docommit后全部提交,每一轮的继续执行依赖于上一轮的结果。

3PC相对2PC增加了一个准备阶段,把大部分错误屏蔽在第一轮,第二轮才真正的锁定资源并准备回滚动作,避免的前置大量的资源占用;并且增加了对于参与者的等待超时,如果长时间没有收到来自于事务管理器的指令,并且检察事务管理器挂掉之后,剩余的参与者会进行决策,会根据当前状态进行下一步的流转(自我决策),对齐了接下来的动作,这样就解掉了事务管理器故障和参与者夯死的问题。

但是在出现网络分区的状态下,仍然会有不一致问题,前置的canCommit、preCommit如果丢失都还好,事务并没有生效。但所有事务都preCommit了,但是某一个事务的回执丢了,整体决策需要回滚,但网络分区的这个事务自决策可以提交,而其他参与者收到了rollback指令,此时数据就不一致了,网络分区状态下,3PC是存在较大一致性风险的。

3PC虽然解决了2PC一些阻塞和单点的风险,但是一致性问题仍然存在,并且3PC多了一个阶段,整体的协议复杂度更高了,性能也会因为额外多了一次IO而导致降低。就目前业界实现来看,用的相对较少,但是3PC的这种对于分布式事务的探索方式,为分布式事务的落地探明了许多方向和提供了一些可借鉴的经验。

4.3.2 最终一致策略– 柔性事务

同上面的提到的追求强一致事务的落地不同,很多方案追求的是最终一致,实现差异上与上面的方案较大。常见的必入有TCC补偿型事务、最大努力通知:比如本地消息表、事务型消息、又或者同步重试。ps:不用太过纠结分类,这里是广义上的最大努力通知。

业界系统的落地往往都是以最终一致的方式进行落地,尤其是最大努力通知更是最常见的实践方式。下面直接说结论,很重要,重点关注

1: 对于常规的业务,实时性要求、一致性要求都没那么高,相对均衡,围绕业务状态机进行幂等重试,同步链路最大可能保证成功,然后异步对账补单就能解决问题了。
2: 对于实时性不高,但一致性要求比较高 的业务可以使用事务型消息中间件,做好幂等重试,成本极低。要是实时性更高一点就试试本地消息表的方式。
3: 对于实时型要求相对较高,一致性要求比较高 的业务可以使用本地消息表的方式,尽可能快的推进业务状态。
4: 对于实时性、一致性要求都比较高,要么快点成功、要么快点失败,但得保证一致性,TCC就是一个不错的选择。
5: 对于实时性、一致性要求都极高,不妨去试试超级计算机解决方案,别搞什么分布式事务了,如果胆大点,成熟的2PC解决方案也可一试。

之前写过的一个业务中间件(业务事件总线)的设计分享,整体一致性保障思路,就是基于最大努力通知-简易实现 + 本地消息表(考虑到性能做了下变种,先尝试通知,然后记录失败,围绕失败记录重试)、事务型消息多组组合模式落地的,可以根据业务场景选择不同的方式。而之前在蚂蚁做支付的时候,用的XTS是基于TCC落地的。

4.3.2.1 最大努力通知 — 简易实现

就一句话:围绕业务状态机进行幂等重试,同步链路最大可能保证成功,然后异步对账补单就能解决问题。相信我,如果不是做交易、做支付,够用了。

4.3.2.2 最大努力通知 — 事务型消息

消息本身会几个状态,能保证如果消费不成功,则一直消费。这样就做到了能把一个事务内需要处理的动作全部推成功。比如使用rocketMQ,可以看下具体过程:

事务发起方首先会将事务消息发送到RocketMQ当中,但此时该条消息并不会对消费者可见,即所谓的半消息。

当RocketMQ确定消息已经发送成功后,事务发起方即会开始执行本地事务。同时根据本地事务的执行结果,告知给RocketMQ相应的状态信息——commit、rollback。

-当RocketMQ得到commit状态,则会将之前的事务消息转为对消费者可见、并开始投递;
-当RocketMQ得到rollback状态,则会相应的删除之前的事务消息,保证了本地事务回滚的同时消息也不会投递到消费者侧,保障了二者的原子性。
-当RocketMQ未收到本地事务的执行状态时,则会通过事务回查机制定时检查本地事务的状态.

然后下游就开始消费,开始幂等重试。

这种方式需要保证下游在业务上一定是可成功的,只能屏蔽技术错误导致的失败。最好把条件动作作为发起方,比如扣款后订单状态变更,比如扣库存后发放奖励;或者发起方需要有定期校验机制,对业务数据进行兜底,也就是对账补单。

并且由于是基于消息队列落地的,实效性上会比较差。

这个方式本质上2PC思路保证消息一定提交成功,然后最大努力通知消费方(事务参与方)。

4.3.2.3 最大努力通知 — 本地消息表机制

这个也跟mysql写redolog保障多节点数据一致是一样的思路(WAL,日志是本地消息表,数据是具体事务动作),只不过一个是在本地环境下,一个是在网络环境。把分布式事务转变为本地事务,利用本地事务的一致性保证操作绝对写成功。

本地消息表的机制也更像是自己实现了一个rocketMQ的能力,只不过把这部分能力做了一定的简化并且放到本地了,然后本地事务中多了一张本地消息表,一个事务内处理分布式事务发起方的内部逻辑、本地消息表的写入,就不用2PC提交了。其他的原理都一样,保证通知成功就好了,可以按照一致性的实效要求,同步通知或者定时任务按照一定频率通知更新,不断流转事务处理的全局状态机(虚拟的)。

同样需要保证下游是业务可成功的,也需要进行对账进行数据校验,做一致性兜底。

并且由于是在本地事务中处理,会有单表的上限,可以适当的切表,但是单机上限就无能为力了。

4.3.2.4 TCC 补偿性事务

TCC是一种同步链路分布式事务的落地方式,实现方式跟2PC的思想也比较像,但是不像2PC、3PC这样要实现严格遵循ACID要求的事务(刚性事务),而是基于BASE理论落地的一种柔性事务,允许软状态的发生,最终一致即可;并且2PC、3PC是站在数据库层面来进行实践落地的,TCC是站在应用层出发去落地的。

相对于通知型事务,TCC是一种补偿机制,更适用于快速成功、快速失败的场景。这样的方式可以贴近业务去做数据一致性的保障,虽然对业务落地有一定的侵入性,但是对于事务本身,由于是从业务出发的,并且是追求最终一致,反而落地的效果更好。

接下来看下TCC的实现思路和执行过程:

所有事务参与方都需要实现try,confirm,cancle接口。并且要求confirm、cancle 是幂等的。

事务发起方向事务协调器发起事务请求,事务协调器调用所有事务参与者的try方法完成资源的预留,这时候并没有真正执行业务,而是为后面具体要执行的业务预留资源,这里完成了一阶段。

如果事务协调器发现有参与者的try方法预留资源时候发现资源不够,则调用参与方的cancle方法回滚预留的资源,需要注意cancle方法需要实现业务幂等,因为有可能调用失败(比如网络原因参与者接受到了请求,但是由于网络原因事务协调器没有接受到回执)会重试。

如果事务协调器发现所有参与者的try方法返回都OK,则事务协调器调用所有参与者的confirm方法,不做资源检查,直接进行具体的业务操作。

如果协调器发现所有参与者的confirm方法都OK了,则分布式事务结束。

如果协调器发现有些参与者的confirm方法失败了,或者由于网络原因没有收到回执,则协调器会进行重试。这里如果重试一定次数后还是失败,就做事务补偿。

4.4 牺牲一致性拆分下的一致性保障

按照CAP理论,对于需要强一致不可再拆的数据,我们进行了强拆或复制,导致其变成了多个真实数据节点 + 逻辑数据,牺牲了一致性换取了有效吞吐的提升(可用性),这种场景下,要保证的一致性是子数据节点和逻辑数据节点的一致性,只要有可靠的数据同步机制、数据规集机制,数据最终肯定能达成一致,最关键的是怎么去处理这个软状态,让业务可接受。

4.4.1 关于软状态

软状态是指在到达最终一致前,数据的中间状态,而这个状态是逻辑上不一致的。但不一致是可以按照不一致的情况来细分的,比如计算型数据:数据读到了少的、数据读到了多的。比如说按时间分类:过早的读出了新数据、旧数据迟迟未更新。

我们是可以按照业务倾向去选择具体的不一致的,所以关于这个软状态是可以有业务偏向性实现的,让这个软,硬一点,需要根据具体的业务场景来进行合理的设计,不一致业务友好。

  • 拿主库存-子库存举例:
    1. 扣减场景,不允许超发,那就按照实际库存进行切分,请求到来时,可能逻辑上仍有库存,但请求进行处理时,当前分片已无库存,逻辑上倾向于多占库存。
    2. 展示场景,请求到来时,不需要展示当前的实时库存,展示的相对多一点问题不大,逻辑上倾向于库存少占。
  • 拿主账务-子账务举例:
    1. 大量收款场景,写多读少,对于展示,用户应该马上看到当前的余额和流水,那每次展示的时候就做一次实时规集进行展示,要展示最新的(如果告知用户入账延迟,展示老的也没问题)。
    2. 对于写入操作,主账务和子账务是否一致无所谓。
  • 拿评论写入热点举例:
    1. 写入一条评论,进入其中一个写入队列,全局数据未更新问题不大,但个人写入记录需要立即展示,这里全局(对其他用户)展示老的,对个人展示新的即可。
  • 拿榜单读热点举例:
    1. 榜单更新后,由于读数据复制多份,又是无状态请求,榜单分数一会儿增一会儿减不可接受,更新过程中读老数据,指定时间点全局生效。

4.4.2 弱一致性要求的甄别

要做上述的设计,我们最核心的是要知道对于数据的一致性要求,这个数据最终会被用来干啥,计算?展示?分析?记录?

这部分需要深入业务去考虑,说几个对C相关的:用户所见即所得、变化基于现实世界的时间轴、即写即读。

5.多活架构下的分布式数据库系统

前面从单机上的磁盘读写,再到针对操作系统的文件读写,最后到应用层数据库mysql的实践落地,再到数据切分之后分布式环境中集中式存储的使用,接下来看下在多活架构下的分布式存储 — 分布式数据库系统。

集中式数据库,指的是将数据集中存储在单一节点上,虽然拆表、拆库了,但是对于一个原子数据(比如一行)来说仍然只有一个主Server进行其存储和操作,也就是常说的单主。

而分布式数据库,则是将数据在多个节点进行分散存储,每个节点就是一个集中式存储,然后多个节点之间按照一定的规则进行组织和访问,对于一个原子数据来说,会有多个主Server进行存储和操作,也就是常说的多主。

在上一篇章,讲集中式存储时就提到了这种模式,当时说把数据做复制仍然突破不了流量极限,因为对于单机来说“一致性流量 + 实际流量”仍然相当于单机去扛所有流量,并且还浪费了这么多机器去承接复制数据。

但其实在实践过程中,尤其是在具体的某一业务场景下,会出现时间、空间上的差值,只要我们在用的时候把数据搞一致就好了,这样就可以根据地理位置跨度、时间差等信息做数据复制分布和数据同步。单机的流量就变成了“少量一致性流量 + 实际流量”。

突破单机极限只是一方面,我们更多的时候做多活并不是为了突破单机上的极限(做多),更多的时候是为了系统的时延缩短、容灾处理(做活)

多活架构下,整个系统的可扩展性、容错能力、可用性都被提升了,但是相应的整体系统也变的更加复杂了。

5.1 两地三中心 & 异地多活

两地三中心,本质上是一种容灾架构,IDC机房本地+异地、数据中心本地主数据中心 + 本地备份数据中心 + 异地备份数据中心。这样就具备了机房级的容灾,但不具备全部城市级的容灾能力。在故障时通过数据节点选主完成故障切换。

这种架构下通常我们的服务会做双活(伪活),比如说同城双活或者异地双活,但是背后的数据时单主的只做了相应的灾备功能,数据库写数据是存在跨机房调用的,但是对于我们的常规业务,可用性/容灾要求并没有那么高的业务来看,足够了。

但是对于时延要求极高、容灾要求极高的场景,两地三中心的模式已经不够用了,通常需要把数据库也进行相应的拆分,和服务一起构成逻辑意义上的活节点(单元化能力),单元化的产物是LDC-逻辑数据中心。

在多活架构下,提供服务时,活节点之间是不会进行通信的,自己搞自己的,有多活的调度机制,负责进行流量的分发和数据的一致性保障,在故障时把故障流量切入其他的活节点即可。三地五中心就是一种落地实践,三个城市,4个常驻服务单元化加一个单元,提供城市级容灾,并且基于就近访问策略提升响应。

5.2 GZONE RZONE CZONE

并不是所有服务都需要做单元化部署的,按照数据的类型,比如可以分为全局共享数据、用户类数据、用户间共享数据,依赖这些数据再加上对应的服务可以大致划分为GZONE RZONE CZONE

GZone(Global Zone):全局单元,意味着全局只有一份。部署了不可拆分的数据和服务,比如系统配置等。

CZone(City Zone):顾名思义,这是以城市为单位部署的单元。同样部署了不可拆分的数据和服务,比如用户账号服务,客户信息服务等,基于“写读时间差现象”,把全局数据整了多个副本。

RZone(Region Zone):最符合理论上单元定义的 zone,每个 RZone 都是自包含的,拥有自己的数据,能完成所有业务。

有没有发现GZONE、CZONE很像是之前提到的牺牲数据一致的思路,其实本质上就是这个,只不过做了单元化处理并结合部署架构,让这套方案更标准化了。

5.2 多活的核心是RZONE

当GZONE 负责全局信息写入,CZONE提供就近读能力之后,全局数据的读写效率就提上来了,而对于大部分应用来说,用户状态型相关的数据才是最需要进行做活的。

RZone 每一个单元都有一个用户维度的逻辑主库,既承担了水平分库的流量sharding的能力,同时每个RZone之间是互备的,当一个zone挂掉之后,是可以把流量且到其他zone而可以进行正常读写的,每个zone拥有全量的用户态数据,但是只有一个zone对外提供服务。

这个机制是基于数据复制策略实现的,并且在这种体系下,我们可以动态的挂载新的zone 实现状态数据的横向扩容(弹性伸缩)。

mysql 本身是不支持这种机制的,所以就诞生了新一代应用:**`分布式数据库`**,比如OceanBase、TDSQL等等,提供了多主的能力(多节点读写)

真的就这么强嘛,一点问题都没有?

不过就之前的做支付的经历来看,弹性伸缩、故障恢复时的一致性考验还是相当强的,由于要求每一笔都不能错,但是我们对于分布式数据库的使用并没有那么极致和标准,流水型数据还好,如果是状态型数据,状态丢失、状态延迟等问题还是会诱发较多的问题。

5.3 OceanBase 实现原理

OceanBase是蚂蚁自研的一款分布式数据库,并且提供mysql、oracle两种不同的租户模式,自身通过LSM存储结构实现,好奇的话可以看看之前写的LevelDB的实现

这里核心要说的ob是怎么实现多主的,ob里面还提供了很多高性能的操作,比如高效本地事务、高效分布式事务、SQL优化等等能力,有兴趣可以看看ob的官方文档, 或者OceanBase文档收集

OceanBase 数据库以分区为单位组建 Paxos协议组。每个分区都有多份副本,通过维护成员组关系,自动建立 Paxos组。同时在以分区为单位的 Paxos 协议组的基础上,自动选举主副本。然后不同的主副本是存在于多个zone之中的,Paxos 协议组成员通过redolog的多数派强同步来确保数据的持久化,具体来看:

当一个分区的 Leader 有日志需要提交时,会首先将待提交日志拷贝到本机内存的 Buffer 之中,同时将这部分日志异步的发给多个 Follower。

拷贝到本机 Buffer 的日志会由后台的写盘线程完成落盘操作,并在此之后标记落盘完成。同步给 Follower 的日志会在落盘完成后,给 Leader 回复确认消息。

Leader 只需要收到包括自己在内的多数派的落盘完成消息后,即认为此日志已经完成了强同步,而无需等待其他 Follower 副本的反馈,此时即会向上层返回提交成功。

复制时使用日志流(LS、Log Stream)在多副本之间同步状态。每个 Tablet 都会对应一个确定的日志流,DML 操作写入 Tablet 的数据所产生的 Redo 日志会持久化在日志流中。日志流的多个副本会分布在不同的可用区中,多个副本之间维持了共识算法,选择其中一个副本作为主副本,其他的副本皆为从副本。Tablet 的 DML 和强一致性查询只在其对应的日志流的主副本上进行。

日志流使用自研的 Paxos 协议实现了将 Redo 日志在本服务器持久化,同时通过网络发送给日志流的从副本,从副本在完成各自持久化后应答主副本,主副本在确认有多数派副本都持久化成功后确认对应的 Redo 日志持久化成功。从副本利用 Redo 日志的内容实时回放,保证自己的状态与主副本一致。

OccanBase主备同步也允许配置为异步模式,支持最终一致性,这种模式一般用来支持异地容灾,有概率丢数据。

除此之外,oceanbase 对于分布式事务支持强一致的分布式事务,使用两阶段提交协议来实现,并将将 Paxos 分布式一致性协议引入到两阶段提交,使得分布式事务具备自动容错能力。

5.4 TDSQL 实现原理

OceanBase是一种自研的数据库,然后向下兼容了mysql、oracle等,那直接基于mysql做不出来分布式数据库吗,答案是可以,又不少公司就是这么实践的,毕竟完完全全新写一个DB成本太高了,但是新写也有自己的优势,整个应用完全可控&历史包袱较小。

腾讯的TDSQL就是基于mysql来进行分布式数据库实践落地的,做了一些适配工作,包括存储集群化、按需自动伸缩、自动扩容、极致性能优化等。

多主的实现跟oceanbase比较像,同样是多节点可提供服务,但是常驻单节点提供服务,然后节点之间实现数据复制同步,然后复制机制是基于原声mysql的binlog复制能力的。

6.回过头来总结下启发吧

  • 存储这东西,是真的好复杂呀,专业的事情,交给专业的人去做哈,千万别自己完花活,log、logstream、mysql、levelDB、mongoDB、neo4j、oceanbase 是真香呀
  • 磁盘,机械磁盘慢,固态磁盘够快但是稳定性似乎没那么好(但也不至于拉胯),有钱,就上固态硬盘吧。
  • 文件读写时,我们需要兼顾性能和数据写入的可靠性,多熟悉几个系统调用及其内部实现,没啥坏处。
  • 顺序读写、磁盘读写性能差异真的很大,尤其是机械硬盘的场景,寻道时间太夸张了。
  • linux是有文件描述符上限的,要额外注意;还有别并发写,会有问题的。
  • 除了磁盘、文件系统的合理使用,磁盘页的组织形式相当重要,好的数据结构能帮你减少IO,比如B+树、LSM等等
  • 当面对并发的时候,首先别并发,真需要并发,如果不知道怎么整了,直接挂锁串行,扛不住?那就异步削峰,不让削?那能直接拒绝流量吗?
  • 如果要有持久性保证,那就WAL,但是要注意部分写问题。
  • 如果要控“分布”的数据节点,经典的2PC是一个不错的解决方案。
  • 要做原子性,就要支持完整且可靠的回滚机制,记录修改前的版本,并且对于这份记录做持久性保障。
  • 要做事务动作,持久性(解决不丢)、原子性(支持回滚)、隔离性(并发下的控制) 缺一不可。
  • 并发问题不好解了,要么不要并发,直接串行(上锁),或者以串行的方式去并发(数据不共享)。
  • 牺牲可靠性,使用缓冲批次读写,对于吞吐的提升真的很大,规避IO时,相当好用。
  • 顺序读写、随机读写性能差异很大,尤其是在大量IO的场景下,整体的差异会更大,WAL 中日志顺序写,数据文件异步写,然后写入加缓冲,性能比次次fsync要快太多太多。
  • 要写好where,得对索引门清,能用主键查的就用主键。
  • 尽可能避免null,会导致索引、索引统计、值比较很麻烦,然后要做索引,区分度最好高一些。
  • 范围查询会导致索引失效,所以优先使用 = in等操作,避免失效,记住最左。
  • 优化索引是能修改当前的,就不要新增新的索引,会增加维护成本和速度(索引页会多,插入缓冲的消费也会更慢),并且需要更大的空间。
  • 推荐使用联合索引,减少辅助索引的数量,一次辅助索引查找就找到主键值。
  • where 子句中对字段进行表达式操作或者函数操作,都会导致引擎放弃使用索引而进行全表扫描
  • 做中间件使用的时候,知道其大致实现,才能更好的使用,很多时候设计者真的没办法把用户当小白。
  • 做数据存储,能用中间件、就用中间件,别自己瞎整。
  • 当碰到极限了就拆分,水平拆、垂直拆 拆到扛得住为准,要是有热点,那就按流量性质在拆,做读写分离,读数据复制拆分,写数据 做子节点、子通道
  • 分布式下保证数据的一致性真的很难,做不了强一致,就试试最终一致,柔性事务挺香的。
  • CAP理论很重要,很多时候为了换取性能(有效吞吐)可以适当的牺牲一致性。为了一致性,可用性必然受影响。
  • BASE理论对于日常几乎所有的应用都绝对够用了。
  • 一致性一定要根据具体的业务场景来判定,很多场景下你并不需要强一致,甚至软状态长期存在也是没问题的。
  • 如何低成本实现分布式事务,最大努力通知是一个不错的选择。
  • 分布式数据库能更好的帮你落地多活架构,就现在来看,现成解决方案还是很多的。
  • 一定要考虑下,你真的需要多活架构或者分布式数据库吗?
  • 多写几个demo试试,再看看官方文档,比看漫天复制的文章强得多。

7.写在最后

这篇文章写的是真过瘾啊,比上一篇写网络更过瘾。

原本是想把脑海中的知识框架以文章的方式呈现给大家,但是写的过程中不断查缺补漏,并深究了太多次 “为什么这么实现”。致使自己因为这几篇文章、因为这些“为什么”,实实在在的打开了计算机视野的另一扇大门。之前顶多面试前梳理个大致概念,知道大致的实现原理,与存储这个领域实质上隔了一堵墙,虽然本篇文章深入程度也不够,但是已经基本跨过那堵墙了,让很多事情,从未知的未知,变成了已知的未知。

技术是真的奇妙呀,多少人付出了多少青春去搞定的这些事情,才能让我们现在的应用如此的方便。忍不住致敬。

最后再回头看一下这张图,是不是都完全清晰了

0人推荐
随时随地看视频
慕课网APP