表:reception,add_time为接待时间,customer_id为客户id
表:project_customer,flag为客户类型,id为客户id
注意:
- 首访、复访的定于需要在查全局的前提下,8月来访1次的不能直接算作首访,需要看该客户在之前是否来访过
- reception 中 同一个 customer_id 可出现多次
- 需要获取的是复访客户数,而不是重复到访次数
- 给 reception(customer_id, add_time) 建复合索引;
首访
查询 时间段(2024 年 8 月到 12 月)接待的首访客户ids
筛选:客户在2024年8月到12月仅到访过1次,并且在2024年8月前没到到访过。
反连接(Anti‑Join)(5.4最优)
SELECT m.customer_id FROM ( SELECT customer_id FROM reception WHERE project_id = 1 AND add_time BETWEEN UNIX_TIMESTAMP('2024-08-01') AND UNIX_TIMESTAMP('2024-09-01') GROUP BY customer_id HAVING COUNT(*) = 1 ) AS m LEFT JOIN reception AS r2 ON r2.customer_id = m.customer_id AND r2.add_time < UNIX_TIMESTAMP('2024-08-01') WHERE r2.customer_id IS NULL;
执行步骤
① 子查询 m
执行(即 FROM 子句中的派生表):
-
从
reception
表中筛选:-
project_id = 1
-
add_time
在时间区间[@start, @end]
之间
-
-
然后按
customer_id
分组 -
使用
HAVING COUNT(*) = 1
:仅保留这个时间范围内只来访一次的客户 -
输出结果为唯一
customer_id
列表(这些客户在该时间段内仅来访一次)
② 外层 LEFT JOIN:
-
将上述结果(命名为
m
)与reception
表再次连接:-
条件是
m.customer_id = r2.customer_id
-
且
r2.add_time < @start
:检查该客户在这段时间之前有没有接待记录
-
③ WHERE 子句过滤:
-
WHERE r2.customer_id IS NULL
-
表示该
customer_id
在@start
之前没有出现过记录 -
实质上是排除有早于指定时间的接待记录的客户
-
这个写法在 5.5 下会被当作两张索引扫描+一次外连接,通常比 NOT IN
/NOT EXISTS
子查询更稳定、高效。
NOT EXISTS
语句 B(嵌套子查询 GROUP BY + HAVING
+ NOT EXISTS
)
SELECT m.customer_id FROM ( SELECT customer_id FROM reception WHERE customer_id > 0 AND add_time BETWEEN UNIX_TIMESTAMP('2024-08-01') AND UNIX_TIMESTAMP('2024-12-31') GROUP BY customer_id HAVING COUNT(*) = 1 ) AS m WHERE NOT EXISTS ( SELECT 1 FROM reception AS r WHERE r.customer_id = m.customer_id AND r.add_time < UNIX_TIMESTAMP('2024-08-01') ); -> 2025.0630给出的代码
语句 A(单层 NOT EXISTS
+ GROUP BY
)
SELECT log1.customer_id FROM reception log1 WHERE log1.add_time BETWEEN UNIX_TIMESTAMP('2024-08-01') AND UNIX_TIMESTAMP('2024-12-31') AND NOT EXISTS ( SELECT 1 FROM reception excluded WHERE excluded.customer_id = log1.customer_id AND excluded.add_time < UNIX_TIMESTAMP('2024-08-01') ) GROUP BY log1.customer_id HAVING COUNT(*) = 1
差异分析
比较项 | 语句 A | 语句 B | 差异说明 |
---|---|---|---|
过滤时间段 | WHERE 先限制 add_time 范围 |
子查询 中限制时间范围 |
若某客户在范围外也有数据,A 可能漏算(因为 WHERE 先排除了) |
排除早期数据 | 外层记录中已排除 < 8月1日 |
先保留范围内记录,再判断是否存在早期记录 | A 有可能“提前”过滤掉某些需要对比的记录 |
客户多条记录 | 统计的是范围内记录数 = 1 | 也是范围内 COUNT = 1,但记录全集中判断历史是否有记录 | 两者逻辑接近,但计算路径不同 |
性能 | 通常慢,因 NOT EXISTS 对 log1 每一行检查历史记录 |
内层先聚合再判断是否存在 | B 更容易被优化器优化,索引使用率高 |
是否等价 | ❌ 不完全等价 | ✅ 更精确控制逻辑 | A 若记录在区间外存在但被 WHERE 过滤,则无法进 NOT EXISTS |
逻辑执行顺序
① 过滤主表 reception log1
-
执行
WHERE log1.add_time BETWEEN 2024-08-01 AND 2024-12-31
过滤条件; -
如果
add_time
有索引,这一步会进行 范围扫描(range scan)。 - 结果是一个满足日期范围的临时结果集(假设为 N 条记录)。
② 对临时结果做 NOT EXISTS
子查询判断
-
对上面筛选出的每一条记录:
-
用当前
log1.customer_id
去执行子查询 - 如果该子查询返回任何一行(即存在历史记录),则该
log1.customer_id
被排除。
-
MySQL 5.5/5.6 中,这会使用 嵌套循环子查询,对每条外部行执行一次内部查询。
✅ 优化建议:给 (customer_id, add_time)
添加联合索引,以便子查询快速匹配。
③ 对剩下的结果按 customer_id
分组 (GROUP BY
)
-
对满足条件的记录,按
customer_id
分组。
④ 筛选仅出现 1 次的 (HAVING COUNT(*) = 1
)
-
只保留在该时间段内只出现一次的
customer_id
。
执行顺序图解
步骤 | 操作 | 说明 |
---|---|---|
1 | FROM reception log1 |
扫描主表 |
2 | WHERE log1.add_time BETWEEN ... |
范围过滤 |
3 | NOT EXISTS (...) |
子查询过滤历史记录 |
4 | GROUP BY customer_id |
分组 |
5 | HAVING COUNT(*) = 1 |
保留只出现一次的客户 |
NOT IN
SELECT customer_id FROM reception WHERE add_time >= UNIX_TIMESTAMP('2024-08-01') AND add_time < UNIX_TIMESTAMP('2025-01-01') GROUP BY customer_id HAVING COUNT(*) = 1 AND customer_id NOT IN ( SELECT DISTINCT customer_id FROM reception WHERE add_time < UNIX_TIMESTAMP('2024-08-01') );
逻辑执行顺序
- 子查询
SELECT DISTINCT customer_id ... add_time < 8月
先执行一次,生成一个“8月前来访过的客户集合”; - 外层再筛选 8 月内记录;
GROUP BY customer_id
;HAVING
同时判断COUNT = 1
,MIN(add_time) >= 8月1日
,且该customer_id
不在子查询集合中。
复访
查询 时间段(2024 年 8 月到 12 月)接待的复访客户数
筛选:客户在2024年8月到12月之前的到访过,并且在2024年12月前来过2次。(简而言之就是8月到12月有访问且累计访问>1的客户)
方法1、EXISTS(最优)
SELECT DISTINCT customer_id FROM reception WHERE add_time >= UNIX_TIMESTAMP('2024-08-01') AND add_time < UNIX_TIMESTAMP('2025-01-01') AND EXISTS ( SELECT 1 FROM reception r2 WHERE r2.customer_id = reception.customer_id GROUP BY r2.customer_id HAVING COUNT(*) > 1 );
推荐用 EXISTS
,性能更优于 IN
和 JOIN
(尤其大表时)
标准 SQL 逻辑执行顺序(理论)
-
先执行 FROM 和 WHERE:从
reception
表筛选出满足时间条件的行(add_time
在 8 月区间的)。 -
对每一行,判断
EXISTS
子查询条件是否成立。 -
EXISTS
子查询会用当前外层行的customer_id
作为条件。 -
满足的行输出
customer_id
。 -
对结果做
DISTINCT
去重。
子查询执行次数
-
按照 SQL 语义,
EXISTS
子查询看似会针对 外层每一行单独执行一次。 -
但实际 MySQL 优化器通常不会简单地逐行执行子查询,尤其是这种与外层表关联的子查询。
-
MySQL 会尝试做semi-join优化,批量处理外层表的
customer_id
,避免 N 次子查询调用。 -
但由于这里
GROUP BY r2.customer_id HAVING COUNT(*) > 1
,逻辑比较复杂,执行器可能会先预计算满足COUNT(*)>1
的客户列表,再快速匹配。
方法2、INNER JOIN
SELECT COUNT(DISTINCT log1.customer_id) AS mcount FROM reception AS log1 INNER JOIN ( SELECT customer_id FROM reception WHERE add_time < UNIX_TIMESTAMP('2025-01-01') GROUP BY customer_id HAVING COUNT(*) > 1 ) AS log2 ON log1.customer_id = log2.customer_id WHERE log1.add_time >= UNIX_TIMESTAMP('2024-08-01') AND log1.add_time < UNIX_TIMESTAMP('2025-01-01')
标准 SQL 逻辑执行顺序(理论)
1.子查询 log2 执行:
-
这个子查询会从全表中过滤出 累计访问次数 >1 的客户。
-
MySQL 会做一次分组聚合扫描,扫描的行数等于整个表。
2.外层 log1 查询执行:
-
读取8月及之后的数据;
-
再和 log2 做
customer_id
连接,只保留同时满足“8月访问”和“累计>1”的客户; -
结果去重后计数。
推荐:EXISTS
写法更优(尤其在大数据量下)
🔬 性能对比
对比点 | JOIN 写法 |
EXISTS 写法 |
---|---|---|
子查询执行 | 先全表分组,生成临时表 | 优化器支持半连接,避免临时表 |
连接代价 | 可能 JOIN 产生大量中间结果 | EXISTS 只判断是否“存在” |
可优化空间 | 限于索引和临时表缓存 | 优化器可将子查询重写为索引快速判断 |
内存消耗 | 可能因 JOIN 存储结果而高 | 更节省内存 |
推荐等级 | ✅次选,逻辑直观,略慢 | ✅✅首选,尤其大数据时效率更高 |
💡建议
-
给
reception(customer_id, add_time)
建复合索引; -
小表 JOIN 和 EXISTS 区别不明显,但 千万级数据时 EXISTS 更优;
🔬模拟测试
-
表:
reception
,共10万行 -
有复合索引:
(customer_id, add_time)
-
2024年8月数据约 5000 行
1. EXISTS
写法的执行方式
-
外层扫描大约 5000 行(8月数据);
-
每一行都判断一次
customer_id
是否满足COUNT(*) > 1
; -
如果有复合索引
(customer_id, add_time)
,子查询只扫描该客户的记录,通常仅几十行以内,性能非常高; -
MySQL 可能将子查询优化为 semi-join + index lookup,非常快。
2. JOIN
写法的执行方式
-
子查询先处理所有记录,扫描 10 万行做
GROUP BY customer_id
+HAVING COUNT > 1
; -
即使建了索引,也必须完成分组后再 JOIN;
-
产生的 log2 临时表可能含数万行,JOIN 代价大,尤其内存不够或 JOIN 过程中未能使用索引时,明显变慢;
-
MySQL 无法用 semi-join 优化 JOIN 子查询,所以性能略差。
首访 VS 复访
从性能角度看,查询“首访”客户通常比“复访”客户更慢,原因如下:
首访查询通常更慢的原因:
1. 首访需要排除历史记录(负判断)
-
你需要判断客户 在8月前没有任何访问记录,这类逻辑常常要用:
-
NOT EXISTS
-
NOT IN
-
-
这类排除式查询性能较差,尤其是在没有索引或数据量大的时候。
2. 需要聚合+排除
复访查询通常更快的原因:
1.复访查询更容易正向判断
你只需判断:
-
访问次数是否
> 1
-
或某客户在不同时间段都出现过(如 8 月来过 & 8 月前也来过)
这类逻辑可以直接写成:HAVING COUNT(*) > 1,或用 EXISTS
来判断是否出现多次,MySQL 优化器能较好地执行。
2. 复访场景不需要“排除历史”
你只关心客户“是否来过多次”,不用确认“他从未来过”,因此查询路径更直、更快。