返回

SQL技巧:合并LEFT JOIN冗余行,清晰展示数据

mysql

合并 LEFT JOIN 产生的冗余行:一个 SQL 技巧

遇到多表 LEFT JOIN 后出现同一用户多条部分 null 的记录? 就像这样:

user_id | country | purpose_id
2         CA       NULL
2         NULL     367
2         NG       NULL
2         NULL     368

目标是将这些零散的信息整合成更清晰的形式:

user_id | country | purpose_id
2         CA       367
2         NG       368

但是,常规的 JOIN 操作似乎行不通。 这篇博客聊聊怎么解决这个问题。

问题根源:LEFT JOIN 的特性

先捋一下问题的原因。 问题的核心在于 LEFT JOIN 和数据结构。LEFT JOIN 会保留左表的所有行,即使右表中没有匹配项(用 NULL 填充)。 上面 SQL 查询的多次 LEFT JOIN,针对 'country' 和 'purpose' 这两个分类分别进行关联。

假设一条数据,既有国家信息,又有用途信息。但是,数据库将两者分开存储, 这两次 LEFT JOIN 会产生两条记录:一条 country 有值,purpose_id 为 NULL;另一条 country 为 NULL,purpose_id 有值。 当用户属于多个国家或有多个用途时,情况会更复杂,产生更多行。

解决方案:巧妙利用聚合函数

要实现数据的合并,关键在于利用 SQL 的聚合功能,把同一个 user_id 的多行记录合并成一行。

方案一:MAX() 或 MIN() 函数

由于我们知道 country 和 purpose_id 不会同时存在, 可以直接对它们用聚合操作.

SELECT
    cs.user_id,
    MAX(UPPER(country_term.slug)) AS country,
    MAX(purpose_term.term_id) AS purpose_id
FROM
    wp_user_services cs
INNER JOIN wp_posts v ON
    v.ID = cs.visa_id AND v.post_status = 'publish'
INNER JOIN wp_term_relationships tr ON
    tr.object_id = v.ID
INNER JOIN wp_term_taxonomy tt ON
    tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy IN('country', 'purpose')
LEFT JOIN wp_terms country_term ON
    tt.term_id = country_term.term_id AND tt.taxonomy = 'country'
LEFT JOIN wp_terms purpose_term ON
    purpose_term.term_id = tt.term_id AND tt.taxonomy = 'purpose'
WHERE
    cs.user_id = 2
GROUP BY
    cs.user_id;
  • 原理:
    对于每个 user_idMAX(country_term.slug) 会在所有行中找到非 NULL 的 country 值(因为 NULL 在比较中通常被认为是最小值)。同理,MAX(purpose_term.term_id) 会找到非 NULL 的 purpose_id。

  • 代码解释:
    跟原始 SQL 比, 最主要的修改是在 SELECT 列表和添加了 GROUP BY 子句。GROUP BY cs.user_id 将所有 user_id 相同的行分组。

  • 使用MIN也能达到一样的效果

方案二:COALESCE() 函数 + 子查询 (更具通用性)

如果数据逻辑发生变化, country 和 purpose_id 同时有值. 或者您希望对关联过程进行控制, 方案一的方式就不够灵活.

SELECT
    cs.user_id,
    COALESCE(country_sub.country, purpose_sub.country) AS country,
    COALESCE(purpose_sub.purpose_id, country_sub.purpose_id) AS purpose_id
FROM
    wp_user_services cs
INNER JOIN wp_posts v ON
    v.ID = cs.visa_id AND v.post_status = 'publish'
LEFT JOIN (
    SELECT
        tr.object_id,
        UPPER(t.slug) AS country,
        NULL AS purpose_id -- 添加一个purpose_id字段占位
    FROM
        wp_term_relationships tr
    INNER JOIN wp_term_taxonomy tt ON
        tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = 'country'
    INNER JOIN wp_terms t ON
        tt.term_id = t.term_id
) AS country_sub ON
    country_sub.object_id = v.ID
LEFT JOIN (
    SELECT
        tr.object_id,
        NULL AS country,-- 添加一个country字段占位
        t.term_id AS purpose_id
    FROM
        wp_term_relationships tr
    INNER JOIN wp_term_taxonomy tt ON
        tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = 'purpose'
    INNER JOIN wp_terms t ON
        tt.term_id = t.term_id
) AS purpose_sub ON
    purpose_sub.object_id = v.ID
WHERE
    cs.user_id = 2;

  • 原理:

    1. 分离查询: 将 'country' 和 'purpose' 的关联逻辑分别放在两个子查询(country_subpurpose_sub)中。 每个子查询只负责获取对应分类的数据. 注意, 这里为不涉及的字段添加了NULL占位符, 目的是为了统一两个子查询返回的字段.
    2. 主查询合并: 在主查询中,使用 COALESCE() 函数. COALESCE() 函数会返回第一个非 NULL 的参数。
  • 代码解释:

    两个子查询分别处理countrypurpose, 子查询结构清晰, 可以更好的分别进行管理和修改。
    在主查询使用COALESCE(), 如果您需要保留两者的数据,COALESCE提供了一种更简单的方式。

  • 进阶使用技巧:

如果, 不再仅仅满足于优先级的选择,而是需要保留country和purpose的对应关系, 需要略作调整.
核心在于修改group by. 让其既能够区分不同的country,又能够区分不同的purpose.

SELECT
    cs.user_id,
	country_sub.country,
    purpose_sub.purpose_id
FROM
    wp_user_services cs
INNER JOIN wp_posts v ON
    v.ID = cs.visa_id AND v.post_status = 'publish'
LEFT JOIN (
    SELECT
        tr.object_id,
        UPPER(t.slug) AS country,
        NULL AS purpose_id -- 添加一个purpose_id字段占位
    FROM
        wp_term_relationships tr
    INNER JOIN wp_term_taxonomy tt ON
        tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = 'country'
    INNER JOIN wp_terms t ON
        tt.term_id = t.term_id
) AS country_sub ON
    country_sub.object_id = v.ID
LEFT JOIN (
    SELECT
        tr.object_id,
        NULL AS country,-- 添加一个country字段占位
        t.term_id AS purpose_id
    FROM
        wp_term_relationships tr
    INNER JOIN wp_term_taxonomy tt ON
        tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = 'purpose'
    INNER JOIN wp_terms t ON
        tt.term_id = t.term_id
) AS purpose_sub ON
    purpose_sub.object_id = v.ID
WHERE
    cs.user_id = 2
group by cs.user_id, country_sub.country, purpose_sub.purpose_id;

输出

user_id | country | purpose_id
2         CA       NULL
2         NULL     367
2         NG       NULL
2         NULL     368

由于country_sub.country, purpose_sub.purpose_id不可能同时有值, 所以该聚合查询输出和未聚合的输出一模一样.

假设有表如下

user_id | visa_id | ...
2 | 1 | ...

ID | post_status | ...
1  | publish  | ...

object_id |term_taxonomy_id|
1 1
1 2

term_taxonomy_id | term_id | taxonomy |
1                  1       country
2                  2      purpose

term_id |  slug      |
1                  CA
2                367

根据新表进行联表查询有:

SELECT
    cs.user_id,
    UPPER(country_term.slug) as country,
    purpose_term.term_id as  purpose_id
FROM
    wp_user_services cs
INNER JOIN wp_posts v ON
    v.ID = cs.visa_id AND v.post_status = 'publish'
INNER JOIN wp_term_relationships tr ON
    tr.object_id = v.ID
INNER JOIN wp_term_taxonomy tt ON
    tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy IN('country', 'purpose')
LEFT JOIN wp_terms country_term ON
    tt.term_id = country_term.term_id AND tt.taxonomy = 'country'
LEFT JOIN wp_terms purpose_term ON
    purpose_term.term_id = tt.term_id AND tt.taxonomy = 'purpose'
WHERE
    cs.user_id = 2;

有:

user_id | country | purpose_id
2         CA       367

在此条件下,聚合查询

SELECT
    cs.user_id,
	country_sub.country,
    purpose_sub.purpose_id
FROM
    wp_user_services cs
INNER JOIN wp_posts v ON
    v.ID = cs.visa_id AND v.post_status = 'publish'
LEFT JOIN (
    SELECT
        tr.object_id,
        UPPER(t.slug) AS country,
        NULL AS purpose_id -- 添加一个purpose_id字段占位
    FROM
        wp_term_relationships tr
    INNER JOIN wp_term_taxonomy tt ON
        tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = 'country'
    INNER JOIN wp_terms t ON
        tt.term_id = t.term_id
) AS country_sub ON
    country_sub.object_id = v.ID
LEFT JOIN (
    SELECT
        tr.object_id,
        NULL AS country,-- 添加一个country字段占位
        t.term_id AS purpose_id
    FROM
        wp_term_relationships tr
    INNER JOIN wp_term_taxonomy tt ON
        tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = 'purpose'
    INNER JOIN wp_terms t ON
        tt.term_id = t.term_id
) AS purpose_sub ON
    purpose_sub.object_id = v.ID
WHERE
    cs.user_id = 2
group by cs.user_id, country_sub.country, purpose_sub.purpose_id;

仍然会合并为

user_id | country | purpose_id
2         CA       367

因此这种情况下和直接max的效果相同。

方案三:调整数据模型 (治本之策)

上面两种方案, 更像是一种临时补救措施。 更好的方式是从源头上优化。 如果应用场景允许, 可以考虑调整数据模型, 直接避免冗余。 例如:

  • 合并表: 如果 'country' 和 'purpose' 信息总是与同一个用户服务(wp_user_services)相关联,可以考虑将它们直接存储在 wp_user_services 表中,或者创建一个新的关联表,包含 user_idcountry_idpurpose_id
  • JSON/数组字段(适用性评估): 如果数据库支持 JSON 或数组类型(如 PostgreSQL),可以将多个 country 或 purpose 存储在一个字段中。但这可能增加查询复杂性。

如果进行数据模型调整, 请提前备份,并在非高峰时段操作。 还要全面测试,确保修改后的模型符合业务需求, 同时没有引入新问题。

这几种方法都能有效解决 LEFT JOIN 带来的数据冗余问题. 根据具体的情况和需求,灵活运用这些 SQL 技巧,可以让数据更清晰、查询更高效。