返回

MySQL查询优化:列表去重 同用户只显示最新记录

mysql

MySQL 小技巧:如何在广告列表中避免同一用户重复出现?(基于 Email/Phone/IP)

咱们在开发展示用户发布内容的列表时,比如广告、帖子、评论等,经常会碰到一个头疼的问题:某个活跃用户(或者机器人)可能会发布大量内容,导致在按时间排序的列表页面上,连续多条甚至整页都是这个用户发布的信息。这不仅影响其他用户的曝光,也让列表看起来很“水”。

具体来说,问题是这样的:有一个广告表(ads),记录了广告ID (ad_id)、发布者的 Email (user_email)、电话 (user_phone)、IP地址 (user_ip 可能也需要,原问题中省略但逻辑类似) 、广告内容 (ad_description) 和发布日期 (ad_date)。我们想按照 ad_date 降序展示广告列表,但希望对于同一个发布者,只展示他/她最新的一条广告 。这里的“同一个发布者”判断标准比较复杂:如果两条广告的 user_email 相同,或者 user_phone 相同,或者 user_ip 相同(假设也需要考虑IP),就算同一个人。

举个例子,看下面的输入数据:

| ad_id | user_email        | user_phone   | ad_description | ad_date    | comment
|-------|-------------------|--------------|----------------|------------|---------
| 1     | NULL              | 123-456-7891 | ad_desc1       | 2023-01-01 | 展示
| 2     | [email protected] | 123-456-7891 | ad_desc2       | 2023-01-02 | 跳过,因为电话 123-456-7891 已在 ad_id=1 展示过
| 3     | [email protected] | 123-456-7892 | ad_desc3       | 2023-01-03 | 跳过,因为 Email [email protected] 已在 ad_id=2 关联过(虽然 2 被跳过了,但逻辑上我们看到过这个 email)-- **这里逻辑修正:按期望输出,应该是判断前面 *已输出* 的记录。** 
| 4     | [email protected] | 123-456-7892 | ad_desc4       | 2023-02-01 | 跳过,因为电话 123-456-7892 已在 ad_id=3 关联过
| 5     | [email protected] | NULL         | ad_desc5       | 2023-02-02 | 跳过,因为 Email [email protected] 已在 ad_id=2,3,4 关联过
| 6     | [email protected] | 123-456-7893 | ad_desc6       | 2023-03-01 | 展示,Email 和 Phone 都是新的
| 7     | NULL              | 123-456-7892 | ad_desc7       | 2023-03-02 | 跳过,因为电话 123-456-7892 已在 ad_id=3, 4 关联过
| 8     | [email protected] | 123-456-7895 | ad_desc8       | 2023-03-03 | 跳过,因为 Email [email protected] 已在 ad_id=6 关联过
| 9     | [email protected] | NULL         | ad_desc9       | 2023-03-02 | 跳过,因为 Email [email protected] 已在 ad_id=6, 8 关联过
| 10    | NULL              | 123-456-7899 | ad_desc10      | 2023-03-02 | 展示,Phone 是新的

我们期望的输出结果是(只包含应该展示的广告):

| ad_id | user_email        | user_phone   | ad_description | ad_date    |
|-------|-------------------|--------------|----------------|------------|
| 1     | NULL              | 123-456-7891 | ad_desc1       | 2023-01-01 |
| 6     | [email protected] | 123-456-7893 | ad_desc6       | 2023-03-01 |
| 10    | NULL              | 123-456-7899 | ad_desc10      | 2023-03-02 |

注意: 这个期望输出表明,我们不是简单地找每个 Email 或每个 Phone 的最新记录,而是实现一种“去重”逻辑:按 ad_date 降序处理,如果当前广告的 Email 或 Phone (或 IP) 在已经确定要输出的、发布时间更早(ad_date 更大)的广告中出现过,那么就跳过当前这条。

直接用 PHP 把所有数据(比如 20 万条)捞出来再循环处理?内存扛不住,而且分页的时候,怎么知道当前页应该从数据库的第几条记录开始取呢?(因为跳过的数量不固定)。所以,这个过滤逻辑必须在数据库层面用 SQL 实现。

问题分析:为什么这么棘手?

这个问题的难点在于:

  1. 复杂的“相同用户”定义: 不是基于单一字段(如 user_id)来判断重复,而是跨越多个字段 (user_email, user_phone, user_ip),并且是 (OR) 的关系。
  2. 顺序依赖性: 是否跳过某条广告,取决于在它 之前(按 ad_date DESC 排序)是否已经 选择并展示 了具有相同 Email 或 Phone 或 IP 的广告。这带有程序循环处理的意味,用纯粹的集合查询语言 SQL 来表达有点绕。
  3. 性能要求: 广告数据量可能很大(几十万甚至更多),查询效率必须高,否则页面加载会很慢。
  4. 分页兼容: 最终的 SQL 查询需要能方便地配合 LIMIT offset, count 来实现分页。

普通的 GROUP BY user_email, user_phone 这类方法行不通,因为它会将 Email 和 Phone 的 组合 视为一个分组键,无法处理 Email 相同但 Phone 不同(或反之)算作同一个人的情况,也解决不了顺序依赖问题。

解决方案:用 SQL 模拟“跳过已出现”逻辑

好消息是,我们可以利用 SQL 的子查询或 JOIN 来模拟这种“检查前面是否已出现”的逻辑。核心思想是:对于每一条广告 a1,我们去查找是否存在另一条广告 a2,它满足以下条件:

  • a2a1 来自“同一个人”(即 a2.user_email = a1.user_emaila2.user_phone = a1.user_phonea2.user_ip = a1.user_ip)。
  • a2a1 “更优先”被展示(即 a2 的发布时间更靠前,a2.ad_date > a1.ad_date;如果发布时间相同,可以用 ad_id 来保证唯一排序,比如 a2.ad_id > a1.ad_id)。

如果能找到这样的 a2,说明 a1 就是应该被跳过的重复记录。反之,如果找不到这样的 a2,那么 a1 就是它所属“用户”(基于Email/Phone/IP首次出现)的那条应该被展示的记录。

下面提供两种常用的 SQL 实现方式。

方案一:使用 LEFT JOINIS NULL (推荐)

这是比较常用且通常性能较好的一种方式。

原理说明:

我们尝试将 ads 表(别名为 a1)与它自身(别名为 a2)进行 LEFT JOINLEFT JOIN 的条件设置为上面的查找“更优先的同用户广告 a2”的逻辑。

  • JOIN 条件: a2 必须是比 a1 更优先的 (a2.ad_date > a1.ad_date OR (a2.ad_date = a1.ad_date AND a2.ad_id > a1.ad_id))。同时,a2a1 必须能通过 Email 或 Phone 或 IP 关联上,表示是“同一个人”。这里要注意处理 NULL 值,只有非 NULL 且相等的值才能用来判断相同。
  • 过滤: 如果对于某条 a1,能成功找到一个或多个满足条件的 a2 (即 JOIN 成功),那么 a2 的列就不会是 NULL。反过来,如果 LEFT JOIN 之后 a2 的某个主键列(如 a2.ad_id) 是 NULL,那就意味着找不到任何“更优先的同用户广告”,这条 a1 就是我们要保留的。

代码示例:

SELECT
    a1.ad_id,
    a1.user_email,
    a1.user_phone,
    a1.ad_description,
    a1.ad_date
    -- 其他 a1 的字段...
FROM
    ads a1
LEFT JOIN
    ads a2 ON
    -- 条件1: a2 必须比 a1 更优先 (发布日期更大,或日期相同但 ID 更大)
    (a2.ad_date > a1.ad_date OR (a2.ad_date = a1.ad_date AND a2.ad_id > a1.ad_id))
    AND
    -- 条件2: a1 和 a2 是“同一个人”,通过 Email 或 Phone 或 IP 之一匹配
    (
        (a1.user_email IS NOT NULL AND a1.user_email = a2.user_email)
        OR
        (a1.user_phone IS NOT NULL AND a1.user_phone = a2.user_phone)
        -- 如果还需要比较 user_ip,在这里添加 OR 条件:
        -- OR (a1.user_ip IS NOT NULL AND a1.user_ip = a2.user_ip)
    )
WHERE
    -- 只保留那些找不到“更优先的同用户广告 a2”的 a1 记录
    a2.ad_id IS NULL
ORDER BY
    -- 最终结果仍然按 ad_date 降序排列
    a1.ad_date DESC, a1.ad_id DESC
-- 添加 LIMIT 实现分页
-- LIMIT [offset], [count];
-- 例如,获取第一页,每页 10 条:
-- LIMIT 0, 10;
-- 获取第二页,每页 10 条:
-- LIMIT 10, 10;

解释:

  1. FROM ads a1: 选择 ads 表作为主表 a1
  2. LEFT JOIN ads a2 ON ...: 尝试为 a1 中的每一行,在 ads 表中找到满足 ON 条件的 a2 记录。
  3. ON 子句中的第一个括号 (...) 定义了 a2 必须是“更优先”的。使用 ad_date 降序,日期相同则用 ad_id 降序(假设 ad_id 递增,所以 > 表示更晚创建的记录,但按 ad_date DESC 逻辑,如果日期相同,ID 大的反而更优先显示? 这里需要根据实际 ad_id 含义调整,如果 ad_id 越大表示越新,那么排序应该是 a2.ad_date > a1.ad_date OR (a2.ad_date = a1.ad_date AND a2.ad_id > a1.ad_id),最终 ORDER BY 也要一致。按原问题示例的输出,应展示 ad_date 最新的,日期相同则 ad_id 也可考虑。代码中 a2.ad_date > a1.ad_date OR (...) 是对的,表示查找已存在于输出结果里的更高优先级的记录。最终 ORDER BY a1.ad_date DESC, a1.ad_id DESC 也是对的。
  4. ON 子句中的第二个括号 (...) 定义了“同一个人”的判断逻辑:Email 相同(且非NULL), Phone 相同(且非NULL), IP 相同(如果需要,且非NULL)。IS NOT NULL 的检查很重要,避免 NULL = NULL 被错误地认为是匹配。
  5. WHERE a2.ad_id IS NULL: 这是关键!只保留那些 LEFT JOIN 没找到匹配 a2a1 记录。这意味着对于这条 a1,在所有比它更优先的记录中,没有找到来自同一个人的记录。
  6. ORDER BY a1.ad_date DESC, a1.ad_id DESC: 保证最终输出的顺序符合要求。
  7. LIMIT offset, count: 标准的分页语法。

安全和性能建议:

  • 索引!索引!索引! 这个查询的性能高度依赖于索引。务必在 ad_date, ad_id, user_email, user_phone (以及 user_ip 如果使用) 这些列上建立合适的索引。
    • 理想情况是有一个覆盖 (ad_date, ad_id) 的复合索引用于排序和优先级的比较。
    • user_email, user_phone, user_ip 上单独建立索引,可以加速 JOIN 条件中 OR 连接的部分查找。
    • 注意,OR 条件有时会让 MySQL 优化器难以最高效地利用索引。根据实际表大小和数据分布,可能需要观察 EXPLAIN 输出来调整索引策略。
  • 处理 NULL 值: 查询中已经考虑了 IS NOT NULL,确保逻辑正确性。
  • 数据一致性: 确保 user_emailuser_phone 等字段格式规范化存储,例如 Email 转小写,电话号码去除特殊字符等,避免因为格式差异导致同一个人被识别为不同。

进阶技巧:

  • 对于非常大的表,如果 LEFT JOIN 的性能仍然不理想,可以考虑使用 NOT EXISTS 子查询(见下文),或者在应用层做一些缓存策略(例如缓存用户首次出现的广告 ID,但这会增加复杂度)。
  • 如果判断“同一个人”的列非常多,OR 条件可能会增多,进一步影响性能。这时可能要反思数据模型或业务逻辑是否能简化。

方案二:使用 NOT EXISTS 子查询

NOT EXISTS 通常用来表达与 LEFT JOIN / IS NULL 相同的逻辑,但写法更接近自然语言的。

原理说明:

直接翻译我们的需求:“选择广告 a1,条件是不存在 另一条广告 a2,使得 a2a1 更优先,并且 a2a1 是同一个人发的。”

代码示例:

SELECT
    a1.ad_id,
    a1.user_email,
    a1.user_phone,
    a1.ad_description,
    a1.ad_date
    -- 其他 a1 的字段...
FROM
    ads a1
WHERE
    NOT EXISTS (
        SELECT 1
        FROM ads a2
        WHERE
            -- 条件1: a2 必须比 a1 更优先
            (a2.ad_date > a1.ad_date OR (a2.ad_date = a1.ad_date AND a2.ad_id > a1.ad_id))
            AND
            -- 条件2: a1a2 是“同一个人”
            (
                (a1.user_email IS NOT NULL AND a1.user_email = a2.user_email)
                OR
                (a1.user_phone IS NOT NULL AND a1.user_phone = a2.user_phone)
                -- OR (a1.user_ip IS NOT NULL AND a1.user_ip = a2.user_ip) -- 如果需要比较 IP
            )
    )
ORDER BY
    a1.ad_date DESC, a1.ad_id DESC
-- 添加 LIMIT 实现分页
-- LIMIT [offset], [count];

解释:

查询主体是 SELECT ... FROM ads a1WHERE NOT EXISTS (...) 子句是核心。

  • 子查询 SELECT 1 FROM ads a2 WHERE ... 尝试为当前的 a1 找到满足条件的 a2
  • 子查询的 WHERE 条件与 LEFT JOIN 方案中的 ON 条件完全相同,定义了 a2 是“更优先的同用户广告”。
  • NOT EXISTS:如果子查询没有 返回任何行(即找不到满足条件的 a2),则 NOT EXISTS 为真,外层查询的 WHERE 条件满足,a1 被选中。

对比与建议:

  • 可读性: 有些人觉得 NOT EXISTS 更直观地表达了“不存在满足...条件的记录”。
  • 性能: 理论上,MySQL 优化器可能会将 LEFT JOIN / IS NULLNOT EXISTS 转化为相似的执行计划。但在某些 MySQL 版本或特定场景下,它们的性能表现可能有差异。通常建议两种都测试一下,看哪个在你的具体环境中表现更好。经验上,LEFT JOIN / IS NULL 在 MySQL 中有时会被优化得更好一些。
  • 索引要求:LEFT JOIN 方案相同,合适的索引对 NOT EXISTS 的性能至关重要。子查询中的 WHERE 条件涉及的列都需要索引。

方案三:窗口函数 (简单场景适用, 本问题复杂)

对于更现代的 MySQL 版本(8.0+),窗口函数提供了处理这类“分组内排序取Top N”问题的简洁方式。但是, 对于本问题中跨多列 OR 定义的复杂“分组”,直接用窗口函数并不方便。

窗口函数如 ROW_NUMBER() 通常需要一个明确的 PARTITION BY 子句来定义分组。例如,如果仅仅是想取每个 user_id 最新的广告,可以这样做:

-- 仅适用于按单一 user_id 分组的简单情况
WITH RankedAds AS (
    SELECT
        ad_id, user_id, ad_description, ad_date,
        ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ad_date DESC, ad_id DESC) as rn
    FROM ads
)
SELECT ad_id, user_id, ad_description, ad_date
FROM RankedAds
WHERE rn = 1
ORDER BY ad_date DESC, ad_id DESC;

这里 PARTITION BY user_id 将广告按 user_id 分组,ORDER BY ad_date DESC, ad_id DESC 在每个分组内按时间排序,ROW_NUMBER() 给每组内的行编号,rn = 1 就取出了每个用户最新的那条。

局限性:

要将 PARTITION BY 的逻辑扩展到 user_email OR user_phone OR user_ip,就变得很困难。我们无法直接用 PARTITION BY (user_email OR user_phone) 这样的语法。虽然可以通过一些复杂的技巧(比如先为每个 email/phone/ip 找到它们首次出现的记录,再关联等)来模拟,但实现起来通常比 LEFT JOINNOT EXISTS 更复杂,且未必性能更优。

因此,对于本问题描述的场景,窗口函数不是最直接或简单的解决方案。

总结一下

面对“列表去重,同用户只显示一条,用户识别跨多列 OR 逻辑”的问题,直接在 MySQL 查询层面解决是最高效、且能支持分页的方式。

推荐使用 LEFT JOIN ... ON ... WHERE joined_table.id IS NULLNOT EXISTS 子查询。这两种方法都能有效地模拟出“跳过那些已被更优先同用户记录覆盖”的逻辑。

别忘了,性能的关键在于为查询涉及的所有排序列、连接列建立正确的索引 。根据实际数据量和服务器负载,务必测试并用 EXPLAIN 分析查询计划。