SQL 面试高频:用户、商品、订单三表查询全解析

同样是三表关联,有人查出的销售额比财务报表多出 30%,问题不在数据,在 JOIN 选错了。

这篇用一个电商库,把用户、商品、订单三张表的关联、聚合、子查询串成一条问题链,照着走就能写对。

一、场景:三表查询到底在查什么

电商库最核心的链路是"人---货---单":用户表回答谁在买,商品表回答买了什么,订单表记录什么时候买、买多少、花多少钱。

三张表的字段和角色先对齐:

表 别名 回答的问题 核心字段
user 用户表 u 谁在买 user_id(主键)、user_name、user_phone、user_region、register_time
product 商品表 p 买了什么 product_id(主键)、product_name、product_category、unit_price、stock
order 订单表 o 何时买、买多少、花多少 order_id(主键)、user_id(外键)、product_id(外键)、order_quantity、order_amount、order_time、order_status

三张表的关联关系一眼能看清:

latex 复制代码
 user(用户表)                     product(商品表)
       ▲                                    ▲
       │ user_id                            │ product_id
       │                                    │
       └───────── order(订单表)────────────┘
                    (事实表,居中)

表结构就这么简单,那三张表一拼是不是就完事了?我第一次写三表查询,拼出来的就是一场事故。

二、踩坑:三表一拼,结果要么翻倍,要么丢行

我图省事写过逗号隐式连接,还漏了连接条件:

sql 复制代码
SELECT u.user_name, p.product_name, o.order_amount
FROM `user` u, `order` o, product p;

没有连接条件,三张表的行数两两相乘,order_amount 被重复累加,销售额自然比财务多出一大截。这就是笛卡尔积(Cartesian Product)。

后来换成 JOIN,又栽在第二个坑:用内连接做销量榜,没卖出去的商品直接消失,排行榜变成"全员有销量"。

连接类型的差异,决定了哪些行能留下:

连接方式 保留哪些行 无匹配时 典型场景
INNER JOIN 内连接 双方都匹配的行 直接过滤 查有效订单明细
LEFT JOIN 左连接 左表全部行 右表补 NULL 查全部用户及订单
RIGHT JOIN 右连接 右表全部行 左表补 NULL 查全部商品及订单

要查每笔订单的用户名、商品名、数量、金额,订单表做驱动表,两次内连接:

sql 复制代码
SELECT u.user_name, p.product_name, o.order_quantity, o.order_amount
FROM `order` o
INNER JOIN `user` u ON o.user_id = u.user_id
INNER JOIN product p ON o.product_id = p.product_id;

要保留全部用户,包括从没下单的,换成左连接:

sql 复制代码
SELECT u.user_name, o.order_id, o.order_amount
FROM `user` u
LEFT JOIN `order` o ON u.user_id = o.user_id;

只想看哪些用户从未下单,在外连接后补一刀:

sql 复制代码
SELECT u.user_name
FROM `user` u
LEFT JOIN `order` o ON u.user_id = o.user_id
WHERE o.order_id IS NULL;

注意:订单表是中心,驱动表选谁,取决于你要保留谁的全部行;要保留全部行,就用外连接。

明细看对了,算指标呢?我第一次写消费排行,直接在 WHERE 里写 SUM,数据库当场报错。

三、聚合:WHERE 报错,HAVING 才算对

我当时的写法是这样的:

sql 复制代码
SELECT u.user_name, SUM(o.order_amount) AS total_spent
FROM `user` u
INNER JOIN `order` o ON u.user_id = o.user_id
WHERE SUM(o.order_amount) > 1000
GROUP BY u.user_id, u.user_name;

WHERE 执行在分组之前,此刻还没有聚合结果,聚合函数写进 WHERE 必然报错。

一条 SQL 的实际执行顺序如下,和书写顺序并不一样:

latex 复制代码
FROM/JOIN → WHERE → GROUP BY → 聚合函数 → HAVING → SELECT → ORDER BY
 取数连接    过滤行    分组      SUM/COUNT   过滤组    取列      排序

统计每个用户的消费总额并排序,状态过滤放在 WHERE,排序用聚合列:

sql 复制代码
SELECT u.user_name, SUM(o.order_amount) AS total_spent
FROM `user` u
INNER JOIN `order` o ON u.user_id = o.user_id
WHERE o.order_status = '已支付'
GROUP BY u.user_id, u.user_name
ORDER BY total_spent DESC;

再筛"消费超 1000 的用户",把条件挪到 HAVING:

sql 复制代码
SELECT u.user_name, SUM(o.order_amount) AS total_spent
FROM `user` u
INNER JOIN `order` o ON u.user_id = o.user_id
GROUP BY u.user_id, u.user_name
HAVING SUM(o.order_amount) > 1000;

这里有两个细节不能省:

  • 状态过滤放 WHERE:聚合前就剔除无效订单,指标不被污染
  • GROUP BY 带上 user_id 和 user_name:SELECT 里所有非聚合列,都要出现在 GROUP BY 中

商品销量榜要把没卖动的商品也显示出来、销量为 0,就得换左连接加 COALESCE:

sql 复制代码
SELECT p.product_name, COALESCE(SUM(o.order_quantity), 0) AS total_sold
FROM product p
LEFT JOIN `order` o ON p.product_id = o.product_id
GROUP BY p.product_id, p.product_name
ORDER BY total_sold DESC;

注意:WHERE 过滤行,HAVING 过滤组;聚合条件写进 HAVING,没有例外。

如果筛选条件本身就是另一组聚合结果------比如买过手机的用户、消费超过平均值的用户------又该怎么办?这时候该子查询出场。

四、进阶:条件套结果,子查询分步拆

找买过某类商品的用户,先在内层确定符合条件的用户 ID,外层再取用户信息:

sql 复制代码
SELECT user_name, user_phone
FROM `user`
WHERE user_id IN (
    SELECT DISTINCT o.user_id
    FROM `order` o
    INNER JOIN product p ON o.product_id = p.product_id
    WHERE p.product_category = '手机'
);

找消费金额高于平台平均值的用户,内层先算每人总额,再算总额平均值,外层用 HAVING 比较:

sql 复制代码
SELECT u.user_name, SUM(o.order_amount) AS total_spent
FROM `user` u
INNER JOIN `order` o ON u.user_id = o.user_id
GROUP BY u.user_id, u.user_name
HAVING SUM(o.order_amount) > (
    SELECT AVG(user_total)
    FROM (
        SELECT user_id, SUM(order_amount) AS user_total
        FROM `order`
        GROUP BY user_id
    ) AS t
);

找"从未被购买过的商品",我在生产上踩过一次:订单表的 product_id 允许 NULL,NOT IN 子查询里只要混进一个 NULL,外层结果直接为空------任何值和 NULL 比较,结果都是未知。

稳妥写法是在子查询里先排除 NULL:

sql 复制代码
SELECT product_name, product_category
FROM product
WHERE product_id NOT IN (
    SELECT product_id
    FROM `order`
    WHERE product_id IS NOT NULL
);

更省心的写法是直接用 NOT EXISTS,语义和空值行为都更稳:

sql 复制代码
SELECT p.product_name, p.product_category
FROM product p
WHERE NOT EXISTS (
    SELECT 1
    FROM `order` o
    WHERE o.product_id = p.product_id
);

注意:NOT IN 怕 NULL,要么先排除 NULL,要么改用 NOT EXISTS,后者更稳。**

单点查询都会了,把它们串成一条完整的分析链路,还能不能一次写对?下面用一个真实报表需求验证。

五、实操验证:一条完整分析链路

需求是:统计 2025 年第一季度各地区"已支付"订单的销售额,按销售额降序,同时显示该地区的下单用户数。

你可以按下面这条链路写:

sql 复制代码
SELECT 
    u.user_region AS region,
    COUNT(DISTINCT o.user_id) AS user_count,
    SUM(o.order_amount) AS total_sales
FROM `order` o
INNER JOIN `user` u ON o.user_id = u.user_id
WHERE o.order_status = '已支付'
  AND o.order_time >= '2025-01-01'
  AND o.order_time < '2025-04-01'
GROUP BY u.user_region
ORDER BY total_sales DESC;

写完别急着交,按这张表逐项核对:

检查点 这条 SQL 的处理 目的
连接方式 INNER JOIN 用户表 只统计有地区归属的订单
过滤位置 状态、时间放 WHERE 聚合前剔除,金额不被污染
用户数 COUNT(DISTINCT o.user_id) 一人多单只算一次
时间范围 >= 季度首日 AND < 下季度首日 避开边界和时分秒误差
追加筛选 HAVING SUM(o.order_amount) > 5000 组级条件必须放在聚合后

收尾交叉验证:把同一时间、同一状态的订单明细单独拉出来求和,和地区汇总的 total_sales 合计对齐,数字才敢往报表上放。

sql 复制代码
SELECT order_id, order_amount
FROM `order`
WHERE order_status = '已支付'
  AND order_time >= '2025-01-01'
  AND order_time < '2025-04-01';

能跑通、能核对、能解释,这条 SQL 才算真正落地。

六、总结延伸:把语法变成决策链路

三表查询的难点不在语法,在于拿到需求先做哪个判断。下面这条链路,我现在写每条 SQL 都在走:

  • 先定表:需要哪些表的信息,决定 JOIN 谁
  • 再定形态:看明细用普通查询,算指标用 GROUP BY 加聚合函数
  • 后定过滤:聚合前用 WHERE,聚合后用 HAVING

术语统一速查:

术语 英文 一句话说明
主键 Primary Key(PK) 唯一标识表内一行的字段
外键 Foreign Key(FK) 指向其他表主键的关联字段
内连接 INNER JOIN 只保留双方匹配的行
外连接 OUTER JOIN 保留一侧全部行,如 LEFT/RIGHT JOIN
笛卡尔积 Cartesian Product 缺连接条件时,行数两两相乘
分组 GROUP BY 按字段把多行归为一组
聚合函数 Aggregate Function 对组计算的 SUM/COUNT/AVG 等
子查询 Subquery 嵌套在其他语句中的查询
表别名 Table Alias u、o、p 这类表的临时简称

参考链接(官方文档):

相关推荐
IT19954 小时前
Fail2Ban 防护 MySQL 实战|登录失败 3 次,IP 封禁 24 小时
数据库·mysql
夕除4 小时前
redis--IO 多路复用(select + poll)
数据库·sql
paopaokaka_luck7 小时前
流浪动物救助与收养系统(DeepSeek AI对话、 ECharts多维分析、救助流程衔接、领养与回访衔接、 捐赠确认与公示、志愿者协作)
java·前端·javascript·人工智能·mysql·echarts·推荐算法
lzhdim8 小时前
100条常用SQL语句
数据库·sql·mysql·mybatis
天河归来8 小时前
从原始消息到业务数据:三层数据架构的设计与实践
数据库·数据仓库·架构
数据库小学妹9 小时前
金融数据库成本怎么算?五笔钱加一张TCO表
数据库·金融行业·数据库选型·国产化替代·tco·金融数据库
KaiwuDB9 小时前
KaiwuDB 分布式集群扩缩容,从原理到完整操作踩坑总结
运维·数据库·分布式
auspicious航9 小时前
PostgreSQL的备份与恢复
数据库·postgresql
这个DBA有点耶10 小时前
从 MySQL 到时序数据库:我帮朋友做迁移的完整记录
数据库·架构·dba
泡海椒10 小时前
深度解析JQuick-SQL AST到逻辑计划转换机制|分布式SQL原理
数据库·分布式·sql