目录
当前存在的问题
使用状态机解决问题
什么是状态机?
编写订单状态机
订单状态枚举类
状态变更事件枚举类
编辑
定义订单快照类
定义事件变更动作类
定义订单状态机类
状态机表设计
编辑 测试启动状态机
编辑 测试状态变更方法
分库分表
什么是分库分表
当前遇到的问题
随着公司业务快速发展,数据库中的数据量猛增,访问性能也变慢了,如何优化呢?
分库分表的四种方式
当前存在的问题
在预约下单模块设计订单状态共有7种,如下图
订单状态存在硬编码问题
在业务代码中对订单状态进行硬编码如果有一天更改了业务逻辑就需要更改代码,不方便进行系统扩展和维护。
另外对订单状态的管理是散落在很多地方不方便对订单状态进行统一管理和维护。
使用状态机解决问题
我们可以使用状态机对订单状态进行统一管理。
什么是状态机?
状态机是一种数学模型,应用在自动化控制、计算机科学、通信等很多领域,简单理解状态机就是对状态进行统一管理的数学模型。
我们画的状态图是状态机在计算机科学中的应用方法,还有状态机设计模式也是状态机在软件领域的应用方法。
状态机设计模式是状态机在软件中的应用,状态机设计模式描述了一个对象在内部状态发生变化时如何改变其行为,将状态之间的变更定义为事件,将事件暴露出去,通过执行状态变更事件去更改状态,这是状态机设计模式的核心内容。
理解状态机设计模式需要理解四个要素:现态、事件、动作、次态。
1、现态:是指当前所处的状态。
2、事件:当一个条件被满足,状态会由现态变为新的状态,事件发生会触发一个动作,或者执行一次状态的迁移。
3、动作:发生事件执行的动作,动作执行完毕后,可以迁移到新的状态,也可以仍旧保持原状态。动作不是必需的,当条件满足后,也可以不执行任何动作,直接迁移到新状态。
4、次态:条件满足后要迁往的新状态。
编写订单状态机
Docshttps://mx67xggunk5.feishu.cn/wiki/YcFGwXxT9i1o2rkhBtqci1FunWh
目标:
能够基于状态机组件实现订单状态机
订单状态枚举类
枚举类的定义
实现状态机接口
package com.jzo2o.orders.base.enums;import com.jzo2o.statemachine.core.StatusDefine;
import lombok.AllArgsConstructor;
import lombok.Getter;/*** @author itcast*/
@Getter
@AllArgsConstructor
public enum OrderStatusEnum implements StatusDefine {NO_PAY(0, "待支付", "NO_PAY"),DISPATCHING(100, "派单中", "DISPATCHING"),NO_SERVE(200, "待服务", "NO_SERVE"),SERVING(300, "服务中", "SERVING"),FINISHED(500, "已完成", "FINISHED"),CANCELED(600, "已取消", "CANCELED"),CLOSED(700, "已关闭", "CLOSED");private final Integer status;private final String desc;private final String code;/*** 根据状态值获得对应枚举** @param status 状态* @return 状态对应枚举*/public static OrderStatusEnum codeOf(Integer status) {for (OrderStatusEnum orderStatusEnum : values()) {if (orderStatusEnum.status.equals(status)) {return orderStatusEnum;}}return null;}
}
状态变更事件枚举类
所有状态之间存在的变更都需要定义状态变更事件,它实现了StatusChangeEvent 状态变更事件接口,事件对应状态机四要素的事件
package com.jzo2o.orders.base.enums;import com.jzo2o.statemachine.core.StatusChangeEvent;
import lombok.AllArgsConstructor;
import lombok.Getter;/*** @author itcast*/
@Getter
@AllArgsConstructor
public enum OrderStatusChangeEventEnum implements StatusChangeEvent {PAYED(OrderStatusEnum.NO_PAY, OrderStatusEnum.DISPATCHING, "支付成功", "payed"),DISPATCH(OrderStatusEnum.DISPATCHING, OrderStatusEnum.NO_SERVE, "接单/抢单成功", "dispatch"),START_SERVE(OrderStatusEnum.NO_SERVE, OrderStatusEnum.SERVING, "开始服务", "start_serve"),COMPLETE_SERVE(OrderStatusEnum.SERVING, OrderStatusEnum.FINISHED, "完成服务", "complete_serve"),
// EVALUATE(OrderStatusEnum.NO_EVALUATION, OrderStatusEnum.FINISHED, "评价完成", "evaluate"),CANCEL(OrderStatusEnum.NO_PAY, OrderStatusEnum.CANCELED, "取消订单", "cancel"),SERVE_PROVIDER_CANCEL(OrderStatusEnum.NO_SERVE, OrderStatusEnum.DISPATCHING, "服务人员/机构取消订单", "serve_provider_cancel"),CLOSE_DISPATCHING_ORDER(OrderStatusEnum.DISPATCHING, OrderStatusEnum.CLOSED, "派单中订单关闭", "close_dispatching_order"),CLOSE_NO_SERVE_ORDER(OrderStatusEnum.NO_SERVE, OrderStatusEnum.CLOSED, "待服务订单关闭", "close_no_serve_order"),CLOSE_SERVING_ORDER(OrderStatusEnum.SERVING, OrderStatusEnum.CLOSED, "服务中订单关闭", "close_serving_order"),
// CLOSE_NO_EVALUATION_ORDER(OrderStatusEnum.NO_EVALUATION, OrderStatusEnum.CLOSED, "待评价订单关闭", "close_no_evaluation_order"),CLOSE_FINISHED_ORDER(OrderStatusEnum.FINISHED, OrderStatusEnum.CLOSED, "已完成订单关闭", "close_finished_order");/*** 源状态*/private final OrderStatusEnum sourceStatus;/*** 目标状态*/private final OrderStatusEnum targetStatus;/*** 描述*/private final String desc;/*** 代码*/private final String code;
}
定义订单快照类
快照是订单变化瞬间的状态及相关信息。
比如:001号订单创建成功此时记录它的快照信息(订单号、下单人、订单详细信息、订单状态等),当001号订单支付成功由待支付状态变化为派单中状态此时也会记录它的快照信息(订单号、下单人、支付状态、支付相关信息,订单状态等相关信息),由此可以看出订单快照可以追溯订单的历史变化信息,只要状态发生变化便会记录快照。
快照基础类型是StateMachineSnapshot,如果我们要实现订单快照则需要定义一个订单快照类OrderSnapshotDTO 去继承StateMachineSnapshot类型,
定义事件变更动作类
当执行状态变更事件会伴随着执行具体的动作,此部分对应状态机四要素中的动作。
定义订单支付成功动作类,实现StatusChangeHandler接口,泛型中指定快照类型。
此动作是订单支付成功执行的动作。
动作类的bean名称为"状态机名称_事件名称",例如下边的动作类bean的名称为order_payed,表示order状态机的payed事件。
定义订单状态机类
AbstractStateMachine状态机抽象类是状态机的核心类,是具体的状态机要继承的抽象类,比如我们实现订单状态机就需要继承AbstractStateMachine抽象类。
成员变量:
初始状态:设置初始状态,比如订单的初始状态为待支付。
状态机名称:返回的状态机的标识,比如订单状态机返回“order”作为订单状态机的名称。
方法:
返回状态机名称:返回状态机的名称
返回初始状态:返回初始状态
启动状态机:开始进行状态机管理,通常在新建实例时调用此方法,比如:新建一个订单调用此方法将订单状态设置为初始状态,传入参数:业务主键(如订单id)
变更状态:调用此方法更改状态
后处理方法:当状态变更后统一执行的逻辑。
package com.jzo2o.orders.base.config;/*** 订单状态机** @author itcast* @create 2023/8/4 11:20**/
@Component
public class OrderStateMachine extends AbstractStateMachine<OrderSnapshotDTO> {public OrderStateMachine(StateMachinePersister stateMachinePersister, BizSnapshotService bizSnapshotService, RedisTemplate redisTemplate) {super(stateMachinePersister, bizSnapshotService, redisTemplate);}/*** 设置状态机名称** @return 状态机名称*/@Overrideprotected String getName() {return "order";}@Overrideprotected void postProcessor(OrderSnapshotDTO orderSnapshotDTO) {}/*** 设置状态机初始状态** @return 状态机初始状态*/@Overrideprotected OrderStatusEnum getInitState() {return OrderStatusEnum.NO_PAY;}}
状态机表设计
状态机使用MySQL对状态进行持久化
测试启动状态机
插入订单号,启动状态机,如果存在该订单号,不可以初始化。如果不存在该订单,则插入持久化表,和快照表中新增该信息。
测试状态变更方法
状态变更前会判断订单的当前状态是否和事件定义的源状态一致,如果不一致则说明当前订单的状态不能通过该事件去更新状态,此时将终止状态变更,否则将通过状态变更处理器更新订单的状态。
public void changeStatus(Long dbShardId, String bizId, StatusChangeEvent statusChangeEventEnum, T bizSnapshot) {String statusCode = this.getCurrentState(bizId);if (ObjectUtil.isNotEmpty(statusChangeEventEnum.getSourceStatus()) && ObjectUtil.notEqual(statusChangeEventEnum.getSourceStatus().getCode(), statusCode)) {throw new CommonException(500, "状态机起止状态与事件不匹配");} else {String eventCode = statusChangeEventEnum.getCode();StatusChangeHandler bean = null;try {bean = (StatusChangeHandler)SpringUtil.getBean(this.name + "_" + eventCode, StatusChangeHandler.class);} catch (Exception var9) {log.info("不存在‘{}’StatusChangeHandler", this.name + "_" + eventCode);}if (bizSnapshot == null) {bizSnapshot = (StateMachineSnapshot)ReflectUtil.newInstance(this.getSnapshotClass(), new Object[0]);}bizSnapshot.setSnapshotId(bizId);bizSnapshot.setSnapshotStatus(statusChangeEventEnum.getTargetStatus().getStatus());if (ObjectUtil.isNotNull(bean)) {bean.handler(bizId, statusChangeEventEnum, bizSnapshot);}this.stateMachinePersister.persist(this.name, bizId, statusChangeEventEnum.getTargetStatus());String key;if (ObjectUtil.isNotEmpty(bizSnapshot)) {bizSnapshot = this.buildNewSnapshot(bizId, bizSnapshot, statusChangeEventEnum.getSourceStatus());key = JSONUtil.toJsonStr(bizSnapshot);this.bizSnapshotService.save(dbShardId, this.name, bizId, statusChangeEventEnum.getTargetStatus(), key);}key = "JZ_STATE_MACHINE:" + this.name + ":" + bizId;this.redisTemplate.delete(key);this.postProcessor(bizSnapshot);}
分库分表
什么是分库分表
当前遇到的问题
随着订单数据的增加,当MySQL单表存储数据达到一定量时其存储及查询性能会下降,在阿里的《Java 开发手册》中提到MySQL单表行数超过 500 万行或者单表容量超过 2GB时建议进行分库分表,分库分表可以简单理解为原来一个表存储数据现在改为通过多个数据库及多个表去存储,这就相当于原来一台服务器提供服务现在改成多台服务器组成集群共同提供服务,从而增加了服务能力。
这里说的500 万行或单表容量超过 2GB并不是定律,只是根据生产经验而言,为什么MySQL单表当达到一定数量时性能会下降呢?我们知道为了提高表的查询性能会增加索引,MySQL在使用索引时会将索引加入内存,如果数据量非常大内存肯定装不下,此时就会从磁盘去查询索引就会产生很多的磁盘IO,从而影响性能,这些和表的设计及服务器的硬件配置都有关,所以如果当表的数据量达到一定程度并且还在不断的增加就需要考虑进行分库分表了
随着公司业务快速发展,数据库中的数据量猛增,访问性能也变慢了,如何优化呢?
我们可以把数据分散在不同的数据库中,使得单一数据库的数据量变小来缓解单一数据库的性能问题,从而达到提升数据库性能的目的,比如将电商数据库,进行细拆、
分库分表的四种方式
分库分表包括分库和分表两个部分,在生产中通常包括:垂直分库、水平分库、垂直分表、水平分表四种方式。
-
垂直分表
-
垂直分表是将一个表按照字段分成多表,每个表存储其中一部分字段,比如按冷热字段进行拆分。
垂直分表带来的好处是:充分发挥热门数据的操作效率,商品信息的操作的高效率不会被商品描述的低效率所拖累。
通常我们按以下原则进行垂直拆分:
-
垂直分库
通过垂直分表性能得到了一定程度的提升,但是还没有达到要求,并且磁盘空间也快不够了,因为数据还是始终限制在一台服务器,库内垂直分表只解决了单一表数据量过大的问题,但没有将表分布到不同的服务器上,因此每个表还是竞争同一个物理机的CPU、内存、网络IO、磁盘。
经过思考,他把原有的SELLER_DB(卖家库),分为了PRODUCT_DB(商品库)和STORE_DB(店铺库),并把这两个库分 散到不同服务器,如下图:
由于商品信息与商品描述业务耦合度较高,因此一起被存放在PRODUCT_DB(商品库);而店铺信息相对独立,因此 单独被存放在STORE_DB(店铺库)。
垂直分库是指按照业务将表进行分类,分布到不同的数据库上面,每个库可以放在不同的服务器上,它的核心理念是专库专用,微服务架构下通常会对数据库进行垂直分为,不同业务数据放在单独的数据库中,比如:客户信息数据库、订单数据库等。
它带来的提升是
1、解决业务层面的耦合,业务清晰
2、能对不同业务的数据进行分级管理、维护、监控、扩展等
3、高并发场景下,垂直分库一定程度的提升IO、降低单机硬件资源的瓶颈。
垂直分库通过将表按业务分类,然后分布在不同数据库,并且可以将这些数据库部署在不同服务器上,从而达到多个服务器共同分摊压力的效果,但是依然没有解决单表数据量过大的问题。
-
水平分库
经过垂直分库后,数据库性能问题得到一定程度的解决,但是随着业务量的增长,PRODUCT_DB(商品库)单库存储数据已经超出预估。粗略估计,目前有8w店铺,每个店铺平均150个不同规格的商品,再算上增长,那商品数量得往1500w+上预估,并且PRODUCT_DB(商品库)属于访问非常频繁的资源,单台服务器已经无法支撑。此时该如何优化?
再次分库?但是从业务角度分析,目前情况已经无法再次垂直分库。
尝试水平分库,将店铺ID为单数的和店铺ID为双数的商品信息分别放在两个库中。
也就是说,要操作某条数据,先分析这条数据所属的店铺ID。如果店铺ID为双数,将此操作映射至
RRODUCT_DB1(商品库1);如果店铺ID为单数,将操作映射至RRODUCT_DB2(商品库2)。
水平分库是把同一个表的数据按一定规则拆到不同的数据库中,每个库可以放在不同的服务器上,比如:单数订单在db_orders_0数据库,偶数订单在db_orders_1数据库。
它带来的提升是:
1、解决了单库大数据,高并发的性能瓶颈。
2、提高了系统的稳定性及可用性。
当一个应用难以再细粒度的垂直切分,或切分后数据量行数巨大,存在单库读写、存储性能瓶颈,这时候就需要进行水平分库了,经过水平切分的优化,往往能解决单库存储量及性能瓶颈。但由于同一个表被分配在不同的数据库,需要额外进行数据操作的路由工作,因此大大提升了系统复杂度。
-
水平分表
按照水平分库的思路把PRODUCT_DB_X(商品库)内的表也可以进行水平拆分,其目的也是为解决单表数据量大 的问题,如下图:
与水平分库的思路类似,不过这次操作的目标是表,商品信息及商品描述被分成了两套表。如果商品ID为双数,将 此操作映射至商品信息1表;如果商品ID为单数,将操作映射至商品信息2表。此操作要访问表名称的表达式为商品 信息[商品ID%2 + 1]
水平分表是在同一个数据库内,把同一个表的数据按一定规则拆到多个表中,比如:0到500万的订单在orders_0数据、500万到1000万的订单在orders_1数据表。
水平分表优化了单一表数据量过大而产生的性能问题
一般来说,在系统设计阶段就应该根据业务耦合松紧来确定垂直分库,垂直分表方案,在数据量及访问压力不是特别大的情况,首先考虑缓存、读写分离、索引技术等方案。若数据量极大,且持续增长,再考虑水平分库水平分表方案。
搭建分库分表环境
ShardingSphere介绍
Apache ShardingSphere 是一款分布式的数据库生态系统,可以将任意数据库转换为分布式数据库,并通过数据分片、弹性伸缩、加密等能力对原有数据库进行增强。
所以数据分片是应对海量数据存储与计算的有效手段。ShardingSphere 基于底层数据库提供分布式数据库解决方案,可以水平扩展计算和存储。使用ShardingSphere 的数据分片功能即可实现分库分表。
通过配置来区分路由
数据源配置 :: ShardingSphereApache ShardingSphere Documenthttps://shardingsphere.apache.org/document/current/cn/user-manual/shardingsphere-jdbc/yaml-config/data-source/
broadcast广播会把信息插入到所有库的这些表
dataSources:jzo2o-orders-0:dataSourceClassName: com.zaxxer.hikari.HikariDataSourcejdbcUrl: jdbc:mysql://192.168.101.68:3306/jzo2o-orders-0?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghaiusername: rootpassword: mysqljzo2o-orders-1:dataSourceClassName: com.zaxxer.hikari.HikariDataSourcejdbcUrl: jdbc:mysql://192.168.101.68:3306/jzo2o-orders-1?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghaiusername: rootpassword: mysqljzo2o-orders-2:dataSourceClassName: com.zaxxer.hikari.HikariDataSourcejdbcUrl: jdbc:mysql://192.168.101.68:3306/jzo2o-orders-2?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghaiusername: rootpassword: mysql
rules:
- !TRANSACTIONdefaultType: BASEproviderType: Seata
- !SHARDINGtables:orders:actualDataNodes: jzo2o-orders-${0..2}.orders_${0..2}tableStrategy:standard:shardingColumn: idshardingAlgorithmName: orders_table_inlinedatabaseStrategy:standard:shardingColumn: user_idshardingAlgorithmName: orders_database_inlineorders_serve:actualDataNodes: jzo2o-orders-${0..2}.orders_serve_${0..2}tableStrategy:standard:shardingColumn: idshardingAlgorithmName: orders_serve_table_inlinedatabaseStrategy:standard:shardingColumn: serve_provider_idshardingAlgorithmName: orders_serve_database_inlinebiz_snapshot:actualDataNodes: jzo2o-orders-${0..2}.biz_snapshot_${0..2}tableStrategy:standard:shardingColumn: biz_idshardingAlgorithmName: biz_snapshot_table_inlinedatabaseStrategy:standard:shardingColumn: db_shard_idshardingAlgorithmName: biz_snapshot_database_inlineshardingAlgorithms:# 订单-分库算法orders_database_inline:type: INLINEprops:# 分库算法表达式algorithm-expression: jzo2o-orders-${user_id % 3}# 分库支持范围查询allow-range-query-with-inline-sharding: true# 订单-分表算法orders_table_inline:type: INLINEprops:# 分表算法表达式algorithm-expression: orders_${(int)Math.floor(id % 10000000000 / 15000000)}# 允许范围查询allow-range-query-with-inline-sharding: true# 服务单-分库算法orders_serve_database_inline:type: INLINEprops:# 分库算法表达式algorithm-expression: jzo2o-orders-${serve_provider_id % 3}# 允许范围查询allow-range-query-with-inline-sharding: true# 服务单-分表算法orders_serve_table_inline:type: INLINEprops:# 允许范围查询algorithm-expression: orders_serve_${(int)Math.floor(id % 10000000000 / 15000000)}# 允许范围查询allow-range-query-with-inline-sharding: true# 快照-分库算法biz_snapshot_database_inline:type: INLINEprops:# 分库算法表达式algorithm-expression: jzo2o-orders-${db_shard_id % 3}# 允许范围查询allow-range-query-with-inline-sharding: true# 快照-分表算法biz_snapshot_table_inline:type: INLINEprops:# 允许范围查询algorithm-expression: biz_snapshot_${(int)Math.floor((Long.valueOf(biz_id)) % 10000000000 / 15000000)}# 允许范围查询allow-range-query-with-inline-sharding: true# id生成器keyGenerators:snowflake:type: SNOWFLAKE
- !BROADCASTtables:- breach_record- orders_canceled- orders_refund- orders_dispatch- orders_seize- serve_provider_sync- state_persister- orders_dispatch_receive- undo_log- history_orders_sync- history_orders_serve_sync
props:sql-show: true
指定驱动:
因为配置了,分库分表策略,所以查询的时候要指定,分库分表的字段,否则他就全部扫描的进行查询。
比如 查询某个日期之间的订单,这个是胡我们也没指定分库分表的字段,那么他就从所有的库表中找到对应的数据,然后汇总起来返回。所以有失必有得。多个库联合查询的时候性能不好,但没办法。
查询的时候没指定分库分表的字段,在控制台就会发现,成片的日志,全都查一遍
在我们启动状态机时,
为啥要用userid作为分库的字段?
避免查询一个用户的订单进行跨库访问
订单优化方案
订单查询优化方案
1) 为什么要优化订单查询?
1)订单查询是一个高频接口,并且订单数据非常大。
2)面向C端用户的订单查询接口其访问量非常大。
3)对于运营端的订单查询接口虽然访问量不大但由于订单数据较多也需要进行优化,提高查询性能。
2) 确定优化方向
面向查询类的接口的优化方向是什么呢?
1)使用缓存
将查询的订单数据进行缓存,提高查询性能。
2)优化数据库索引提高查询性能
当缓存中没有时或缓存过期时会从数据库查询数据,通过添加数据库索引去提高数据库的查询性能。
3) 订单详情优化方案
针对单条订单信息查询接口可以使用缓存进行优化。
对于单条订单信息查询接口通过快照查询接口查询订单的详情信息。
参考AbstractStateMachine类的String getCurrentSnapshotCache(String bizId)快照查询方法,将快照信息缓存到 redis提供查询效率。
根据订单Id查询缓存信息,先从缓存查询如果缓存没有则查询快照表的数据然后保存到缓存中。缓存设置了过期时间是30分钟。
当订单状态变更,此时订单最新状态的快照有变更,会删除快照缓存,当再次查询快照时从数据库查询最新的快照信息进行缓存。
用户端订单列表优化方案
用户端通过小程序查询订单列表,界面上没有分页查询的按钮,用户端查询订单列表可采用滚动查询的方法。
滚动查询就是一次查询指定数量的记录,不用进行count查询,省去count查询的消耗。
具体方案如下:
首先查询符合条件的订单ID。
由于是滚动查询需要传入滚动ID,这里我们在订单表使用排序字段sort_by作为滚动ID。
滚动ID是一种递增的序列号,按服务预约时间降序排列且滚动ID具有唯一性,滚动ID的规则是:服务预约时间+订单号后5位。
滚动查询方式: 按SORT_BY降序,取小于SORT_BY的n条记录 第一页传入SORT_BY参数为空 从第二页开始传入上一页最后一条记录的SORT_BY
如果谁再问你“如何优化mysql分页查询”,请把这篇文章甩给他-腾讯云开发者社区-腾讯云
limit分页的原理
为什么会慢?我们不妨先猜测一下,像函数、扫描记录过多等等都会影响查询的速度,很显然这里我们并没有使用到函数,所以这会不会是扫描的记录过多呢?
这个就和limit有关了,你们知道limit是如何实现分页的吗?我们使用wxplain关键字来分别打印一下偏移量=0、1000、10000、400w的查询详情。
我们对比一下上面的信息,会发现只有一个字段的值有着很大的区别,那就是rows:扫描的行数,当limit分页的偏移量越大的时候扫描的行数就越多,这就是为什么我们查询越靠后的数据越慢。
假如你现在要查询的偏移量为100w,那么limit会扫描1000010行,然后丢弃前100w行数据,留下最后10行,返回给我们,所以说我们只需要控制扫描的行数,查询的速度自然就快了,那如何控制扫描的行数呢?
使用覆盖索引优化
聚集索引:查询条件只有主键的情况会通过聚集索引查询。
非聚集索引:查询条件有多个,此时为了提高查询效率可以创建多个字段的联合索引,根据非聚集索引找到符合条件主键,如果要查询的列只有索引字段则通过非聚集索引直接拿到字段值返回,如果要查询列有一部分在索引之外此时会进行回表查询聚集索引最终拿到数据。
覆盖索引(covering index)指一个查询语句的执行只需要从非聚集索引中就可以得到查询记录,而不需要回表去查询聚集索引,可以称之为实现了索引覆盖。
根据上边的需求,我们根据查询条件建立联合索引,通过联合索引找到符合条件的订单ID(主键),从索引中找到的符合条件的订单ID无需回表查询聚集索引。
使用订单ID匹配缓存,如果有缓存则直接获取否则从数据库查询。
订单详情查询优化
“根据订单id查询”,首先查询快照,快照存在了redis中,
@Overridepublic OrderResDTO getDetail(Long id) {//查询订单
// Orders orders = queryById(id);//从快照表查询快照String currentSnapshotJson = orderStateMachine.getCurrentSnapshotCache(String.valueOf(id));OrderSnapshotDTO orderSnapshotDTO = JsonUtils.toBean(currentSnapshotJson, OrderSnapshotDTO.class);//懒加载方式取消支付超时的 订单orderSnapshotDTO = canalIfPayOvertime(orderSnapshotDTO);OrderResDTO orderResDTO = BeanUtil.toBean(orderSnapshotDTO, OrderResDTO.class);return orderResDTO;}/*** 如果支付过期则取消订单* @param orderSnapshotDTO*/
public OrderSnapshotDTO canalIfPayOvertime(OrderSnapshotDTO orderSnapshotDTO){//订单状态Integer ordersStatus = orderSnapshotDTO.getOrdersStatus();//判断订单是未支付且支付超时(从订单创建时间开始15分钟未支付)
// if(ordersStatus==OrderStatusEnum.NO_PAY.getStatus() && orders.getCreateTime().isBefore(LocalDateTime.now().minusMinutes(15)) ){if(ordersStatus==OrderStatusEnum.NO_PAY.getStatus() && orderSnapshotDTO.getCreateTime().plusMinutes(15).isBefore(LocalDateTime.now())){//查询一下最新的支付状态,如果没有支付成功,再执行下边的取消代码OrdersPayResDTO payResultFromTradServer = ordersCreateService.getPayResultFromTradServer(orderSnapshotDTO.getId());//如果没有支付成功,再执行下边的取消代码if(ObjectUtils.isNotNull(payResultFromTradServer) && payResultFromTradServer.getPayStatus()!= OrderPayStatusEnum.PAY_SUCCESS.getStatus()){OrderCancelDTO orderCancelDTO = BeanUtils.toBean(orderSnapshotDTO,OrderCancelDTO.class);orderCancelDTO.setCurrentUserType(UserType.SYSTEM);orderCancelDTO.setCancelReason("订单支付超时系统自动取消");cancelByNoPay(orderCancelDTO);//从快照中查询订单数据String jsonResult = orderStateMachine.getCurrentSnapshotCache(String.valueOf(orderSnapshotDTO.getId()));orderSnapshotDTO = JSONUtil.toBean(jsonResult, OrderSnapshotDTO.class);return orderSnapshotDTO;}}return orderSnapshotDTO;}
每次状态变更时,都会删除缓存
优化用户端订单列表
建索引
按照最左前缀法则,查询条件必须包括最左边的索引列并将索引字段的顺序和SQL语句where 条件字段的顺序一致
索引失效
- 不要在索引列上进行运算操作,索引将失效。
- 字符串类型字段使用时,不加引号,索引将失效
- 如果仅仅是尾部模糊匹配,索引不会失效。如果是头部模糊匹配,索引失效(在like模糊查询中,在关键字后面加%,索引可以生效。而如果在关键字前面加了%,索引将会失效)
- 用 or 分割开的条件,如果 or 前的条件中的列有索引,而后面的列中没有索引,那么涉及的索引都不会被用到。
- 数据分布影响 如果MySQL评估使用索引比全表更慢,则不使用索引( 因为 MySQL 在查询时,会评估使用索引的效率与走全表扫描的效率, 如果走全表扫描更快,则放弃索引,走全表扫描。因为索引是用来索引少 量数据的,如果通过索引查询返回大批量的数据,则还不如走全表扫描来的快,此时索引就会失效。
执行计划
索引创建完成如何知道这个SQL语句是否用到这个索引呢?
重点看的是key,key_len,table,extra
id: 每个查询步骤的唯一标识符。
select_type: 查询的类型,例如 SIMPLE(简单查询)或 PRIMARY(主查询,嵌套子查询的最外层查询)。
table: 查询涉及的表。
partitions: 使用的分区(如果有分区表的话)。
type: 查询使用的连接类型,例如 ALL(全表扫描)或 index(索引扫描)。
possible_keys: 可能用于此查询的键列表。
key: 实际用于此查询的键。
key_len: 使用的索引长度。
ref: 显示索引的哪一列被用于查询。
rows: MySQL 估计将需要读取的行数。
filtered: 在表中的行数的百分比,表示查询的条件有多少行满足。
Extra: 其他的额外信息,例如使用了哪些索引、是否使用了文件排序等。
type: ALL
key和key_len为null
说明整个查询是全表查询没有用到索引。虽然我们创建的两个索引中包括了display 和sort_by,根据最左前缀法则 查询条件中没有包含索引中最左边的列,索引无效。
全表扫描通常会比较耗费情况,但也不是绝对的,有时候虽然有索引MySQL也会选择全表扫描的方式,这是因为优化器分析使用全表扫描比使用索引更耗费时间。
优化查询订单ID列表
这是原来的代码,直接从数据库查
优化后的代码
首先只查询订单的id,这样走的是索引覆盖,然后根据ids进行batchquery这样走的是聚集索引
/*** 滚动分页查询** @param currentUserId 当前用户id* @param ordersStatus 订单状态,0:待支付,100:派单中,200:待服务,300:服务中,400:待评价,500:订单完成,600:已取消,700:已关闭* @param sortBy 排序字段* @return 订单列表*/
@Override
public List<OrderSimpleResDTO> consumerQueryList(Long currentUserId, Integer ordersStatus, Long sortBy) {//构件查询条件LambdaQueryWrapper<Orders> queryWrapper = Wrappers.<Orders>lambdaQuery().eq(ObjectUtils.isNotNull(ordersStatus), Orders::getOrdersStatus, ordersStatus).lt(ObjectUtils.isNotNull(sortBy), Orders::getSortBy, sortBy).eq(Orders::getUserId, currentUserId).eq(Orders::getDisplay, EnableStatusEnum.ENABLE.getStatus()).select(Orders::getId);//只查询id列Page<Orders> queryPage = new Page<>();queryPage.addOrder(OrderItem.desc(SORT_BY));queryPage.setSearchCount(false);//查询订单id列表Page<Orders> ordersPage = baseMapper.selectPage(queryPage, queryWrapper);if (ObjectUtil.isEmpty(ordersPage.getRecords())) {return new ArrayList<>();}//提取订单id列表List<Long> orderIds= CollUtils.getFieldValues(ordersPage.getRecords(), Orders::getId);//todo: 先查询缓存,缓存没有再查询数据库....//根据订单id查询订单列表List<Orders> ordersList = batchQuery(orderIds);List<OrderSimpleResDTO> orderSimpleResDTOS = BeanUtil.copyToList(ordersList, OrderSimpleResDTO.class);return orderSimpleResDTOS;}
增加缓存
缓存订单数据使用Hash结构
我们需求是首先拿多个订单ID去缓存查询,如果有些key在缓存中不存在,此时去查询数据库,查询到数据再存入缓存。
根据缓存需求我们开发缓存工具类CacheHelper
@Component
public class CacheHelper {/*** 批量获取缓存数据,按照id列表顺序返回目标数据,如果缓存不存在则查询数据库** @param dataType 目标数据类型,CACHE_加dataType 为redisKey* @param objectIds 目标数据唯一id* @param batchDataQueryExecutor 批量目标数据获取执行器用于当缓存数据不存在时查询数据库* @param clazz 目标数据类型class* @param ttl 目标数据整体过期时间(ttl大于0才会设置有效期)* @param <K> 目标数据id数据类型* @param <T> 目标数据类型* @return*/
public <K, T> List<T> batchGet(String dataType, List<K> objectIds, BatchDataQueryExecutor<K, T> batchDataQueryExecutor, Class<T> clazz, Long ttl) {
查询到订单id后
首先查询所有的key,看看有没有value为null的,然后找出订单id的value为null的去数据库查询。存在则放入缓存,然后再放到list集合中返回
opsforhash()的mutiget,pullall
public <K, T> List<T> batchGet(String dataType, List<K> objectIds, BatchDataQueryExecutor<K, T> batchDataQueryExecutor, Class<T> clazz, Long ttl) {if (!StringUtils.isEmpty(dataType) && !CollUtils.isEmpty(objectIds)) {String redisKey = "CACHE_" + dataType;List<T> list = this.redisTemplate.opsForHash().multiGet(redisKey, objectIds);List<Integer> noCacheObjectIdIndexs = CollUtils.getIndexsOfNullData(list);if (CollUtils.isEmpty(noCacheObjectIdIndexs)) {return BeanUtils.copyToList(list, clazz);} else {List<K> noCacheObjectIds = CollUtils.valueofIndexs(objectIds, noCacheObjectIdIndexs);if (batchDataQueryExecutor == null) {return BeanUtils.copyToList(list, clazz);} else {Map<K, T> data = batchDataQueryExecutor.execute(noCacheObjectIds, clazz);if (CollUtils.isEmpty(data)) {return BeanUtils.copyToList(list, clazz);} else {this.redisTemplate.opsForHash().putAll(redisKey, data);if (ttl > 0L) {this.redisTemplate.expire(redisKey, ttl, TimeUnit.SECONDS);}Iterator var11 = noCacheObjectIdIndexs.iterator();while(var11.hasNext()) {Integer noCacheObjectIdIndex = (Integer)var11.next();list.set(noCacheObjectIdIndex, data.get(objectIds.get(noCacheObjectIdIndex)));}return BeanUtils.copyToList(list, clazz);}}}} else {return null;}}