You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

186 lines
18 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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页数据则需要从每个库里取前1000100\*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号店订单水平分库的实际案例并给出了具体的做法和原因相信你已经掌握了如何通过对数据库的水平拆分来保证系统的高性能和可伸缩。
**水平分库是针对有状态的存储节点进行水平扩展**,相对于无状态的节点,系统改造的复杂性比较高,要考虑的点也比较多。通过今天的分享,希望你以后在设计一个复杂方案时,能够更全面地思考相关的细节,提升架构设计能力。
**最后,给你留一道思考题**:你公司的数据库有什么瓶颈吗,你计划对它做什么样的改造呢?
欢迎在留言区和我互动,我会第一时间给你反馈。如果这节课对你有帮助,也欢迎你把它分享给你的朋友。感谢阅读,我们下期再见。