详解如何优雅实现先分组再组内排序取数据解决方案

本文涉及的产品
检索分析服务 Elasticsearch 版,2核4GB开发者规格 1个月
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
简介: 本文介绍了在数据库查询中常见的业务需求:先对数据进行分组,然后在每组内按规则排序并取出特定记录。使用MySQL和Elasticsearch实现这一操作,并对比了不同方法的性能。具体包括:**MySQL实现**:通过窗口函数`ROW_NUMBER()`、子查询和JOIN关联查询三种方式实现分组排序取数据,并探讨了索引优化的效果。**Elasticsearch实现**:利用`terms`聚合和`top_hits`聚合实现分组排序,适用于大规模数据场景。推荐优先使用窗口函数,结合索引优化提升查询性能。对于小规模查询,可在应用层处理。通过实例和性能对比,帮助读者选择最适合的实现方案。

1.背景

之前有一段时间,老碰到一个需求业务逻辑场景:先对数据进行分组,然后在每组数据内按某种规则排序,最后取出每组的第一条记录,当然也不一定就是取第一条数据,也有可能是取前两条数据等等,看具体业务逻辑需要,总的来说是一种在数据库查询中常见的业务需求。本文将详细从使用MySQL, elasticsearch等方式总结这种操作的实现方案、使用场景以及优化技巧。

2.准备工作

所谓万事俱备只欠东风,要研究这个查询场景就得先准备好数据,还是使用一贯的用户表User

CREATE TABLE `tb_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_no` varchar(255) NOT NULL COMMENT '编号',
  `name` varchar(255) DEFAULT NULL COMMENT '昵称',
  `email` varchar(255) DEFAULT NULL COMMENT '邮箱',
  `phone` varchar(255) NOT NULL COMMENT '手机号',
  `gender` tinyint(4) NOT NULL DEFAULT '0' COMMENT '性别  0:男生   1:女生',
  `birthday` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '出生日期',
  `is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标志 0:否  1:是',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `address` varchar(1024) DEFAULT NULL COMMENT '地址',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=5201011 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;

这里我插入了500多万条数据,为啥是500w条数据?因为我有个小目标先挣个500w,哈哈,开个玩笑跑题了~~~,其实是我个人认为单表500w条数据不多也不少,正好可以验证一些SQL性能差异。

插入都是比较真实的模拟数据,至于怎么插入这么多数据,你可以写写存储过程,或者使用代码脚本插入,想使用代码插入的,可以看看之前我们总结的文章:最近做百万级数据性能压测,来看看人家如何使用MyBatis 优雅批量插入数据,从80s优化到1s!!!

这里我使用的数据库配置是:阿里云的云数据库RDS-MySQL 2核4GB,阿里云上可以免费使用3个月,有需要的可以去申请

数据已到位,我们开始分析先分组、再组内排序取数据实现方式,基于上面的数据:我们根据名字查询出每个名字最近插入的个人信息

3.MySQL实现

数据库查询,一条SQL搞定,永远是我们在实现功能需求逻辑的第一追求,当然只是首选也不一定必选,要考虑性能问题,凡事不能一概而论,这就像算法的好坏需要从空间和时间两个维度去考量一个道理。言归正传,我们看看数据库MySQL查询层面有哪些实现方式

3.1 使用窗口函数 ROW_NUMBER()

窗口函数是解决该问题的首选方法,既简洁又高效

SELECT
    * 
FROM
    ( SELECT id, NAME, birthday, ROW_NUMBER () OVER ( PARTITION BY NAME ORDER BY id DESC ) AS row_num FROM tb_user
      where name in('徐千云', '李亿石')
    ) AS u 
WHERE
    u.row_num=1

查询结果:

耗时:1.547s 注意窗口函数要 MySQL 8.0 及以上版本才有哦。

3.2 使用子查询和JOIN关联查询

话不多说直接上SQL:

SELECT id, name, birthday from tb_user 
where id in (SELECT MAX(id) from tb_user where name in('徐千云', '李亿石') group by name)

耗时:3.687s 明显比使用窗口函数要慢的多

换一种写法:

SELECT id, name, birthday from tb_user u
INNER JOIN (SELECT MAX(id) max_id from tb_user where name in('徐千云', '李亿石') group by name) as t
on u.id=t.max_id

耗时:1.418s 明显比子查询快很多,这就是大量数据下,不同的SQL查询性能差别是挺大的

3.3 优化点

上面的查询最快的都要1s多,这算是慢查询了肯定要优化,直接加索引

ALTER TABLE `db_test`.`tb_user` 
ADD INDEX `idx_name`(`name`) USING BTREE;

500w条的数据表,加索引会需要小一会儿。加完索引再次分别执行上面的SQL语句,执行结果耗时如下:

窗口函数:0.026s 子查询:2.229s JOIN关联查询:0.014s

从结果上来看,执行速度明显变快了,效果可谓是立竿见影

如果我们查询不需要出生日期birthday,以最慢的子查询为例:

SELECT id,name from tb_user 
where id in (SELECT MAX(id) from tb_user where name in('徐千云', '李亿石') group by name)

耗时:1.77s,也有算提升,因为这个可以使用覆盖索引,建设了回表的次数。

3.4 讨论

也不一定查询每个名字最新插入的数据,可能是查询每个名字年纪最小的第一条数据,这时候窗口函数查询如下:

SELECT
    id, name, birthday
FROM
    ( SELECT id, name,birthday, ROW_NUMBER () OVER ( PARTITION BY NAME ORDER BY birthday DESC ) AS row_num FROM tb_user
      where name in('徐千云', '李亿石')
    ) AS u 
WHERE
    u.row_num =1

耗时:0.30s 挺快的

使用子查询:

SELECT id, name, birthday from tb_user u1
where birthday = (select max(birthday) from tb_user u2 where u2.name in('徐千云', '李亿石') and u1.`name`=u2.`name`)

直接查询不出来~~~

项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用

Github地址https://github.com/plasticene/plasticene-boot-starter-parent

Gitee地址https://gitee.com/plasticene3/plasticene-boot-starter-parent

微信公众号Shepherd进阶笔记

交流探讨qun:Shepherd_126

4.elasticsearch实现

数据异构是解决数据量大的一大常见方式,我们经常使用elasticsearch来缓解MySQL数据库的查询压力,来应对海量数据的复杂查询,那接下来我们就来看看基于elasticsearch怎么实现先分组再组内排序取数据

在 Elasticsearch 中,可以使用 terms 聚合来实现分组,再结合 top_hits 聚合实现组内排序并取每组的第一条数据。terms 聚合用于对某个字段进行分组,而 top_hits 聚合用于在每个分组内获取指定数量的文档并按某种顺序排序。

查询每个名字年纪最小的第一条数据:

GET user_info/_search
{
   
  "size": 0,
  "query": {
   
    "bool": {
   
      "filter": [
        {
    "terms": {
    "name": ["徐千云", "李亿石"] }},
      ]
    }
  },
  "aggs": {
   
    "group_by_name": {
   
      "terms": {
   
        "field": "name",
        "size": 1000
      },
      "aggs": {
   
        "latest_user": {
   
          "top_hits": {
   
            "sort": [
              {
    "birthday": {
    "order": "desc" }}
            ],
            "_source": ["id", "name", "org_id", "birthday"],
            "size": 1
          }
        }
      }
    }
  }
}
  • size: 0:在根查询中设置 size0,表示不返回顶层文档,只返回聚合结果。
  • query 部分:使用 bool 查询,限定 name["徐千云", "李亿石"]内。
  • terms 聚合 (group_by_name):按 name 分组,size 设置为较大值(如 1000)以确保返回尽可能多的分组。
  • top_hits 聚合 (latest_user):在每个 name 分组内,按 birthday 字段降序排序,并只返回每组的第一个文档(即年龄最小的 的文档)。指定 _source 字段过滤以返回所需字段。

terms 聚合的 size 限制了返回分组的数量。若需要更多分组,可以增大 size 或使用 composite 聚合。对于较大数据集,此查询可能较慢,因为需要对分组中的每个文档进行排序,直接看代码:

    public Integer aggregateResults(UserQueryDTO dto, Consumer<List<UserInfo>> consumer) {
   
        final int pageSize = 500;
        Integer total = 0;
        Map<String, Object> afterKey = null;
        while (true) {
   
            // 创建 SearchRequest
            SearchRequest searchRequest = new SearchRequest(getAliasName());
            // 构建查询条件
            BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
            if (StringUtils.isNotBlank(dto.getTypes())) {
   
                boolQueryBuilder.filter(QueryBuilders.termsQuery("name", dto.getNames));
            }
            // Composite聚合实现并分页
            CompositeAggregationBuilder compositeAggregation = AggregationBuilders
                    .composite("group_by_name",
                            Lists.newArrayList( new TermsValuesSourceBuilder("name").field("name")))
                    .size(pageSize);
            // 设置分页的 afterKey
            compositeAggregation.aggregateAfter(afterKey);
            // 添加top_hits子聚合,size=1获取每个分组中的最新文档
            TopHitsAggregationBuilder topHitsAggregation = AggregationBuilders
                    .topHits("latest_user")
                    .sort("birthday", SortOrder.DESC)
                    .fetchSource(new String[]{
   "id", "name", "birthday"}, null)
                    .size(1);
            // 添加子聚合到 composite 聚合
            compositeAggregation.subAggregation(topHitsAggregation);
            // 构建 SearchSourceBuilder size=0不返回文档,只返回聚合结果
            SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder()
                    .size(0)
                    .query(boolQueryBuilder)
                    .aggregation(compositeAggregation);

            // 设置搜索请求,执行搜索
            searchRequest.source(searchSourceBuilder);
            SearchResponse response = search(searchRequest);

            // 处理分页聚合结果
            List<String> dataList = new ArrayList<>();
            ParsedComposite compositeAgg = response.getAggregations().get("group_by_name");
            // 遍历当前页的分组结果
            compositeAgg.getBuckets().forEach(bucket -> {
   
                ParsedTopHits topHits = bucket.getAggregations().get("latest_user");
                SearchHit[] hits = topHits.getHits().getHits();
                for (SearchHit hit : hits) {
   
                   dataList.add(hit.getSourceAsString());
                }
            });
            List<UserInfo> results = convert(dataList, UserInfo.class);
            // 回调
            consumer.accept(results);
            total = total + results.size();
            // 更新 afterKey,为下一页查询准备, 如果afterKey为空,说明已经查询到最后一页
            afterKey = compositeAgg.afterKey();
            if (afterKey == null) {
   
                break;
            }
        }
        return total;
    }

afterKey 参数:在 CompositeAggregationBuilder 中的 aggregateAfter(afterKey) 设置为上一次查询的 afterKey,用于实现分页。

分页循环:每次查询一页数据,更新 afterKey,直到 afterKeynull,表示已达到最后一页。

TopHitsAggregationBuilder:按 id 降序排序并返回每组中的最新文档,用于获取分组的最新记录。

不知道你是否有注意到方法aggregateResults()有一个回调函数参数Consumer<List<UserInfo>> consumer,当我们在操作大批量数据时,不可能一次性查出所有数据来处理,性能扛不住,只能分批分批查询,如果分页查询出来放到一个集合最后在处理,内存也支撑不住,这时候我们只在分页查询数据的同时处理相关数据,比如分页每页200条查询出200个名字年纪最小的人,并把他的更新时间更新为当前,这样就可以写一个更新更新时间的方法,传入aggregateResults(),等分页查询出用户之后,根据用户id回调更新即可。

关于优雅回调的编程代码技巧请看之前总结的:拒绝一直写CRUD!!!使用回调机制Callback和函数式编程码出优雅结构化代码

5.总结

分组后组内排序取第一条记录的操作,是 SQL 查询中的高频需求。推荐优先使用窗口函数 ROW_NUMBER(),其次是子查询方法,并根据实际业务场景选择合适的实现方式。同时,结合索引和字段优化,可以显著提升查询性能。当然这是针对大批量查询的情况下,如果你这是查询一两个姓名的最小年纪这种,你完全可以查询出所有数据,在Java代码逻辑中利用内存分组之后再排序,取相应数据即可。如果本文对你有帮助的话,麻烦给个一键三连(点赞、在看、转发分享)支持一下,感谢Thanks♪(・ω・)ノ

相关实践学习
使用阿里云Elasticsearch体验信息检索加速
通过创建登录阿里云Elasticsearch集群,使用DataWorks将MySQL数据同步至Elasticsearch,体验多条件检索效果,简单展示数据同步和信息检索加速的过程和操作。
ElasticSearch 入门精讲
ElasticSearch是一个开源的、基于Lucene的、分布式、高扩展、高实时的搜索与数据分析引擎。根据DB-Engines的排名显示,Elasticsearch是最受欢迎的企业搜索引擎,其次是Apache Solr(也是基于Lucene)。 ElasticSearch的实现原理主要分为以下几个步骤: 用户将数据提交到Elastic Search 数据库中 通过分词控制器去将对应的语句分词,将其权重和分词结果一并存入数据 当用户搜索数据时候,再根据权重将结果排名、打分 将返回结果呈现给用户 Elasticsearch可以用于搜索各种文档。它提供可扩展的搜索,具有接近实时的搜索,并支持多租户。
目录
相关文章
|
1天前
|
搜索推荐 数据挖掘 API
淘宝商品描述 API 接口的开发、应用与收益
淘宝商品描述API接口是淘宝开放平台提供的服务,允许开发者编程获取商品详情,如标题、价格、图片等。通过注册账号、申请权限并调用API,可构建比价工具、推荐系统、自动化上架工具等应用,提升用户体验与运营效率,创造新的商业模式。该接口为电商从业者提供了强大的数据支持和创新机会。
47 22
|
17天前
|
边缘计算 自然语言处理 索引
大模型承重墙,去掉了就开始摆烂!苹果给出了超级权重
近期,苹果公司与圣母大学的研究人员发现大型语言模型(LLM)中存在“超级权重”参数,尽管仅占模型参数的0.01%,却对模型性能有极大影响。去除一个超级权重可使困惑度增加三个数量级,零样本准确性大幅下降。研究提出一种数据无关方法识别这些权重,并通过保留超级权重和超级激活,显著提升模型压缩效果,在量化后保持较高性能。该发现对资源受限环境下的LLM部署有重要意义。论文链接:https://arxiv.org/pdf/2411.07191。
46 20
|
1天前
|
人工智能 算法 搜索推荐
单纯接入第三方模型就无需算法备案了么?
随着人工智能的发展,企业接入第三方模型提升业务能力的现象日益普遍,但算法备案问题引发诸多讨论。根据相关法规,无论使用自研或第三方模型,只要涉及向中国境内公众提供算法推荐服务,企业均需履行备案义务。这不仅因为服务性质未变,风险依然存在,也符合监管要求。备案内容涵盖模型基本信息、算法优化目标等,且需动态管理。未备案可能面临法律和运营风险。建议企业提前规划、合规管理和积极沟通,确保合法合规运营。
|
17天前
|
人工智能 监控 安全
面向代码语言模型的安全性研究全新进展,南大&NTU联合发布全面综述
南京大学与新加坡管理大学联合发布论文《代码语言模型的安全性:系统文献综述》,系统分析了67篇相关论文,探讨了CodeLMs面临的数据泄露、模型篡改等安全威胁,并介绍了数据加密、模型加固等防御策略。论文不仅总结了现有研究成果,还指出了未来研究方向,强调了在提升模型性能的同时确保其安全性的重要性。该研究对推动代码语言模型的安全性发展具有重要意义。
45 27
|
2天前
|
搜索推荐 数据挖掘 API
Lazada 淘宝详情 API 的价值与应用解析
在全球化电商浪潮下,Lazada 和淘宝作为东南亚和中国电商市场的关键力量,拥有海量商品数据和庞大用户群体。详情 API 接口为电商开发者、商家和分析师提供了获取商品详细信息(如描述、价格、库存、评价等)的工具,助力业务决策与创新。本文深入解析 Lazada 和淘宝详情 API 的应用场景及价值,并提供 Python 调用示例,帮助读者更好地理解和运用这两个强大的工具。
31 18
|
22天前
|
机器学习/深度学习 存储 算法
Pandas数据应用:客户流失预测
本文介绍如何使用Pandas进行客户流失预测,涵盖数据加载、预处理、特征工程和模型训练。通过解决常见问题(如文件路径错误、编码问题、列名不一致等),确保数据分析顺利进行。特征工程中创建新特征并转换数据类型,为模型训练做准备。最后,划分训练集与测试集,选择合适的机器学习算法构建模型,并讨论数据不平衡等问题的解决方案。掌握这些技巧有助于有效应对实际工作中的复杂情况。
134 95
|
1天前
|
关系型数据库 MySQL 数据库
Docker Compose V2 安装常用数据库MySQL+Mongo
以上内容涵盖了使用 Docker Compose 安装和管理 MySQL 和 MongoDB 的详细步骤,希望对您有所帮助。
61 42
|
1天前
|
JSON Shell 数据格式
初识dockerFile之RUN和WORKDIR
通过本文的介绍,我们详细讲解了Dockerfile中的RUN和WORKDIR指令。RUN指令用于执行命令,生成新的镜像层;WORKDIR指令用于设置工作目录,简化路径管理。合理使用这两个指令,可以提高Dockerfile的可读性和效率。
133 109
|
2月前
|
监控 算法 网络协议
Java 实现局域网电脑屏幕监控算法揭秘
在数字化办公环境中,局域网电脑屏幕监控至关重要。本文介绍用Java实现这一功能的算法,涵盖图像采集、数据传输和监控端显示三个关键环节。通过Java的AWT/Swing库和Robot类抓取屏幕图像,使用Socket进行TCP/IP通信传输图像数据,并利用ImageIO类在监控端展示图像。整个过程确保高效、实时和准确,为提升数字化管理提供了技术基础。
80 15
|
15天前
|
人工智能 搜索推荐 Serverless
AI 剧本生成与动画创作方案评测
《AI剧本生成与动画创作》解决方案评测:该方案利用阿里云技术,实现从剧本撰写到视频合成的一站式自动化流程,部署文档指引准确,逻辑清晰。内容创作上显著简化流程、降低门槛,适合短视频创作者等用户,但部分术语较晦涩,特定风格的动画创作个性化不足。建议增加模板和教程,优化服务初始化流程,进一步提升用户体验。
54 15