秒级到毫秒级的跨越—一次慢SQL优化历险

一次慢 SQL 优化过程

一、背景

对于公司内部的一个发票管理系统,财务人员经常需要对发票的开票交易进行查询,这里涉及到两张表:发票订单表和发票信息表,我们需要查询订单 ID开票 APP开票主体订单类型支付渠道支付总额支付状态开票用户的 uid开票用户的 showId支付时间开票时间开票的 InvoiceId。其中发票订单表中的数据量将近1个亿

其中支付用户的 showId 需要使用用户的 uid 进行 RPC 调用获取开票时间需要从发票信息表中获取,其他字段信息只需要从发票订单表中获取。

二、优化过程

2.1 原 SQL 存在问题

我们先来看原来的发票订单信息查询代码:

public PageResult<InvoiceTransactionQueryResponse> queryInvoiceTransaction(InvoiceTransactionQueryRequest request) {
    if (StringUtils.isBlank(request.getUid())) {
        String showNo = request.getShowNo();
        if (StringUtils.isNotBlank(showNo)) {
            UserDto userInfo = externalUserService.getUserInfoByShowNo(Long.valueOf(showNo.trim()), APP.BIXIN.getCode());
            if (userInfo == null) {
                throw new UserNotFoundException(InvoiceErrorCode.USER_NOT_EXIST_ERROR, String.format("showNo=%s 用户不存在", showNo));
            }
            request.setUid(String.valueOf(userInfo.getUid()));
        }
    }
    Long queryUid = StringUtils.isNotBlank(request.getUid()) ? Long.parseLong(request.getUid()) : null;
    String invoiceStartTime = request.getInvoiceStartTime();
    String invoiceEndTime = request.getInvoiceEndTime();
    
    // 根据开票时间参数从发票信息表中获取对应invoiceId
    List<String> invoiceNoList = new ArrayList<>();
    if (StringUtils.isNotBlank(invoiceStartTime) || StringUtils.isNotBlank(invoiceEndTime)) {
        invoiceNoList = paymentInvoiceService.getPageInvoiceByCondition(queryUid, invoiceStartTime, invoiceEndTime, request.getPageNo(), request.getPageSize());
    }
    // 查询发票订单表
    PageInfo<PaymentInvoiceOrder> invoiceOrderPageInfo = paymentInvoiceOrderService.getInvoiceTransaction(queryUid, request, invoiceNoList);
    List<PaymentInvoiceOrder> invoiceOrderList = invoiceOrderPageInfo.getList();
    List<InvoiceTransactionQueryResponse> invoiceTransactionQueryResponseList = new ArrayList<>();
    invoiceOrderList.forEach(invoiceOrder -> {
        InvoiceTransactionQueryResponse invoiceTransactionQueryResponse = new InvoiceTransactionQueryResponse();
        Long uid = invoiceOrder.getUid();

        // 用户信息的 RPC 调用
        UserDto userInfo = externalUserService.getUserInfoByUid(uid, APP.BIXIN.getCode());
        invoiceTransactionQueryResponse.setOrderNo(invoiceOrder.getOrderNo());
        invoiceTransactionQueryResponse.setShowNo(null != userInfo ? userInfo.getShowNo().toString() : "");
        invoiceTransactionQueryResponse.setUid(String.valueOf(uid));
        PaymentInvoice paymentInvoice = null;

        // 查询发票具体信息
        String invoiceNo = invoiceOrder.getInvoiceNo();
        if (StringUtils.isNotBlank(invoiceNo)) {
            paymentInvoice = paymentInvoiceService.getInvoice(invoiceNo);
        }
        InvoiceKind invoiceKind = InvoiceKind.getInvoiceKind(invoiceOrder.getOrderType(), invoiceOrder.getTargetCurrency());
        invoiceTransactionQueryResponse.setPayTime(DateUtils.stringFormat(invoiceOrder.getPayTime()));
        invoiceTransactionQueryResponse.setInvoiceApp(invoiceOrder.getInvoiceApp());
        invoiceTransactionQueryResponse.setInvoiceSubject(InvoiceMainEnum.getSubjectByTemplate(invoiceOrder.getInvoiceTemplate(), ""));
        invoiceTransactionQueryResponse.setOrderType(paymentInvoiceOrderService.getQueryOrderType(invoiceKind));
        invoiceTransactionQueryResponse.setPayChannel(StringUtils.isNotBlank(invoiceOrder.getPayChannel()) ? InvoicePayChannel.lookupByName(invoiceOrder.getPayChannel()).getDesc() : "");
        invoiceTransactionQueryResponse.setPayAmount(invoiceOrder.getPayAmount().toPlainString());
        invoiceTransactionQueryResponse.setInvoiceStatus(InvoiceState.getDescByType(invoiceOrder.getInvoiceStatus()));
        invoiceTransactionQueryResponse.setApplyUser(null != paymentInvoice ? paymentInvoice.getApplyUser() : "");
        invoiceTransactionQueryResponse.setInvoiceTime(null != paymentInvoice ? DateUtils.stringFormat(paymentInvoice.getInvoiceDate()) : "");
        invoiceTransactionQueryResponse.setInvoiceNumber(null != paymentInvoice ? paymentInvoice.getInvoiceNumber() : "");
        invoiceTransactionQueryResponseList.add(invoiceTransactionQueryResponse);
    });
    int pages = (int) Math.ceil((invoiceOrderPageInfo.getTotal() + 0.0) / request.getPageSize());

    return PageResult.newPageResult(invoiceTransactionQueryResponseList, pages == request.getPageNo(), invoiceOrderPageInfo.getTotal());
}

我们不难可以看到以下的查询问题:

  • 问题一:在查询发票订单表前,要先根据 web 端开票起始时间参数从发票信息表中查询符合该开票起始时间内的 invoiceId;
  • 问题二:在 for 循环中拼接数据时,进行了一次 RPC 调用通过用户的 uid 查询用户的 showId 和 一次数据库查询操作获取该发票的具体开票时间;

下面,我们对这两个问题着手进行解决。

2.2 在循环中抽取 RPC 调用和数据库查询操作

对于问题二:我们不应该在 for 循环中不断地进行 RPC 调用和数据库查询操作,前者会造成多次的网络调用,频率建立和断开 TCP 连接,后者每次 SQL 查询都会建立一个 SqlSession,创建数据库连接,带来网络开销开销的同时,可能会耗尽连接池的资源,给数据带来压力。

所以,我们的解决办法也十分明显,就是把 for 循环中的 RPC 调用和数据库查询操作提到 for 循环外面,通过批量查询一次把所需要的数据查询出来,代码如下:

public PageResult<InvoiceTransactionQueryResponse> queryInvoiceTransaction(InvoiceTransactionQueryRequest request) {
      
    // 批量查询invoice
    List<String> invoiceList = invoiceOrderList.stream().map(PaymentInvoiceOrder::getInvoiceNo).collect(Collectors.toList());
    Map<String, PaymentInvoice> invoiceMap = paymentInvoiceService.queryInvoiceList(invoiceList).stream().collect(Collectors.toMap(PaymentInvoice::getInvoiceNo, invoice -> invoice));

    // 批量查询uid
    List<Long> uidList = invoiceOrderList.stream().map(PaymentInvoiceOrder::getUid).toList().stream().distinct().toList();
    Map<Long, UserDto> userDtoMap = externalUserService.getUserInfoList(uidList, APP.BIXIN.getCode()).stream().collect(Collectors.toMap(UserDto::getUid, userDto -> userDto));

    List<InvoiceTransactionQueryResponse> invoiceTransactionQueryResponseList = new ArrayList<>();
    invoiceOrderList.forEach(invoiceOrder -> {
        InvoiceTransactionQueryResponse invoiceTransactionQueryResponse = new InvoiceTransactionQueryResponse();
        Long uid = invoiceOrder.getUid();
        // 从Map中获取用户信息
        UserDto userInfo = userDtoMap.get(uid);
        invoiceTransactionQueryResponse.setOrderNo(invoiceOrder.getOrderNo());
        invoiceTransactionQueryResponse.setShowNo(null != userInfo ? userInfo.getShowNo().toString() : "");
        invoiceTransactionQueryResponse.setUid(String.valueOf(uid));
        PaymentInvoice paymentInvoice = null;
        
        // 从Map中获取发票信息
        String invoiceNo = invoiceOrder.getInvoiceNo();
        if (StringUtils.isNotBlank(invoiceNo)) {
            paymentInvoice = invoiceMap.get(invoiceNo);
        }
        //...
    });
    int pages = (int) Math.ceil((invoiceOrderPageInfo.getTotal() + 0.0) / request.getPageSize());

    return PageResult.newPageResult(invoiceTransactionQueryResponseList, pages == request.getPageNo(), invoiceOrderPageInfo.getTotal());
}
  • 批量查询 invoice 和批量查询用户信息的操作,都是通过 Java 8 提供的 stream 流,借助 Collectors.toMap()方法,根据一个集合转换为一个 Map 的存储形式,key 一般为业务 ID,value 为实体类;

得到 Map 之后,我们就可以在 for 循环中根据业务 ID 来获取对应的实体类,相对于网络传输,直接在内存中的操作是十分快的!

2.3 数据同步,迁移表数据

对于问题一的解决办法其实有两个:

  • 方案一:在发票信息表中,对 invoiceId 和 invoiceDate 字段加上联合索引,通过联合索引来减少回表查询的成本;
  • 方案二:在发票订单表中加上 invoiceDate 字段,将发票信息表中的 invoiceDate 数据同步到发票订单表;

对比这两种方式,尽管方法一可以提升查询的速度,但相对与方法二而言,减少一次数据库的操作比加上索引进行一次数据库查询要实际得多,所以我们下面采取方案二。

数据同步任务如下

@Slf4j
@JobListener
public class SyncInvoiceDateJob implements JobListener {
    @Value("${invoice.syncInvoiceDate.pageNo:1}")
    private int pageNo = 1;
    @Value("${invoice.syncInvoiceDate.pageSize:10}")
    private int pageSize;
    @Value("${invoice.syncInvoiceDate.syncSwitch:false}")
    private boolean syncSwitch;
    @Resource
    private PaymentInvoiceMapper paymentInvoiceMapper;
    @Resource
    private PaymentInvoiceOrderMapper paymentInvoiceOrderMapper;
    @Override
    public void execute(JobExecutionContext jobExecutionContext) {

        // 每一次执行都要把设置为1
        pageNo = 1;
        String parameter = jobExecutionContext.getParameter();
        JSONObject jsonObject = JSON.parseObject(parameter);
        Date beginDate = jsonObject.getDate("beginDate");
        Date endDate = jsonObject.getDate("endDate");
        if (beginDate == null || endDate == null) {
            log.info("起始参数为空");
            return;
        }
        while (true) {
            if (!syncSwitch) {
                log.info("同步开关为关闭状态");
                break;
            }
            int offset = (pageNo - 1) * pageSize;
            List<PaymentInvoice> paymentInvoices = paymentInvoiceMapper.queryInvoiceAndInvoiceDate(offset, pageSize, beginDate, endDate);
            if (paymentInvoices == null || paymentInvoices.isEmpty()) {
                log.info("同步invoiceDate的Job执行完成");
                break;
            }
            for (PaymentInvoice paymentInvoice : paymentInvoices) {
                String invoiceNo = paymentInvoice.getInvoiceNo();
                Date invoiceDate = paymentInvoice.getInvoiceDate();
                if (StrUtil.isEmpty(invoiceNo) || invoiceDate == null) {
                    continue;
                }
                log.info("同步的发票信息, invoiceNo:{}, invoiceDate:{}", invoiceNo, invoiceDate);
                paymentInvoiceOrderMapper.updateInvoiceDateByInvoiceNo(invoiceNo, invoiceDate);
            }
            int count = paymentInvoices.size();
            if (count != pageSize) {
                log.info("同步invoiceDate的Job执行完成");
                break;
            } else {
                pageNo++;
            }
        }
    }

}
  • 为了避免一次更新太多的数据,给数据库带来压力,这里采取分页查询的形式进行更新,同时使用客户端的参数来控制同步的时间段;

2.4 分页查询的陷阱,增加查询索引

经过上述的优化后,笔者在生产环境进行测试,发现查询的 RT 并没有降下来,反而从原来的 1.2s 上升到平均 3s,现在这段代码中只存在一个调用查询 Mapper 的方法,考虑到这个 Mapper 查询发票订单表进行的是分页查询,消耗的时间不是很多,到生产的数据库验证也的确如此,发现查询的速度在 200ms 左右,究竟是什么导致的呢?

排查后发现,这里使用到了公司提供的分页插件 PageResult,底层把计算分页 total 数量的查询操作屏蔽掉了,这是我们分页查询中最容易忽略的地方!

到生产库验证,发现这条 count 查询耗时在 3s 左右,与接口的响应 RT 差不多:

select count(*) from invoice_order where order_time > '' and order_time < '' and invoice_date > '' and invoice_date < '';

目前对于这条 SQL,只存在一个 order_time 的索引,其的执行计划为:

索引idx_order_time执行计划

  • 可见,该查询是走了 order_time 索引,在 extra 字段中出现了 using index conditionusing where,表示索引没有完全覆盖查询的字段,通过回表查询后,将完整的数据返回给 server 层,还要在 server 层根据其他查询条件进行过滤;

using index condition 和 using where 底层的工作原理类似:

  1. server 层调用存储引擎的接口定位到满足非聚簇索引查询条件的第一条二级索引记录;
  2. 存储引擎根据 B+ 树索引快速定位到这条二级索引记录后,根据该二级索引记录的主键值进行回表操作,将完整的用户记录返回给 server 层;
  3. server 层在判断其他的搜索条件是否成立,如果成立将其发给客户端,否则跳过改该记录,然后向存储引擎层要下一条记录;
  4. 由于每条记录都有一个 next_record 属性,根据该属性可以快速找到符合条件的下一条二级索引,然后再执行回表操作,将完整的记录返回给 server 层。然后重复步骤 3;

根据上述的分析可知,该 SQL 先是根据 order_time 进行回表查询,然后将完整记录返回给 server 层,server 层再根据 invoice_date 进行过滤。可见返回给 server 进行判断的这步是十分耗时的。

所以,笔者接着创建了 order_time 和 invoice_date 的联合索引,我们继续查询执行计划:

索引idx_order_time_invoice_date索引的优化

  • 可见,该查询是走了 idx_order_time_invoice_date 索引,在 extra 字段中出现了 using index 字段,表示该查询通过二级索引就将数据查询出来了。

在 MySQL 8.0.13 后,对 select count(*) from table_name 这条 SQL 做过一定的优化,它会选择一个成本较低的索引。在 InnoDB 中,索引分为聚簇索引和非聚簇索引,前者的叶子节点存储的完整的记录,而后者保存的是该行记录的主键值。相比之下,非聚簇索引比聚簇索引小很多,所以会优先使用最小的非聚簇索引来扫表。

执行完后,count 查询的速度从 3s 降低到 0.2s ,查询速度提升了 15 倍!这个查询接口的 RT 从原来的 1.2s 降低到 294 ms,可以说性能有了很大的提升。

三、优化总结

  1. 从代码角度考虑问题,比如是否存在 for 循环中进行 RPC调用,数据库操作等,如果有就可以通过批量查询的方式,提前把数据查出来;
  2. 善于使用 explain 执行计划分析 SQL,根据字段 type、key、extra 基本就能判断 SQL 语句是否走索引,其中 extra 字段可以为我们提供更加详细的信息;
  3. 建索引时多考虑是否可以建立联合索引来减少回表的操作;

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/395406.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Linux系统安全:安全技术和防火墙

目录 一、安全技术和防火墙 1.安全技术 2.防火墙的分类 二、防火墙 1.iptables四表五链 2.黑白名单 3.iptables基本语法 4.iptables选项 5.控制类型 6.隐藏扩展模块 7.显示扩展模块 8.iptables规则保存 9.自定义链使用 一、安全技术和防火墙 1.安全技术 入侵检测系…

AMD FPGA设计优化宝典笔记(5)低频全局复位与高扇出

亚军老师的这本书《AMD FPGA设计优化宝典》&#xff0c;他主要讲了两个东西&#xff1a; 第一个东西是代码的良好风格&#xff1b; 第二个是设计收敛等的本质。 这个书的结构是一个总论&#xff0c;加上另外的9个优化&#xff0c;包含的有&#xff1a;时钟网络、组合逻辑、触发…

在ubuntu20.04 上配置 qemu/kvm linux kernel调试环境

一&#xff1a;安装qemu/kvm 和 virsh qemu/kvm 是虚拟机软件&#xff0c;virsh是管理虚拟机的命令行工具&#xff0c;可以使用virsh创建&#xff0c;编辑&#xff0c;启动&#xff0c;停止&#xff0c;删除虚拟机。 &#xff08;1&#xff09;&#xff1a;安装之前&#xff0c…

Matlab|基于支持向量机的电力短期负荷预测【最小二乘、标准粒子群、改进粒子群】

目录 主要内容 部分代码 结果一览 下载链接 主要内容 该程序主要是对电力短期负荷进行预测&#xff0c;采用三种方法&#xff0c;分别是最小二乘支持向量机&#xff08;LSSVM&#xff09;、标准粒子群算法支持向量机和改进粒子群算法支持向量机三种方法对负荷进行…

Shiro-05-shiro 基础知识补充密码学+哈希散列

密码学 密码术是隐藏或混淆数据的过程&#xff0c;因此窥探眼睛无法理解它。 Shiro的加密目标是简化JDK的加密支持并使之可用。 需要特别注意的是&#xff0c;密码通常不是特定于主题的&#xff0c;因此Shiro API的其中一个领域不是特定于主题的。 即使未使用“主题”&…

Android 架构组件全示例

Android 架构组件全示例 Android架构组件属于Jetpack的组成部分&#xff0c;彻底改变了开发人员构建健壮且易于维护的Android应用程序的方式。通过Room、Lifecycle-aware组件、ViewModels、LiveData、Paging、Navigation、ViewBinding和WorkManager等组件&#xff0c;开发人员…

K8s进阶之路-命名空间级-服务发现 :

服务发现&#xff1a; Service&#xff08;东西流量&#xff09;&#xff1a;集群内网络通信、负载均衡&#xff08;四层负载&#xff09;内部跨节点&#xff0c;节点与节点之间的通信&#xff0c;以及pod与pod之间的通信&#xff0c;用Service暴露端口即可实现 Ingress&#…

python绘制k线图均线图

AAPL.csv 数据文件 Date,Close,Volume,Open,High,Low 06/23/2023,$186.68,53117000,$185.55,$187.56,$185.01 06/22/2023,$187.00,51245330,$183.74,$187.045,$183.67 06/21/2023,$183.96,49515700,$184.90,$185.41,$182.5901 06/20/2023,$185.01,49799090,$184.41,$1…

unity学习(15)——服务器组装(1)

1.新建好的c#项目如下&#xff1a; 文件夹中内容如下&#xff1a; 此时已经可以通过vs2022打开.sln文件&#xff0c;就可以打开项目了。 2.我们把逆向后&#xff08;主程序&#xff09;的内容的代码粘贴过去。有些逆向功底&#xff0c;很快可以定位到&#xff0c;服务器的入口…

uniapp项目准备工作

1.封装请求 export const baseUrl function getHeaders () {let token uni.getStorageSync(token)let header {"access-token":token,// X-Requested-With: XMLHttpRequest,Content-Type: application/json; charsetUTF-8}return header } function reLogin(){/…

Leetcoder Day14|二叉树part03

语言&#xff1a;Java/C 104.二叉树的最大深度​​​​​​​ 给定一个二叉树&#xff0c;找出其最大深度。 二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。 说明: 叶子节点是指没有子节点的节点。 示例&#xff1a; 给定二叉树 [3,9,20,null,null,15,7]&#xf…

如何配置OSS中的文件是预览还是下载

如何决定文件是预览还是下载 1. 首先需要绑定自己的二级域名&#xff0c;下载时使用自己的二级域名下载 链接&#xff1a;关于绑定域名的官方文档 2. 文件需要配置正确的请求头 链接&#xff1a; 关于设置文件Content-Type的官方文档 2.1 设置或修改文件请求头包含多种方式…

【C++】C++11中

C11中 1.lambda表达式2.可变参数模板3.包装器 1.lambda表达式 在前面我们学习过仿函数。仿函数的作用到底是干什么的呢&#xff1f; 它为了抛弃函数指针&#xff01; 主要是因为函数指针太难学了 就比如下面这个&#xff0c;看着也挺难受的。 它的参数是一个函数指针&#x…

IO线程-day2

1> 使用fread和fwrite完成两个文件的拷贝 程序&#xff1a; #define MAXSIZE 1024 #include<myhead.h>int main(int argc, char const *argv[]) {FILE *srcfpNULL;FILE *destfpNULL;if(!(srcfpfopen("pm.bmp","r")))PRINT_ERR("");if…

【漏洞复现-通达OA】通达OA video_file.php 任意文件下载漏洞

一、漏洞简介 通达OA video_file.php文件存在任意文件下载漏洞&#xff0c;攻击者通过漏洞可以读取服务器敏感文件。 二、影响版本 ● 通达OA2011 三、资产测绘 ● hunterapp.name"通达 OA" ● 特征 四、漏洞复现 GET /general/mytable/intel_view/video_file.…

比特浏览器bit_selenium3bit_selenium4使用

bit_selenium3 from selenium import webdriver from selenium.common.exceptions import TimeoutException from selenium.webdriver.common.keys import Keys from selenium.webdriver.chrome.options import Options from bit_api import *# /browser/open 接口会返回 selen…

【C/C++】实现Reactor高并发服务器 完整版

代码结构 文件介绍 InetAddress.h InetAddress类 ip和端口设置 Socket.h Socket类 设置fd Epoll.h epollfd 管理类 Channel.h Channel类 管理epoll以及对应回调函数实现 EventLoop.h EventLoop事件循环类 TcpServer.h 服务器类 tcpepoll.cpp 主函数 InetAddress.h #if…

腾讯云OSS文件上传功能

腾讯云COS介绍 腾讯云COS&#xff08;Cloud Object Storage&#xff09;是一种基于对象的存储服务&#xff0c;用于存储和管理海量的非结构化数据&#xff0c;如图片、音视频文件、备份数据等。它具有以下特点和优势&#xff1a; 高可靠性&#xff1a;采用分布式存储架构&…

什么是485远程水表?

485远程水表是一种利用RS485通信协议进行数据传输的智能水表&#xff0c;它具有远程读数、实时监控、数据存储等功能&#xff0c;为水资源管理和居民用水提供了便捷。在我国&#xff0c;随着物联网、大数据等技术的发展&#xff0c;485远程水表得到了广泛的应用&#xff0c;为智…

数据库索引面试的相关问题

查看索引的执行计划 索引失效的情况 1、索引列上做了计算&#xff0c;函数&#xff0c;类型转换等操作。索引失效是因为查询过程需要扫描整个索引并回表。代价高于直接全表扫描。 Like匹配使用了前缀匹配符“%abc” 字符串不加引号导致类型转换。 原因&#xff1a; 常见索…
最新文章