随着业务的快速增长,一家电商公司每天的订单量从30万单迅速增长到100万单,总订单量突破一亿。该平台使用的是MySQL数据库。根据监控数据显示,每秒的最高订单量已经达到2000笔(不包括秒杀,秒杀的TPS已经上万)。
重构? 有人可能会觉得“重构”是个高大上的说法,但实际上,主要的工作就是“分库分表”。
然而,除了分库分表,平台还需要解决管理端的一些问题。例如,运营、客服和商务等部门需要从多维度查询订单数据。分库分表后,如何满足他们的需求?
此外,分库分表后的上线方案和数据迁移方案也需要特别慎重,尤其是要确保系统不停机。同时,为了保障系统的稳定性,还需要考虑降级方案。
技术选型
为确保系统的稳定性和高效性,该电商平台最终选择了第二种方案,即使用轻量级的Sharding-JDBC。
分库分表架构规划
悲观的预估
基于上述数据,我们决定将数据库分成16个库,每个库包含16张表,并按照user_id进行Hash分片。
即便按每天1000万订单量来规划,经过两年,平台的总订单量将达到73亿。此时,每个库的数据量平均为4.56亿(73亿 ÷ 16),每张表的数据量平均为2850万(4.56亿 ÷ 16)。虽然这略超出了1000万单的推荐值,但我们是基于两年后的理想预期来做的估算。实际情况可能不会达到这个数量。
乐观的预估
如果按照每天100万订单量来规划,经过两年,平台的总订单量将为7.3亿,每个库的数据量平均为0.456亿(7.3亿 ÷ 16),每张表的数据量则为285万(0.456亿 ÷ 16)。
提升查询性能
有读者可能会问,直接查询数据库是否会存在性能问题?答案是:会有性能压力。为了提高查询性能,主要有以下几个方案:
异构索引 读写分离
异构索引
在查询时,我们采用了异构索引的方案。
在系统架构上,我们在上层加了 Redis,并使用 Redis 分片集群来存储活跃用户最近的 50 条订单。
通过这种方式,只有少部分在 Redis 中找不到订单的用户请求才会到数据库查询订单,从而有效减轻了数据库的查询压力。
读写分离
同时,每个数据库实例都有两个从库,查询操作仅在从库上执行,进一步分担了每个数据库实例的压力。
管理端技术方案
在进行分库分表后,不同用户的订单数据会分散到不同的库和表中。如果需要根据用户 ID 以外的其他条件查询订单,就会遇到一个问题:例如,运营同学想查询某天 iPhone 7 的订单量,就需要从所有数据库和表中提取数据,并将结果聚合到一起。
这就回到了一个核心问题:分库分表后,如何实现关联查询? 插一句:当数据不在同一个库中时,实现关联查询会变得复杂。
实现这种查询功能的代码将非常复杂,而且查询性能也会变差。因此,我们需要一种更好的方案来解决这一问题。
我们采用了 ES(ElasticSearch)+HBase 的组合方案,将索引和数据存储分离。
在该方案中,可能参与条件检索的字段都会在 ES 中建立索引,例如商家、商品名称、订单日期等。所有订单数据会全量存储到 HBase 中。我们知道,HBase 支持海量数据存储,并且根据 rowkey 查询的速度非常快。另一方面,ES 的多条件检索能力非常强大。可以说,这个方案将 ES 和 HBase 的优点结合得淋漓尽致。
ES+HBase 组合方案的查询过程
查询过程如下:首先,基于输入条件在 ES 的索引中查询符合条件的 rowkey 值;然后,用这些 rowkey 值去 HBase 中查询相应的数据。由于 HBase 查询的速度极快,因此查询时间几乎可以忽略不计。
该方案解决了管理端通过各种条件查询订单的业务需求,同时也满足了商家端根据商家 ID 和其他条件查询订单的需求。
如果用户希望查询最近 50 条订单之前的历史订单,也可以使用此方案。
MySQL 实时同步到 HBase 和 ES 中
MySQL 中的订单数据需要实时同步到 HBase 和 ES 中。为了实现这一目标,我们采用了以下同步方案:
我们利用 Canal 实时获取 MySQL 数据库表中的增量订单数据,然后将订单数据推送到消息队列 RocketMQ 中。消费端获取消息后,将数据写入 HBase,并在 ES 中更新索引。
Canal 模拟 MySQL 的 slave 协议,伪装成 MySQL 的从库,向 MySQL 的主库发送 dump 协议。MySQL 主库收到 dump 协议后,会将 binary log 发送给 Canal。Canal 解析 binary log 字节流,根据应用场景对字节流进行相应的处理。
为了确保数据一致性并避免数据丢失,我们使用了 RocketMQ 的事务型消息,确保消息能够成功发送。另外,只有在 HBase 和 ES 的操作都成功后,才进行 ack 操作,确保消息最终被消费。
不停机数据迁移
在互联网行业,许多系统的访问量非常高,即使是在凌晨两三点也会有一定的访问量。由于数据迁移导致服务停机,是很难被业务方接受的!接下来我们讨论一下如何在用户无感知的情况下,实现不间断的数据迁移。
在进行数据迁移时,我们需要注意以下几个关键点:
保证迁移后数据准确且不丢失:每条记录都必须准确且无丢失。 不影响用户体验:尤其是访问量高的 C 端业务,要求平滑迁移且不中断服务。 保证迁移后的系统性能和稳定性。
常用的数据迁移方案主要有以下几种:
方案一:挂从库 方案二:双写 方案三:利用数据同步工具
接下来,我们将分别介绍这些方案。
挂从库
在主库上创建一个从库,待从库的数据同步完成后,将从库升级为主库(新库),然后将流量切换到新库。这种方式适用于表结构不变,且在空闲时间段流量较低,允许停机迁移的场景。一般用于平台迁移,比如从机房迁移到云平台,或者从一个云平台迁移到另一个云平台。
对于大多数中小型互联网系统,在空闲时段的访问量通常较低,几分钟的停机时间对用户影响较小,业务方通常能够接受。因此,我们可以采用停机迁移方案,步骤如下:
新建从库(新数据库),并开始将数据从主库同步到从库。 数据同步完成后,选择一个空闲时间段进行迁移。为了确保主从数据库的数据一致性,需要暂停服务,并将从库升级为主库。如果通过域名访问数据库,可以直接将域名解析到新数据库(从库升级为主库);如果通过 IP 访问数据库,则需要修改为新数据库的 IP 地址。 最后,重新启动服务,整个迁移过程完成。
这种迁移方案的优势
迁移成本低,迁移周期短。
缺点
切换数据库时需要停止服务。
考虑到我们系统的并发量较高,而且已经进行了分库分表,且表结构也发生了变化,因此无法采用这种方案。
双写
双写方案是指老库和新库同时写入数据,然后批量迁移老数据到新库,最终将流量切换到新库并关闭老库的读写。
这种方式适合数据结构发生变化且不允许停机迁移的场景,通常出现在系统重构时,如表结构改变或分库分表等场景。
对于一些大型互联网系统,即使在空闲时段,访问量依然很高。几分钟的停机时间不仅会影响用户体验,还可能导致用户流失,这对业务方来说是不可接受的。因此,我们需要考虑一种用户无感知的不停机迁移方案。
下面是我们的具体迁移方案,步骤如下:
代码准备
在服务层,对订单表的增删改操作需要同时写入新库(分库分表后的数据库表)和老库。因此,需修改相关代码以支持双写。同时,准备迁移程序脚本用于迁移老数据,准备校验程序脚本用于校验新库和老库的数据是否一致。
双写
老库和新库同时写入数据。需要注意的是,任何对数据库的增删改都需要双写;对于更新操作,如果新库中没有相关记录,需要先从老库中查出记录,再将更新后的记录写入新库。为了保证写入性能,老库写完后,可以使用消息队列异步写入新库。
迁移老数据
使用脚本程序,将某一时间戳之前的老数据迁移到新库。注意:
时间戳选择应为双写开启后的时间点,例如开启双写后的 10 分钟,以避免遗漏部分老数据。
迁移过程中遇到记录冲突应直接忽略,因为第 2 步中的更新操作已将记录写入新库。
迁移过程必须记录日志,尤其是错误日志。如果双写失败,可以通过日志恢复数据,以保证新老库的数据一致性。
数据校验
第 3 步完成后,通过脚本程序进行数据校验,确保新库中的数据准确且没有遗漏。
开启双读
在数据校验无误后,开始双读,将少部分流量分配给新库,同时老库和新库同时读取数据。由于延时问题,新库和老库之间可能会出现少量数据不一致的情况,因此当新库读取不到时,需重新从老库读取数据。然后逐步将读流量切换到新库,这相当于灰度上线的过程。如果遇到问题,可以及时将流量切回老库。
切换到新库
当所有读流量切换到新库后,停止老库的写入操作(可以通过代码中的热配置开关进行控制),确保只向新库写入数据。
迁移完成
迁移完成后,去掉与双写和双读相关的无用代码。
利用数据同步工具
我们可以看到,上述双写方案比较繁琐,需要修改很多数据库写入的地方。是否有更好的方案呢?
我们还可以利用 Canal、DataBus 等工具进行数据同步。以阿里开源的 Canal 为例。
使用同步工具的好处是,不需要开启双写,服务层也不需要编写双写代码,可以直接使用 Canal 进行增量数据同步。相应的步骤如下:
代码准备
准备 Canal 代码,解析 binary log 字节流对象,并将解析好的订单数据写入新库。
同时准备迁移程序脚本,用于迁移老数据,准备校验程序脚本,用于校验新库和老库的数据是否一致。
运行 Canal 同步数据
启动 Canal 代码,开始将线上产生的新数据从老库同步到新库。
迁移老数据
使用脚本程序将某一时间戳之前的老数据迁移到新库。
注意:
时间戳应选择 Canal 程序开始运行后的时间点,例如运行 Canal 后 10 分钟的时间点,避免遗漏部分老数据。
迁移过程必须记录日志,尤其是错误日志。如果某些记录写入失败,可以通过日志恢复数据,确保新老库的数据一致。
数据校验
第 3 步完成后,通过脚本程序进行数据校验,确保新库数据准确且没有遗漏。
开启双读
数据校验无误后,开始双读,将少部分流量分配给新库,同时老库和新库同时读取数据。由于延时问题,新库和老库之间可能会有少量数据不一致的情况,因此新库读取不到时需要再从老库读取数据。逐步将读流量切换到新库,相当于灰度上线的过程。如果遇到问题,可以及时将流量切回老库。
切换写入流量
当读流量全部切换到新库后,将写入流量切换到新库(可以通过代码中的热配置开关控制)。由于 Canal 程序仍在运行,能够继续同步老库中的数据变化到新库,因此切换过程不会导致部分老库的数据未能同步到新库。
关闭 Canal 程序
完成数据迁移后,关闭 Canal 程序。
迁移完成
迁移完成后,相关的同步流程和程序可以清理掉。
扩容缩容方案
当需要对数据进行扩容或缩容时,首先需要重新进行哈希取模,并将原来多个库表的数据迁移到扩容后的库表中。整体扩容方案和之前讨论的 不停机迁移方案 基本一致。
此过程可以采用 双写 或 Canal 等数据同步方案。
异步降级方案
在大促期间,订单服务的压力会显著增大,此时可以将同步调用改为异步消息队列方式,来减轻订单服务压力并提高吞吐量。
大促时,某些时间点订单量可能会迅速激增。为了解决这一问题,我们采取了 异步批量写数据库 的方式,减少数据库访问频次,从而降低数据库的写入压力。
详细步骤:
后端服务接到下单请求时,直接将请求放入消息队列。 订单服务从队列中取出消息后,首先将订单信息写入 Redis,然后每隔 100ms 或积攒 10 条订单后,批量写入数据库一次。 前端页面下单后定时向后端拉取订单信息,获取到订单信息后跳转到支付页面。 通过这种异步批量写入数据库的方式,数据库写入频次大幅减少,从而显著降低了订单数据库的写入压力。
然而,由于订单是异步写入数据库的,因此可能会出现数据库订单与相应库存数据暂时不一致的情况,也可能导致用户下单后无法立即查询到订单信息。
由于这是降级方案,适当降低用户体验是可以接受的,我们确保数据最终一致性即可。
根据系统压力情况,可以在大促开始时开启异步批量写的降级开关,大促结束后再关闭降级开关。
还没有评论,来说两句吧...