随着我们业务的发展,每日的订单量接近 100 万。这个时候,订单库已有上亿条记录,订单表有上百个字段,这些数据存储在一个 Oracle 数据库里。当时,我们已经实现了订单的服务化改造,只有订单服务才能访问这个订单数据库,但随着单量的增长以及在线促销的常态化,单一数据库的存储容量和访问性能都已经不能满足业务需求了,订单数据库已成为系统的瓶颈。所以,对这个数据库的拆分势在必行。
数据库拆分一般有两种做法,一个是垂直分库,还有一个是水平分库。
垂直分库简单来说,垂直分库就是数据库里的表太多,我们把它们分散到多个数据库,一般是根据业务进行划分,把关系密切的表放在同一个数据库里,这个改造相对比较简单。
水平分库某些表太大,单个数据库存储不下,或者数据库的读写性能有压力。通过水平分库,我们把一张表拆成多张表,每张表存放部分记录,分别保存在不同的数据库里,水平分库需要对应用做比较大的改造。
图片
通过水平分库,你们将订单的基本信息、商品明细和扩展信息分散到多个数据库中,这样做主要是为了解决两个问题:一是减少了单个数据库的数据量,从而提高了读写效率;二是分散了数据库的负载,使系统能够更好地扩展和维护。简而言之,就是让数据库更快、系统运行更稳定。
水平分库后,应用通过订单服务来访问多个订单数据库,具体的方式如下图所示:
图片
原来的一个 Oracle 库被现在的多个 MySQL 库给取代了,每个 MySQL 数据库包括了 1 主 1 备 2 从,都支持读写分离,主备之间通过自带的同步机制来实现数据同步。所以,你可以发现,这个项目实际包含了水平分库和去 Oracle 两大改造目标。
分库维度怎么定?
首先,我们需要考虑根据哪个字段来作为分库的维度。这个字段选择的标准是,尽量避免应用代码和 SQL 性能受到影响。
具体地说,就是现有的 SQL 在分库后,它的访问尽量落在单个数据库里,否则原来的单库访问就变成了多库扫描,不但 SQL 的性能会受到影响,而且相应的代码也需要进行改造。
具体到订单数据库的拆分,你可能首先会想到按照用户 ID 来进行拆分。这个结论是没错,但我们最好还是要有量化的数据支持,不能拍脑袋。这里,最好的做法是,先收集所有 SQL,挑选出 WHERE 语句中最常出现的过滤字段,比如说这里有三个候选对象,分别是用户 ID、订单 ID 和商家 ID,每个字段在 SQL 中都会出现三种情况:
单 ID 过滤,比如说“用户 ID=?”;
多 ID 过滤,比如“用户 ID IN(?,?,?)”;
该 ID 不出现。
最后,我们分别统计这三个字段的使用情况,假设共有 500 个 SQL 访问订单库,3 个候选字段出现的情况如下:
图片
从这张表格的分析来看,选择按用户 ID 进行分库是显而易见的最佳选择。但这只是基于静态数据的判断。实际上,不同 SQL 的访问频次各不相同,因此,进一步分析每条 SQL 的实际访问量变得至关重要。在我们的项目中,我们聚焦于执行频率最高的前15条 SQL,它们占据了总执行次数的85%,具有很高的代表性。通过分析,如果采用用户 ID 作为分库的依据,发现这些 SQL 中有85% 的访问会集中在某个特定的数据库上,13% 的访问分布在几个数据库中,仅有2% 的访问需要查询所有数据库。因此,从动态的 SQL 执行频次来看,使用用户 ID 进行分库显然优于采用其他标识符。这样的量化分析不仅证实了按用户 ID 分库是最优策略,而且还明确了分库对现有系统的具体影响。例如,在本案例中,85% 的 SQL 都会被定向到同一个数据库,这意味着相对于未分库的状态,这部分数据的访问性能将得到提升,同时也消除了我们对分库有效性的疑虑,增强了我们进行分库的决心。
数据怎么分?
一般有两种数据分法:根据 ID 范围进行分库,比如把用户 ID 为 1 ~ 999 的记录分到第一个库,1000 ~ 1999 的分到第二个库,以此类推。
根据 ID 取模进行分库,比如把用户 ID mod 10,余数为 0 的记录放到第一个库,余数为 1 的放到第二个库,以此类推。
这两种分法,各自存在优缺点,如下表所示:
图片
在实践中,为了运维方便,选择 ID 取模进行分库的做法比较多。同时为了数据迁移方便,一般分库的数量是按照倍数增加的,比如说,一开始是 4 个库,二次分裂为 8 个,再分成 16 个。这样对于某个库的数据,在分裂的时候,一半数据会移到新库,剩余的可以不用动。与此相反,如果我们每次只增加一个库,所有记录都要按照新的模数做调整。
分几个库?
确定了数据分片的方案后,接下来要解决的问题是分成多少个数据库。数据库能够处理的记录数量上限是决定因素之一。
通常情况下,当MySQL数据库的记录数超过5000万,或Oracle数据库的记录数超过1亿时,数据库的负载会变得很高,这也取决于字段的数量、大小以及查询的复杂性。
在保证不超过单个数据库记录处理上限的情况下,如果分库太少,我们无法实现分散存储的目的,也不能有效减轻数据库的性能压力;而分得太多虽然能提高单个数据库的访问性能,但对于需要跨多个数据库进行的访问操作,应用程序必须同时连接多个数据库。并行访问会消耗更多的线程资源,而串行访问则会大幅增加处理时间。
此外,数据库数量的增加也意味着更多的硬件成本。因此,确定分库数量需要进行全面评估。通常建议初次进行数据库分片时,可以选择创建4到8个数据库。例如,在我们的项目中,我们选择创建了6个数据库,这一数量能够满足订单业务在未来一段时间内的需求。
分库路由
在实施数据库分库时,虽然这一变化不涉及业务逻辑,但它必定会对应用程序产生影响。为最小化这种影响,关键在于确保分库逻辑尽可能只在数据访问层(DAL)处理,而对上层的订单服务保持透明,这样,服务层代码就无需进行大幅修改。实现这一目标确实具有一定挑战性。以下是一些建议,用以明确DAL和订单服务各自的职责范围:
当涉及到单一数据库的访问时,例如查询操作明确指定了用户ID,则此类SQL查询应直接路由至特定的数据库。这种情况下,DAL应负责自动完成路由,且当数据库分裂时,仅需调整路由逻辑中的模数即可,而无需修改应用层代码。
对于简单的跨库查询,DAL应负责汇集来自各个分库的结果,并对上层应用保持透明。
针对需要进行聚合操作的复杂查询(如涉及groupby、orderby、min、max、avg等操作),建议让DAL层先行汇总各分库的查询结果,随后由订单服务层完成进一步处理。这样的安排既考虑到了DAL层实现所有聚合操作逻辑复杂度较高的问题,也顾及到根据实际经验,这类需求在应用层处理会更加灵活和高效。
数据访问层还可以细分为底层的JDBC驱动层和更上层的数据访问层。若在JDBC层面实现分库逻辑,会使系统开发复杂度增加,灵活性降低,且目前缺乏成功案例。在实践中,更常见的做法是在持久层框架的基础上进一步封装,形成一个分布式数据访问层(DDAL),以便实现分库路由。
分页处理
在实施水平分库之后,确实,分页查询变得更加复杂,特别是当查询需要遍历所有分库时。例如,假设要按时间顺序展示某商家的所有订单,每页显示100条记录。因为是按商家查询,需遍历全部数据库。若数据库共有8个,分页逻辑会相应变复杂:
对于第1页数据,需从每个数据库获取前100条记录,汇总后共得到800条记录。接着,在应用层进行二次排序,最终仅保留前100条。
若获取第10页数据,每个库需要提供前1000条记录(100*10),合计8000条记录。然后,再次在应用层进行排序,并选取第900到1000条记录作为结果。
这一机制说明,分页查询在分库环境下需要从每个数据库中获取更多数据,并且在应用层执行二次排序,导致内存和处理时间需求随着分页深度的增加而显著增长。与此相对,单库环境下的分页查询则简单得多,直接从数据库获取所需页的记录,无需应用层排序。
解决分库环境下分页问题的策略包括:
前端应用分页:可以限制用户只能访问前n页数据。这种做法业务上通常是可接受的,因为用户很少需要查看后续页面。若用户确实需要访问更深的页面,可以引导他们缩小查询范围再次进行查询。
在采用用户 ID 作为分库依据的系统中,确实存在一个挑战,即如何高效地处理基于非分库字段(例如订单 ID)的查询。直接查询所有分库会导致大量不必要的资源消耗。为了解决这一问题,创建一个订单 ID 与用户 ID 之间的映射关系(Lookup 表)是一个有效的策略。这样,每次基于订单 ID 的查询首先访问 Lookup 表来确定对应的用户 ID,进而实现对特定分库的直接定位。
Lookup 表的设计是这样的:
字段限定:该表仅包含两个字段——订单 ID 和用户 ID,这使得它在存储和查询性能上都非常高效。
独立存储:Lookup 表存放在一个独立的数据库中,这样做不仅避免了与业务数据的混淆,还能提高查询效率。
缓存优化:为进一步提高查询性能,可以利用分布式缓存来存储这些映射关系。这样,常见的查询可以直接从缓存中获取结果,大大减少数据库访问次数。
数据一致性:在新增订单时,除了在订单数据库中添加记录外,还需同步更新 Lookup 表。这确保了订单 ID 到用户 ID 的映射关系始终保持最新。
理任务:对于需要批量获取数据的场景,可以增加每批次的数据量,例如,每次处理5000条记录。这样做可以有效减少分页访问次数,减轻数据库压力。
利用大数据平台:在分库设计中,通常会配备大数据平台来汇总所有分库的数据。对于某些分页查询,可以考虑通过大数据平台来实现,特别是当需要全库数据概览时。
整体架构
在这种架构下,上层应用(如订单服务)与数据库之间的交云通过一系列中间层来实现分库的逻辑,同时保持对上层服务的透明性。这种设计不仅优化了数据存取效率,还大大简化了应用层的开发和维护。下面是这个系统架构的关键组成部分及其功能:
分库代理:这是分库逻辑的核心,它负责实现包括聚合运算和订单 ID 到用户 ID 的映射在内的所有分库相关功能。通过分库代理,订单服务无需关心背后的分库逻辑,可以像访问单一数据库一样进行数据操作。
Lookup 表:专门用于存储订单 ID 和用户 ID 之间的映射关系,这样即使是基于非分库字段(订单 ID)的查询也能快速定位到对应的数据库。Lookup 表的存在是优化查询性能的关键,尤其是在基于订单 ID 查询时,能够直接指向单个库,避免全库扫描。
缓存(Cache):为了进一步提升查询效率,Lookup 表的数据会被缓存在分布式缓存中。这种机制可以减少对数据库的直接访问,快速响应查询请求,特别是对于高频访问的数据。
分布式数据访问层(DDAL):提供数据库路由功能,能够根据用户 ID 准确地定位到特定的分库。对于需要跨多个数据库进行的操作,DDAL 还支持多线程并发访问模式以及简单的结果汇总,优化了数据访问的效率和响应时间。
数据初始化与同步:Lookup 表的初始数据来源于现有的分库数据,确保了从一开始就能正确映射订单 ID 和用户 ID。当有新的订单记录产生时,分库代理会负责异步将这些新记录写入Lookup 表,保持数据的一致性和最新性。
如何安全落地?
为了确保订单水平分库的平稳过渡,整个迁移和上线过程采用了分阶段实施的策略。这种方法不仅能够降低风险,还能确保在转换期间系统的稳定性和数据的一致性。下面是实施过程的详细步骤:
阶段一:技术验证和部分功能迁移
并行运行Oracle和MySQL数据库:在初始阶段,所有的数据读写操作仍然指向原有的Oracle数据库。同时,通过数据同步程序,定期(例如,每3分钟)将Oracle数据库中的数据增量同步到多个MySQL库中。这样做的目的是确保MySQL数据库能够及时反映最新数据状态,同时验证数据同步的准确性和效率。
选择非实时场景进行验证:挑选一些对数据实时性要求不高的业务场景(如查询历史订单)作为先行者,将这部分业务的数据访问切换到MySQL数据库。这一步骤旨在验证整个方案的可行性,包括分库代理、DDAL、Lookup表等基础设施的功能和性能。
阶段二:全面业务迁移
全面接入MySQL:在第一阶段验证无大的技术问题后,接下来将所有实时读写操作切换到MySQL数据库。这一步骤要求彻底废弃Oracle数据库,意味着MySQL将成为系统的主要数据存储方案。
业务功能验证:在这一阶段,重点是验证迁移后的业务功能是否正常。所有依赖订单服务的应用都需要接入新的MySQL数据库。通过大量的测试来确保性能和功能符合预期。
实施这个分库方案时,分两个阶段一次性成功地完成了上线,特别是在第二阶段,超过100个依赖订单服务的应用仅通过简单重启就顺利完成了系统升级,期间未遇到较大的问题。这个案例充分证明了分阶段实施策略的有效性,同时也展示了良好的前期准备和详尽测试的重要性。通过这种方式,可以在最大程度上减少迁移带来的风险,确保系统迁移的平稳执行。