likes
comments
collection
share

设计方案-大数据量查询接口优化

作者站长头像
站长
· 阅读数 18

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情

前言

最近在做看板相关的项目,主要在做数据清洗和数据运算这块的东西,逻辑链路不长,就是各种奇形怪状的运算规则太折磨人了,也没法抽象出一些运算模块或者通用的工具类。本来以为一直都是搬砖来着,没想到遇到一个有意思的问题,网上百度了半天也没有合适的法子,所以自己就整了个野路子,算是一点不成熟的想法,但是夹杂了一些知识点,个人感觉还是有点意思,所以放了上来,希望对同样受难的读者们有所帮助。

需求及现状说明

需求简述

设计方案-大数据量查询接口优化

如上图,一共四个查询条件,高低版本、物料编码、波动风险,只有波动风险是固定的字典值,即高中低,其它三个都是从数据源中获取。

数据A来源于外系统Oracle多版本数据的去重。数据B来源于外系统同事给的一条查询SQL,入参有高低版本的快照时间。数据C需要根据数据D的数据通过公式得到结果。数据D需要从外系统获取每天的数据,计算低版本对应快照时间的未来9周+10月(约为1年)的数据,不同类型(例如低版本需求)有不同的计算公式。

在以上基础上需要根据查询条件分页。

当前看板列表是以物料为维度进行汇总,除此维度还需要供应商、采购员、大类、小类进行汇总。开发时间平摊下来一个人约1天处理这个列表,算上5个维度冗余的时间,大致会有两天半的时候做整个看板的数据库设计、方案设计以及代码实现。

问题描述

开发时间短

时间短这个不必多说,在对接了ERP提供的数据后,我突然发现大数据量成了瓶颈,在我完成最终的初版逻辑后运行,发现查询时间达到了半小时……然后我尝试放在redis里,发现最后筛选出来的数据有12mb,redis连接超时,大key问题…...麻了,问题很多,关键这是个查询接口,我只有两天不到的时间(不要问我为啥时间这么紧,问就是敏捷开发,"简单")

无法提前计算

所有性能问题的来源就在于无法提前计算,这样一个查询接口我需要拉取外系统、计算未来一年的数据,最重要的一个原因就是强关联的高低版本号是页面动态选择的。一年按周排除节假日会有约50个版本号,两两配对会有1225种情况。假设我提前算一年的数据,也就是每增加一个版本号,需要多算49版数据。列表有五大维度,物料、供应商、大类、小类和采购员,需要根据维度汇总数据,每个版本的物料数量差别不大,假设都是4000颗,也就是说提前计算一年的版本号的数据,会有1225x4000,这还是一个维度,并且数据量膨胀非常快

大量字段需要SQL读取或者运算

计算数据BCD,共需要如下4条SQL填充字段或者提供数据运算

  1. 根据数据A中的版本号、开始时间、结束时间获取一段时间内该版本号对应的需求数量、需求日期
  2. 根据数据A中的组织、供应商获取库存
  3. 如果当前是物料维度,则根据数据A中的物料ID、低版本快照时间、高版本快照时间获取一段时间的收货量
  4. 如果当前是物料维度,则根据数据A中的物料ID、组织ID获取当前最大比例的供应商

通过我简单的描述,可能还没啥感觉。但问题就来源于这里,这些SQL本身就带有复杂的计算,一条SQL最少运行个几秒。关键是不能批量查询,也不能提前查询,因为还是上面的原因,数据量大,不能提前存。并且每运算一条数据都要经过上面4个SQL,处理时间累加起来就长了。

方案设计

分库分表

一开始有这个想法,后来粗略估了下数据量,就放弃了。一是团队内部之前玩的MySQL双主,现在迁移TiDB分布式数据库,都没有分库分表的场景。没有硬件基础,也没有相关开发经验,无论是分库还是分表,短短两天我肯定是整不出来了。

胡思乱想

晚上下班后短暂头脑风暴了一下,如果非要存全量的话,首要就是压缩数据。但是压缩还是有极限的,可以压缩后面动态时间区间的数据,但是数据还是需要一行行展示,毕竟是个列表。所以MySQL肯定还是扛不住的,这时候就要选用别的来存储了,比如说ES。存储问题解决了,就要考虑查询问题,查询可以参考数据库索引以及ES两阶段查询(Query-Fetch),先造一个类似于普通索引的数据,这个数据只包含列表查询的条件以及定位到每行数据的主键。一阶段分页或者条件筛选就从这个索引数据查,二阶段类似回表,根据一阶段返回的主键去查询全量数据。

妥协方案

根据开发时间以及和PM商量,用户能够接受和ERP类似的延迟查询。最终定下来的方案就是用户查询触发异步计算,立即返回前端弹窗提示用户正在计算中。后台异步计算后将结果存放至Redis,运算时用户再次重复相同查询,从Redis获取数据,如果能获取到说明已运算完毕,返回全量数据再做分页和条件查询。如果获取不到,说明正在计算,返回用户提示正在计算。

解决方案

获取版本基础数据

设计方案-大数据量查询接口优化

该数据也就是列表的数据A部分是通过ERP提供的SQL,然后我直连Oracle从库根据版本号获取数据,这里我是选择了建一个新表通过定时任务转储。

为什么再建表存储,直连不香吗?

ERP提供的SQL有80行,包含子查询、函数、连接等复杂操作,一个版本总计五千多行数据需要8s,将无用字段去除后,同样的数据放到MySQL单表查询仅需0.9s。

接口查询逻辑

设计方案-大数据量查询接口优化

伪代码

//获取全量数据分片总数
RBucket<Integer> countInfo = redissonClient.getBucket("dashboard:count:" + lowVer + highVer);
Integer count = countInfo.get();
if (count != null) {
    log.info("缓存存在已计算数据,开始比对分片数,版本号={},当前分片数为={}", lowVer + highVer, count);
     switch (count) {
        case -1:
            throw new PtmException(lowVer + highVer + "版本运算异常,请联系相关负责人处理");
        case 0:
            throw new PtmException("200", "当前版本数据运算中,请于二十分钟后在当前页面重新查询数据");
        default:
            RKeys keys = redissonClient.getKeys();
            //一定要给count设置,默认10太小了
            Stream<String> keysStream = keys.getKeysStreamByPattern(
"dashboard:" + lowVer + highVer + "-*", 10000);
            String[] keyStr = keysStream.toArray(String[]::new);
            if (count != keyStr.length) {
                //这里20是我测试多个版本的平均运算时间    
                throw new PtmException("当前数据运算中,预计" + (20 / keyStr.length) + "分钟后可查看");
            } else {
                log.info("开始从缓存中获取数据,版本号={}", lowVer + highVer);
                RBuckets buckets = redissonClient.getBuckets();
                //获取分片数据后汇总得到全量数据,抛出交由外部方法做筛选和分页
                Map<String, List<MaterialDataDTO>> resMap = buckets.get(keyStr);
                List<MaterialDataDTO> allData = new ArrayList<>();
                resMap.forEach((queue, list) -> {
                    allData.addAll(list);
                });
                return allData;
            }
       }
} else {
    CompletableFuture.runAsync(() -> {
        countInfo.set(0, 1, TimeUnit.HOURS);
        List<MaterialDataDTO> dataList = computeAsync(highVer, lowVer,dataDimension);
        //切分数据,按照1000条进行拆分,并存储分片总数用于判断是否运算完毕
        List<List<MaterialDataDTO>> partition = Lists.partition(dataList, 1000);
        
        countInfo.set(partition.size(), 5, TimeUnit.DAYS);
        //存储数据,注意这里所有涉及的类都需要implements Serializable
        for (int i = 0; i < partition.size(); i++) {
            RBucket<List<MaterialDataDTO>> bucket =
                    redissonClient.getBucket("dashboard:" + lowVer + highVer + "-" + i);
                        
            //用默认编码方式序列化比String编码少了一半大小
            bucket.set(new ArrayList<>(partition.get(i)), 5, TimeUnit.DAYS);
        }

    }).exceptionally(e -> {
        log.error("异步计算列表数据出现异常", e);
        countInfo.set(-1, 1, TimeUnit.HOURS);
        return null;
    });
    //异步运算,直接返回提示
    throw new PtmException("200", "当前版本数据运算中,请于二十分钟后在当前页面重新查询数据");
}

优化点

Redisson优化

  1. 修改命令超时时间以及连接超时时间,默认只有3s、

命令超时时间以及连接超时时间,默认3s太短了,我设置的是40s,超过这个时间的我觉得肯定需要去主动优化了。

  1. 使用合适的序列化编码方式

同时序列化编码方式最好选个合适的,我原本使用的String编码,当时考虑是在redis可视化工具上有更好的可读性。后来发现了坑,就换了编码方式,结果数据压缩了一半,String编码一个全量查询结果有12MB,换成默认的Marshalling编码缩减到7MB......这还是没用LZ4这种压缩用编码的效果,没想到竟然会有如此大的差距,震惊。

设计方案-大数据量查询接口优化

查询外部接口优化

  1. 多线程查询--详见上篇多线程骚操作,38收藏干货文章
  2. 缓存查询结果--如图做缓存,加速外系统查询

设计方案-大数据量查询接口优化

切分数据结果集

考虑到查询是高频操作,因此肯定要切分结果集,避免单个结果集太大,压缩后平均会有7MB的总数据,切分后约为1MB一条,调用MGET命令去批量查询数据。

为避免用户短时间重复查询同一数据,增加分片数Count,标识当前运算状态,-1是运算异常,0是正在处理,其他则是正常运算中

遇到的坑

序列化问题

因为需要将数据持久化存储,所以相关类都需要implements Serializable。这里有个隐藏的坑,我用guava的com.google.common.collect.Lists做的集合切割,这里内部使用List.subList方法实现的。问题就是这个方法切割出来的集合只是快照,当对其进行IO流相关操作时报不能序列化,java.io.NotSerializableException: java.util.ArrayList$SubList。解决方法也很简单,换一个实现序列化的类包装一下,比如ArrayList

设计方案-大数据量查询接口优化

Redisson序列化编码配置问题

使用String编码序列化时会出现RBucket转换异常,这里直接使用泛型接收会报String转Integer异常。

Caffeine的put方法

主要是我自己傻了,我不该不看API的,但凡多看一眼,不至于查问题查半小时。我早该想到的,Caffeine底层用的ConcurrentHashmap,为避免并发场景下的二义性,key和value都是不能为null的。

设计方案-大数据量查询接口优化

Redisson的Keys命令实现

为了避免Keys命令阻塞Redis,Redisson的开发团队将Keys的命令实现转为scan,默认count为10。我用的Redis里有几十万Key,一次遍历10个,属实太慢了,这个就根据实际情况调整了,我改成了10000.下面是源码截图。

设计方案-大数据量查询接口优化

写在最后

最近属实坐牢,这篇也算是半夜写完,本来打算这个方案写完当天趁着热乎劲给弄了,结果每天加班,然后回家每天写一两个小时,总是算赶出来了。后续会持续更新,也欢迎大家去看我之前更新的十篇原创文章。

转载自:https://juejin.cn/post/7146058348935249934
评论
请登录