笔记之可伸缩架构

326 阅读14分钟

本文来自个人笔记本:dbses.gitbook.io/technotes

原文

01 技术架构的目标

对于一个系统来说,技术架构都要解决哪些非功能性需求呢?

系统非功能性需求:比如一个订单页面打开需要多少时间,页面是不是每次都能打开,这些就和具体的业务逻辑没有关系,属于系统非功能性需求的范畴。

总的来说,系统的非功能性需求主要分为以下3个部分。

  • 第一:系统的高可用

导致系统可用性出问题,一般是两种情况:一种是软硬件本身有故障,比如机器断电,网络不通;

还有一种是高并发引起的系统处理能力的不足,软硬件系统经常在处理能力不足时,直接瘫痪掉,比如 CPU 100% 的时候,整个系统完全不工作。

  • 第二:系统的高性能

保证合理的性能分两种情况:一种是常规的流量进来,但系统内部处理比较复杂,我们就需要运用技术手段进行优化。比如针对海量商品的检索,我们就需要构建复杂的搜索系统来支持。

第二种是高并发的流量进来,系统仍旧需要在合理的时间内提供响应,这就更强调我们做架构设计时,要保证系统的处理能力能够整体上做水平扩展,而不仅仅是对某个节点做绝对的性能优化,因为流量的提升是很难准确预计的。

  • 第三:系统的可伸缩和低成本

我们的架构设计要保证系统在业务高峰时,要能快速地增加资源来提升系统处理能力;反之,当业务低谷时,可以快速地减少系统资源,保证系统的低成本。

本节我们重点来讨论一下系统的可伸缩性。

02 可伸缩架构原则

  • 可水平拆分和无状态

这意味着节点支持多实例部署,我们可以通过水平扩展,线性地提升节点的处理能力,保证良好的伸缩性以及低成本。

  • 计算可并行

如果计算可并行,我们就可以通过增加机器节点,加快单次请求的速度,提高性能。Hadoop 对大数据的处理就是一个很好的例子。

  • 虚拟化和容器化

虚拟化和容器化对系统的资源切分得更细,也就说明对资源的利用率更高,系统的成本也就更低。举个例子,我们可以为单个 Docker 容器分配 0.1 个 CPU,当容器的处理能力不足时,我们可以给它分配更多的 CPU,或者增加 Docker 容器的数量,从而实现系统的弹性扩容。

03 可伸缩的策略和手段

上面我们说了,保证系统的可伸缩,也就是要在系统在业务高峰时,能快速地增加资源;当业务低谷时,能快速地减少资源。

按照业务的粒度维度,系统的可伸缩实现方式有两种。

  • 第一个是节点级别的可伸缩

对于无状态的节点,我们直接增减节点就可以了。比如说订单服务,白天我们需要 10 台机器来提供服务,到了半夜,由于单量减少,我们就可以停掉部分机器。

而对于有状态的服务,我们需要能够支持状态数据的重新分布。比如进行水平分库的时候,要从 4 个库增加到 8 个库,我们需要把原先 4 个库的数据,按照新的分库规则,重新分布到 8 个库中。如果这个调整对应用的影响小,那系统的可伸缩性就高。

  • 第二个是系统级别的可伸缩

我们可以把多个处理节点打包在一起,形成一个处理单元。举个例子,针对交易场景,我们可以把商品浏览、加购物车、下单、支付这几个节点放一起,形成一个逻辑上的单元,在单元内部形成调用的闭环。

具体使用的时候,我们可以按照用户维度,来划分交易单元。比如说,让交易单元 A 处理用户 ID 对 2 取模为 0 的用户下单流程,交易单元 B 处理用户 ID 对 2 取模为 1 的用户下单流程。

这样,我们对一个整体的交易单元进行扩容或者缩容,每增加一个交易单元,就意味着同时增加商品浏览、加购物车、下单、支付 4 个节点,这 4 个节点的处理能力是匹配的。你可以参考下面的这张交易单元化的示意图:

image-20220414224947590

通过单元化处理,我们把相关的节点绑定在一起,同进同退,更容易实现系统的可伸缩。

04 可伸缩架构案例:如何无限扩展你的数据库?

2013 年,随着 1 号店业务的发展,每日的订单量接近 100 万。这个时候,订单库已有上亿条记录,订单表有上百个字段,这些数据存储在一个 Oracle 数据库里。

随着单量的增长以及在线促销的常态化,单一数据库的存储容量和访问性能都已经不能满足业务需求了,订单数据库已成为系统的瓶颈。所以,对这个数据库的拆分势在必行。

  • 垂直分库:就是数据库里的表太多,我们把它们分散到多个数据库,一般是根据业务进行划分,把关系密切的表放在同一个数据库里。
  • 水平分库:某些表太大,单个数据库存储不下,或者数据库的读写性能有压力。我们把一张表拆成多张表,每张表存放部分记录,分别保存在不同的数据库里。

当时,1 号店的问题不是表的数量太多,而是单表的数据量太大,读写性能差。因此我们要考虑的是水平分库。

4.1 分库策略

确定要水平分库后,随即而来的就是要回答这几个问题:要选择哪个分库维度?数据记录如何划分?要分为几个数据库?

分库维度怎么定?

首先,我们需要考虑根据哪个字段来作为分库的维度。

这个字段选择的标准是,尽量避免应用代码和 SQL 性能受到影响。具体地说,就是现有的 SQL 在分库后,它的访问尽量落在单个数据库里,否则原来的单库访问就变成了多库扫描,不但 SQL 的性能会受到影响,而且相应的代码也需要进行改造。

我们先收集所有 SQL,挑选出 WHERE 语句中最常出现的过滤字段,比如说这里有三个候选对象,分别是用户 ID、订单 ID 和商家 ID,每个字段在 SQL 中都会出现三种情况:

  1. 单 ID 过滤,比如说“用户 ID=?”;
  2. 多 ID 过滤,比如“用户 ID IN(?,?,?)”;
  3. 该 ID 不出现。

最后,我们分别统计这三个字段的使用情况,假设共有 500 个 SQL 访问订单库,3 个候选字段出现的情况如下:

image-20220417223112170

从这张表来看,结论非常明显,我们应该选择用户 ID 来进行分库。

在项目中,我们分析了 Top15 执行次数最多的 SQL (它们占总执行次数 85%,具有足够代表性),按照执行的次数,如果使用用户 ID 进行分库,这些 SQL 85% 会落到单个数据库,13% 落到多个数据库,只有 2% 需要遍历所有的数据库。所以说,从 SQL 动态执行次数的角度来看,用户 ID 分库也明显优于使用其他两个 ID 进行分库。

数据怎么分?

我们如何把记录分到各个库里呢?一般有两种数据分法:

  1. 根据 ID 范围进行分库,比如把用户 ID 为 1 ~ 999 的记录分到第一个库,1000 ~ 1999 的分到第二个库,以此类推。
  2. 根据 ID 取模进行分库,比如把用户 ID mod 10,余数为 0 的记录放到第一个库,余数为 1 的放到第二个库,以此类推。

这两种分法,各自存在优缺点,如下表所示:

image-20220417223218255

在实践中,为了运维方便,选择 ID 取模进行分库的做法比较多。同时为了数据迁移方便,一般分库的数量是按照倍数增加的。

比如说,一开始是 4 个库,二次分裂为 8 个,再分成 16 个。这样对于某个库的数据,在分裂的时候,一半数据会移到新库,剩余的可以不用动。与此相反,如果我们每次只增加一个库,所有记录都要按照新的模数做调整。

分几个库?

分库数量,首先和单库能处理的记录数有关。一般来说,MySQL 单库超过了 5000 万条记录,Oracle 单库超过了 1 亿条记录,DB 的压力就很大(当然这也和字段数量、字段长度和查询模式有关系)。

具体分多少个库,需要做一个综合评估,一般初次分库,我建议你分成 4~8 个库。在项目中,我们拆分为了 6 个数据库,这样可以满足较长一段时间的订单业务需求。

4.2 分库带来的问题

分库路由变复杂了

分库从某种意义上来说,意味着 DB Schema 改变了,必然会影响应用,但这种改变和业务无关,所以我们要尽量保证分库相关的逻辑都在数据访问层(DAL)进行处理,对上层的订单服务透明,服务代码无需改造。

对于单库访问,比如查询条件已经指定了用户 ID,那么该 SQL 只需访问特定库即可。此时应该由 DAL 层自动路由到特定库,当库二次分裂时,我们也只需要修改取模因子就可以了,应用代码不会受到影响。

对于简单的多库查询,DAL 层负责汇总各个分库返回的记录,此时它仍对上层应用透明。

对于带聚合运算的多库查询,比如说带 groupby、orderby、min、max、avg 等关键字,可以让 DAL 层汇总单个库返回的结果,然后由上层应用做进一步的处理。

这样做有两方面的原因,一方面是因为让 DAL 层支持所有可能的聚合场景,实现逻辑会很复杂;

另一方面,从 1 号店的实践来看,这样的聚合场景并不多,在上层应用做针对性处理,会更加灵活。

DAL 层还可以进一步细分为底层 JDBC 驱动层和偏上面的数据访问层。如果我们基于 JDBC 层面实现分库路由,系统开发难度大,灵活性低,目前也没有很好的成功案例。

在实践中,我们一般是基于持久层框架,把它进一步封装成 DDAL(Distributed Data Access Layer,分布式数据访问层),实现分库路由。1 号店的 DDAL 就是基于 iBatis 进一步封装而来的。

分页处理变困难了

假设我们要按时间顺序展示某个商家的订单,每页有 100 条记录,由于是按商家查询,我们需要遍历所有数据库。假设库数量是 8,我们来看下水平分库后的分页逻辑:

  • 如果是取第 1 页数据,我们需要从每个库里按时间顺序取前 100 条记录,8 个库汇总后共有 800 条,然后我们对这 800 条记录在应用里进行二次排序,最后取前 100 条;
  • 如果取第 10 页数据,则需要从每个库里取前 1000(100*10)条记录,汇总后共有 8000 条记录,然后我们对这 8000 条记录进行二次排序后,取第 900 到 1000 之间的记录。

在分库情况下,对于每个数据库,我们要取更多的记录,并且汇总后,还要在应用里做二次排序,越是靠后的分页,系统要耗费更多的内存和执行时间。

那么,我们如何解决分库情况下的分页问题呢?如果是为前台应用提供分页,我们可以限定用户只能看到前面 n 页;分库设计时,一般还有配套的大数据平台负责汇总所有分库的记录,所以有些分页查询,我们可以考虑走大数据平台。

分库字段映射

分库字段只有一个,比如这里,我们用的是用户 ID。但在订单服务里,根据订单 ID 查询的场景也很多见,如果不做特殊处理,系统会盲目查询所有分库,从而带来不必要的资源开销。

所以,这里我们为订单 ID 和用户 ID 创建映射,保存在 Lookup 表里,我们就可以根据订单 ID,找到相应的用户 ID,从而实现单库定位。

Lookup 表的记录数和订单库记录总数相等,但它只有 2 个字段,所以存储和查询性能都不是问题;这个表在单独的数据库里存放,在实际使用时,我们可以通过分布式缓存,来优化 Lookup 表的查询性能;此外,对于新增的订单,除了写订单表,我们同时还要写 Lookup 表。

4.3 整体架构

最终的 1 号店订单水平分库的总体技术架构如下图所示:

image-20220418215907347

上层应用通过订单服务访问数据库;

分库代理实现了分库相关的功能,包括聚合运算、订单 ID 到用户 ID 的映射,做到分库逻辑对订单服务透明;

Lookup 表用于订单 ID 和用户 ID 的映射,保证订单服务按订单 ID 访问时,可以直接落到单个库,Cache 是 Lookup 表数据的缓存;

DDAL 提供库的路由,可以根据用户 ID 定位到某个库,对于多库访问,DDAL 支持可选的多线程并发访问模式,并支持简单的记录汇总;

Lookup 表初始化数据来自于现有的分库数据,当新增订单记录时,由分库代理异步写入。

这样的架构如何安全落地呢?订单表是系统的核心业务表,它的改动很容易导致依赖订单服务的应用出现问题。我们在上线时,必须谨慎考虑。

  • 首先,实现 Oracle 和 MySQL 两套库并行,所有数据读写指向 Oracle 库,我们通过数据同步程序,把数据从 Oracle 拆分到多个 MySQL 库,比如说 3 分钟增量同步一次。
  • 其次,我们选择几个对数据实时性要求不高的访问场景(比如访问历史订单),把订单服务转向访问 MySQL 数据库,以检验整套方案的可行性。
  • 最后,经过大量测试,如果性能和功能都没有问题,我们再一次性把所有实时读写访问转向 MySQL,废弃 Oracle。

这里,我们把上线分成了两个阶段:第一阶段,把部分非实时的功能切换到 MySQL,这个阶段主要是为了验证技术,它包括了分库代理、DDAL、Lookup 表等基础设施的改造;

第二阶段,主要是验证业务功能,我们把所有订单场景全面接入 MySQL。1 号店两个阶段的上线都是一次性成功的,特别是第二阶段的上线,100 多个依赖订单服务的应用,通过简单的重启就完成了系统的升级,中间没有出现一例较大的问题。