0

0

处理复杂多对多关系中SQL聚合函数的挑战:以多币种销售数据为例

碧海醫心

碧海醫心

发布时间:2025-10-06 14:32:20

|

242人浏览过

|

来源于php中文网

原创

处理复杂多对多关系中SQL聚合函数的挑战:以多币种销售数据为例

在处理包含多对多关系的数据库查询时,直接使用SQL聚合函数(如SUM)可能因行重复导致结果不准确。本文将深入探讨这一常见陷阱,并通过一个多币种销售数据的实际案例,展示如何利用子查询、CTE(公共表表达式)以及基于正确维度(如种)的聚合策略,有效解决聚合计算中的数据重复问题,确保复杂业务场景下数据统计的准确性。

1. 多对多关系与聚合陷阱

在关系型数据库中,当我们需要从多个通过一对多或多对多关系连接的表中汇总数据时,常常会遇到一个被称为“聚合陷阱”的问题。例如,一个销售记录(sale)可以有多个销售明细(sale_lines),也可以有多个现金交易记录(cash_transactions)。如果直接将这些表连接起来,再对某个字段进行求和,sql server(或其他数据库)会先生成一个包含所有连接行的大结果集,然后在这个结果集上进行聚合。这导致与主表(sale)相关的重复行被多次计算,从而得出错误的汇总结果。

考虑以下数据库结构:

  • currency (币种信息)
  • product (产品信息)
  • sale (销售主记录,记录销售使用的币种)
  • sale_lines (销售明细,记录每个产品的销售价格和数量,币种与sale一致)
  • cash_transactions (现金交易记录,记录收到和转换的金额及币种)

其中,sale与sale_lines是一对多关系,sale与cash_transactions也是一对多关系。cash_transactions中的received_currency_id和converted_currency_id可能与sale的currency_items_sold_in不同。

示例数据模型 (DDL):

CREATE TABLE currency (
  iso_number CHARACTER VARYING(3) PRIMARY KEY,
  iso_code CHARACTER VARYING(3)
);
INSERT INTO currency(iso_number, iso_code) VALUES ('208','DKK'), ('752','SEK'), ('572','NOK');

CREATE TABLE product (
  id SERIAL PRIMARY KEY,
  name CHARACTER VARYING(12),
  current_price INTEGER
);
INSERT INTO product(id,name,current_price) VALUES (1,'icecream',200), (2,'sunglasses',300);

CREATE TABLE sale (
  id SERIAL PRIMARY KEY,
  time_of_sale TIMESTAMP,
  currency_items_sold_in CHARACTER VARYING(3)
);
INSERT INTO sale(id, time_of_sale, currency_items_sold_in) 
VALUES 
(1, CURRENT_TIMESTAMP, '208'), -- 销售1以DKK计价
(2, CURRENT_TIMESTAMP, '752')  -- 销售2以SEK计价
;

CREATE TABLE sale_lines (
  id SERIAL PRIMARY KEY,
  sale_id INTEGER,
  product_id INTEGER,
  price_paid INTEGER,
  quantity FLOAT
);
INSERT INTO sale_lines(id, sale_id, product_id, price_paid, quantity)
VALUES 
(1, 1, 1, 200, 1.0), -- 销售1明细1
(2, 1, 2, 300, 1.0), -- 销售1明细2

(3, 2, 1, 100, 1.0), -- 销售2明细1
(4, 2, 1, 100, 1.0)  -- 销售2明细2
;

CREATE TABLE cash_transactions (
  id SERIAL PRIMARY KEY,
  sale_id INTEGER,
  received_currency_id CHARACTER VARYING(3),
  converted_currency_id CHARACTER VARYING(3),
  received_amount INTEGER,
  converted_amount INTEGER
);
INSERT INTO cash_transactions(id, sale_id, received_currency_id, converted_currency_id, received_amount, converted_amount)
VALUES
(1, 1, '208', '208', 200, 200), -- 销售1交易1: DKK -> DKK
(2, 1, '752', '208', 400, 300), -- 销售1交易2: SEK -> DKK (收到SEK 400,转换为DKK 300)

(3, 2, '572', '208', 150, 100), -- 销售2交易1: NOK -> DKK (收到NOK 150,转换为DKK 100)
(4, 2, '208', '208', 100, 100)  -- 销售2交易2: DKK -> DKK
;

问题表现:直接连接与聚合

如果我们尝试直接连接所有相关表并按sale的币种分组求和,sale_lines.price_paid和cash_transactions的金额都会因行重复而计算错误。

-- 错误示例:直接连接并聚合
SELECT 
  s.currency_items_sold_in, 
  SUM(sl.price_paid) as "price_paid",
  SUM(ct.received_amount) as "total_received_amount",
  SUM(ct.converted_amount) as "total_converted_amount"
FROM sale s
LEFT JOIN sale_lines sl ON sl.sale_id = s.id
LEFT JOIN cash_transactions ct ON ct.sale_id = s.id
GROUP BY s.currency_items_sold_in;

上述查询将产生不正确的结果,因为sale_lines和cash_transactions的行数可能不同,导致sale的每一行被重复多次,进而导致SUM操作重复计算。

2. 子查询预聚合方法("Monkey-Poop" Method)

为了解决行重复导致的聚合问题,一种常见的方法是在连接之前,先在子查询中对每个一对多关系进行聚合。这种方法被称为“Monkey-Poop”方法,即“先拉出来(聚合),再拼回去(连接)”。

-- 子查询预聚合方法 (部分解决,但仍有币种问题)
SELECT 
  s.currency_items_sold_in, 
  SUM(sale_line_aggregates.price_paid) as "total_price_paid",
  SUM(cash_transaction_aggregates.converted_amount) as "total_converted_amount",
  SUM(cash_transaction_aggregates.received_amount) as "total_received_amount"
FROM sale s
LEFT JOIN (
  SELECT 
    sale_id,
    SUM(price_paid) AS price_paid
  FROM sale_lines
  GROUP BY sale_id
) AS sale_line_aggregates ON sale_line_aggregates.sale_id = s.id
LEFT JOIN (
  SELECT
    sale_id,
    SUM(converted_amount) as converted_amount,
    SUM(received_amount) as received_amount
  FROM cash_transactions
  GROUP BY sale_id
) AS cash_transaction_aggregates ON cash_transaction_aggregates.sale_id = s.id
GROUP BY s.currency_items_sold_in;

这个查询解决了sale_lines和cash_transactions各自内部的重复计算问题。然而,它仍然存在一个关键的限制:它尝试将所有金额都聚合到sale的currency_items_sold_in这个维度下。对于cash_transactions,received_amount和converted_amount可能涉及多种不同的币种(received_currency_id和converted_currency_id),直接按sale的币种分组会导致不同币种的金额被混合在一起求和,失去实际业务意义。例如,它会将DKK、SEK、NOK等不同币种的收到金额简单相加,这在财务上是无意义的。

ShutterStock AI
ShutterStock AI

Shutterstock推出的AI图片生成工具

下载

3. 使用CTE和币种维度聚合的完整解决方案

为了彻底解决上述问题,我们需要更精细地处理币种维度。解决方案的核心思想是:

  1. 确定主查询的上下文: 使用CTE定义一个基础销售集合。
  2. 按各自的币种维度聚合: 分别对sale_lines、cash_transactions的received_amount和converted_amount进行聚合,但这次是根据它们各自的币种ID进行分组。
  3. 连接到币种维度表: 将这些按币种聚合的结果,通过LEFT JOIN连接到currency主表,以获取每个币种的汇总数据。

以下是使用CTE实现的完整解决方案:

WITH CTE_SALE AS (
  -- 定义一个CTE来获取所有销售的主ID和销售币种
  SELECT
    id AS sale_id, 
    currency_items_sold_in AS iso_number
  FROM sale
)
SELECT 
  curr.iso_code AS currency,
  COALESCE(line.price_paid, 0) AS total_price_paid,
  COALESCE(received.amount, 0) AS total_received_amount,
  COALESCE(converted.amount, 0) AS total_converted_amount
FROM currency AS curr
LEFT JOIN (
  -- 聚合销售明细金额,按销售主币种分组
  SELECT 
    s.iso_number,
    SUM(sl.price_paid) AS price_paid
  FROM sale_lines sl
  JOIN CTE_SALE s ON s.sale_id = sl.sale_id
  GROUP BY s.iso_number
) AS line 
  ON line.iso_number = curr.iso_number
LEFT JOIN (
  -- 聚合收到的金额,按收到的币种分组
  SELECT 
    tr.received_currency_id AS iso_number,
    SUM(tr.received_amount) AS amount
  FROM cash_transactions tr
  JOIN CTE_SALE s ON s.sale_id = tr.sale_id
  GROUP BY tr.received_currency_id
) AS received
  ON received.iso_number = curr.iso_number
LEFT JOIN (
  -- 聚合转换后的金额,按转换后的币种分组
  SELECT 
    tr.converted_currency_id AS iso_number,
    SUM(tr.converted_amount) AS amount
  FROM cash_transactions AS tr
  JOIN CTE_SALE s ON s.sale_id = tr.sale_id
  GROUP BY tr.converted_currency_id
) AS converted
  ON converted.iso_number = curr.iso_number;

结果示例:

currency | total_price_paid | total_received_amount | total_converted_amount
:------- | ---------------: | --------------------: | ---------------------:
DKK      |              500 |                   300 |                    700
SEK      |              200 |                   400 |                      0
NOK      |                0 |                   150 |                      0

代码解析:

  1. CTE_SALE: 这个CTE首先从sale表中提取sale_id和currency_items_sold_in(销售主币种),并将其别名为iso_number。这样做的好处是为后续的子查询提供了一个统一的销售上下文,并且避免了在多个子查询中重复连接sale表。
  2. line 子查询: 负责聚合sale_lines中的price_paid。由于sale_lines的币种始终与sale主记录的币种一致,因此这里直接根据CTE_SALE.iso_number(即销售主币种)进行分组求和。
  3. received 子查询: 负责聚合cash_transactions中received_amount。关键在于,这里是根据cash_transactions.received_currency_id进行分组求和,而不是sale的主币种。这确保了我们得到的是每个具体收到的币种的总金额。
  4. converted 子查询: 类似地,聚合cash_transactions中converted_amount时,是根据cash_transactions.converted_currency_id进行分组求和,以获取每个具体转换后的币种的总金额。
  5. 主查询:
    • 从currency表开始,作为最终结果的基准维度。
    • 通过LEFT JOIN将line、received和converted这三个预聚合的结果连接到currency表上,连接条件是各自的iso_number与currency.iso_number匹配。
    • COALESCE(..., 0)函数用于处理没有对应数据的币种,将其金额显示为0而不是NULL,使结果更清晰。

通过这种方法,我们成功地将不同币种的金额分离开来,并分别进行准确的聚合,最终在一个统一的视图中展示了按币种划分的销售明细总额、收到总额和转换总额。

4. 注意事项与最佳实践

  • 聚合前置原则: 始终记住在连接一对多关系时,如果需要聚合子表数据,应优先在子查询或CTE中完成聚合,然后再将聚合结果连接回主表。
  • 明确聚合维度: 在多币种或多维度场景下,仔细分析每个需要聚合的字段,确定其正确的聚合维度。例如,received_amount应按received_currency_id聚合,而不是强制按sale主币种聚合。
  • 使用CTE提高可读性: 对于复杂的查询,CTE(Common Table Expressions)能够有效地将查询分解为逻辑上独立的、可读性更高的部分,提高查询的组织性和维护性。
  • LEFT JOIN与COALESCE: 当需要显示所有可能的维度(如所有币种),即使某些维度没有对应数据时,使用LEFT JOIN并配合COALESCE可以确保结果集的完整性和友好性。
  • 性能考量: 尽管子查询和CTE可以解决逻辑问题,但在处理海量数据时,需要关注其性能。确保子查询中的GROUP BY字段上有索引,可以显著提升查询效率。

总结

处理包含复杂多对多关系和多币种数据的SQL聚合是数据库查询中的一个高级挑战。通过本文介绍的利用CTE和币种维度进行预聚合的方法,我们能够有效地避免因行重复导致的聚合错误,并确保在多维度业务场景下获得准确、有意义的统计数据。这种方法不仅解决了技术难题,也提升了查询的可读性和可维护性,是专业数据库开发和数据分析中不可或缺的技能。

相关专题

更多
数据分析工具有哪些
数据分析工具有哪些

数据分析工具有Excel、SQL、Python、R、Tableau、Power BI、SAS、SPSS和MATLAB等。详细介绍:1、Excel,具有强大的计算和数据处理功能;2、SQL,可以进行数据查询、过滤、排序、聚合等操作;3、Python,拥有丰富的数据分析库;4、R,拥有丰富的统计分析库和图形库;5、Tableau,提供了直观易用的用户界面等等。

676

2023.10.12

SQL中distinct的用法
SQL中distinct的用法

SQL中distinct的语法是“SELECT DISTINCT column1, column2,...,FROM table_name;”。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

320

2023.10.27

SQL中months_between使用方法
SQL中months_between使用方法

在SQL中,MONTHS_BETWEEN 是一个常见的函数,用于计算两个日期之间的月份差。想了解更多SQL的相关内容,可以阅读本专题下面的文章。

346

2024.02.23

SQL出现5120错误解决方法
SQL出现5120错误解决方法

SQL Server错误5120是由于没有足够的权限来访问或操作指定的数据库或文件引起的。想了解更多sql错误的相关内容,可以阅读本专题下面的文章。

1094

2024.03.06

sql procedure语法错误解决方法
sql procedure语法错误解决方法

sql procedure语法错误解决办法:1、仔细检查错误消息;2、检查语法规则;3、检查括号和引号;4、检查变量和参数;5、检查关键字和函数;6、逐步调试;7、参考文档和示例。想了解更多语法错误的相关内容,可以阅读本专题下面的文章。

357

2024.03.06

oracle数据库运行sql方法
oracle数据库运行sql方法

运行sql步骤包括:打开sql plus工具并连接到数据库。在提示符下输入sql语句。按enter键运行该语句。查看结果,错误消息或退出sql plus。想了解更多oracle数据库的相关内容,可以阅读本专题下面的文章。

675

2024.04.07

sql中where的含义
sql中where的含义

sql中where子句用于从表中过滤数据,它基于指定条件选择特定的行。想了解更多where的相关内容,可以阅读本专题下面的文章。

571

2024.04.29

sql中删除表的语句是什么
sql中删除表的语句是什么

sql中用于删除表的语句是drop table。语法为drop table table_name;该语句将永久删除指定表的表和数据。想了解更多sql的相关内容,可以阅读本专题下面的文章。

414

2024.04.29

Java 项目构建与依赖管理(Maven / Gradle)
Java 项目构建与依赖管理(Maven / Gradle)

本专题系统讲解 Java 项目构建与依赖管理的完整体系,重点覆盖 Maven 与 Gradle 的核心概念、项目生命周期、依赖冲突解决、多模块项目管理、构建加速与版本发布规范。通过真实项目结构示例,帮助学习者掌握 从零搭建、维护到发布 Java 工程的标准化流程,提升在实际团队开发中的工程能力与协作效率。

6

2026.01.12

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
10分钟--Midjourney创作自己的漫画
10分钟--Midjourney创作自己的漫画

共1课时 | 0.1万人学习

Midjourney 关键词系列整合
Midjourney 关键词系列整合

共13课时 | 0.9万人学习

AI绘画教程
AI绘画教程

共2课时 | 0.2万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2026 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号