返回

SQL 查询修复:解决好友提醒筛选与屏蔽内容的冲突

mysql

修复 SQL 查询:在筛选好友提醒时排除已屏蔽内容

咱们在用 SQL 从数据库捞数据的时候,经常会遇到需要组合多个条件、关联好几张表的情况。有时候,一个看似简单的需求,比如“我要看我自己的提醒,还有我朋友发的提醒,但别给我看我屏蔽掉的那些”,写起来就可能踩坑。

这不,最近就碰上一个哥们儿,他想实现上面这个功能,结果加了个屏蔽条件,SQL 就跑不起来了。咱们就来分析分析,看看问题出在哪儿,顺便聊聊几种解决办法。

问题来了:好友提醒查不全,屏蔽的还捣乱?

先看看这位朋友的基础数据长啥样:

用户表 (USERS)

ID ACCOUNT
1 80001
2 90001
3 70001

好友关系表 (FRIENDS)

ID ACCOUNT TO_ACCOUNT
1 80001 70001

(这里假设关系是双向的,或者至少在查询时需要考虑双向)

提醒表 (ALERTS)

ID ACCOUNT ALERT ALERTID
1 80001 Hi XD3000
2 70001 Hey YZ4000

一开始,他写了个查询,用来找出“自己”(假设是 80001)或者“朋友”(和 80001FRIENDS 表里有关联的账号,比如 70001)发的提醒。这查询跑得好好的:

-- 原始查询(能跑通)
SELECT snd.ACCOUNT
FROM ALERTS AS m
JOIN USERS AS snd ON snd.ACCOUNT = m.ACCOUNT
WHERE snd.ACCOUNT IN (
  -- 找出朋友关系中的一方是 80001 或 70001 的 ACCOUNT
  SELECT ACCOUNT
  FROM FRIENDS
  WHERE ACCOUNT = '80001' OR TO_ACCOUNT = '70001' -- (这里的逻辑可能需要根据实际需求微调,先按原样分析)
)
UNION -- 合并两部分结果
SELECT snd.ACCOUNT
FROM ALERTS AS m
JOIN USERS AS snd ON snd.ACCOUNT = m.ACCOUNT
WHERE snd.ACCOUNT IN (
  -- 找出朋友关系中的另一方是 70001 或 80001 的 TO_ACCOUNT
  SELECT TO_ACCOUNT
  FROM FRIENDS
  WHERE TO_ACCOUNT = '70001' OR ACCOUNT = '80001' -- (同上,逻辑待确认)
);

旁白:这个原始查询逻辑有点绕,而且 UNION 两边的子查询条件看起来有点重复,但先不管这个,它至少能跑。它尝试找出与 '80001' 或 '70001' 相关的朋友账号,然后筛选出这些账号发送的提醒。

接着,麻烦来了。他想加个功能:不看自己屏蔽掉的提醒。他新建了张屏蔽表:

屏蔽记录表 (ALERTS_BLOCKED)

ID ACCOUNT ALERTID
1 80001 XD3000

然后,他信心满满地修改了查询,试图在原来的基础上加上 NOT IN 条件:

-- 修改后的查询(跑不通!)
SELECT snd.ACCOUNT
FROM ALERTS AS m
JOIN USERS AS snd ON snd.ACCOUNT = m.ACCOUNT
WHERE snd.ACCOUNT IN (
  SELECT ACCOUNT
  FROM FRIENDS
  WHERE ACCOUNT = '80001' OR TO_ACCOUNT = '70001'
)
-- 问题在这里!
AND WHERE m.ALERTID NOT IN (
  SELECT ALERTID
  FROM ALERTS_BLOCKED
  WHERE ACCOUNT = '80001' -- 屏蔽自己账户 80001 屏蔽的
)
UNION
SELECT snd.ACCOUNT
FROM ALERTS AS m
JOIN USERS AS snd ON snd.ACCOUNT = m.ACCOUNT
WHERE snd.ACCOUNT IN (
  SELECT TO_ACCOUNT
  FROM FRIENDS
  WHERE TO_ACCOUNT = '70001' OR ACCOUNT = '80001'
)
-- 问题也在这里!
AND WHERE m.ALERTID NOT IN (
  SELECT ALERTID
  FROM ALERTS_BLOCKED
  WHERE ACCOUNT = '70001' -- 屏蔽账户 70001 屏蔽的???
);

结果,这 SQL 直接报错,根本跑不起来。为啥呢?

刨根问底:为啥查询会“罢工”?

这个“加料”后的查询失败,主要是两个原因捣的鬼:一个是语法不过关,另一个是逻辑可能没理顺。

语法错误:重复的 “WHERE”

这是最直接的原因。你看,他在 WHERE 子句后面,又跟了个 AND WHERE

WHERE condition1
AND WHERE condition2 -- 这样是错的!

SQL 语法规定,一个 SELECT 语句块里(或者 UNION 的每一个子查询里),WHERE 只能出现一次。如果你有多个过滤条件,应该用 AND 或者 OR 来连接它们,像这样:

WHERE condition1 AND condition2 AND condition3 ...

或者

WHERE condition1 OR condition2

所以,那个 AND WHERE 彻底把 SQL 解析器给整懵了,直接就报错说语法不对。

逻辑不清:屏蔽条件放错地儿?

就算咱们把语法修正了(比如改成 WHERE ... AND m.ALERTID NOT IN (...)),原来的逻辑也可能有点问题。

  1. 屏蔽主体混乱 :
    UNION 的第一部分,他加的屏蔽条件是 ALERTS_BLOCKED 表里 ACCOUNT = '80001' 的记录。这看起来合理,假设用户 80001 在操作,那么他应该看自己设置的屏蔽列表。
    但是在 UNION 的第二部分,他加的屏蔽条件却是 ALERTS_BLOCKED 表里 ACCOUNT = '70001' 的记录。这就奇怪了,用户 80001 查看提醒,为啥要根据用户 70001 的屏蔽列表来过滤呢?这通常不符合逻辑。除非需求特殊,否则屏蔽规则应该是基于当前查看者(这里是 80001)来的。

  2. 过滤时机 :
    这个查询结构是先通过 UNION 合并两批“可能相关”的提醒发送者账号,然后再(试图)在各自部分内部进行屏蔽过滤。这样做有点割裂,不够清晰。一个更自然的思路或许是:先找出所有“我或我朋友发的”提醒,然后作为一个整体,再排除掉“我屏蔽的”提醒。

效率考量:UNION 和 子查询

虽然不是直接导致错误的原因,但原始查询的结构——使用 UNION 合并两个相似度很高的查询,并且在 WHERE 子句里嵌套 IN 子查询——在大数据量下可能不是最高效的。数据库可能需要执行两次相似的子查询和连接操作。后面咱们看解决方案时,也会顺便考虑一下效率问题。

对症下药:修复查询的几种姿势

既然知道了问题所在,咱们来开方子。下面提供几种解决思路,从简单修复到结构优化,总有一款适合你。假设当前操作的用户是 80001

方案一:修正语法,合并条件 (简单直接)

这是最“头痛医头脚痛医脚”的方法,直接在原有结构上修改语法错误,并统一屏蔽逻辑。

原理:
保留 UNION 结构,将 AND WHERE 改成 AND,并且确保两个部分的屏蔽条件都使用当前用户(80001)的屏蔽列表。

代码示例:

-- 假设当前用户是 '80001'
SELECT snd.ACCOUNT, m.ALERT, m.ALERTID -- 多选点字段看看效果
FROM ALERTS AS m
JOIN USERS AS snd ON snd.ACCOUNT = m.ACCOUNT
WHERE snd.ACCOUNT IN (
    -- 查找好友逻辑 (这里的逻辑最好也优化下,但先保持和原问题一致)
    SELECT ACCOUNT FROM FRIENDS WHERE ACCOUNT = '80001' OR TO_ACCOUNT = '70001' -- 保持原始逻辑
)
AND m.ALERTID NOT IN ( -- 使用 AND 连接,而不是 AND WHERE
    -- 只根据当前用户 80001 的屏蔽列表过滤
    SELECT ALERTID FROM ALERTS_BLOCKED WHERE ACCOUNT = '80001'
)

UNION -- 注意:UNION 默认会去重,如果需要保留所有,用 UNION ALL

SELECT snd.ACCOUNT, m.ALERT, m.ALERTID
FROM ALERTS AS m
JOIN USERS AS snd ON snd.ACCOUNT = m.ACCOUNT
WHERE snd.ACCOUNT IN (
    -- 查找好友逻辑 (同上)
    SELECT TO_ACCOUNT FROM FRIENDS WHERE TO_ACCOUNT = '70001' OR ACCOUNT = '80001' -- 保持原始逻辑
)
AND m.ALERTID NOT IN ( -- 使用 AND 连接
    -- 同样,根据当前用户 80001 的屏蔽列表过滤
    SELECT ALERTID FROM ALERTS_BLOCKED WHERE ACCOUNT = '80001'
);

解释:
这个改动最小,直接修正了语法错误,并且统一了屏蔽规则(都按 80001 屏蔽)。它能工作,解决了眼前的报错问题和逻辑偏差。但还是那个问题,UNION 两边的查询逻辑相似度高,可能不够高效。

进阶使用技巧:

  • 如果确定 UNION 两边的结果集不可能有重复(或者允许重复),可以考虑使用 UNION ALL,它通常比 UNION 快,因为它省去了去重的步骤。但在这个场景下,由于筛选的是发送者 ACCOUNT,同一个发送者可能因为满足不同的朋友关系条件出现在两侧,用 UNION 去重可能是需要的。
  • 检查 FRIENDS 表的索引情况,ACCOUNTTO_ACCOUNT 列最好有索引,能加速子查询。

方案二:先 UNION/组合 后过滤 (逻辑更清晰)

换个思路,咱们先把所有潜在要看的提醒(自己发的 + 朋友发的)找出来,形成一个临时的结果集,然后再对这个结果集应用屏蔽规则。这样逻辑更顺畅。可以用 公共表表达式 (CTE) 来实现。

原理:

  1. 创建一个 CTE (例如 PotentialAlerts),在这个 CTE 内部,合并所有来自用户自己和其朋友的提醒记录(包含 ALERTID)。这里需要改进好友查找逻辑,确保准确找到所有相关账号。
  2. 主查询从 PotentialAlerts 中选取数据。
  3. 在主查询的 WHERE 子句中,使用 NOT INNOT EXISTS 结合 ALERTS_BLOCKED 表来排除被当前用户屏蔽的提醒。

代码示例 (使用 CTE):

-- 假设当前用户是 '80001'
WITH RelevantAccounts AS (
    -- 找出 '80001' 自己以及所有和他有好友关系的人的账号
    -- 改进好友查找逻辑:
    SELECT '80001' AS ACCOUNT -- 添加自己
    UNION -- 用 UNION 去重账号
    SELECT TO_ACCOUNT FROM FRIENDS WHERE ACCOUNT = '80001'
    UNION
    SELECT ACCOUNT FROM FRIENDS WHERE TO_ACCOUNT = '80001'
),
PotentialAlerts AS (
    -- 从 ALERTS 表中找出由这些 RelevantAccounts 发出的提醒
    SELECT m.ACCOUNT AS SenderAccount, m.ALERT, m.ALERTID
    FROM ALERTS AS m
    WHERE m.ACCOUNT IN (SELECT ACCOUNT FROM RelevantAccounts)
)
-- 主查询:从潜在提醒中筛选,并排除被屏蔽的
SELECT pa.SenderAccount, pa.ALERT, pa.ALERTID
FROM PotentialAlerts AS pa
WHERE pa.ALERTID NOT IN (
    -- 排除当前用户 '80001' 屏蔽的 ALERTID
    SELECT ALERTID FROM ALERTS_BLOCKED WHERE ACCOUNT = '80001'
);

解释:

  • RelevantAccounts CTE:先搞清楚哪些人的提醒是咱们关心的(自己+所有好友)。这里的好友查找逻辑比原查询更清晰直接。
  • PotentialAlerts CTE:基于上面确定的账号列表,去 ALERTS 表里捞出他们发的所有提醒,记下 ALERTID 很关键。
  • 最后的主查询:非常简单,就是从 PotentialAlerts 里选,并且用 WHERE pa.ALERTID NOT IN (...)80001 屏蔽的踢出去。

这种方式结构清晰,每一步干什么一目了然,便于理解和维护。

进阶使用技巧:

  • NOT IN 在处理子查询结果包含 NULL 值时可能有坑,如果 ALERTS_BLOCKED.ALERTID 可能为 NULL,建议使用 NOT EXISTS,通常更健壮也可能更快:
-- 使用 NOT EXISTS 替代 NOT IN
SELECT pa.SenderAccount, pa.ALERT, pa.ALERTID
FROM PotentialAlerts AS pa
WHERE NOT EXISTS (
    SELECT 1
    FROM ALERTS_BLOCKED AS b
    WHERE b.ACCOUNT = '80001' AND b.ALERTID = pa.ALERTID
);
  • 如果 RelevantAccounts 可能会非常大,WHERE m.ACCOUNT IN (SELECT ...) 的效率也需要关注。有时用 JOIN 可能效果更好(见方案三)。

方案三:优化好友查找与 JOIN (性能更优)

这个方案在方案二的基础上,尝试进一步优化性能,特别是连接(JOIN)和过滤部分。它避免了多次子查询,并且用性能通常更好的 LEFT JOIN ... IS NULLNOT EXISTS 来处理屏蔽逻辑。

原理:

  1. 同样,先用 CTE (或子查询) 得到所有相关账号 (RelevantAccounts)。
  2. 直接将 ALERTS 表与 RelevantAccounts 进行 JOIN,筛选出相关账号发的提醒。
  3. 使用 LEFT JOIN 将上一步结果与 ALERTS_BLOCKED 表关联(条件是当前用户的 ACCOUNT 和匹配的 ALERTID)。
  4. 在最终的 WHERE 子句中,只保留那些 LEFT JOIN 没有匹配到屏蔽记录的行(即 ALERTS_BLOCKED 表对应列为 NULL)。

代码示例 (使用 LEFT JOIN):

-- 假设当前用户是 '80001'
WITH RelevantAccounts AS (
    -- 找出 '80001' 自己以及所有和他有好友关系的人的账号 (同方案二)
    SELECT '80001' AS ACCOUNT
    UNION
    SELECT TO_ACCOUNT FROM FRIENDS WHERE ACCOUNT = '80001'
    UNION
    SELECT ACCOUNT FROM FRIENDS WHERE TO_ACCOUNT = '80001'
)
-- 主查询:直接 JOIN 并使用 LEFT JOIN + IS NULL 过滤
SELECT m.ACCOUNT AS SenderAccount, m.ALERT, m.ALERTID
FROM ALERTS AS m
-- 步骤1: 先 JOIN 相关账号,确保提醒是来自这些人
INNER JOIN RelevantAccounts ra ON m.ACCOUNT = ra.ACCOUNT
-- 步骤2: LEFT JOIN 屏蔽表,尝试找到当前用户对这条提醒的屏蔽记录
LEFT JOIN ALERTS_BLOCKED AS b
    ON m.ALERTID = b.ALERTID AND b.ACCOUNT = '80001' -- 关键:匹配提醒ID 且 屏蔽者是当前用户
-- 步骤3: 筛选出没有匹配到屏蔽记录的提醒 (即 b.ID 或 b.ACCOUNT 等非空列为 NULL)
WHERE b.ID IS NULL; -- 或者 b.ACCOUNT IS NULL, 任何 b 表中本应非空的列都行

解释:

  • INNER JOIN RelevantAccounts: 确保只处理来自自己或好友的提醒,替代了方案二中的 IN 子查询。
  • LEFT JOIN ALERTS_BLOCKED: 尝试为每一条相关提醒找到匹配的屏蔽记录。如果找到了(表示这条提醒被 80001 屏蔽了),b 表的相关列(如 b.ID)就会有值;如果没找到(没被屏蔽),b 表的列就是 NULL
  • WHERE b.ID IS NULL: 这就是点睛之笔,它筛掉了所有成功匹配到屏蔽记录的行,只留下那些没被屏蔽的提醒。

这种方式通常被认为性能较好,因为它把过滤逻辑整合到了 JOIN 操作中,数据库优化器可能更容易处理。

安全建议:

  • SQL 注入防护 : 代码示例中的 '80001' 是硬编码的。在实际应用中,这个用户 ID 应该是动态传入的。务必使用参数化查询 (Prepared Statements),绝对不要直接拼接字符串来构造 SQL 语句,否则极易受到 SQL 注入攻击。例如,在后端代码中,查询会写成类似 WHERE b.ACCOUNT = ? 的形式,然后把用户 ID 作为参数绑定。
  • 权限控制 : 确保数据库用户只有执行所需查询的最小权限。

进阶使用技巧:

  • 索引优化 :
    • USERS 表:ACCOUNT 列应有索引(通常是主键)。
    • FRIENDS 表:ACCOUNTTO_ACCOUNT 列都应该有索引,最好是组合索引或者各自有独立索引,以加速好友查找。
    • ALERTS 表:ACCOUNT 列(用于 JOIN RelevantAccounts)和 ALERTID 列(用于 JOIN ALERTS_BLOCKED)应有索引。
    • ALERTS_BLOCKED 表:(ACCOUNT, ALERTID) 的复合索引会非常有用,因为它正好匹配 LEFT JOINON 条件和 WHERE 子句(如果用 NOT EXISTS 的话)。
  • 理解 LEFT JOIN ... IS NULL vs NOT EXISTS : 两者效果相似,性能也接近,具体哪个更快可能取决于数据库版本、数据分布和索引情况。可以实际测试一下。NOT EXISTS 有时被认为语义更清晰地表达“不存在满足条件的记录”。
-- 使用 NOT EXISTS 的等价写法 (接在 RelevantAccounts CTE 之后)
SELECT m.ACCOUNT AS SenderAccount, m.ALERT, m.ALERTID
FROM ALERTS AS m
INNER JOIN RelevantAccounts ra ON m.ACCOUNT = ra.ACCOUNT
WHERE NOT EXISTS (
    SELECT 1
    FROM ALERTS_BLOCKED AS b
    WHERE b.ACCOUNT = '80001' AND b.ALERTID = m.ALERTID
);

总结与思考

这次 SQL 查询失败,主要是因为一个基础的语法错误(AND WHERE)和一个潜在的逻辑问题(屏蔽规则应用混乱)。通过分析,咱们找到了几种修复和优化的方法:

  1. 简单修复 : 直接修改语法,统一逻辑,改动最小,但不一定最高效。
  2. CTE + 后过滤 : 利用 CTE 拆分步骤,先找全量再过滤,逻辑清晰,易于维护。
  3. JOIN 优化 : 改进好友查找,用 JOIN 替代 IN,用 LEFT JOIN ... IS NULLNOT EXISTS 处理屏蔽,通常性能更优。

选择哪种方案,取决于你对代码可读性、性能的要求以及团队的技术偏好。但无论如何,理解 SQL 的基本语法、掌握不同的查询模式(如 CTE、JOIN 类型、子查询替代方案),以及关注安全(参数化查询)和性能(索引)总是没错的。下次再遇到类似的多条件、多表关联查询,思路或许就能更清晰些了。