一种优雅实现多表查询的新思路

2024年 5月 9日 55.6k 0

哈喽,各位代码战士们,我是Jensen,一个梦想着和大家一起在代码的海洋里遨游,顺便捡起那些散落的知识点的程序员小伙伴。

上一篇文章有一些小伙伴在吐槽我的AC架构:

一种优雅实现多表查询的新思路-1

这里我统一补充:AC架构不是银弹,不适用于所有场景。对于需要精细化管理接口的业务,还是要拆开一个个接口去写;而对于小而美的微服务、单表CRUD比较多的管理后台,采用AC架构可以节省大量重复性编码。

也有很多小伙伴私信我:使用AC架构怎么解决联表查询问题?本文将为大家揭晓。

本文涉及技术点:存储方式、内存聚合。

一、冗余存还是范式存

在数据库设计中,冗余存储(Redundant Storage)和范式存储(Normal Form Storage)是两种不同的数据组织方式,它们各自有不同的优缺点。

冗余存储的优缺点:

优点:

  • 查询性能:冗余存储可以通过减少连接操作来提高查询性能,因为所需数据已经被存储在了同一个地方。
  • 减少I/O:在查询时,可以减少磁盘I/O操作,因为所有数据都在同一个表中。
  • 简化应用逻辑:应用层不需要处理复杂的多表关联,简化了应用逻辑。

缺点:

  • 数据不一致:冗余数据可能导致数据不一致性问题,特别是在数据更新时,需要确保所有冗余的副本都被正确更新。
  • 存储空间:冗余存储会占用更多的存储空间,因为相同的数据在多个地方被存储。
  • 维护困难:随着数据量的增加,冗余数据的维护变得更加困难,任何结构变更都可能涉及到多个表的修改。

范式存储的优缺点:

优点:

  • 数据一致性:范式化减少了数据冗余,有助于保持数据一致性。
  • 存储效率:通过消除重复数据,可以节省存储空间。
  • 数据完整性:范式化有助于实施数据完整性约束,如实体完整性、参照完整性等。

缺点:

  • 查询性能:范式化可能导致查询性能下降,因为需要执行多表连接来获取完整数据。
  • 增加I/O:多表连接可能会增加磁盘I/O操作,特别是在涉及大型表的情况下。
  • 复杂查询:应用层可能需要编写更复杂的SQL查询来处理多表关联。

我们再复习一下大学老师教过的数据库范式(Normal Forms):

范式化是数据库设计中的一个概念,旨在减少数据冗余和提高数据完整性。有几种不同的范式,包括:

  • 第一范式(1NF):每个表格的每个列都是不可分割的基本数据项。
  • 第二范式(2NF):在1NF的基础上,所有非主属性完全依赖于主键。
  • 第三范式(3NF):在2NF的基础上,没有传递依赖,即非主属性只能依赖于主键,不能依赖于其他非主属性。

在实际应用中,完全遵循范式化可能会导致查询性能问题,因此通常会根据实际情况进行适度的反范式化(Denormalization),即有意引入一些冗余来优化性能。

设计数据库时,需要在数据一致性、存储效率和查询性能之间做出权衡。

二、走进“聚合”

“聚合”二字经常出现在程序员之间、程序员与产品经理的对话中,虽然产品不懂技术语言,他们只管给客户实现特定需求,为技术提供产品原型,但是技术比较关心到底是冗余存还是范式存。

如果涉及的数据需要分表存储,单表查满足不了客户需求,这时就需要进行数据聚合,传统的方式是写SQL,Join连接多张表,返回多张表的数据给前端,这种方式对数据库查询有一定的压力,整体性能虽然较好,但缺点也很明显:

随着业务量越来越大,工程里的SQL会满天飞,并且SQL会写得越来越复杂,对维护SQL的人简直是噩耗!

这意味着什么?代码写了两三年后,SQL已经很难维护了,没人敢动,每次看SQL都特别费劲,还不知道哪一天会出现慢SQL。

其实除了写SQL语句,我们还能通过仓库实现层做数据聚合,这就是所谓的“内存Join”,把数据库压力转移到了不要钱的CPU与内存中,具体方法如下:

  • 查询参数Query对象添加Boolean fillXxx字段,用于控制是否要聚合。
  • 仓库实现XxxRepositoryImpl实现fill(Query query, List models)方法,models就是查询出主表的结果集,通过query.fillXxx=true控制聚合其他表的数据。

这个fillXxx就是聚合参数,大多数情况下是默认关闭的,也就是默认还是单表查询,如果需要聚合,由前端传参控制,这样既可以兼顾性能,也可以兼顾功能,我们按需聚合数据即可。

下面我们看看一个真实案例,预约单仓库实现,聚合预约主体、预约记录、预约主体评价:

/**
 * 预约单仓库实现
 */
@Repository
public class AppointmentOrderRepositoryImpl extends
        BaseRepositoryImpl implements
        AppointmentOrderRepository {


    // 聚合方法
    @Override
    public void fill(AppointmentOrderQuery query, List appointmentOrders) {
        Map orderId2appointment = appointmentOrders.stream().collect(Collectors.toMap(AppointmentOrder::getOrderId, o -> o));
        if (query.getFillAppointment()) {
            // 聚合预约主体
            this.fillAppointment(appointmentOrders);
        }
        if (query.getFillAppointmentRecords()) {
            // 聚合预约记录列表
            this.fillAppointmentRecords(orderId2appointment);
        }
        if (query.getFillAppointmentComment()) {
            // 聚合预约主体评论
            this.fillAppointmentComments(appointmentOrders);
        }
    }


    private void fillAppointment(List appointmentOrders) {
        // 通过预约单的主体ID列表(去重)查询主体
        Set appointIds = appointmentOrders.stream().map(AppointmentOrder::getAppointId).collect(Collectors.toSet());
        List appointments = AppointmentQuery.builder().idIn(appointIds).build().list();
        if (CollKit.isEmpty(appointments)) {
            return;
        }
        // 按主体ID映射,把主体的信息写到预约单内
        Map map = appointments.stream().collect(Collectors.toMap(Appointment::getId, o -> o));
        for (AppointmentOrder appointmentOrder : appointmentOrders) {
            Appointment appointment = map.get(appointmentOrder.getAppointId());
            if (appointment != null) {
                appointmentOrder.setAppointment(appointment);
                appointmentOrder.setAppointName(appointment.getName());
            }
        }
    }


    private void fillAppointmentRecords(Map orderId2appointment) {
        // 查出指定预约单ID列表的预约记录
        List appointmentRecords = AppointmentRecordQuery.builder().orderIdIn(orderId2appointment.keySet()).fillTimeSchedules(true).build().list();
        if (CollKit.isEmpty(appointmentRecords)) {
            return;
        }
        // 按预约单ID分组,让预约单关联多条预约记录
        Map map = appointmentRecords.stream().collect(Collectors.groupingBy(AppointmentRecord::getOrderId));
        for (String id : map.keySet()) {
            orderId2appointment.get(id).setAppointmentRecords(map.get(id));
        }
    }


    private void fillAppointmentComments(List appointmentOrders) {
        // 查出指定预约单ID列表的主体评论
        List orderIds = appointmentOrders.stream().map(AppointmentOrder::getOrderId).collect(Collectors.toList());
        List appointmentComments = AppointmentCommentQuery.builder().orderIdIn(orderIds).build().list();
        if (CollKit.isEmpty(appointmentComments)) {
            return;
        }
        // 按预约单ID分组,让预约单关联首条预约主体评论
        Map map = appointmentComments.stream().collect(Collectors.groupingBy(AppointmentComment::getOrderId));
        for (AppointmentOrder appointmentOrder : appointmentOrders) {
            appointmentOrder.setAppointmentComment(CollKit.isNotEmpty(map.get(appointmentOrder.getOrderId())) ? map.get(appointmentOrder.getOrderId()).get(0) : null);
        }
    }


}

需要注意的是,fill方法要对多条结果进行批量处理,如果是单个结果,每一条结果都需要聚合多表数据,那势必会加大内存与数据库连接的负担。以上预约单表聚合其他三张表,比SQL的Join多调了3次查询,但好在逻辑清晰,减少维护SQL的负担。

聚合查询的打开方式就很简单了,如果前端不传聚合参数,那么由后端接口来控制:

// 设置聚合参数(或由前端传入)
query.setFillAppointment(true);
query.setFillAppointmentRecords(true);
query.setFillAppointmentComment(true);
// 分页查询后,会自动调用fill方法,对结果集聚合
Page page = query.page();
// 调用聚合方法(以下代码在D3Boot框架内已实现,不需要写)
appointmentOrderRepository.fill(query, page.getRecords());

我们通过这种方式来灵活聚合多表数据,不仅不用改动Controller,还能兼顾多种数据聚合的场景,这种数据的聚合,不一定要聚合数据库的数据,也能聚合第三方接口的数据。仓库接口只关心需要提供什么数据即可,怎么聚合数据、数据源来自哪儿,是仓库实现要负责的事情。

三、写在最后

目前这种聚合方式也只适用于大部分场景,对于多张大表的聚合,还得考虑是用数据库Join还是内存Join的方式进行,或者在设计的时候就要考虑冗余存而不是范式存。

相关文章

Oracle如何使用授予和撤销权限的语法和示例
Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
社区版oceanbase安装
Oracle 导出CSV工具-sqluldr2
ETL数据集成丨快速将MySQL数据迁移至Doris数据库

发布评论