应使用 exists 而非 in 判断领券状态,因 in 遇 null 返回 false,exists 不受 null 影响;多条件排他需在子查询中显式约束所有字段;高并发下须配合 for update 防超发;复杂规则可借助 cte 与窗口函数实现。

子查询必须用 EXISTS 而不是 IN 来判断用户是否已领过券
IN 会因 NULL 值导致整个条件失效,比如 user_id IN (SELECT user_id FROM coupon_log WHERE coupon_id = 123) 在子查询返回 NULL 时结果恒为 FALSE,哪怕用户确实领过。EXISTS 则只关心是否存在匹配行,不受 NULL 干扰。
实际写法应为:
SELECT * FROM coupons c
WHERE c.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM coupon_log l
WHERE l.coupon_id = c.id
AND l.user_id = 10086
)
- 子查询里用
SELECT 1,语义清晰且数据库不实际取数据 -
NOT EXISTS比NOT IN更安全,也更容易命中索引(尤其当coupon_log(coupon_id, user_id)有联合索引时) - 避免在子查询中写
SELECT user_id后再和外层比较——那是 IN 的写法,已淘汰
多条件排他(如“同一用户+同一商品+同类型券”不可重复领取)需在子查询中严格对齐字段
电商常见需求不是“不能领同一张券”,而是“不能对同一商品领第二张满减券”。这时子查询的 WHERE 条件必须同时约束多个维度,漏掉任一字段都会破坏排他性。
例如限制用户 10086 对商品 5566 不得重复领取 type='discount' 的券:
SELECT c.* FROM coupons c
WHERE c.type = 'discount'
AND c.target_product_id = 5566
AND c.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM coupon_log l
WHERE l.user_id = 10086
AND l.coupon_id = c.id
AND l.target_product_id = 5566
)
- 子查询中
l.target_product_id = 5566必须显式写出,不能依赖外层c.target_product_id——因为外层是主表扫描,子查询需独立完成约束 - 如果业务允许“不同商品可领同类型券”,但“同一商品仅限一张”,那
target_product_id就是关键过滤字段,缺它等于没排他 - 注意
coupon_log表上要有覆盖(user_id, coupon_id, target_product_id)的索引,否则子查询会变全表扫描
INSERT … SELECT 场景下子查询需加 FOR UPDATE 防并发超发
单纯 SELECT 子查询能查出可用券,但并发请求可能同时读到同一张券并插入两条记录。必须在子查询阶段就锁定数据行。
正确姿势是把子查询嵌入 INSERT,并在子查询中用 SELECT ... FOR UPDATE:
INSERT INTO coupon_log (user_id, coupon_id, created_at)
SELECT 10086, c.id, NOW()
FROM coupons c
WHERE c.id = 789
AND c.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM coupon_log l
WHERE l.coupon_id = c.id AND l.user_id = 10086
)
AND c.stock > 0
AND (
SELECT COUNT(*) FROM coupon_log l2
WHERE l2.coupon_id = c.id
)
- 上面 SQL 仍存在竞态:
c.stock > 0和子查询里的COUNT是两次查询,中间 stock 可能被其他事务扣减 - 真正防超发要改用
SELECT ... FOR UPDATE先锁住 coupon 行,再做校验,例如在事务中: BEGIN;SELECT stock, used_count FROM coupons WHERE id = 789 FOR UPDATE;- 应用层判断是否可发,再执行 INSERT 和 UPDATE stock
- 直接靠子查询 + INSERT 一行解决高并发场景,基本不可靠
MySQL 8.0+ 可用 CTE + ROW_NUMBER() 简化复杂排他规则
当排他逻辑涉及“每个用户最多领 N 张某类券”或“按时间顺序只发前 M 名”,用嵌套子查询会非常绕。CTE 配合窗口函数更直观,也便于复用。
例如:每个用户对优惠券类型 'new_user' 最多领 2 张,当前想查用户 10086 还能领几张:
WITH user_coupon_cnt AS (
SELECT user_id, coupon_id,
ROW_NUMBER() OVER (
PARTITION BY user_id, type ORDER BY created_at DESC
) rn
FROM coupon_log l
JOIN coupons c ON l.coupon_id = c.id
WHERE c.type = 'new_user' AND l.user_id = 10086
)
SELECT COUNT(*) FROM user_coupon_cnt WHERE rn
- 这里
ROW_NUMBER()按用户+券类型分组排序,rn 就代表“前两张”,比多次子查询 COUNT 更易读 - 但注意:CTE 本身不提升性能,若
coupon_log数据量大,仍需确保(user_id, coupon_id)和coupons(type)有对应索引 - MySQL 5.7 不支持 CTE,强行用会报错
ERROR 1064,上线前必须确认版本
真实排他逻辑从来不是“有没有子查询”,而是“锁不锁得住、判不判得准、并发扛不扛得住”。哪怕语法完全正确,缺了索引、漏了事务、忘了版本兼容,上线后照样发重券。











