手把手0基础项目实战(二)——微服务架构下的数据库分库分表实战

·  阅读 3374

本文你将学到什么?

本文是《手把手项目实战系列》的第二篇文章。上一篇《手把手0基础教你搭建一套可自动化构建的微服务框架(SpringBoot+Dubbo+Docker+Jenkins)》受到巨大好评,在这里也深表感谢。应大家要求继续完成后续章节的撰写。上一篇的实战过程介绍的“高喜商城”项目其实是一个真实项目,它是一个标准的在线商城(为了避嫌,“高喜商城”是我随意起的一个假名字),这个项目的很多技术具有一定的普适性。因此我计划将它实现的方方面面以项目实战的形式介绍给大家,让大家体验一个真实线上项目的开发、运维、升级过程。

关注订阅我的文章

相信很多同学对“分库分表”这一概念一知半解,不用着急,本文的后续章节将会分成知识点扫盲篇实战动手篇两部分。知识点扫盲篇将会从零开始,介绍分库分表的基本知识,然后再带领大家开始动手实践。希望能够给你带来完美的阅读体验。接下来我将用尽量通俗易懂的语言介绍分库分表的相关知识,不装逼,做一个低调的程序猿。

预告一下,整个系列会介绍如下内容:

  • 《手把手0基础项目实战(一)——教你搭建一套可自动化构建的微服务框架(SpringBoot+Dubbo+Docker+Jenkins)》
  • 《手把手0基础项目实战(二)——微服务架构下的数据库分库分表实战》
  • 《手把手0基础项目实战(三)——教你开发一套权限管理系统》
  • 《手把手0基础项目实战(四)——电商订单系统架构设计与实战(分布式事务一致性保证)》
  • 《手把手0基础项目实战(五)——电商系统的缓存策略》
  • 《手把手0基础项目实战(六)——基于配置中心实现集群配置的集中管理和熔断机制》
  • 《手把手0基础项目实战(七)——电商系统的日志监控方案》
  • 《手把手0基础项目实战(八)——基于JMeter的系统性能测试》

知识点扫盲篇

1. 什么是“分库分表”?

随着大数据时代的到来,业务系统的数据量日益增大,数据存储能力逐渐成为影响系统性能的瓶颈。目前主流的关系型数据库单表存储上限为1000万条记录,而这一存储能力显然已经无法满足大数据背景下的业务系统存储要求了。随着微服务架构、分布式存储等概念的出现,数据存储问题也渐渐迎来了转机。而数据分片是目前解决海量数据持久化存储与高效查询的一种重要手段。数据分库分表的过程在系统设计阶段完成,要求系统设计人员根据系统预期的业务量,将未来可能出现瓶颈的数据库、数据表按照一定规则拆分成多个库、多张表。这些数据库和数据表需要部署在不同的服务器上,从而将数据读写压力分摊至集群中的各个节点,提升数据库整体处理能力,避免出现读写瓶颈的现象。

目前数据分片的方式一共有两种:离散分片和连续分片。

离散分片是按照数据的某一字段哈希取模后进行分片存储。只要哈希算法选择得当,数据就会均匀地分布在不同的分片中,从而将读写压力平均分配给所有分片,整体上提升数据的读写能力。然而,离散存储要求数据之间有较强的独立性,但实际业务系统并非如此,不同分片之间的数据往往存在一定的关联性,因此在某些场景下需要跨分片连接查询。由于目前所有的关系型数据库出于安全性考虑,均不支持跨库连接。因此,跨库操作需要由数据分库分表中间件来完成,这极大影响数据的查询效率。此外,当数据存储能力出现瓶颈需要扩容时,离散分片规则需要将所有数据重新进行哈希取模运算,这无疑成为限制系统可扩展性的一个重要因素。虽然,一致性哈希能在一定程度上减少系统扩容时的数据迁移,但数据迁移问题仍然不可避免。对于一个已经上线运行的系统而言,系统停止对外服务进行数据迁移的代价太大。

第二种数据分片的方式即为连续分片,它能解决系统扩容时产生的数据迁移问题。这种方式要求数据按照时间或连续自增主键连续存储。从而一段时间内的数据或相邻主键的数据会被存储在同一个分片中。当需要增加分片时,不会影响现有的分片。因此,连续分片能解决扩容所带来的数据迁移问题。但是,数据的存储时间和读写频率往往呈正比,也就是大量的读写往往都集中在最新存储的那一部分数据,这就会导致热点问题,并不能起到分摊读写压力的初衷。

2. 数据库扩展的几种方式

数据库扩展一共有四种分配方式,分别是:垂直分库、垂直分表、水平分表、水平数据分片。每一种策略都有各自的适用场景。

  1. 垂直分库

    垂直分库即是将一个完整的数据库根据业务功能拆分成多个独立的数据库,这些数据库可以运行在不同的服务器上,从而提升数据库整体的数据读写性能。这种方式在微服务架构中非常常用。微服务架构的核心思想是将一个完整的应用按照业务功能拆分成多个可独立运行的子系统,这些子系统称为“微服务”,各个服务之间通过RPC接口通信,这样的结构使得系统耦合度更低、更易于扩展。垂直分库的理念与微服务的理念不谋而合,可以将原本完整的数据按照微服务拆分系统的方式,拆分成多个独立的数据库,使得每个微服务系统都有各自独立的数据库,从而可以避免单个数据库节点压力过大,影响系统的整体性能,如下图所示。

  2. 垂直分表

    垂直分表如果一张表的字段非常多,那么很有可能会引起数据的跨页存储,这会造成数据库额外的性能开销,而垂直分表可以解决这个问题。垂直分表就是将一张表中不常用的字段拆分到另一张表中,从而保证第一章表中的字段较少,避免出现数据库跨页存储的问题,从而提升查询效率。而另一张表中的数据通过外键与第一张表进行关联,如下图所示。

  3. 水平分表

    如果一张表中的记录数过多(超过1000万条记录),那么会对数据库的读写性能产生较大的影响,虽然此时仍然能够正确地读写,但读写的速度已经到了业务无法忍受的地步,此时就需要使用水平分表来解决这个问题。水平分表是将一张含有很多记录数的表水平切分,拆分成几张结构相同的表。举个例子,假设一张订单表目前存储了2000万条订单的数据,导致数据读写效率极低。此时可以采用水平分表的方式,将订单表拆分成100张结构相同的订单表,分别叫做order_1、order_2……、order_100。然后可以根据订单所属用户的id进行哈希取模后均匀地存储在这100张表中,从而每张表中只存储了20万条订单记录,极大提升了订单的读写效率,如下图所示。 当然,如果拆分出来的表都存储在同一个数据库节点上,那么当请求量过大的时候,毕竟单台服务器的处理能力是有限的,数据库仍然会成为系统的瓶颈,所以为了解决这个问题,就出现了水平数据分片的解决方案。

  4. 水平分库分表

    水平数据分片与数据分片区别在于:水平数据分片首先将数据表进行水平拆分,然后按照某一分片规则存储在多台数据库服务器上。从而将单库的压力分摊到了多库上,从而避免因为数据库硬件资源有限导致的数据库性能瓶颈,如下图所示。

3. 分库分表的几种方式

目前常用的数据分片策略有两种,分别是连续分片和离散分片。

  1. 离散分片

    离散分片是指将数据打散之后均匀地存储在逻辑表的各个分片中,从而使的对同一张逻辑表的数据读取操作均匀地落在不同库的不同表上,从而提高读写速度。离散分片一般以哈希取模的方式实现。比如:一张逻辑表有4个分片,那么在读写数据的时候,中间件首先会取得分片字段的哈希值,然后再模以4,从而计算出该条记录所在的分片。在这种方法中,只要哈希算法选的好,那么数据分片将会比较均匀,从而数据读写就会比较均匀地落在各个分片上,从而就有较高的读写效率。但是,这种方式也存在一个最大的缺陷——数据库扩容成本较高。采用这种方式,如果需要再增加分片,原先的分片算法将失效,并且所有记录都需要重新计算所在分片的位置。对于一个已经上线的系统来说,行级别的数据迁移成本相当高,而且由于数据迁移期间系统仍在运行,仍有新数据产生,从而无法保证迁移过程数据的一致性。如果为了避免这个问题而停机迁移,那必然会对业务造成巨大影响。当然,如果为了避免数据迁移,在一开始的时候就分片较多的分片,那需要承担较高的费用,这对于中小公司来说是无法承受的。

  2. 连续分片

    连续分片指的是按照某一种分片规则,将某一个区间内的数据存储在同一个分片上。比如按照时间分片,每个月生成一张物理表。那么在读写数据时,直接根据当前时间就可以找到数据所在的分片。再比如可以按照记录ID分片,这种分片方式要求ID需要连续递增。由于Mysql数据库单表支持最大的记录数约为1000万,因此我们可以根据记录的ID,使得每个分片存储1000万条记录,当目前的记录数即将到达存储上限时,我们只需增加分片即可,原有的数据无需迁移。连续分片的一个最大好处就是方便扩容,因为它不需要任何的数据迁移。但是,连续分片有个最大的缺点就是热点问题。连续分片使得新插入的数据集中在同一个分片上,而往往新插入的数据读写频率较高,因此,读写操作都会集中在最新的分片上,从而无法体现数据分片的优势。

4. 引入分库分表中间件后面临的问题

  1. 跨库操作

    在关系型数据库中,多张表之间往往存在关联,我们在开发过程中需要使用JOIN操作进行多表连接。但是当我们使用了分库分表模式后,由于数据库厂商处于安全考虑,不允许跨库JOIN操作,从而如果需要连接的两张表被分到不同的库中后,就无法使用SQL提供的JOIN关键字来实现表连接,我们可能需要在业务系统层面,通过多次SQL查询,完成数据的组装和拼接。这一方面会增加业务系统的复杂度,另一方面会增加业务系统的负载。 因此,当我们使用分库分表模式时,需要根据具体的业务场景,合理地设置分片策略、设置分片字段,这将会在本文的后续章节中介绍。

  2. 分布式事务

    我们知道,数据库提供了事务的功能,以保证数据一致性。然而,这种事务只是针对单数据库而言的,数据库厂商并未提供跨库事务。因此,当我们使用了分库分表之后,就需要我们在业务系统层面实现分布式事务。关于分布式事务的详细内容,可以参考笔者的另一篇文章《常用的分布式事务解决方案》

5. 现有分库分表中间件的横向对比

  • Cobar实现数据库的透明分库,让开发人员能够在无感知的情况下操纵数据库集群,从而简化数据库的编程模型。然而Cobar仅实现了分库功能,并未实现分表功能。分库可以解决单库IO、CPU、内存的瓶颈,但无法解决单表数据量过大的问题。此外,Cobar是一个独立运行的系统,它处在应用系统与数据库系统之间,因此增加了额外的部署复杂度,增加了运维成本。

  • 为了解决上述问题,Cobar还推出了一个Cobar-Client项目,它只是一个安装在应用程序的Jar包,并不是一个独立运行的系统,一定程度上降低了系统的复杂度。但和Cobar一样,仍然只支持分库,并不支持分表,也不支持读写分离。

  • MyCat是基于Cobar二次开发的数据库中间件,和Cobar相比,它增加了读写分离的功能,并修复了Cobar的一些bug。但是,MyCat和Cobar一样,都是一套需要独立部署的系统,因此会增加部署的复杂度,提高了后期系统运维的成本。


实战篇

1. 为何要进行分库分表?

高喜商城已经上线了一段时间,用户量超预期增长,业务层采用基于Dubbo的微服务架构,并结合了Docker+Jenkins实现了自动化部署,具备灵活的扩展能力,能够轻松支撑目前的业务量。然而,数据库层面却出现了瓶颈。由于1.0版本采用单库单表设计,虽然使用Mysql读写分离实现了一主多备架构,一定程度上分摊了数据库的读写压力。但按照目前的业务发展速度,很多业务表将会面临单表过长的问题。目前Mysql数据库在保证读写性能的前提下,单表最大支持1000W条数据。当单表超过1000W条数据后,虽然仍然可以存储数据,但读写性能大幅下降。因此,为了满足极速增长的业务需求,需要使用数据库中间件实现数据分库分表存储。分库能将读写压力分摊至不同节点,从而缓解读写压力;而分表能够避免单表过长的问题。此外,大多数分库分表中间件都会提供读写分离的功能,从而进一步缓解数据库的读写压力,提升读写性能。

综上所述,对数据库进行分库分表迫在眉睫!

2. 高喜商城1.0数据库架构介绍

高喜商城1.0的架构如下图所示:

该架构的业务层采用微服务架构,所有将整个应用分成四个业务系统:用户系统、产品系统、订单系统和数据分析系统。关于微服务架构这里不做过多介绍,详细内容请阅读《手把手0基础项目实战(一)——教你搭建一套可自动化构建的微服务框架(SpringBoot+Dubbo+Docker+Jenkins)》,这里主要介绍数据库架构。

在高喜商城1.0版本中,虽然业务层采用微服务架构,业务层被拆分成多个相互独立的子系统,但在数据库层,整个系统的所有表均在同一个数据库中存储。此外,采用数据库的主从复制实现了读写分离,数据库有一个主库和两个从库组成了一个数据库集群。它是一个对等集群,每个库中存储的数据是一致的。

在加入了读写分离后,一方面提升了数据库的读写性能;另一方面,实现了数据库的高可用。当某一个节点发生故障时,仍然有其他两个节点提供服务。

这种架构存在如下几个缺点:

  1. 没有垂直分库:所有业务系统的表均存储在同一个库中,相互之间没有任何隔离,从而导致一个业务系统可以直接读写其他业务系统的数据,这违背了微服务的理念。
  2. 存在单表过长的问题:系统经过一段时间运营后,有些表的数据量较大,单表数据量可能会超过1000W。这将会极大影响该表的读写性能。

针对上述问题,对数据库进行分库分表迫在眉睫。

3. 高喜商城2.0数据库架构的演进

高喜商城2.0数据库架构如下图所示:

在2.0架构中,首先对数据库进行了垂直拆分,每个子系统均拥有自己独立的数据库,不同系统的数据库相互隔离,无法互相访问。这样保证了各个业务系统的纯粹性,不同业务系统之间如果需要数据交互,那么就通过业务系统提供了RPC接口访问,而非通过数据库访问,从而符合微服务的设计理念。

上图对用户系统的数据库架构做了详细介绍,其他系统的数据库架构和用户系统类似,都采用了分库分表+读写分离的架构。

在用户系统中,数据库一共被分成N个主库和N个从库,每个库中的表又被拆分成多张。以上图为例,用户系统的数据库一共被分成两个物理库,分别是db_0和db_1。此外,为了实现读写分离,每个物理库均拥有一个从库,主从数据库的数据保持一致。从而,用户系统的物理库一共被分成四个,分别是:db_0_master、db_1_master、db_0_slave、db_1_slave。

每个库中的表table被水平拆分成两张,分别是table_0、table_1。从而,原本一张table表被水平拆分成了四张,分别是:db_0_master_table_0、db_0_master_table_1、db_1_master_table_0、db_1_master_table_1。与此同时,从库中也有四张这样的table表,并且和主库的数据保持一致,因此,经过水平拆分后,一共有8张table表。

上述table表只是举一个例子,实际每个系统均包含有多张表,每张表的拆分规则和拆分数量要根据该表具体的业务量来决定。具体的拆分过程将在下面介绍。

4. 高喜商城1.0数据库表结构设计

下面将会详细介绍高喜商城数据表的设计。这些设计在在线商城系统中是通用的,具备一定的借鉴意义,因此下面将会详细介绍。

4.1 用户系统数据表

用户系统的数据表一共由如上六张表构成,下面对这六张表的作用以及相互之间的关系作简单介绍。

  • sys_user:用户表。
    • 存储用户的基本信息。
  • sys_role:角色表。
    • 存储本系统中所有的角色,如:超级管理员、普通用户、企业用户等等。
    • 用户和角色之间是多对一的聚合关系,即一个用户只能拥有一种角色,而一种角色却可以属于多个用户。由于角色可以脱离用户单独存在,因此他们之间是一种弱依赖关系——聚合关系。
  • sys_permission:权限表。
    • 存储本系统的权限信息,如:创建角色、删除角色、创建菜单、删除菜单、修改用户信息等等。
    • 角色和权限是多对多的聚合关系,即一种角色可以拥有多种权限,而一种权限也可以属于多种角色。并且由于权限可以脱离角色单独存在,因此他们之间是弱依赖关系——聚合关系。
    • 更多关于本系统权限管理功能的设计,请关注后面即将推出的《手把手0基础教你实现一套权限管理系统》。
  • sys_menu:菜单表。
    • 存储本系统的菜单信息。
    • 由于需要实现角色看到不同的菜单,因此需要建立这张菜单表,存储本系统所有的菜单信息。
    • 角色和菜单是多对多的聚合关系,即一种角色可以拥有多个菜单,而一个菜单也可以属于多种角色。并且由于菜单可以脱离于角色单独存在,因此他们之间是弱依赖关系——聚合关系。
  • location:用户地址信息表。
    • 存储用户的地址信息。
    • 用户下单之后需要填写收货地址,因此需要这张表存储用户的地址信息。
    • 用户和地址之间是一对多的组合关系,即一个用户可以拥有多个收货地址,并且一个收货地址只能属于一个用户。此外,由于收获地址不能脱离于用户单独存在,因此他们之间是强依赖关系——组合关系。
  • receipt:发票表。
    • 用户在下单时可以填写发票信息,因此需要这张表来存储这些发票信息。
    • 用户和发票之间是一对多的组合关系,即一个用户可以拥有多个发票信息,而一个具体的发票信息只能属于一个用户。此外,由于发票不能脱离于用户单独存在,因此他们之间是强依赖关系——组合关系。

到此为止,用户系统的每一张表及表于表之间的关系都已详细介绍完毕。通过这些表以及表之间的关系我们就能看出用户系统的业务需求。

  • 每一个用户都有且仅有一种确定的角色,该角色对应了若干个菜单和若干种权限。当用户登录系统的时候,用户系统就可以根据数据库中存储的这些用户信息,知道该用户能够看到哪些菜单,然后将这些菜单显示在用户的界面上。此外,当用户操作这个系统时,前端就会调用相应的后台接口,每次调用任何接口时,用户系统都会根据用户的权限信息检测该用户是否具有操作该接口的权限,如果没有权限则拒绝执行,从而保证系统的安全性。
  • 一个用户在下单的时候可以要求开具发票,那么这些发票信息将会被存储在receipt表中,当用户再次下单的时候,我们就会查询receipt表,将该用户所有的发票信息展示给他,供用户选择。
  • 一个用户在下单的时候需要填写收货地址,那么这些收获地址就会被存储在location表中,当用户再次下单时,无需再次输入收获地址,我们的系统会查询location表,让用户直接选择。

4.2 产品系统数据表

产品系统的数据表一共由如上四张表构成,下面对这四张表的作用以及相互之间的关系作简单介绍。

  • product:产品表。
    • 存储本系统所有的产品信息。
  • prod_image:产品图片表。
    • 存储本系统所有的产品图片URL。
    • 产品和图片之间是一对多组合关系,即一个产品能够拥有多张图片,而一张图片只能属于某一个产品,并且图片不能脱离于产品单独存在,因此他们之间是强依赖关系——组合关系。
  • brand:品牌表。
    • 存储本系统中所有的品牌信息。
    • 产品和品牌是多对一的聚合关系,即一个产品只属于一种品牌,而一种品牌可以包含多个产品。并且品牌可以独立于产品单独存在,因此他们之间是弱依赖关系——聚合关系。
  • category:类别表。
    • 每件产品都必须属于一个类别,因此通过类别表来存储所有的类别信息。
    • 产品和类别之间是多对一的聚合关系,即一件产品只能属于一种类别,而一种类别却可以包含多件产品。并且类别可以独立于产品存在,因此他们之弱依赖关系——聚合关系。

4.3 订单系统数据表

订单系统的数据表一共由如上三张表构成,下面对这三张表的作用以及相互之间的关系作简单介绍。

  • orders:订单表。
    • 存储本系统所有用户的订单信息。
  • orders_product:订单中的产品表。
    • 每条订单中一般都包含多件产品,这种映射关系就存储在这张表中。
    • 这张表是订单和产品之间的关联表。
    • 订单和产品之间是多对多的聚合关系,即一条订单中可以包含多件产品,并且一件产品也可以属于多条订单。此外,由于产品可以独立于订单而存在,因此他们之间是弱依赖关系——聚合关系。
    • 订单和订单产品表是一对多组合关系。因为,一条订单中往往包含多个产品,而一条订单产品映射只能属于某一条具体的订单。并且订单产品不能独立于订单而存在,因此他们之间是强依赖关系——组合关系。
  • order_state_time:订单中各种状态发生时间表。
    • 一条订单有多种状态,如:已下单、未支付、已支付、发货中、已收获等等。为了能够详细记录订单每个状态的发生时间,因此需要这张order_state_time表。
    • 订单和订单状态之间是一对多的组合关系,即一条订单可以包含多种订单状态时间,而一种订单状态时间只能属于某一条具体的订单。并且订单状态时间不能独立于订单而存在,因此他们之间是强依赖关系——组合关系。

到此为止,一个在线商城中最核心的三大系统的数据表关系已经梳理清楚了,下面将会根据具体的业务指标,对这些数据库和数据表进行合理的分库分表。

5. 高喜商城2.0分库分表方案

在对高喜商城开始分库分表之前,我们先要搞清楚,究竟为何要分库?为何要分表?为何要读写分离?

  • 分库的目的:将对同一个库的读写压力分摊到多个库上,不同库分布在不同的服务器上,从而缓解每个库上的读写压力,避免因服务器硬件资源(如IO、内存、CPU)导致的瓶颈。
  • 分表的目的:将原本一张表中的数据水平拆分至多张表中,从而避免单表过长,提升读写性能。
  • 读写分离的目的:将一个物理库复制多份,主库负责写操作,从库负责读操作。从而避免少量的写操作的表锁或行锁阻塞了大量的读操作,通过降低数据的一致性来提升读操作的性能。

5.1 用户系统的分库分表方案

系统的分库分表策略一定是基于具体的业务指标和实际的业务需求,在正式进行分库分表策略的设计之前,一定要做好这两部分数据的采集。现在高喜商城的业务指标和业务需求如下面两张表格所示:

高喜商城未来五年的业务指标:

表名 未来五年数据量 关键字段
sys_user 1000W uid, username, password, email, phone, role_id
location 5000W uid, location
receipt 5000W uid, 发票相关字段
sys_role 100 role_id, role_name
sys_permission 1000 pms_id, permission
sys_role_permission 100*1000 role_id, pms_id
sys_menu 200 menu_id, menu
sys_role_menu 200*1000 role_id, menu_id

高喜商城的业务需求:

表名 业务需求 涉及字段
sys_user 1.用户登录(用户名登录) username, password
2.用户登录(邮箱登录) email, password
3.用户登录(短信验证码登录) phone
4.根据uid查询用户信息 uid
5.管理员按照某些条件分页查询用户 任何字段都有可能使用
表名 业务需求 涉及字段
location 根据uid查询收货地址 uid
表名 业务需求 涉及字段
receipt 根据uid查询发票信息 uid

高喜商城未来五年预计将会拥有1000万用户,从而用户表将会有1000万条数据。由于目前Mysql单表支持最大长度为1000万,因此为了保险起见,我们需要将用户表水平拆分成两张。此外,为了防止用户表读写压力过大,我们干脆将这两张用户表放入两个物理库中。并且为了保证用户表的高可用,我们对这两个数据库采用主从复制技术,一主一丛,其结构如下图所示:

从高喜商城未来五年的业务量表中可知,系统的角色、权限、菜单数量较少,没有必要分库分表。在用户查询的过程中需要连接用户表、角色表、权限表和菜单表,如果将这些无需拆分的表存储在某一个数据库中,那么用户表将无法和他们进行跨库连接,从而需要在完成用户信息查询后,在业务层再次根据uid分别查询角色信息、权限信息、菜单信息,这无意增加了业务层的实现复杂度。为了解决这个问题,我们可以对角色表、权限表和菜单表进行冗余,即将这些表冗余地存储在sys_user的所有物理库中,从而任何一个物理库的用户查询操作都可以直接通过表连接的方式完成角色信息、权限信息和菜单信息的查询。其结构如下图所示:

此外,用户和收获地址、用户和发票信息之间都是一对多的组合关系,如果每个用户平均拥有5个收货地址和5种发票信息,那么对于1000万用户而言,一共会创建5000万条收获信息和5000万条发票信息。因此,收获地址和发票信息各需6张表来存储。并且,由于这两种信息都是通过uid来查询,并且查询条件只有uid这一项,因此uid毫无争议地成为分片字段,并且这6张表只能分布在6个物理库中。此外,为了实现数据库的高可用性,需要对这6个库提供主从复制功能。最终,收货地址表和发票信息表的结构如下图所示:

高喜商城用户系统的数据库分库分表方案就介绍到这,下面介绍产品系统的分库分表方案。

5.2 订单系统的分库分表方案

和用户系统的分库分表方案设计过程一样,在方案设计之前,首先要确定系统的业务指标和业务需求。

高喜商城未来五年的业务指标:

表名 未来五年数据量 关键字段
orders 2000W order_id, buyer_id, seller_id
orders_product 5*2000W order_id, prodcut_id
order_state_time 10*2000W order_id, state, time
  • 根据运营同学的估算,高喜商城未来五年的订单量最多将会达到2000W条,并且平均每条订单中包含5件商品,因此orders_product表中的数据量将会达到10000W;并且每条订单都有10种状态,因此order_state_time表的数据量将会达到20000W。
  • 基于上述数据,orders需要水平拆分成4张物理表,orders_product需要水平拆分成20张物理表,order_state_time需要水平拆分成40张物理表。
  • 那么这写物理表究竟该分配给多少个物理库中?这需要由业务需求来决定。

高喜商城的业务需求:

表名 业务需求 涉及字段
orders 1.根据buyer_id分页查询某一用户的订单 buyer_id
2.根据order_id查询订单 order_id
3.根据seller_id分页查询某一商家的订单 seller_id
表名 业务需求 涉及字段
orders_product 根据order_id查询产品列表 order_id
表名 业务需求 涉及字段
order_state_time 1.根据order_id和state筛选某一状态下的订单 order_id, state
2.修改指定订单的状态 order_id, state

订单系统的核心业务需求如上述三张表所示。orders表和orders_product表、order_state_time表之间都是一对多的组合关系,在查询过程中需要进行表连接操作。因此,我们必须要指定合理的分库分表方案,能够使得同一订单的产品信息、订单状态信息都落在同一个物理库中,从而能够直接使用SQL语句进行连接操作。如果分库分表方案不合理,那么同一订单的产品信息和订单状态信息会散落在不同的物理库中,由于Mysql并不支持跨库连接,因此这三张表的连接需要拆分成三次数据库查询,并在业务层完成数据的连接,这无意增加了业务层的复杂度。下面详细介绍订单系统的分库分表方案。

通过分析上述三张业务需求表可知,订单系统核心操作所涉及到的字段无非就是三个:order_id、buyer_id、seller_id。当查询指定订单的时候需要使用order_id作为查询条件,当查询某一买家所有订单的时候需要使用buyer_id作为查询条件,当查询某一卖家所有订单的时候需要使用seller_id作为查询条件。由此可见,分片字段需要从这三个字段中选择。那么究竟应该如何选择呢?我们分别来看如下三种方案:

  1. 将order_id作为分片字段 如果将order_id作为分片字段,那么根据order_id查询指定订单的时候可以直接定位到指定的物理表,然而在根据buyer_id和seller_id查询订单的时候,由于无法定位到具体的表,因此就需要全库表查询,这显然是低效的。

  2. 将buyer_id作为分片字段 此时,查询指定买家的订单信息可以直接定位到指定的物理表,但是当需要根据order_id查询具体订单信息、查询卖家订单信息时就显得提襟见肘了。

  3. 将seller_id作为分片字段 此时,查询指定卖家的所有订单信息可以定位到指定的物理表,但查询买家订单、根据订单编号查询订单时就需要全库表查询了。

综上所述,如果只将这三个字段中的某一个作为分片字段,显然无法满足所有的业务场景,必定会存在全库表查询,这就会导致查询效率低下。那么,有没有什么方案能够避免全库表查询呢?当然有!

首先,我们来解决跨库连接的问题。

解决跨库连接问题的根本方法就是避免跨库连接,让需要连接的表存储在同一个物理库中。在订单系统中,orders表要分别和orders_product表、order_state_time表产生连接,并且都是以order_id作为连接字段。但是,如果我们以order_id作为这三张表的分片字段,那么当根据buyer_id、seller_id查询时,都需要全库表操作。所以,我们需要分别以buyer_id和seller_id作为分片字段。听上去很神奇,具体怎么实施呢?

在订单关系中,买家(buyer_id)和卖家(seller_id)是多对多的聚合关系,对于多对多关系,我们可以使用表冗余来实现不同纬度的查询。

此时,我们需要将订单表(orders)一分为二,分别是买家订单表(orders_buyer)卖家订单表(orders_seller),这两张表的数据是完全一致的。在买家订单表中,以buyer_id作为分片字段;在卖家订单表中,以seller_id作为分片字段。那么当需要查询指定买家的订单时,根据买家id(buyer_id)就可以确定该买家订单数据所在的物理表;当需要查询指定卖家的订单时,根据卖家id(seller_id)就可以确定该卖家订单数据所在的物理表。

此时已经避免了上述两个业务场景的全库表查询,那么还有两种业务场景的全库表查询问题如何解决呢?

  1. 根据order_id查询订单
  2. orders表要分别和orders_product表、order_state_time表的连接操作
  • 对于第一个问题,买家(buyer_id)和订单(order_id)之间是一对多组合关系。对于一对多组合关系,我们可以建立“多”——>“一”的映射。在这里,我们需要建立order_id——>buyer_id的映射关系。那么当需要根据order_id查询订单的时候,首先需要查询这个映射关系,找到order_id对应的buyer_id,由于buyer_id是分片字段,因此可以直接计算出数据所在的物理表,从而完成根据order_id查询订单的需求。
  • 对于第二个问题,我们可以使用字段冗余的方法来解决。在创建买家订单表和卖家订单表的同时,再分别创建如下四个表:
    • 买家订单产品表(orders_product_buyer)
      • (order_id, product_id, buyer_id)
      • 加入buyer_id字段,并以buyer_id作为分片字段
    • 卖家订单产品表(orders_product_seller)
      • (order_id, product_id, seller_id)
      • 加入seller_id字段,并以seller_id作为分片字段
    • 买家订单状态表(order_state_time_buyer)
      • (order_id, state, time, buyer_id)
      • 加入buyer_id字段,并以buyer_id作为分片字段
    • 卖家订单状态表(order_state_time_seller)
      • (order_id, state, time, seller_id)
      • 加入seller_id字段,并以seller_id作为分片字段

此时,订单系统的数据库架构如下图所示:

采用了上述方案后,所有的全库表查询问题都得到了解决,但不要止步于此,还可以进一步优化。

上述方案中,我们使用了一张映射表来维护order_id和buyer_id之间的映射关系,当需要根据order_id查询指定订单的时候,先要查询映射表,找到该订单对应的buyer_id,然后再根据buyer_id计算分片,找到相应的物理表。

这个过程经历了两次地址查询,还需要额外的策略存储映射表。那么,有没有什么方法能够解决这两个问题呢?当然是有的,此时就要介绍我的黑科技了。

x%N的结果其实是由x二进制的末尾logN位决定的

举个例子,13534443 % 8,其实是由13534443的二进制表示法的最后log8位决定的。

  • 13534443的二进制是:110011101000010011101011
  • log8=3

因此,13534443 % 8的结果由011决定。也就是说,只要末尾三位都是011的数字,对8取模的结果都是一样的。

基于这个结论,我们只要保证buyer_id和order_id的最后logN位一致,就无需再使用额外的映射表来存储这两者的映射关系。order_id和buyer_id的生成方式如下:

  1. 在创建订单时,首先获取买家的uid
  2. 获取uid二进制表示法的最后logN位,用lastN表示
  3. 将UUID+lastN作为order_id

此时同一个买家的buyer_id%N的结果和order_id%N的结果一致。在根据order_id查询订单的时候直接通过order_id%N计算出订单所在的物理库即可。

5.3 产品系统的分库分表方案

高喜商城未来五年的业务指标:

表名 未来五年数据量 关键字段
product 100W product_id
prod_image 10*100W product_id
brand 1000
category 100

总体而言,产品系统的数据量相对较小。运营同学规划,未来五年,高喜商城的产品数量最多为100W,由于每件产品最多允许拥有10张图片,因此prod_image表的数量预计为1000W,因此需要对prod_image表进行拆分;而产品的品牌、产品的类别数量较小,不需要考虑分库分表。

产品系统的数据库分库分表方案如下图所示:

由于prod_image表的数据量将会达到1000W,因此为了避免单表数据超过1000W,将该表根据prod_id拆分成两张物理表。

此外,在产品系统中,product、brand、category数据量均不会超过1000W,因此无需分库分表。

关注订阅我的文章

收藏成功!
已添加到「」, 点击更改