# 18 | 可伸缩架构案例:数据太多,如何无限扩展你的数据库? 你好,我是王庆友。在[第16讲](https://time.geekbang.org/column/article/217152)中,我和你介绍了很多可伸缩的架构策略和原则。那么今天,我会通过1号店订单水平分库的实际案例,和你具体介绍如何实现系统的可伸缩。 ## 问题和解决思路 2013年,随着1号店业务的发展,每日的订单量接近100万。这个时候,订单库已有上亿条记录,订单表有上百个字段,这些数据存储在一个Oracle数据库里。当时,我们已经实现了订单的服务化改造,只有订单服务才能访问这个订单数据库,但随着单量的增长以及在线促销的常态化,单一数据库的存储容量和访问性能都已经不能满足业务需求了,订单数据库已成为系统的瓶颈。所以,对这个数据库的拆分势在必行。 数据库拆分一般有两种做法,一个是垂直分库,还有一个是水平分库。 * **垂直分库** 简单来说,垂直分库就是数据库里的表太多,我们把它们分散到多个数据库,一般是根据业务进行划分,把关系密切的表放在同一个数据库里,这个改造相对比较简单。 * **水平分库** 某些表太大,单个数据库存储不下,或者数据库的读写性能有压力。通过水平分库,我们把一张表拆成多张表,每张表存放部分记录,分别保存在不同的数据库里,水平分库需要对应用做比较大的改造。 ![](https://static001.geekbang.org/resource/image/6a/81/6a414d387a08a6dc291c3a3a9e763c81.jpg) 当时,1号店已经通过服务化,实现了订单库的**垂直拆分**,它的订单库主要包括订单基本信息表、订单商品明细表、订单扩展表。这里的问题不是表的数量太多,而是单表的数据量太大,读写性能差。所以,1号店通过**水平分库**,把这3张表的记录分到多个数据库当中,从而分散了数据库的存储和性能压力。 水平分库后,应用通过订单服务来访问多个订单数据库,具体的方式如下图所示: ![](https://static001.geekbang.org/resource/image/7c/9f/7cf1df5c241cd515d5e89456d2a7f39f.jpg) 原来的一个Oracle库被现在的多个MySQL库给取代了,每个MySQL数据库包括了1主1备2从,都支持读写分离,主备之间通过自带的同步机制来实现数据同步。所以,你可以发现,**这个项目实际包含了水平分库和去Oracle两大改造目标。** ## 分库策略 我们先来讨论一下水平分库的具体策略,包括要选择哪个分库维度,数据记录如何划分,以及要分为几个数据库。 ### 分库维度怎么定? 首先,我们需要考虑根据哪个字段来作为分库的维度。 这个字段选择的标准是,尽量避免应用代码和SQL性能受到影响。具体地说,就是现有的SQL在分库后,它的访问尽量落在单个数据库里,否则原来的单库访问就变成了多库扫描,不但SQL的性能会受到影响,而且相应的代码也需要进行改造。 具体到订单数据库的拆分,你可能首先会想到按照**用户ID**来进行拆分。这个结论是没错,但我们最好还是要有量化的数据支持,不能拍脑袋。 这里,最好的做法是,先收集所有SQL,挑选出WHERE语句中最常出现的过滤字段,比如说这里有三个候选对象,分别是用户ID、订单ID和商家ID,每个字段在SQL中都会出现三种情况: 1. 单ID过滤,比如说“用户ID=?”; 2. 多ID过滤,比如“用户ID IN(?,?,?)”; 3. 该ID不出现。 最后,我们分别统计这三个字段的使用情况,假设共有500个SQL访问订单库,3个候选字段出现的情况如下: ![](https://static001.geekbang.org/resource/image/26/9a/26480ace17c1629c24f5881f65f2fa9a.jpg) 从这张表来看,结论非常明显,我们应该选择用户ID来进行分库。 不过,等一等,这**只是静态分析**。我们知道,每个SQL访问的频率是不一样的,所以,我们还要分析每个SQL的实际访问量。 在项目中,我们分析了Top15执行次数最多的SQL (它们占总执行次数85%,具有足够代表性),按照执行的次数,如果使用用户ID进行分库,这些SQL 85%会落到单个数据库,13%落到多个数据库,只有2%需要遍历所有的数据库。所以说,**从SQL动态执行次数的角度来看**,用户ID分库也明显优于使用其他两个ID进行分库。 这样,通过前面的量化分析,我们知道按照用户ID分库是最优的选择,同时也大致知道了分库对现有系统会造成多大影响。比如在这个例子中,85%的SQL会落到单个数据库,那么这部分的数据访问相对于不分库来说,执行性能会得到一定的优化,这样也解决了我们之前对分库是否有效果的疑问,坚定了分库的信心。 ### 数据怎么分? 好,分库维度确定了以后,我们如何把记录分到各个库里呢? 一般有两种数据分法: 1. **根据ID范围进行分库**,比如把用户ID为1 ~ 999的记录分到第一个库,1000 ~ 1999的分到第二个库,以此类推。 2. **根据ID取模进行分库**,比如把用户ID mod 10,余数为0的记录放到第一个库,余数为1的放到第二个库,以此类推。 这两种分法,各自存在优缺点,如下表所示: ![](https://static001.geekbang.org/resource/image/3b/81/3bf9fb5fb9e1569cf4478c493c02f081.jpg) 在实践中,为了运维方便,选择ID取模进行分库的做法比较多。同时为了数据迁移方便,一般分库的数量是按照倍数增加的,比如说,一开始是4个库,二次分裂为8个,再分成16个。这样对于某个库的数据,在分裂的时候,一半数据会移到新库,剩余的可以不用动。与此相反,如果我们每次只增加一个库,所有记录都要按照新的模数做调整。 在这个项目中,我们结合订单数据的实际情况,最后采用的是**取模**的方式来拆分记录。 **补充说明:**按照取模进行分库,每个库记录数一般比较均匀,但也有些数据库,存在超级ID,这些ID的记录远远超过其他ID。比如在广告场景下,某个大广告主的广告数可能占很大比例。如果按照广告主ID取模进行分库,某些库的记录数会特别多,对于这些超级ID,需要提供单独库来存储记录。 ### 分几个库? 现在,我们确定了记录要怎么分,但具体要分成几个数据库呢? 分库数量,首先和**单库能处理的记录数**有关。一般来说,MySQL单库超过了5000万条记录,Oracle单库超过了1亿条记录,DB的压力就很大(当然这也和字段数量、字段长度和查询模式有关系)。 在满足前面记录数量限制的前提下,如果分库的数量太少,我们达不到分散存储和减轻DB性能压力的目的;如果分库的数量太多,好处是单库访问性能好,但对于跨多个库的访问,应用程序需要同时访问多个库,如果我们并发地访问所有数据库,就意味着要消耗更多的线程资源;如果是串行的访问模式,执行的时间会大大地增加。 另外,分库数量还直接影响了**硬件的投入**,多一个库,就意味着要多投入硬件设备。所以,具体分多少个库,需要做一个综合评估,一般初次分库,我建议你分成4~8个库。在项目中,我们拆分为了6个数据库,这样可以满足较长一段时间的订单业务需求。 ## 分库带来的问题 不过水平分库解决了单个数据库容量和性能瓶颈的同时,也给我们带来了一系列新的问题,包括数据库路由、分页以及字段映射的问题。 ### 分库路由 分库从某种意义上来说,意味着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之间的记录。 你可以看到,在分库情况下,对于每个数据库,我们要取更多的记录,并且汇总后,还要在应用里做二次排序,越是靠后的分页,系统要耗费更多的内存和执行时间。而在不分库的情况下,无论取哪一页,我们只要从单个DB里取100条记录即可,也无需在应用内部做二次排序,非常简单。 **那么,我们如何解决分库情况下的分页问题呢?**这需要具体情况具体分析: * 如果是为前台应用提供分页,我们可以限定用户只能看到前面n页(这个限制在业务上也是合理的,一般看后面的分页意义不大,如果一定要看,可以要求用户缩小范围重新查询); * 如果是后台批处理任务要求分批获取数据,我们可以加大分页的大小,比如设定每次获取5000条记录,这样可以有效减少分页的访问次数; * 分库设计时,一般还有配套的大数据平台负责汇总所有分库的记录,所以有些分页查询,我们可以考虑走大数据平台。 ### 分库字段映射 分库字段只有一个,比如这里,我们用的是用户ID,如果给定用户ID,这个查询会落到具体的某个库。但我们知道,在订单服务里,根据**订单ID**查询的场景也很多见,不过由于订单ID不是分库字段,如果不对它做特殊处理,系统会盲目查询所有分库,从而带来不必要的资源开销。 所以,这里我们**为订单ID和用户ID创建映射,保存在Lookup表里**,我们就可以根据订单ID,找到相应的用户ID,从而实现单库定位。 Lookup表的记录数和订单库记录总数相等,但它只有2个字段,所以存储和查询性能都不是问题,这个表在单独的数据库里存放。在实际使用时,我们可以通过**分布式缓存**,来优化Lookup表的查询性能。此外,对于新增的订单,除了写订单表,我们同时还要写Lookup表。 ## 整体架构 通过以上分析,最终的1号店订单水平分库的总体技术架构如下图所示: ![](https://static001.geekbang.org/resource/image/3a/9c/3ae46ab0d2d5430f03b436c87247d59c.jpg) * **上层应用**通过订单服务访问数据库; * **分库代理**实现了分库相关的功能,包括聚合运算、订单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多个依赖订单服务的应用,通过简单的重启就完成了系统的升级,中间没有出现一例较大的问题。 ## 项目总结 1号店在完成订单水平分库的同时,也实现了去Oracle,设备从小型机换成了X86服务器,我们通过水平分库和去Oracle,不但支持了订单量的未来增长,并且总体成本也大幅下降。 不过由于去Oracle和订单分库一起实施,带来了双重的性能影响,我们花了很大精力做性能测试。为了模拟真实的线上场景,我们通过**TCPCopy**,把线上实际的查询流量引到测试环境,先后经过13轮的性能测试,最终6个MySQL库相对一个Oracle,在当时的数据量下,SQL执行时间基本持平。这样,我们**在性能不降低的情况下,通过水平分库优化了架构,实现了订单处理能力的水平扩展。** 1号店最终是根据用户ID后三位取模进行分库,初始分成了6个库,理论上可以支持多达768个库。同时我们还改造了订单ID的生成规则,使其包括用户ID后三位,这样新订单ID本身就包含了库定位所需信息,无需走Lookup映射机制。随着老订单归档到历史库,在前面给出的架构中,Lookup表相关的部分就可以逐渐废弃了。 如果要扩充数据库的数量,从6个升到12个,我们可以分三步走: 1. 增加6个MySQL实例,把现有6个库的数据同步到新的库,比如说,0号库同步到6号库,1号库同步到7号库等等; 2. 在配置文件里把分库的取模从6变成12; 3. 通过数据库脚本,每个库删掉一半数据,比如对于0号库,删掉用户ID%12=6的记录,对于6号库,删掉用户ID%12=0的记录。 你可以看到,通过这样的分库方式,整个数据库扩展是非常容易的,不涉及复杂的数据跨库迁移工作。 订单的水平分库是一项系统性工作,需要大胆设计,谨慎实施。**你需要把握住这几个要点:** * 首先,你需要在分库策略的指导下,结合实际情况,在每个方面做出最合适的选择; * 其次,对于特殊场景,如分页查询,你需要具体问题具体解决; * 最后,你要总体规划,控制好落地步骤,包括对系统改造、性能测试、数据迁移、上线实施等各个环节做好衔接,保证业务不出问题。 ## 总结 今天我和你分享了1号店订单水平分库的实际案例,并给出了具体的做法和原因,相信你已经掌握了如何通过对数据库的水平拆分,来保证系统的高性能和可伸缩。 **水平分库是针对有状态的存储节点进行水平扩展**,相对于无状态的节点,系统改造的复杂性比较高,要考虑的点也比较多。通过今天的分享,希望你以后在设计一个复杂方案时,能够更全面地思考相关的细节,提升架构设计能力。 **最后,给你留一道思考题**:你公司的数据库有什么瓶颈吗,你计划对它做什么样的改造呢? 欢迎在留言区和我互动,我会第一时间给你反馈。如果这节课对你有帮助,也欢迎你把它分享给你的朋友。感谢阅读,我们下期再见。