数据的写入和查询分散到多个表,好处非常明显:
高并发性能强,无集中写入瓶颈。可控性强。同时对现有系统影响范围大:
订单号需要全局分发,当前订单号的生成规则已不满足分布式需求。订单服务所有子系统都要改造。需要停机发布。冷热数据分离根据订单冷热数据特点以及与相关的订单系统分组情况,可以把冷数据和热数据单独存储。这种方案有以下优势:

同时有存在一些缺点:
历史库单表容量过大。高并发情况下,存在单点瓶颈。活动库数据不可控。冷热数据分离+冷数据水平分表通过方案2+方案3,它既能解决单表容量过大问题又能满足当前业务的发展。首先数据分离降低活动库的单表数据容量,依据当前的系统并发量活动库单表容量能够保持在合理的范围内。同时冷数据所在库进行水平分表操作,能够保证单表容量在可控范围。

水平分表后,非分片规则的查询变的复杂。避免遍历所有子表的方式就是存储全局的分片键与查询条件的关系。全局的映射关系可以通过以下方式实现:
增加全局表,但是又引入单表过大问题。搜索引擎,查询效率高,水平可扩展。 公司基础架构组已提供稳定高效的搜索服务(Elasticsearch),可直接对接解决查询问题。数据存储架构确定使用方案4后再次复盘所有业务需求,对于少数直接依赖订单库的系统,决定暂时提供一个全量的数据仓库供业务方使用,订单内部服务均不使用此数据仓库。最终订单数据存储分为以下4个部分:
活动库存储实时产生的数据。历史库存储2个月前的数据。全量仓库存储所有数据。ES存储历史库中订单索引数据。
当前应用服务与数据存储的架构如下图所示:
经过这次改造后变为下图所示结构:
方案执行
执行过程中,主要精力放在下面几个方面:
冷数据迁移及水平切分数据迁移主要通过定时任务把冷数据搬到历史库,主要遵循以下规则:
根据冷数据的定义按顺序迁移。例如2个月前的订单属于冷数据,则根据创建订单时间排序,创建订单时间小的先迁移。迁移过程中出现异常则终止迁移并发出异常通知。迁移过程不负责创建冷数据的索引数据。迁移过程不负责删除已迁移的订单。 表水平切分常见方式有时间范围,ID 范围,HASH等方式,我们采用ID 范围作为切分规则,基于以下几点考虑:单表数据可控。冷数据没有并发写入压力。易维护,新增子表时已切分数据无改动。具体规则如下:
订单号范围切分,例如1~10000在tb_order_0001表,10001~20000在tb_order_0002表。所有订单子表必须与主表是同一个切分逻辑。例如订单号123主表数据存储在tb_order_0001表,那么订单商品详情数据必须在tb_order_detail_0001表。创建冷数据索引历史库的索引创建通过以下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倍。当前方案也存在一些问题:全量仓库容量问题,此全量仓库主要目的是减少直接依赖交易库的外围系统的改动。接下来需要与外围系统制定更合理的获取订单全量数据的方式,去除全量仓库的依赖。
作者:何江华
来源-微信公众号:沪江技术
出处:https://mp.weixin.qq.com/s/TGJiwqd4wcQ4KWsbJLLvfA