关注公众号【1024个为什么】,及时接收最新推送文章!
|| 背景
| 表背景
阿里云 MySQL 5.7
trade表,数据量500W,占磁盘空间12G多,41列,平均每条数据2.5K左右
主键是orderId(19位,趋势递增),
status,取值范围 0-7
type,取值范围0-10
business_id,取值范围10个值左右
create_time, update_time 都有索引
| 业务背景
trade表要支撑两种业务场景:
一种是常规的订单交易流程,基本都是按orderId检索,不存在效率问题,可忽略。
另一种是管理后台(查询兼统计功能),很多检索条件,其中就包含很多像 status、type这种区分度很低、建索引也不起任何作用的检索条件,页面还带有分页功能,只要检索条件带有区分度低的条件,count语句就等同于全表扫描,耗时都是分钟级别。
目标:在不改变现有业务诉求的前提下,对管理后台进行优化改造,达到秒级响应的效果。
|| 方案调研
上述场景很明显已经不是MySQL擅长的领域了,所以要换一个角度来解决问题。
Elasticsearch(ES)就很适合这种场景(不用分词技术有点大材小用了)。损失一些写性能、以空间换时间(同样的数据放在ES中占用的磁盘空间会翻很多倍)来提升读性能。
经过测试,上面区分度低的查询场景,响应都在200ms以内。
所以,大方向是常规业务流程还走MySQL,后台查询走ES。
接下来讨论的方案就基于这个方向,而这个方向最大的难题是,MySQL和ES的数据如何同步。
| 方案一,同步双写
写库的地方都写一次ES。
tradeDao.insert(trade);
esClient.save(trade);
return;
优点:易于理解,实现成本低,只要有插入/修改的地方,同步写入ES即可,实时性高,数据可以做到强一致(同一事务)
缺点:会增加接口的耗时,ES写入失败时难处理,回滚会影响正常的业务流程,不回滚会造成数据不一致。与业务耦合严重,扩展成本高,后期有新操作trade表的入口都要同步操作ES
| 方案二,异步双写
新起一个线程,执行写ES的操作。
tradeDao.insert(trade);
ThreadPool.getExecutor().execute(() -> {
esClient.save(trade);
});
return;
优点:即不增加现有接口耗时,又可以保证实时性
缺点:插库成功,写ES失败,无法再通过事务回滚+重试保证数据一致性,写入ES失败造成数据丢失,想要再次触发写入ES,成本会很高
| 方案三,异步MQ
通过发、收MQ消息,实现数据同步。
tradeDao.insert(trade);
MqUtil.send(trade);
return;
MqUtil.receive(trade){
Trade trade = tradeDao.selectByPk(trade.getOrderId());
esClient.save(trade);
}
优点:可以弥补方案二的缺点,写ES失败,重发MQ就可以保证数据最终一致
缺点:时效性相比前两个方案会差一些,还有乱序、消息丢失的风险;引入了新的服务组件,使得系统架构变的复杂,涉及服务越多,出问题的概率就越高
这三个方案存在一个共同的问题,和业务代码耦合严重。 后期有新写库的场景都要考虑重写ES; trade表列如果有调整,所有写ES的地方都要做出相应的修改。 |
| 方案四,定时任务
通过定时任务实现数据同步。
synJob{
Trade trade = tradeDao.selectByPk(trade.getOrderId());
esClient.save(trade);
}
优点:与业务解耦,实现成本也不高,扩展成本低
缺点:业务变更频率不固定导致任务的频率不好控制,频率高了浪费资源,频率低了时效性差或者单次同步的记录太多。
| 方案五,基于binlog
使用第三方的服务插件,监听binlog,实现数据同步。写入ES的动作可以放在server,也可以放在类似Job的共用服务中。
优点:与业务解耦,可以支撑高并发,扩展成本低
缺点:实现成本高,架构变得更加复杂
最后选择的是方案五,主要原因是团队已经搭建了DTS,我只需配置要监听的表,订阅对应的消息实现同步逻辑即可。由于省去了最初的搭建成本,所以实现成本相对低一些。 如果对实时性要求不太高,感觉定时任务是一个不错的选择。选择哪个方案还是要看具体的业务场景和团队的基础架构,能满足业务需求的前提下,选择一个综合成本低的。 |
|| 方案实施
上面的方案主要是针对增量(本文所有增量均包含新增和修改)数据的,要实现历史数据和增量数据都能准确的同步到ES,还有很多问题需要解决。
| 如何在最短时间内同步完历史数据?
这个问题主要考虑查询MySQL和写入ES的速度。
limit 肯定是不能用的,开头提到了主键ID是趋势递增的,可以按主键ID分页查,每次查询记录最后一条记录的主键ID,作为下次查询的起始条件。经过测试(500、1000、1500、2000)得出,每次1000条,查询+写入的耗时是最短的。
SELECT * FROM trade WHERE orderId > #{lastPkId} limit 1000;
| 数据一直在增加,同步历史数据的结束条件是什么?
上面的1000条也不是随意定的,同步1000条的耗时大概是200ms,如果业务量大,200ms内新增数据一直>1000条,那么上面的任务就永远无法结束了。这个1000条对我们的业务量来说是绰绰有余,相信对绝大多数公司来说都是够用的。
假设业务量就是大,调成每次处理2000条又不是最快的怎么办?
可以在开启binlog同步通道(见下图)时记录下同步的第一条主键ID sysPkId,每次查询前比较一下 lastPkId < sysPkId,直到条件不成立,就代表历史数据同步结束。
| 已同步的数据,又发生变更怎么办?
这一点需要通过上线、任务开启的顺序来保证。
要先开启binlog同步,这样就可以保证增量数据通过这个通道同步到ES。
再开启同步历史数据的任务Job1,这个问题就得到解决了。
| 取出了一批历史数据,这批数据未全部写入ES前,其中某条数据变更了怎么办?
这种情况会造成两边数据不一致,ES中本来通过binlog同步过来的新数据被任务里的旧数据覆盖掉。这里没必要每条记录都做比较,毕竟发生概率很低,新起一个任务Job2,保证最近某个时间段的(必须大于同步历史数据的耗时)数据一致性,就可以弥补这个问题。
| DTS通道问题,导致两边数据不一致了,如何发现、修复?
还是借助Job2,查询最近1天(业务量大也可以按小时)的增量数据,取出主键ID,再去ES查询,两边结果做比对,不一致的以MySQL为准直接覆盖ES(或者也可以加一个时间对比逻辑,以最后更新的为准),ES缺少的直接同步过去。既可以发现差异,又可以修复差异。
| MQ服务问题,导致乱序,如何保证新数据不被旧数据覆盖?
写入ES前,先从ES查询,比较最后更新时间,以最后更新的为准。
orderId维度,变更不会这么频繁,几乎不会出现这种情况。
先开启1,再开启2,等2结束了,再开启3,最后1、3并存,保证数据的一致性。
Job1要保留,万一trade表加了一列,ES也要加列,还需要依靠Job1再同步一次历史数据,再来一遍上面的流程。
|| 总结
1、让技术用在它擅长的领域
2、选择最适合自己业务场景的方案