摘要

现在互联网应用已经普及,数据量不断增大。对淘宝、美团、百度等互联网业务来说,传统单实例数据库很难支撑其性能和存储的要求,所以分布式架构得到了很大发展。一定要认识到数据库技术正在经历一场较大的变革,及早掌握好分布式架构设计。

一、分布式数据库概念

Wiki 官方对分布式数据库的定义为:

A distributed database is a database in which data is stored across different physical locations. It may be stored in multiple computers located in the same physical location (e.g. a data centre); or maybe dispersed over a network of interconnected computers.

从定义来看,分布式数据库是一种把数据分散存储在不同物理位置的数据库。对比我们之前学习的数据库,数据都是存放在一个实例对应的物理存储上,而在分布式数据库中,数据将存放在不同的数据库实例上。


数据库架构设计——分布式数据库设计_数据

分布式数据库的架构

从图中我们可以看到,在分布式数据库下,分布式数据库本身分为计算层、元数据层和存储层:

  • 计算层就是之前单机数据库中的 SQL 层,用来对数据访问进行权限检查、路由访问,以及对计算结果等操作。
  • 元数据层记录了分布式数据库集群下有多少个存储节点,对应 IP、端口等元数据信息是多少。当分布式数据库的计算层启动时,会先访问元数据层,获取所有集群信息,才能正确进行 SQL 的解析和路由等工作。另外,因为元数据信息存放在元数据层,那么分布式数据库的计算层可以有多个,用于实现性能的扩展。
  • 存储层用来存放数据,但存储层要和计算层在同一台服务器上,甚至不求在同一个进程中。

分布式数据库的优势是把数据打散到不同的服务器上,这种横向扩展的 Scale Out 能力,能解决单机数据库的性能与存储瓶颈。

  • 从理论上来看,分布式数据库的性能可以随着计算层和存储层的扩展,做到性能的线性提升。
  • 从可用性的角度看,如果存储层发生宕机,那么只会影响 1/N 的数据,N 取决于数据被打散到多少台服务器上。所以,分布式数据库的可用性对比单机会有很大提升,单机数据库要实现99.999% 的可用性或许很难,但是分布式数据库就容易多了。
  • 当然,分布式数据库也存在缺点:正因为数据被打散了,分布式数据库会引入很多新问题,比如自增实现、索引设计、分布式事务等

二、分布式MySQL架构

学习分布式 MySQL 架构前,我们先看一下原先单机 MySQL 架构是怎样的。

数据库架构设计——分布式数据库设计_分布式数据库_02

可以看到,原先客户端是通过 MySQL 通信协议访问 MySQL 数据库,MySQL 数据库会通过高可用技术做多副本,当发生宕机进行切换。

那么对于分布式 MySQL 数据库架构,其整体架构如下图所示:

数据库架构设计——分布式数据库设计_MySQL_03

从上图可以看到,这时数据将打散存储在下方各个 MySQL 实例中,每份数据叫“分片(Shard)”。在分布式 MySQL 架构下,客户端不再是访问 MySQL 数据库本身,而是访问一个分布式中间件。这个分布式中间件的通信协议依然采用 MySQL 通信协议(因为原先客户端是如何访问的MySQL 的,现在就如何访问分布式中间件)。分布式中间件会根据元数据信息,自动将用户请求路由到下面的 MySQL 分片中,从而将存储存取到指定的节点。

另外,分布式 MySQL 数据库架构的每一层都要由高可用,保证分布式数据库架构的高可用性。对于上层的分布式中间件,是可以平行扩展的:即用户可以访问多个分布式中间件,如果其中一个中间件发生宕机,那么直接剔除即可。因为分布式中间件是无状态的,数据保存在元数据服务中,它的高可用设计比较容易。对于元数据来说,虽然它的数据量不大,但数据非常关键,一旦宕机则可能导致中间件无法工作,所以,元数据要通过副本技术保障高可用。最后,每个分片存储本身都有副本,通过我们之前学习的高可用技术,保证分片的可用性。也就是说,如果分片 1 的 MySQL 发生宕机,分片 1 的从服务器会接替原先的 MySQL 主服务器,继续提供服务。

但由于使用了分布式架构,那么即使分片 1 发生宕机,需要 60 秒的时间恢复,这段时间对于业务的访问来说,只影响了 1/N 的数据请求。可以看到,分布式 MySQL 数据库架构实现了计算层与存储层的分离,每一层都可以进行 Scale Out 平行扩展,每一层又通过高可用技术,保证了计算层与存储层的连续性,大大提升了MySQL 数据库的性能和可靠性,为海量互联网业务服务打下了坚实的基础。

三、数据库的分库分表

学习了分布式数据库的架构,知道各类分布式数据库都离不开计算层、存储层、元数据层这三层关系。另外,很重要的一点是,知道分布式数据库是把数据打散存储在一个个分片中。在基于MySQL 的分布式数据库架构中,分片就存在于 MySQL 实例中。学习分布式数据库中,一个非常重要的设计:正确地把数据分片,充分发挥分布式数据库架构的优势。

3.1 选出分片键

对于类似淘宝、京东、拼多多这样业务体量的应用来说,单实例 MySQL 数据库在性能和存储容量上肯定无法满足“双 11、618 ”大促的要求,所以要改造成分布式数据库架构。

在对表中的数据进行分片时,首先要选出一个分片键(Shard Key),即用户可以通过这个字段进行数据的水平拆分。对于我们之前使用的电商业务的订单表orders,其表结构如下所示:

CREATE TABLE `orders` (

  `O_ORDERKEY` int NOT NULL,
  `O_CUSTKEY` int NOT NULL,
  `O_ORDERSTATUS` char(1) NOT NULL,
  `O_TOTALPRICE` decimal(15,2) NOT NULL,
  `O_ORDERDATE` date NOT NULL,
  `O_ORDERPRIORITY` char(15) NOT NULL,
  `O_CLERK` char(15) NOT NULL,
  `O_SHIPPRIORITY` int NOT NULL,
  `O_COMMENT` varchar(79) NOT NULL,
  PRIMARY KEY (`O_ORDERKEY`),
  KEY `idx_custkey_orderdate` (`O_CUSTKEY`,`O_ORDERDATE`),
  KEY `ORDERS_FK1` (`O_CUSTKEY`),
  KEY `idx_custkey_orderdate_totalprice` (`O_CUSTKEY`,`O_ORDERDATE`,`O_TOTALPRICE`),
  KEY `idx_orderdate` (`O_ORDERDATE`),
  KEY `idx_orderstatus` (`O_ORDERSTATUS`),
  CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`O_CUSTKEY`) REFERENCES `customer` (`C_CUSTKEY`)
) ENGINE=InnoDB

对于上面的表orders,可以选择的分片键有:o_orderkey、o_orderdate、也可以是o_custkey。在选出分片键后,就要选择分片的算法,比较常见的有 RANGE 和 HASH 算法。

比如,表 orders,选择分片键 o_orderdate,根据函数 YEAR 求出订单年份,然后根据RANGE 算法进行分片,这样就能设计出基于 RANGE 分片算法的分布式数据库架构:

数据库架构设计——分布式数据库设计_数据库_04

从图中我们可以看到,采用 RANGE 算法进行分片后,表 orders 中,1992 年的订单数据存放在分片 1 中、1993 年的订单数据存放在分片 2 中、1994 年的订单数据存放在分片 3中,依次类推,如果要存放新年份的订单数据,追加新的分片即可。

不过,RANGE 分片算法在分布式数据库架构中,是一种非常糟糕的算法,因为对于分布式架构,通常希望能解决传统单实例数据库两个痛点:

  • 性能可扩展,通过增加分片节点,性能可以线性提升;
  • 存储容量可扩展,通过增加分片节点,解决单点存储容量的数据瓶颈。

那么对于订单表 orders 的 RANGE 分片算法来说,你会发现以上两点都无法实现,因为当年的数据依然存储在一个分片上(即热点还是存在于一个数据节点上)。如果继续拆细呢?比如根据每天进行 RANGE 分片?这样的确会好一些,但是对“双 11、618”这样的大促来说,依然是单分片在工作,热点依然异常集中。所以在分布式架构中,RANGE 分区算法是一种比较糟糕的算法。但它也有好处:可以方便数据在不同机器间进行迁移(migrate),比如要把分片 2 中 1992 年的数据迁移到分片 1,直接将表进行迁移就行。

而对海量并发的 OLTP 业务来说,一般推荐用 HASH 的分区算法。这样分片的每个节点都可以有实时的访问,每个节点负载都能相对平衡,从而实现性能和存储层的线性可扩展。我们来看表 orders 根据 o_orderkey 进行 HASH 分片,分片算法如下:

数据库架构设计——分布式数据库设计_数据库_05

在上述分片算法中,分片键是 o_orderkey,总的分片数量是 4(即把原来 1 份数据打散到 4 张表中),具体来讲,分片算法是将 o_orderkey 除以 4 进行取模操作。最终,将表orders 根据 HASH 算法进行分布式设计后的结果如下图所示:

数据库架构设计——分布式数据库设计_数据_06

可以看到,对于订单号除以 4,余数为 0 的数据存放在分片 1 中,余数为 1 的数据存放在分片 2 中,余数为 2 的数据存放在分片 3 中,以此类推。这种基于 HASH 算法的分片设计才能较好地应用于大型互联网业务,真正做到分布式数据库架构弹性可扩展的设计要求。

但是,表 orders 分区键选择 o_orderkey 是最好地选择吗?并不是。我们看一下库中的其他表,如表 customer、lineitem,这三张表应该是经常一起使用的,比如查询用户最近的订单明细。如果用 o_orderkey 作分区键,那么 lineitem 可以用 l_orderkey 作为分区键,但这时会发现表customer 并没有订单的相关信息,即无法使用订单作为分片键。如果表 customer 选择另一个字段作为分片键,那么业务数据无法做到单元化,也就是对于表customer、orders、lineitem,分片数据在同一数据库实例上。所以,如果要实现分片数据的单元化,最好的选择是把用户字段作为分区键,在表 customer 中就是将 c_custkey 作为分片键,表orders 中将 o_custkey 作为分片键,表 lineitem 中将 l_custkey 作为分片键:

数据库架构设计——分布式数据库设计_数据库_07

这样做的好处是:根据用户维度进行查询时,可以在单个分片上完成所有的操作,不用涉及跨分片的访问,如下面的 SQL:

SELECT * FROM orders

INNER JOIN lineitem ON o_orderkey = l_orderkey

INNER JOIN customer ON o_custkey = c_custkey

WHERE o_custkey = 1

ORDER BY o_orderdate DESC LIMIT 10

所以,分布式数据库架构设计的原则是:选择一个适合的分片键和分片算法,把数据打散,并且业务的绝大部分查询都是根据分片键进行访问

那为什么互联网业务这么适合进行分布式架构的设计呢?因为互联网业务大部分是 To C 业务,分片键就是用户的 ID,业务的大部分访问都是根据用户 ID 进行查询,比如:

  • 查看某个用户下的微博/短视频;
  • 查看某个用户的商品信息/购买记录;
  • 查看某个用户自己的余额信息。

学完分片键的选择后,接着就是规划分片,也就我们经常提到的分库分表。

3.2 分库分表

分片到底是什么呢?其实,前面说的分片本质是一张张表,而不是数据库实例,只是每个分片是在 MySQL 数据库实例中,严格来说:

分片 = 实例 + 库 + 表 = ip@port:db_name:table_name

对于前面的表orders,假设根据 HASH 算法进行分片,那么可以进行如下的分库分表设计:

  1. 每个分片的表名库名都一样,如库 tpch,表名 orders;
  2. 每个分片的库名不一样,表名一样,如库名 tpch01、tpch02、tpch03、tpch04,表名orders;
  3. 每个分片的表名不一样,库名一样,如库名 tpch,表名分别为 orders01、orders02、orders03、orders04;
  4. 每个分片的库名不一样,表名也不一样,如分片 1 的表在库名 tpch01下,表名为oders01;分片 2 的表名在库名 tpch02,表名为 orders02;分片 3 的表名在库名tpch03,表名为 orders03;分片 3 的表名在库名 tpch04,表名为 orders04。

在这 4 种分库分表规则中,最推荐的是第 4 种,也是我们通常意义说的分库分表,这样做的好处有以下几点:

  • 不同分片的数据可以在同一 MySQL 数据库实例上,便于做容量的规划和后期的扩展;
  • 同一分片键的表都在同一库下,方便做整体数据的迁移和扩容。

如果根据第 4 种标准的分库分表规范,那么分布式 MySQL 数据库的架构可以是这样:

数据库架构设计——分布式数据库设计_MySQL_08

 有没有发现,按上面这样的分布式设计,数据分片完成后,所有的库表依然是在同一个 MySQL实例上。牢记,分布式数据库并不一定要求有很多个实例,最基本的要求是将数据进行打散分片。接着,用户可以根据自己的需要,进行扩缩容,以此实现数据库性能和容量的伸缩性。这才是分布式数据库真正的魅力所在

对于上述的分布式数据库架构,一开始我们将 4 个分片数据存储在一个 MySQL 实例上,但是如果遇到一些大促活动,可以对其进行扩容,比如把 4 个分片扩容到 4 个MySQL实例上:

数据库架构设计——分布式数据库设计_数据_09

如果完成了大促活动,又可以对资源进行回收,将分片又都放到一台 MySQL 实例上,这就是对资源进行缩容。总的来说,对分布式数据库进行扩缩容在互联网公司是一件常见的操作,比如对阿里来说,每年下半年 7 月开始,他们就要进行双 11 活动的容量评估,然后根据评估结果规划数据库的扩容。

3.3 数据库的扩缩容

在 HASH 分片的例子中,我们把数据分片到了 4 个节点,然而在生产环境中,为了方便之后的扩缩容操作,推荐一开始就把分片的数量设置为不少于 1000 个。不用担心分片数量太多,因为分片 1 个还是 1000 个,管理方式都是一样的,但是 1000 个,意味着可以扩容到 1000 个实例上,对于一般业务来说,1000 个实例足够满足业务的需求了(BTW,网传阿里某核心业务的分布式数据库分片数量为 10000个)。如果到了 1000 个分片依然无法满足业务的需求,这时能不能拆成 2000 个分片呢?从理论上来说是可以的,但是这意味着需要对一张表中的数据进行逻辑拆分,这个工作非常复杂,通常不推荐。所以,一开始一定要设计足够多的分片。在实际工作中,我遇到很多次业务将分片数量从 32、64 拆成 256、512。每次这样的工作,都是扒一层皮,太不值得。所以,做好分布式数据库设计的工作有多重要!

那么扩容在 MySQL 数据库中如何操作呢?其实,本质是搭建一个复制架构,然后通过设置过滤复制,仅回放分片所在的数据库就行,这个数据库配置在从服务器上大致进行如下配置:

# 分片1从服务器配置

replicate_do_db ="tpch01"

所以在进行扩容时,首先根据下图的方式对扩容的分片进行过滤复制的配置:

数据库架构设计——分布式数据库设计_MySQL_10

然后再找一个业务低峰期,将业务的请求转向新的分片,完成最终的扩容操作:

数据库架构设计——分布式数据库设计_MySQL_11

至于缩容操作,本质就是扩容操作的逆操作

3.4 分库分表的总结

  • 分布式数据库数据分片要先选择一个或多个字段作为分片键;
  • 分片键的要求是业务经常访问的字段,且业务之间的表大多能根据这个分片键进行单元化;
  • 如果选不出分片键,业务就无法进行分布式数据库的改造;
  • 选择完分片键后,就要选择分片算法,通常是 RANGE 或 HASH 算法;
  • 海量 OLTP 业务推荐使用 HASH 算法,强烈不推荐使用 RANGE 算法;
  • 分片键和分片算法选择完后,就要进行分库分表设计,推荐不同库名表名的设计,这样能方便后续对分片数据进行扩缩容;
  • 实际进行扩容时,可以使用过滤复制,仅复制需要的分片数据。

四、分布式数据库索引设计

在分布式数据库架构下,索引的设计也需要做调整,否则无法充分发挥分布式架构线性可扩展的优势

4.1 主键选择

对主键来说,要保证在所有分片中都唯一,它本质上就是一个全局唯一的索引。如果用大部分同学喜欢的自增作为主键,就会发现存在很大的问题。因为自增并不能在插入前就获得值,而是要通过填 NULL 值,然后再通过函数 last_insert_id()获得自增的值。所以,如果在每个分片上通过自增去实现主键,可能会出现同样的自增值存在于不同的分片上。

比如,对于电商的订单表 orders,其表结构如下(分片键是o_custkey,表的主键是o_orderkey):

CREATE TABLE `orders` (
  `O_ORDERKEY` int NOT NULL auto_increment,
  `O_CUSTKEY` int NOT NULL,
  `O_ORDERSTATUS` char(1) NOT NULL,
  `O_TOTALPRICE` decimal(15,2) NOT NULL,
  `O_ORDERDATE` date NOT NULL,
  `O_ORDERPRIORITY` char(15) NOT NULL,
  `O_CLERK` char(15) NOT NULL,
  `O_SHIPPRIORITY` int NOT NULL,
  `O_COMMENT` varchar(79) NOT NULL,
  PRIMARY KEY (`O_ORDERKEY`),
  KEY (`O_CUSTKEY`)
  ......
) ENGINE=InnoDB

如果把 o_orderkey 设计成上图所示的自增,那么很可能 o_orderkey 同为 1 的记录在不同的分片出现,如下图所示:

数据库架构设计——分布式数据库设计_数据_12

所以,在分布式数据库架构下,尽量不要用自增作为表的主键,这也是我们在第一模块“表结构设计”中强调过的:自增性能很差、安全性不高、不适用于分布式架构。讲到这儿,我们已经说明白了“自增主键”的所有问题,那么该如何设计主键呢?依然还是用全局唯一的键作为主键,比如 MySQL 自动生成的有序 UUID;业务生成的全局唯一键(比如发号器);或者是开源的 UUID 生成算法,比如雪花算法(但是存在时间回溯的问题)。总之,用有序的全局唯一替代自增,是这个时代数据库主键的主流设计标准,如果你还停留在用自增做主键,或许代表你已经落后于时代发展了。

4.2 索引设计

通过分片键可以把 SQL 查询路由到指定的分片,但是在现实的生产环境中,业务还要通过其他的索引访问表。还是以前面的表 orders 为例,如果业务还要根据 o_orderkey 字段进行查询,比如查询订单 ID 为 1 的订单详情:

SELECT * FROM orders WHERE o_orderkey = 1

我们可以看到,由于分片规则不是分片键,所以需要查询 4 个分片才能得到最终的结果,如果下面有 1000 个分片,那么就需要执行 1000 次这样的 SQL,这时性能就比较差了。

但是,我们知道 o_orderkey 是主键,应该只有一条返回记录,也就是说,o_orderkey 只存在于一个分片中。这时,可以有以下两种设计:

  • 同一份数据,表 orders 根据 o_orderkey 为分片键,再做一个分库分表的实现;
  • 在索引中额外添加分片键的信息。

这两种设计的本质都是通过冗余实现空间换时间的效果,否则就需要扫描所有的分片,当分片数据非常多,效率就会变得极差。而第一种做法通过对表进行冗余,对于 o_orderkey 的查询,只需要在 o_orderkey = 1 的分片中直接查询就行,效率最高,但是设计的缺点又在于冗余数据量太大。所以,改进的做法之一是实现一个索引表,表中只包含 o_orderkey 和分片键 o_custkey,如:

CREATE TABLE idx_orderkey_custkey (
  o_orderkey INT
  o_custkey INT,
  PRIMARY KEY (o_orderkey)
)

如果这张索引表很大,也可以将其分库分表,但是它的分片键是 o_orderkey,如果这时再根据字段 o_orderkey 进行查询,可以进行类似二级索引的回表实现:先通过查询索引表得到记录 o_orderkey = 1 对应的分片键 o_custkey 的值,接着再根据 o_custkey 进行查询,最终定位到想要的数据,如:

SELECT * FROM orders WHERE o_orderkey = 1

*************************************************************************

# step 1

SELECT o_custkey FROM idx_orderkey_custkey 

WHERE o_orderkey = 1

# step 2

SELECT * FROM orders 

WHERE o_custkey = ? AND o_orderkey = 1

这个例子是将一条 SQL 语句拆分成 2 条 SQL 语句,但是拆分后的 2 条 SQL 都可以通过分片键进行查询,这样能保证只需要在单个分片中完成查询操作。不论有多少个分片,也只需要查询 2个分片的信息,这样 SQL 的查询性能可以得到极大的提升。

通过索引表的方式,虽然存储上较冗余全表容量小了很多,但是要根据另一个分片键进行数据的存储,依然显得不够优雅。因此,最优的设计,不是创建一个索引表,而是将分片键的信息保存在想要查询的列中,这样通过查询的列就能直接知道所在的分片信息。

如果我们将订单表 orders 的主键设计为一个字符串,这个字符串中最后一部分包含分片键的信息,如:

o_orderkey = string(o_orderkey + o_custkey)

那么这时如果根据 o_orderkey 进行查询:

SELECT * FROM Orders

WHERE o_orderkey = '1000-1';

由于字段 o_orderkey 的设计中直接包含了分片键信息,所以我们可以直接知道这个订单在分片1 中,直接查询分片 1 就行。同样地,在插入时,由于可以知道插入时 o_custkey 对应的值,所以只要在业务层做一次字符的拼接,然后再插入数据库就行了。这样的实现方式较冗余表和索引表的设计来说,效率更高,查询可以提前知道数据对应的分片信息,只需 1 次查询就能获取想要的结果。

这样实现的缺点是,主键值会变大一些,存储也会相应变大。只要主键值是有序的,插入的性能就不会变差。而通过在主键值中保存分片信息,却可以大大提升后续的查询效率,这样空间换时间的设计,总体上看是非常值得的。当然,这里我们谈的设计都是针对于唯一索引的设计,如果是非唯一的二级索引查询,那么非常可惜,依然需要扫描所有的分片才能得到最终的结果,如:

SELECT * FROM Orders

WHERE o_orderate >= ? o_orderdate < ?

因此,再次提醒你,分布式数据库架构设计的要求是业务的绝大部分请求能够根据分片键定位到 1 个分片上。如果业务大部分请求都需要扫描所有分片信息才能获得最终结果,那么就不适合进行分布式架构的改造或设计。

最后,我们再来回顾下淘宝用户订单表的设计:

数据库架构设计——分布式数据库设计_数据_13

上图是我的淘宝订单信息,可以看到,订单号的最后 6 位都是 308113,所以可以大概率推测出:

  • 淘宝订单表的分片键是用户 ID;
  • 淘宝订单表,订单表的主键包含用户 ID,也就是分片信息。这样通过订单号进行查询,可以获得分片信息,从而查询 1 个分片就能得到最终的结果。

4.3 全局表设计

在分布式数据库中,有时会有一些无法提供分片键的表,但这些表又非常小,一般用于保存一些全局信息,平时更新也较少,绝大多数场景仅用于查询操作。例如 tpch 库中的表 nation,用于存储国家信息,但是在我们前面的 SQL 关联查询中,又经常会使用到这张表,对于这种全局表,可以在每个分片中存储,这样就不用跨分片地进行查询了。如下面的设计:

数据库架构设计——分布式数据库设计_数据库_14

4.4 唯一索引设计

唯一索引的设计,与主键一样,如果只是通过数据库表本身唯一约束创建的索引,则无法保证在所有分片中都是唯一的。所以,在分布式数据库中,唯一索引一样要通过类似主键的 UUID 的机制实现,用全局唯一去替代局部唯一,但实际上,即便是单机的 MySQL 数据库架构,我们也推荐使用全局唯一的设计。因为你不知道,什么时候,你的业务就会升级到全局唯一的要求了。

4.5 分布式数据库索引总结

  • 分布式数据库主键设计使用有序 UUID,全局唯一;
  • 分布式数据库唯一索引设计使用 UUID 的全局唯一设计,避免局部索引导致的唯一问题;
  • 分布式数据库唯一索引若不是分片键,则可以在设计时保存分片信息,这样查询直接路由到一个分片即可;
  • 对于分布式数据库中的全局表,可以采用冗余机制,在每个分片上进行保存。这样能避免查询时跨分片的查询。

五、分布式数据库架构选型

学习了分布式数据库的分片设计、表结构设计、索引设计等,相信你已经有能力构建一个分布式数据库系统了。但现在数据分好了,索引也设计好了,但是如果访问这些数据和索引呢?

访问分布式数据库有两种模式:

  • 业务直接根据分库分表访问 MySQL 数据库节点;
  • 根据中间件访问。

5.1 分库分表直接访问

在设计分片时,我们已经明确了每张表的分片键信息,所以业务或服务可以直接根据分片键对应的数据库信息,直接访问底层的 MySQL 数据节点,比如在代码里可以做类似的处理:

void InsertOrders(String orderKey, int userKey...) {

  int shard_id = userKey % 4;

  if (shard_id == 0) {

    conn = MySQLConncetion('shard1',...);

    conn.query(...);

  } else if (shard_id == 1) {

    conn = MySQLConncetion('shard2',...);

    conn.query(...);   

  } else if (shard_id == 2) {

    conn = MySQLConncetion('shard3',...);

    conn.query(...);   

  } else if (shard_id == 3) {

    conn = MySQLConncetion('shard4',...);

    conn.query(...);   

  }

}

从这段代码中我们可以看到,在业务代码中会嵌入分库分表的路由逻辑,在业务层计算出对应分片的信息,然后访问数据库:

  • 这种处理方式的好处是与单实例数据库没有太大的不同,只是多了一次计算分片的操作,没有额外的开销,性能非常好(我听说支付宝的分布式数据库为了最求极致的性能,用的就是直接访问分片的方式)。
  • 这种处理逻辑的缺点是业务需要知道分片信息,感知分片的变化。对于上面的例子,如果分片 shard1 发生变化,又或者进行了扩容,业务就需要跟着修改。

为了解决这个缺点,比较好的处理方式是使用名字服务,而不要直接通过 IP 访问分片。这样当分片发生切换,又或者扩容缩容时,业务也不需要进行很大的改动。

又因为业务比较多,需要访问分布式数据库分片逻辑的地方也比较多。所以,可以把分片信息存储在缓存中,当业务启动时,自动加载分片信息。比如,在 Memcached 或 Redis 中保存如下的分片信息,key 可以是分库分表的表名,value通过 JSON 或字典的方式存放分片信息:

{
  'key': 'orders',
  'shard_info' : {
    'shard_key' : 'o_custkey',
    'shard_count' : 4,
    'shard_host' : ['shard1.xxx.com','shard2.xxx.com','...'],
    ‘shard_table' : ['tpch00/orders01','tpch01/orders02','...'],
  }
}

如果要进行跨分片的访问,则需要业务自己处理相关逻辑。不过我们前面已经说过,分布式数据库设计要求单元化,绝大部分操作需要在一个分片中完成。如果不能,那么可能都不推荐分布数据库的改造。总之,分库分表的直接访问方式,要求业务控制一切有关分布式数据库的操作,需要明确每个分片的具体信息,做好全流程的把控。

5.2 使用中间件技术

另一种比较流行的分布式数据库访问方式是通过分布式数据库中间件。数据库中间件本身模拟成一个 MySQL 数据库,通信协议也都遵循 MySQL 协议:业务之前怎么访问MySQL数据库的,就如何访问MySQL分布式数据库中间件。

这样做的优点是:业务不用关注分布式数据库中的分片信息,把它默认为一个单机数据库使用就好了。这种模式也是大部分同学认为分布式数据库该有的样子,如下面的图:

数据库架构设计——分布式数据库设计_MySQL_15

可以看到,通过分布式 MySQL 中间件,用户只需要访问中间件就行,下面的数据路由、分布式事务的实现等操作全部交由中间件完成。所以,分布式数据库中间件变成一个非常关键的核心组件。业界比较知名的 MySQL 分布式数据库中间件产品有:ShardingShpere、DBLE、TDSQL 等。

  • ShardingSphere于 2020 年 4 月 16 日成为 Apache 软件基金会的顶级项目、社区熟度、功能支持较多,特别是对于分布式事务的支持,有多种选择(ShardingSphere 官网地址)。
  • DBLE 是由知名 MySQL 服务商爱可生公司开源的 MySQL 中间件产品,已用于四大行核心业务,完美支撑传统银行去 IOE,转型分布式架构的探索。除了中间件技术外,爱可生公司还有很多关于 MySQL 数据库、分布式数据库设计等方面的综合经验。
  • TDSQL MySQL 版(TDSQL for MySQL)是腾讯打造的一款分布式数据库产品,具备强一致高可用、全球部署架构、分布式水平扩展、高性能、企业级安全等特性,同时提供智能 DBA、自动化运营、监控告警等配套设施,为客户提供完整的分布式数据库解决方案。

目前 TDSQL 已经为超过500+的政企和金融机构提供数据库的公有云及私有云服务,客户覆盖银行、保险、证券、互联网金融、计费、第三方支付、物联网、互联网+、政务等领域。TDSQL MySQL 版亦凭借其高质量的产品及服务,获得了多项国际和国家认证,得到了客户及行业的一致认可。

你要注意,使用数据库中间件虽好,但其存在一个明显的缺点,即多了一层中间层的访问,单个事务的访问耗时会有上升,对于性能敏感的业务来说,需要有这方面的意识和考虑。重要的一点是,虽然使用分布式数据库中间件后,单个事务的耗时会有所上升,但整体的吞吐率是不变的,通过增大并发数,可以有效提升分布式数据库的整体性能。

5.3 分布式数据库架构选型

那么,选择业务直连分布式数据库?还是通过数据库中间件访问?这是一个架构选型要考虑的问题。根据我的经验来说,对于较小业务(高峰期每秒事务不超过 1000 的业务),选择通过数据库中间件访问分布式数据库是比较优的方式。

  • 因为这样的业务通常处于爬升期,满足业务的各项功能或许是业务的主要目标。通过分布式中间件屏蔽下面的分片信息,可以让开发同学专注于业务的开发。
  • 另一方面,通过使用中间件提供的分布式事务就能满足简单的跨分片交易,解决分布式数据库中最难的问题。
  • 但如果你的业务是一个海量互联网业务,中间件的瓶颈就会显现,单个事务的耗时会上升,低并发下,性能会有一定下降。而且中间件提供的 2PC 分布式事务性能就更不能满足业务的需求了。所以类似支付宝、阿里这样的业务,并没有使用分布式数据库中间件的架构,而是采用了业务直连的模式。

很多同学或许会问,如果不用数据库中间件,怎么解决 JOIN 这些问题呢?业务层去实现还是很麻烦的。的确,中间件可以完成这部分的功能。但如果真是数据量比较大,跨分片的场景,相信我,中间件也不能满足你的要求。所以,使用分布式数据库架构是一种折中,你要学会放弃很多,从而才能得到更多。

六、全链路的条带化设计

学习了分布式数据库架构的基本设计,完成了数据分片、表结构、索引的设计,相信学完这几讲之后,你已经基本了解分布式数据库了,也能够设计出一个分布式数据库的基础架构。但这些远远不够,因为当我们提到分布式架构时,除了数据库要完成分布式架构的改造,业务层也要完成分布式架构的改造,最终完成条带化的设计。

6.1 什么是条带化

条带化是存储的一种技术,将磁盘进行条带化后,可以把连续的数据分割成相同大小的数据块,简单的说,条带化就是把每段数据分别写入阵列中不同磁盘上的方法。可以看到,条带化的本质是通过将数据打散到多个磁盘,从而提升存储的整体性能,这与分布式数据库的分片理念是不是非常类似呢?下图显示了 RAID0 的条带化存储:

数据库架构设计——分布式数据库设计_数据库_16

从图中可以看到,进行 RAID 条带化后,数据存放在了三块磁盘上,分别是磁盘 1、磁盘 2、磁盘 3,存储的数据也进行了打散,分别存储在了条带 1、条带 2、条带 3 上。这样一来,当访问某一个数据的时候,可以并行地从 3 个磁盘上取出数据,写入也可以同时写入 3 个磁盘,提升了存储的性能。

6.2 全链路的条带化设计

分布式数据库的本质是:将数据根据某个或几个列(称之为“分片键”),然后依据预先设定的算法(分片算法)进行打散,形成一个个的分片。更重要的是,分布式数据库中的表,要能选出一个统一的分片键,即大部分表都能根据这个分片键打散数据,这样当后续业务进行访问数据时,可以在一个分片中完成单元化的闭环操作,不用涉及跨分片的访问。下图显示了对于 tpch 分布式架构改造后的分片效果:

数据库架构设计——分布式数据库设计_分布式数据库_17

从图中我们可以看到,这与我们之前所提倡的条带化的思想比较类似,即数据打散,性能得到提升,对于分布式数据库来说,分片越多,性能上限也就越高。

但是,这只是对数据库层做了条带化,没有站在全链路的角度上进行条带化设计。我们来看一个例子,假设是电商中比较重要的订单服务,并且对表 orders 进行了分布式的条带化设计:

数据库架构设计——分布式数据库设计_数据_18

可以看到,订单服务可以根据字段 o_custkey 访问不同分片的数据,这也是大部分业务会进行的设计(由于服务层通常是无状态的,因此这里不考虑高可用的情况)。但是,这样的设计不符合全链路的条带化设计思想。全链路的设计思想,要将上层服务也作为条带的一部分进行处理,也就是说,订单服务也要跟着分片进行分布式架构的改造。所以,如果进行全链路的条带化设计,那么上面的订单服务应该设计成:

数据库架构设计——分布式数据库设计_数据库_19

可以看到,如果要进行分布式的条带化设计时,上层业务服务也需要进行相应的分布式改造,将1个“大”订单服务层也拆分成多个“小”订单服务,其中每个订单服务访问自己分片的数据。

这样设计的好处在于:

  • 安全性更好,每个服务可以校验访问用户是否本分片数据;
  • 上层服务跟着数据分片进行条带化部署,业务性能更好;
  • 上层服务跟着数据分片进行条带化部署,可用性更好;

第1点通常比较好理解,但是 2、3点 就不怎么好理解了。为什么性能也会更好呢?这里请你考虑一下业务的部署情况,也就是,经常听说的多活架构设计。

6.3 多活架构

对于高可用的架构设计要做到跨机房部署,实现的方式是无损半同复制,以及最新的 MySQL Group Rreplication 技术。数据库实例通过三园区进行部署。这样,当一个机房发生宕机,可以快速切换到另一个机房。我们再来回顾下三园区的架构设计:

数据库架构设计——分布式数据库设计_分布式数据库_20

图中显示了通过无损半同步复制方式进行的三园区高可用架构设计,从而实现同城跨机房的切换能力。但这只是单实例 MySQL 数据库架构,如果到分布式架构呢?所有分片都是在一个机房吗?

如果所有分片都在一个机房,你会发现,这时机房 2、机房3 中的数据库都只是从机,只能进行读取操作,而无法实现写入操作,这就是我们说的单活架构。与单活架构不同,多活架构是指不同地理位置上的系统,都能够提供业务读/写服务。这里的“活”是指实时提供读/写服务的意思,而不仅仅只是读服务。多活架构主要是为了提升系统的容灾能力,提高系统的可用性,保障业务持续可用。

要实现多活架构,首先要进行分布式数据库的改造,然后是将不同数据分片的主服务器放到不同机房,最后是实现业务条带化的部署。如下面的这张图:

数据库架构设计——分布式数据库设计_数据库_21

可以看到,对于上一节的订单服务和订单数据分片,通过将其部署在不同的机房,使得订单服务1 部署在机房 1,可以对分片1进行读写;订单服务 2 部署在机房 1,可以对分片 2 进行读写;订单服务 3 部署在机房 3,可以对分片 3 进行读写。这样每个机房都可以有写入流量,每个机房都是“活”的,这就是多活架构设计。若一个机房发生宕机,如机房 1 宕机,则切换到另一个机房,上层服务和数据库跟着一起切换,切换后上层服务和数据库依然还是在一个机房,访问不用跨机房访问,依然能提供最好的性能和可用性保障。

七、分布式事务解决方案

学习了分布式数据库中数据的分片设计、索引设计、中间件选型,全链路的条带化设计分布式数据库中最令人头疼的问题,那就是分布式事务。详细的分布式事务解决方案在:高并发项目设计——分布式事务解决方案_庄小焱-CSDN博客

7.1 分布式事务概念

事务的概念相信你已经非常熟悉了,事务就是要满足 ACID 的特性,总结来说。

  • A(Atomicity) 原子性:事务内的操作,要么都做,要么都不做;
  • C(Consistency) 一致性:事务开始之前和事务结束以后,数据的完整性没有被破坏;如唯一性约束,外键约束等;
  • I(Isolation)隔离性:一个事务所做的操作对另一个事务不可见,好似是串行执行;
  • D(Durability)持久性:事务提交后,数据的修改是永久的。即使发生宕机,数据也能修复;

特别需要注意的是,当前数据库的默认事务隔离级别都没有达到隔离性的要求,MySQL、Oracle、PostgreSQL等关系型数据库都是如此。大多数数据库事务隔离级别都默认设置为 READ-COMMITTED,这种事务隔离级别没有解决可重复度和幻读问题(除了Mysql以外,mysql已经在可重复读中解决了数据的幻读问题)。

但由于在绝大部分业务中,都不会遇到这两种情况。若要达到完全隔离性的要求,性能往往又会比较低。因此在性能和绝对的隔离性前,大多数关系型数据库选择了一种折中。那什么是分布式事务呢?简单来说,就是要在分布式数据库的架构下实现事务的ACID特性。

分布式数据库架构设计的一个原则,即大部分的操作要能单元化。即在一个分片中完成。如对用户订单明细的查询,由于分片键都是客户ID,因此可以在一个分片中完成。那么他能满足事务的ACID特性。

但是,如果是下面的一个电商核心业务逻辑,那就无法实现在一个分片中完成,即用户购买商品,其大致逻辑如下所示:

START TRANSATION;

INSERT INTO orders VALUES (......);

INSERT INTO lineitem VALUES (......);

UPDATE STOCK SET COUNT = COUNT - 1 WHERE sku_id = ?

COMMIT;

可以看到,在分布式数据库架构下,表orders、linitem的分片键是用户ID。但是表stock是库存品,是商品维度的数据,没有用户ID的信息。因此stock的分片规则肯定与表orders和lineitem不同。所以,上述的事务操作大部分情况下并不能在一个分片中完成单元化,因此就是一个分布式事务,它要求用户维度的表 orders、lineitem 和商品维度的表 stock 的变更,要么都完成,要么都完成不了。常见的分布式事务的实现就是通过 2PC(two phase commit 两阶段提交)实现,接着我们来看下 2PC。

7.2 2PC的分布式事务实现

2PC 是数据库层面实现分布式事务的一种强一致性实现。在 2PC 中,引入事务协调者的角色用于协调管理各参与者(也可称之为各本地资源)的提交和回滚。而 2PC 所谓的两阶段是指parepare(准备)阶段和 commit(提交)两个阶段。在 2PC 的实现中,参与者就是分钟的 MySQL 数据库实例,那事务协调者是谁呢?这取决于分布式数据库的架构。若分布式数据库的架构采用业务通过分库分表规则直连分片的话,那么事务协调者就是业务程序本身。如下图所示:

数据库架构设计——分布式数据库设计_分布式数据库_22

 若采用数据库中间件的模式,那么事务协调者就是数据库中间件。如下图所示:

数据库架构设计——分布式数据库设计_数据库_23

从上图可以发现,使用分布式数据库中间件后,可以对上层服务屏蔽分布式事务的实现,服务不需要关心下层的事务是本地事务还是分布式事务,就好像是单机事务本身一样。2PC 要求第一段 prepare 的操作都成功,那么分布式事务才能提交,这样最终能够实现持久化,2PC 的代码逻辑如下所示:

数据库架构设计——分布式数据库设计_数据库_24

上面就是 2PC 的 Java 代码实现,可以看到只有2个参与者第一阶段 prepare 都成功,那么分布式事务才能提交。但是 2PC 的一个难点在于 prepare 都成功了,但是在进行第二阶段 commit 的时候,其中一个节点挂了。这时挂掉的那个节点在恢复后,或进行主从切换后,节点上之前执行成功的prepare 事务需要人为的接入处理,这个事务就称之为悬挂事务。

用户可以通过命令 XA_RECOVER 查看节点上事务有悬挂事务:

数据库架构设计——分布式数据库设计_数据库_25

如果有悬挂事务,则这个事务持有的锁资源都是没有释放的。可以通过命令SHOW ENGINE INNODB STATUS 进行查看:

数据库架构设计——分布式数据库设计_分布式数据库_26

从上图可以看到,事务 5136 处于 PREPARE状态,已经有 218 秒了,这就是一个悬挂事务,并且这个事务只有了两个行锁对象。

可以通过命令 XA RECOVER 人工的进行提交:

数据库架构设计——分布式数据库设计_分布式数据库_27

同学们应该都了了分布式事务的 2PC 实现和使用方法。它是一种由数据库层实现强一致事务解决方案。其优点是使用简单,当前大部分的语言都支持 2PC 的实现。若使用中间件,业务完全就不用关心事务是不是分布式的。

然而,他的缺点是,事务的提交开销变大了,从 1 次 COMMIT 变成了两次 PREPARE 和COMMIT。而对于海量的互联网业务来说,2PC 的性能是无法接受。因此,这就有了业务级的分布式事务实现,即柔性事务。

7.3 柔性事务

柔性事务是指分布式事务由业务层实现,通过最终一致性完成分布式事务的工作。可以说,通过牺牲了一定的一致性,达到了分布式事务的性能要求。业界常见的柔性事务有 TCC、SAGA、SEATA 这样的框架、也可以通过消息表实现。它们实现原理本身就是通过补偿机制,实现最终的一致性。柔性事务的难点就在于对于错误逻辑的处理。

为了讲述简单,这里用消息表作为柔性事务的案例分享。对于上述电商的核心电商下单逻辑,用消息表就拆分为 3 个阶段:

阶段1:

START TRANSACTION;

# 订单号,订单状态

INSERT INTO orders VALUES (...) 

INSERT INTO lineitem VALUES (...)

COMMIT;

**********************************************************************

阶段2:

START TRANSACTION;

UPDATE stock SET count = count -1 WHERE sku_id = ?

# o_orderkey是消息表中的主键,具有唯一约束

INSERT INTO stock_message VALUES (o_orderkey, ... )  

COMMIT;

**********************************************************************

阶段3:

UPDATE orders SET o_orderststus = 'F' WHERE o_orderkey = ?
  • 上面的柔性事务中,订单表中的列 o_orderstatus 用于记录柔性事务是否完成,初始状态都是未完成。表 stock_message 记录对应订单是否已经扣除过相应的库存。若阶段 2 完成,则柔性事务必须完成。阶段 3 就是将柔性事务设置为完成,最终一致性的确定。
  • 接着我们来下,若阶段 2 执行失败,即执行过程中节点发生了宕机。则后台的补偿逻辑回去扫描订单表中 o_orderstatus 为未完成的超时订单有哪些,然后看一下是否在对应的表stock_message 有记录,若有,则执行阶段 3。若无,可选择告知用户下单失败。
  • 若阶段 3 执行失败,处理逻辑与阶段 2 基本一致,只是这时 2 肯定是完成的,只需要接着执行阶段 3 即可。

所以,这里的补偿逻辑程序就是实时/定期扫描超时订单,通过消息表判断这个柔性事务是继续执行还是执行失败,执行失败又要做哪些业务处理。上面介绍的框架实现的柔性事务原理大致如此,只不过对于补偿的逻辑处理有些不同,又或者使用上更为通用一些。对于海量的互联网业务来说,柔性事务性能更好,因此支付宝、淘宝等互联网业务都是使用柔性事务完成分布式事务的实现。