用NOT EXISTS比NOT IN更安全,因NOT IN遇NULL返回UNKNOWN致结果为空;必须在子查询中加时间过滤,否则漏掉从未活跃的客户。

直接结论:用 NOT EXISTS 比 NOT IN 更安全,且必须在子查询中显式过滤时间范围,否则会漏掉“从未有过活跃记录”的客户。
为什么 NOT IN 在这里容易出错?
常见写法是:SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM activity WHERE created_at > NOW() - INTERVAL 30 DAY)。但只要子查询返回任意一个 NULL(比如 customer_id 允许为空),整个 NOT IN 就返回空结果——这是 SQL 的三值逻辑陷阱,不是数据没查出来,而是条件本身失效了。
-
NOT IN遇到NULL时恒为UNKNOWN,被当作FALSE处理 - 即使表结构干净,下游 ETL 或历史数据迁移也可能悄悄引入
NULL值 -
NOT EXISTS完全规避这个问题,只关心“是否存在匹配行”
正确写法:用 NOT EXISTS + 时间条件下沉
关键点在于:时间过滤必须写在子查询内部,而不是外层加 WHERE。否则外层 NOT EXISTS 会把“有老记录但无新记录”的客户也排除掉。
SELECT c.id, c.email
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM activity a
WHERE a.customer_id = c.id
AND a.created_at > NOW() - INTERVAL 30 DAY
);- 子查询中的
a.created_at > NOW() - INTERVAL 30 DAY是强制项,不能省略或挪到外层 -
SELECT 1是惯用写法,比SELECT *更轻量,语义也更清晰 - 如果数据库不支持
NOW()(如 PostgreSQL),改用CURRENT_DATE - INTERVAL '30 days'
性能和索引注意事项
这个查询实际执行时,数据库需要对每个客户检查 activity 表是否有匹配的新记录。没有合适索引的话,可能全表扫描 activity 上万次。
- 必须在
activity(customer_id, created_at)上建联合索引(顺序不能颠倒) - 如果
activity表极大且冷热分离,考虑是否先用物化视图或每日汇总表预计算“最近活跃客户 ID 集合” - MySQL 8.0+ 或 PostgreSQL 可用
LATERAL优化复杂关联,但本场景通常没必要
最常被忽略的一点:这个查询默认包含“注册后从未产生任何 activity 记录”的客户——这通常是业务想要的,但如果需求明确只要“曾经活跃过、最近30天沉寂”的客户,就得额外加 EXISTS (SELECT 1 FROM activity a2 WHERE a2.customer_id = c.id) 条件。别想当然认为“无活跃”就等于“该拉回流”,得先对齐业务定义。

















