正文
文: 何江华
本文原创,转载请注明作者及出处
背景
随着沪江产品线的不断丰富,背后由交易产生的数据也已每年数倍的速度在增长。当前整个交易系统已经完成领域拆分,包括订单域,商品域,营销域,清结算域等,其中订单域的数据增长量最为突出,它提供的服务有创建订单,改单,查单,配送,售后等。根据当前增长率及对未来预估,数据库单表存储容量将会在半年内超过合理阀值。单表容量过大对于插入和查询操作都会消耗大量数据库资源(CPU,IO等),进而对订单域各种服务产生影响。为了避免因单表容量过大带来的整个交易服务的系统性风险,我们提出订单分表项目。本文就是介绍订单系统分表项目的实践过程。
现状分析
主要从以下几个维度描述订单系统现状:
系统分类
数据分类
根据订单的生成和修改频次,我们把订单数据分为两类:
系统与数据关系
外围系统主要通过订单服务访问订单数据,根据访问的数据类型对订单子系统进行分类:
并发量
这里重点分析访问热数据的订单子系统,根据历史访问量及对未来3年的预估(每年3倍),当前系统可承受的并发量在单表合理阀值内完全能够满足业务的高速发展。
方案选型
解决单表容量过大问题,有非常多的成熟方案。我们比较了以下几种方案:
MySQL表分区技术
普通表是一个逻辑表的所有数据存在相同的文件。分区表同样是一个独立的逻辑表,但是底层由多个物理子表组成。分区类似粗粒度的索引,减少访问的数据集。使用分区表要从以下几个方面考虑:
-
分区规则的选择,MySQL 提供范围,列表,HASH 等方式。不同的规则会影响数据的查询效率。
-
分区的维护,对于增加和删除分区比较容易,但是对于重组分区或 ALTER 语句复杂一些:需要先创建临时分区,然后复制数据,最后删除分区。这期间需要停止应用服务。
-
可优化性弱,所有的优化都只能在数据库层面进行,可控性不高。
订单系统存在多维度的查询需求,例如用户,订单号,商品,商品类型,机构,来源,平台,时间,状态,金额等,因此分区表对订单查询服务不会带来明显的提升。
水平分表
数据的写入和查询分散到多个表,好处非常明显:
同时对现有系统影响范围大:
冷热数据分离
根据订单冷热数据特点以及与相关的订单系统分组情况,可以把冷数据和热数据单独存储。这种方案有以下优势:
-
影响范围缩小,工作量减少,仅影响订单查询系统。
-
冷数据库数量可控。
-
无停机要求。
同时有存在一些缺点:
-
历史库单表容量过大。
-
高并发情况下,存在单点瓶颈。
-
活动库数据不可控。
冷热数据分离+冷数据水平分表
通过方案2+方案3,它既能解决单表容量过大问题又能满足当前业务的发展。首先数据分离降低活动库的单表数据容量,依据当前的系统并发量活动库单表容量能够保持在合理的范围内。同时冷数据所在库进行水平分表操作,能够保证单表容量在可控范围。
查询
水平分表后,非分片规则的查询变的复杂。避免遍历所有子表的方式就是存储全局的分片键与查询条件的关系。全局的映射关系可以通过以下方式实现:
数据存储架构
确定使用方案4后再次复盘所有业务需求,对于少数直接依赖订单库的系统,决定暂时提供一个全量的数据仓库供业务方使用,订单内部服务均不使用此数据仓库。最终订单数据存储分为以下4个部分:
-
活动库存储实时产生的数据。
-
历史库存储2个月前的数据。
-
全量仓库存储所有数据。
-
ES存储历史库中订单索引数据。
当前应用服务与数据存储的架构如下图所示:
经过这次改造后变为下图所示结构:
方案执行
执行过程中,主要精力放在下面几个方面:
冷数据迁移及水平切分
数据迁移主要通过定时任务把冷数据搬到历史库,主要遵循以下规则:
具体规则如下:
创建冷数据索引
历史库的索引创建通过以下2种方式创建:
发送消息成功但是订单系统无法得知搜索引擎创建结果,因此部署定时巡检任务检查历史库订单是否已成功创建索引。
由于在第1步冷数据迁移过程中规定必须按顺序迁移订单,因此成功创建索引的订单号可以作为订单迁移的分界点,具体业务逻辑如下:
-
小于此订单号的订单已成功迁移并创建了索引。
-
大于此订单号的订单未迁移或者未创建索引。
因此在定时巡检任务检查过程中,把已成功创建索引的最大订单号存储到订单分界表和缓存中,便于其它订单业务逻辑使用(例如查询服务,定时删除任务等)。
订单全量库同步
引用阿里开源项目 Otter 作为准实时同步工具,它基于 MySql binlog 实现,稳定性高,同时支持高可用部署。为了避免 DELETE 语句同步到目标库,修改Otter 中部分源码实现 DELETE 语句过滤。具体原理参考 Otter 官网介绍,我们采用的是单机房部署模式。同时部署定时巡检任务,核查实时同步结果。
多数据源一致性
针对冷数据迁移过程中可能出现的丢订单或者数据不一致问题,通过以下措施解决:
针对全量仓库的一致性保证,除了 Otter 本身基于 binlog 的一套保障机制外,我们增加定时巡检任务检查1个小时内全量仓库与活动库的数据是否一致。
订单查询
订单查询使用2个库:活动库和历史库。具体执行过程如下:
-
根据分片规则查询即订单号
-
根据非分片规则查询 在上面的两个流程中主要通过分界点订单号保证查询不会出现重复数据,具体规则如下:
-
活动库只查找:x>n 的订单。
-
历史库只查询:x<=n 的订单。
订单查询服务包含两种分页查询方式:
多数据源分页问题本质是多个有序集合的分页问题
。对于第1种查询方式,每次查询都会知道上一次的起始或者结束位置,因此只需在查询条件中添加起始或者结束位置可以定位到哪几个数据源。对于第2种查询方式,需要通过遍历数据源获取数据总量,计算总页码数,并且记录每个数据源满足条件的数据总量,然后根据当前请求页码和每页个数判断数据落在哪几个数据源。
实现过程中,我们遇到的问题是搜索服务存在深度分页限制(最多返回1000条记录),例如:每页20条记录,则只能翻到第50页。而实际情况下后台某些运营查询业务超过这种限制。因此我们采取修改查询条件的策略实现深度分页,下面简化描述深度分页实现:假设搜索服务中有10条记录,查询接口通过offset 和 limit 实现分页,具体如下图
如果分页没有限制的情况下,每页2条数据,则查询第1,2,3页数据语法如下: 第1页,offset=0,limit=2,返回100,102 第2页,offset=2,limit=2,返回110,200 第3页,offset=4,limit=2,返回201,300 现在offset和limit添加如下限制:offset<=1,limit<=2,则实际分页中这两个变量都有可能超过限制,解决思路如下:
-
先解决offset问题,如果offset>maxOffset,则通过修改查询条件使offset=0,例如查询第2页数据(offset=2,limit=2),查询条件中添加orderId>102,就可以把offset修改为0。
-
Offset满足条件后,再处理limit问题。如果limit>maxLimit,则通过修改查询条件循环查询使每次查询的limit<=maxLimit。 示例代码如下:
public static void paging(int offset, int limit) {
List<Integer> result = null;
if (offset <= MAX_OFFSET) {
result = pagingForLimit(offset, limit, null);
} else if (offset > MAX_OFFSET) {
int skipSize = offset; //需跳跃个数
Integer currentItem = skip(skipSize);
result = pagingForLimit(0, limit, currentItem);
}
if (result != null) {
System.out.println(result.toString());
} else {
System.out.println("无值");
}
}
如果 offset 大于限制数(offset>MAX_OFFSET),则需要找到前一个满足条件的订单号,从而修改查询条件使 offset=0。代码如下:
else if (offset > MAX_OFFSET) {
int skipSize = offset; //需跳跃个数
Integer currentItem = skip(skipSize);
result = pagingForLimit(0, limit, currentItem);
}
查找前一个订单号的主要方法是 skip,找到订单号后就可以修改 offset=0 解决offset 限制。下面列出 skip 方法部分代码,如下所示。
/**
* @param size 表示前一个订单号的位置
* @return 返回前一个订单号
*/
private static Integer skip(int size) {
int maxSkipStep = MAX_OFFSET + MAX_LIMIT;//最大跳跃步长
if (size <= maxSkipStep) {
return get(size, null);
} else {
Integer preInteger = null;
while (size > maxSkipStep) {
List<Integer> tmp = query(MAX_OFFSET, MAX_LIMIT, preInteger);
preInteger = tmp.get(tmp.size() - 1);
size -= maxSkipStep;
}
return get(size, preInteger);
}
}
主要利用搜索服务最大查询个数跳跃查询,减少查找次数。 解决 offset 的限制后,开始着手处理 limit 的限制。同样通过修改前一个订单号多次查询获取当前页的所有数据。
/**
* 带限制的分页
* @param offset 请求偏移量
* @param limit 请求个数
* @param currentItem 前一个订单号
* @return
*/
private static List<Integer> pagingForLimit(int offset, int limit, Integer currentItem) {
if (limit <= MAX_LIMIT) {
return query(offset, limit, currentItem);
} else {
List<Integer> result = query(offset, MAX_LIMIT, currentItem);
limit -= MAX_LIMIT;
while (limit > MAX_LIMIT) {
Integer pre = null;
if (result != null) {
pre = result.get(result.size() - 1);
}
result.addAll(query(0, MAX_LIMIT, pre));
limit -= MAX_LIMIT;
}
if (limit > 0) {
Integer preInteger = result.get(result.size() - 1);
result.addAll(query(0, limit, preInteger));
}
return result;
}
}
上线发布
为了保证平稳上线,整个项目分6个步骤,4次发布,具体执行计划如下:
第1批发布:
-
冷热数据分离订单迁移任务。
-
Otter部署,全量库同步。
第2批发布:
-
增量冷数据迁移定时任务。
-
接入搜索服务,创建订单索引数据。
第3批发布:
第4批发布:
-
改造订单前台查询服务。
-
启动定时任务删除热库的冷数据。
总结
通过这次项目,订单单表容量得到有效控制,用户端订单查询 QPS 提升2倍,运营端历史订单查询提升4倍。当前方案也存在一些问题:全量仓库容量问题,此全量仓库主要目的是减少直接依赖交易库的外围系统的改动。接下来需要与外围系统制定更合理的获取订单全量数据的方式,去除全量仓库的依赖。
推荐阅读
沪江ABTest测试平台实践
解构:Google打电话幕后的人机对话技术
Hello World, AndroidX
理解 MySQL 一致性非锁定读原理
你真的懂 A/B 测试吗?(上)