基于索引的单表查询,是 MySQL 正确打开方式!
基于 QueryObject 的声明式查询,是简单查询的正确使用方式!
单表查询在业务开发中占比最大,是所有 CRUD Boy 的入门必备,所有人在 JAVABean 和 SQL 之间乐此不疲。
整体架构如下图所示:
这是一个简单的分层架构,主要有:
其中 ORM 框架尤为重要,帮我们完成 对象 与 关系数据 间的相互转换。因此,不少人认为玩好 ORM 就成为了高级开发人员。而实际情况是:该部分是最枯燥、最没有技术含量的“技能”。
目前,最常见的 ORM 便是 MyBatis 和 JPA,以一个简单的分页查询 User 为例做一个简短介绍。
按照用户状态分页查询 User 信息:
查询入参如下:
@Data
public class QueryUserByStatus {
private Integer status;
private String mobile;
private Date birthAfter;
private Date birthBefore;
private Pageable pageable;
}
接口签名如下:
Page<User> queryByStatus(QueryUserByStatus queryByStatus);
这个是最简单的 case,分别使用 MyBatis 和 Jpa 进行实现。
MyBatis是一款基于 Java 语言的持久层框架,它为SQL映射、数据处理和事务管理提供了优秀的支持。MyBatis已成为使用最广泛的ORM框架之一,它支持极为灵活的自定义SQL,同时也提供了与Spring Framework和Spring Boot等流行框架的集成方案,为Java程序员提供了极大的便利。
基于MyBatis实现的核心代码如下:
@Autowired
private MyBatisUserMApper userMapper;
public Page<MyBatisUser> queryByStatus(QueryUserByStatus query){
// 状态不填
if (query.getStatus() null){
throw new IllegalArgumentException("status can not null");
}
// 分页必填
if (query.getPageable() null){
throw new IllegalArgumentException("pageable can not null");
}
MyBatisUserExample userExample = new MyBatisUserExample();
MyBatisUserExample.Criteria criteria = userExample.createCriteria();
// 添加状态过滤
criteria.andStatusEqualTo(query.getStatus());
// 添加手机号过滤
if (query.getMobile() != null){
criteria.andMobileEqualTo(query.getMobile());
}
// 添加生日过滤
if (query.getBirthAfter() != null){
criteria.andBirthAtGreaterThan(query.getBirthAfter());
}
// 添加生日过滤
if (query.getBirthBefore() != null){
criteria.andBirthAtLessThan(query.getBirthBefore());
}
// 添加分页信息
userExample.setOffset(query.getPageable().offset());
userExample.setRows(query.getPageable().getPageSize());
// 查询数据
long totalItems = this.userMapper.countByExample(userExample);
List<MyBatisUser> users = this.userMapper.selectByExample(userExample);
// 封装结果
return new Page<>(users, query.getPageable(), totalItems);
}
JPA是Java Persistence API(Java持久化API)的简称,它是Sun官方提供的一套标准的ORM框架(对象关系映射框架)。JPA提供了一种以面向对象方式来管理关系型数据库的方法,使开发人员可以使用对象而不是SQL来操作数据库。JPA提供了一套公共的API,使开发人员可以在不同的ORM实现(如Hibernate、EclipseLink等)中自由切换。
基于Jpa实现的核心代码如下:
@Autowired
private JpaUserRepository jpaUserRepository;
public Page<JpaUser> queryByStatus(QueryUserByStatus queryByStatus){
// 状态必填
if (queryByStatus.getStatus() null){
throw new IllegalArgumentException("status can not null");
}
// 分页必填
if (queryByStatus.getPageable() null){
throw new IllegalArgumentException("pageable can not null");
}
// 构建分页参数
Pageable pageable = PageRequest.of(queryByStatus.getPageable().getPageNo(), queryByStatus.getPageable().getPageSize());
// 构建过滤条件
Specification<JpaUser> spec = Specification.where((root, query, cb) -> {
List<Predicate> predicates = Lists.newArrayList();
// 添加状态过滤
Predicate statusPredicate = cb.equal(root.get("status"), queryByStatus.getStatus());
predicates.add(statusPredicate);
// 添加手机过滤
if (queryByStatus.getMobile() != null){
Predicate mobilePredicate = cb.equal(root.get("mobile") , queryByStatus.getMobile());
predicates.add(mobilePredicate);
}
// 添加生日过滤
if (queryByStatus.getBirthAfter() != null){
Predicate birthAfterPredicate = cb.greaterThan(root.get("birthAt") , queryByStatus.getBirthAfter());
predicates.add(birthAfterPredicate);
}
// 添加生日过滤
if (queryByStatus.getBirthBefore() != null){
Predicate birthBeforePredicate = cb.lessThan(root.get("birthAt") , queryByStatus.getBirthBefore());
predicates.add(birthBeforePredicate);
}
// 组合过滤条件
return cb.and(predicates.toArray(new Predicate[predicates.size()]));
});
// 查询数据
org.springframework.data.domAIn.Page<JpaUser> all = this.jpaUserRepository.findAll(spec, pageable);
// 封装结果
return new Page<>(all.getContent(), queryByStatus.getPageable(), all.getTotalElements());
}
通常情况下,使用哪个 ORM 框架,都是由公司规范规定,一般人没办法左右。但,无论使用哪个框架,面对的问题基本是一致的。
这种开发模型,存在以下几个问题:
MySQL 常见的查询优化手段非常多:
在众多优化方式中选择最主要的一项便是:索引优化:
B+Tree 在 MySQL 中极为重要,它既是一种数据的组织结构,比如聚簇索引。又是查询优化最重要的一种手段,比如非聚簇索引。
B+Tree 在 MySQL 中是如此重要,它是 MySQL 使用的默认索引。B+Tree 索引不仅可以加速单个键值查询,还可以支持范围查找并为查询结果排序。此外,B+Tree 还可以支持高效的插入和删除操作,当在一个 B+Tree 索引中插入或删除记录时,B+Tree 索引通过特定规则进行拆分和合并来实现重新平衡。
在 MySQL 中,B+Tree 索引不仅适用于普通表,还适用于主键索引、唯一索引、辅助索引等。因此,了解 B+Tree 索引的设计和原理对于开发高效、可扩展的 MySQL 应用程序至关重要。
以下是一个 B+Tree 的示意图:
B+Tree作为一种数据组织方式,有以下几个特点:
MySQL 中最常见的索引包括:
如下图所示:
这种先查辅助索引再查主键索引的行为,我们称之为“回表”。
看一个回表的例子:
table: id, category, publisher, status, title
index: idx_categity(category,status)
查询语句:select * from tb_news where category = 2 and publisher = 14
执行逻辑如图所示:
一般情况下,回表的性能损失还是可接受的,可以在发现问题后进行处理。可将更多精力放在提升研发效率上。
基于 B+Tree 数据结构的特点,在以下场景可以高效使用索引:
以下几种情况无法使用索引:
在了解 MySQL B+Tree 的内部实现之后,可以推导出一套规范,来对查询性能进行保障。
对于一个查询请求,需要具备:
假如在order表中存在一个索引(user_id, status),那么可以存在以下查询:
// 可以支持多组高效查询
// User维度查询对象
@Data
public class QueryOrderByUser {
// user id 不能为 null,不然无法使用索引
@NotNull
private Long userId;
private Integer status;
private Pageable pageable;
}
// User 和 Status 维度查询
@Data
public class QueryOrderByUserAndStatus {
// user id 不能为 null,不然无法使用索引
@NotNull
private Long userId;
// status 不能为 null,不然无法使用索引
@NotNull
private Integer status;
private Pageable pageable;
}
// 查询服务如下
public interface OrderService {
// User 维度查询
List<Order> listByUser(QueryOrderByUser query);
Long countByUser(QueryOrderByUser query);
Page<Order> pageByUser(QueryOrderByUser query);
// User 和 Status 维度查询
List<Order> listByUserAndStatus(QueryOrderByUserAndStatus query);
Long countByUserAndStatus(QueryOrderByUserAndStatus query);
Page<Order> pageByUserAndStatus(QueryOrderByUserAndStatus query);
}
这样便可以在性能和扩展性间找到一个良好的平衡点。
我们需要一个框架,在满足原则和规范前提下,灵活的定制简单数据查询,但又不能过于灵活,需要对使用方式进行严格限制。
灵活定制,快速开发,提升效率,降低bug;对使用进行限制,是为了将掌控权控制在开发,不会因为使用不当造成线上问题。因此,对框架有如下要求:
框架整体流程如下:
该模式下,开发查询功能只需:
只需在QueryObject上进行定义,无需编写 SQL,由框架对 QueryObject 进行解析,完成动态查询。
核心功能全部在 QueryRepository 中,其核心流程如下:
流程如下:
为了支持多个 ORM 框架,整体结构设计如下:
核心模块包括:
提供统一的接口和配置能力,对使用方式进行规范。其中包括两大部分:
注解配置于 QueryObject 之上,以声明化的方式,对过滤功能进行描述。
常见注解如下:
注解 |
含义 |
FieldEqualTo |
等于 |
FieldGreaterThan |
大于 |
FieldGreaterThanOrEqualTo |
大于等于 |
FieldIn |
in 操作 |
FieldIsNull |
是否为 null |
FieldLessThan |
小于 |
FieldLessThanOrEqualTo |
小于等于 |
FieldNotEqualTo |
不等于 |
FieldNotIn |
not in |
EmbeddedFilter |
嵌入查询对象 |
针对之前的 User 查询实例,对应的 查询对象定义如下:
@Data
public class QueryUserByStatus {
// 状态相等
@FieldEqualTo("status")
@NotNull
private Integer status;
// 手机号相等
@FieldEqualTo("mobile")
private String mobile;
// 生日比该值大
@FieldGreaterThan("birthAt")
private Date birthAfter;
// 生日比该值小
@FieldLessThan("birthAt")
private Date birthBefore;
// 自动具备分页能力
private Pageable pageable;
}
有了 QueryObject 之后,需要一组查询 API 以满足各个场景需求,标准的 API 接口定义如下:
public interface QueryObjectRepository<E> {
// 检查查询对象的有效性
void checkForQueryObject(Class cls);
// 单条查询
<Q> E get(Q query);
// 分页查询
default <Q, R> R get(Q query, Function<E, R> converter) {
E entity = this.get(query);
return entity null ? null : converter.apply(entity);
}
// 统计查询
<Q> Long countOf(Q query);
// 列表查询
default <Q, R> List<R> listOf(Q query, Function<E, R> converter) {
List<E> entities = this.listOf(query);
return CollectionUtils.isEmpty(entities) ? Collections.emptyList() : (List)entities.stream().filter(Objects::nonNull).map(converter).filter(Objects::nonNull).collect(Collectors.toList());
}
// 列表查询
<Q> List<E> listOf(Q query);
// 分页查询
default <Q, R> Page<R> pageOf(Q query, Function<E, R> converter) {
Page<E> entityPage = this.pageOf(query);
return entityPage null ? null : entityPage.convert(converter);
}
// 分页查询
<Q> Page<E> pageOf(Q query);
}
有了 QueryObject 和 API 之后,便可以轻松完成各种查询:
public class SingleQueryService {
@Autowired
private QueryObjectRepository<JpaUser> repository;
public List<JpaUser> listByStatus(QueryUserByStatus query){
return repository.listOf(query);
}
public Long countByStatus(QueryUserByStatus query){
return this.repository.countOf(query);
}
public Page<JpaUser> pageByStatus(QueryUserByStatus query){
return this.repository.pageOf(query);
}
}
万事具备,只欠最后的 QueryObjectRepository 实现,针对不同的 ORM 提供不同的实现。
基于 MyBatis Generator 的 Example 机制实现,需要配置相关的 Generator 以生成 EntityExample 对象。
直接继承BaseReflectBasedExampleSingleQueryRepository,注入 Mapper 实现,指定好 Example 类即可,具体如下:
@Service
public class MyBatisBasedQueryRepository extends BaseReflectBasedExampleSingleQueryRepository {
// 注入 MyBatis 的 Mapper 类
public MyBatisBasedQueryRepository(MyBatisUserMapper mapper) {
// 指定查询所需的 Example 类
super(mapper, MyBatisUserExample.class);
}
}
整体架构如下:
核心流程如下:
其中,从 QueryObject 到 Example 实例的转换为框架的核心,主要包括如下几部分:
基于 JPA 框架的 JpaSpecificationExecutor 实现,EntityRepository 需继承 JpaSpecificationExecutor 接口。
直接继承BaseSpecificationQueryObjectRepository,注入 JpaSpecificationExecutor 和 实体对象即可,具体如下:
public class JpaBasedQueryRepository extends BaseSpecificationQueryObjectRepository {
// 注入 JpaUserRepository 和 specificationConverterFactory(框架自动生成)
public JpaBasedQueryRepository(JpaUserRepository userRepository,
SpecificationConverterFactory
specificationConverterFactory) {
// 指定实体对象 JpaUser
super(userRepository, JpaUser.class, specificationConverterFactory);
}
}
整体架构如下:
核心流程如下:
其中,从 QueryObject 到相关输入参数的转换为框架的核心,主要包括如下几部分:
本文从一个日常开发场景出发,提出两个关键问题:
对于性能问题,从 MySQL B+Tree 进行推演,总结出该场景下的最佳使用实践,并将其提取为规范。
对于代码繁琐问题,提出通过在 QueryObject 上增加注解的方式来实现简单查询。
两者相结合,便形成了 Single Query 框架: