秒级到毫秒级的跨越—一次慢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();// 根据开票时间参数从发票信息表中获取对应invoiceIdList<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) {// 批量查询invoiceList<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));// 批量查询uidList<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;@Resourceprivate PaymentInvoiceMapper paymentInvoiceMapper;@Resourceprivate PaymentInvoiceOrderMapper paymentInvoiceOrderMapper;@Overridepublic void execute(JobExecutionContext jobExecutionContext) {// 每一次执行都要把设置为1pageNo = 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.mzph.cn/news/688733.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

洛夫克拉夫特“克苏鲁神话”艺术风格探索(二)

三、多元的叙事风格 洛夫克拉夫特的克苏鲁神话作为当时独特的文学创造&#xff0c;有独特的叙事特征[8]。 一是侦探小说不稳定的叙事。最有名气的早期侦探小说是爱伦坡的《莫格街凶杀案》&#xff0c;并产生了“疑案”的经典设定&#xff0c;两次世界大战期间的侦探小说批评认…

《UE5_C++多人TPS完整教程》学习笔记18 ——《P19(实现子系统函数)创建会话(Create Session)》

本文为B站系列教学视频 《UE5_C多人TPS完整教程》 —— 《P19 &#xff08;使用子系统函数&#xff09;创建会话&#xff08;Create Session&#xff09;》 的学习笔记&#xff0c;该系列教学视频为 Udemy 课程 《Unreal Engine 5 C Multiplayer Shooter》 的中文字幕翻译版&am…

基于Java SSM框架实现疫情防控系统项目【项目源码】

基于java的SSM框架实现疫情防控系统演示 Java技术 Java技术它是一个容易让人学会和使用的一门服务器语言。它在编程的过程当中只需要很少的知识就能建立起一个真正的交互站点。对于这个教程来说它并不需要你完全去了解这种语言&#xff0c;只要能快速融入web站点就可以&#x…

Spring 事务原理总结六

不知不觉&#xff0c;关于Spring事务的文章已经写了五篇了。老实讲我自己不断质疑过自己&#xff1a;现在写这些文章还有意义吗&#xff1f;当前的市场已经成什么样了&#xff0c;为什么还要固守这落后的技术&#xff1f;但是贝索斯一次接受访谈的回答&#xff0c;让我写下去的…

ESP32-Cam学习(1)——拍摄第一张照片

1.开发板介绍 使用的ESP32-Cam实物图为&#xff1a; 在某宝可以轻易买到。它分为主板&#xff0c;和底板。底板的主要功能是供电、程序下载等等。主板才是ESP32芯片的核心。 2.固件烧录 使用摄像头之前&#xff0c;需要给ESP32刷入支持摄像头的固件库&#xff0c;其下载地址为…

数据库-----范式判断

目录 (1)求最小函数依赖集 (2)求候选码 (3)求R最高属于哪级范式 总结: 以一道例题来看: 3.已知关系模式R<ABCDEG> F{BC-->E&#xff0c;DC-->B,D-->A,B-->G,D-->E,E-->G,B-->C} 求: ①F的最小函数依赖集 ②R的候选码 ③R最高属于…

美国中性原子量子公司QuEra宣布将在英国建造量子测试平台

编辑丨慕一 编译/排版丨沛贤 深度好文&#xff1a;1250字丨7分钟阅读 中性原子量子公司QuEra Computing宣布&#xff0c;英国国家量子计算中心&#xff08;NQCC&#xff09;将成为一个量子计算测试平台的所在地。 通过NQCC的资助&#xff0c;并在小型企业研究计划&#xff…

【Kubernetes in Action笔记】1.快速开始

在Kubernetes上运行一个程序 基础运行环境 当前的运行环境为使用虚拟机构建的单master集群。 [rootk8s-master ~]# kubectl get nodes NAME STATUS ROLES AGE VERSION k8s-master Ready control-plane 109d v1.27.1 k8s-node1 Ready …

如何基于YAML设计接口自动化测试框架?看完秒会!

在设计自动化测试框架的时候&#xff0c;我们会经常将测试数据保存在外部的文件&#xff08;如Excel、YAML、CSV&#xff09;或者数据库中&#xff0c;实现脚本与数据解耦&#xff0c;方便后期维护。目前非常多的自动化测试框架采用通过Excel或者YAML文件直接编写测试用例&…

沁恒CH32V30X学习笔记05--串口接收中断和空闲中断组合接收数据

同步异步收发器(USART)** 包含 3 个通用同步异步收发器(USART1/2/3)和 5 个通用异步收发器(UART4/5/6/7/8) 空闲帧,空闲帧是 10 位或 11 位高电平,包含停止位。 断开帧是 10 位或 11 位低电平,后跟着停止位 引脚模式配置 引脚分配 bsp 驱动代码 bsp_uart_it.c /…

固定资产与总账对账,业务系统出不来数据?

1、【财务会计】-【固定资产】-【与总账对账】 2、【财务会计】-【总账】-【对账执行】 以上两个节点都可以进行 “固定资产与总账” 对账执行 操作。 问题&#xff1a; 固定资产与总账对账&#xff0c;业务系统出不来数据&#xff1f;如下图 &#xff1a; 原因&#xff…

麒麟linux和东方通TongWeb时区timezone不同步问题的解决

默认东方通文件夹位置如下&#xff1a; /data/TongWeb7.0.4.9_M3_Enterprise_Linux 在bin文件夹下有一个external.vmoptions 文件。 将下面这行&#xff1a; -Duser.timezoneAsia/Shanghai 添加到external.vmoptions 文件中。 重启东方通&#xff0c;时区问题解决。

VR直播:只需五步,即可实现直播“黑科技”

现如今&#xff0c;VR直播的应用范围较为广泛&#xff0c;有很多人可能在现场见过VR直播的拍摄设备&#xff0c;不仅有高性能的电脑、VR相机&#xff0c;还有专业的灯光和拍摄机器等。只需要五步&#xff0c;就可以实现安全、高效的VR全景直播。 首先是专业全景采集设备进行全景…

svg之全局组件,配合雪碧图解决vue2的svg优化问题

这里是vue2中的svg的完整解决方案的另一篇。 <template><svg :class"svgClass"><use :xlink:href"#${name}"></use></svg> </template><script>export default {name: icon,props: {name: {type: String,requi…

几种SLAM算法跑出的效果比较

以下所有的SLAM算法均使用此辆ROS小车跑。 文章目录 1.Gmapping SLAM算法构建地图2.Hector SLAM算法构建地图3.Karto SLAM算法构建地图4.Cartographer SLAM算法构建地图5.深度摄像头的建图6.rtab-map(深度双目与激光雷达构建三维建图)7.ORB-SLAM8.无奖竞猜 1.Gmapping SLAM算法…

OpenAI Sora视频生成机制:时空补丁

AI如何将静态图像转化为动态、逼真的视频&#xff1f;OpenAI 的 Sora 通过时空补丁&#xff08;spacetime patches&#xff09;的创新使用给出了答案。 独特的视频生成方法 在生成模型的世界中&#xff0c;我们看到了从 GAN 到自回归和扩散模型的许多方法&#xff0c;它们都有…

【C++初阶】值得一刷的字符串string相关oj题

&#x1f466;个人主页&#xff1a;Weraphael ✍&#x1f3fb;作者简介&#xff1a;目前学习C和算法 ✈️专栏&#xff1a;C航路 &#x1f40b; 希望大家多多支持&#xff0c;咱一起进步&#xff01;&#x1f601; 如果文章对你有帮助的话 欢迎 评论&#x1f4ac; 点赞&#x1…

【每天学习一点点 day04】工程化 npm create 脚手架 create-vue, vue-cli 执行原理① - npm cli

希望我们每个人都能找到属于自己的花期&#xff0c;不急不躁&#xff0c;静等风来。 今天打算用 Docusaurus 开始搭建自己的知识库&#xff0c;之前早已有此想法&#xff0c;遗憾的是没有坚持下来。 这次借助这个机会&#xff0c;也计划将自己【每天学习一点点】系列整理在自己…

java面试多线程篇

文章说明 在文档中对所有的面试题都进行了难易程度和出现频率的等级说明 星数越多代表权重越大&#xff0c;最多五颗星&#xff08;☆☆☆☆☆&#xff09; 最少一颗星&#xff08;☆&#xff09; 1.线程的基础知识 1.1 线程和进程的区别&#xff1f; 难易程度&#xff1a;☆☆…

代码随想录刷题笔记 DAY 29 | 非递减子序列 No.491 | 全排列 No.46 | 全排列 II No. 47

文章目录 Day 2901. 非递减子序列&#xff08;No. 491&#xff09;1.1 题目1.2 笔记1.3 代码 02. 全排列&#xff08;No. 46&#xff09;2.1 题目2.2 笔记2.3 代码 03. 全排列 II&#xff08;No. 47&#xff09;3.1 题目3.2 笔记3.3 代码 Day 29 01. 非递减子序列&#xff08;…