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 这类表的临时简称

参考链接(官方文档):

相关推荐
jyOverQ12 小时前
Redis 持久化怎么做?RDB、AOF 与混合持久化
数据库·redis
j7~12 小时前
【C++ 标准项目】发布订阅消息队列(篇四):sqlite 与 gtest 断言框架介绍及实战应用
数据库·sqlite·gtest·断言·事件机制·tset宏
于指尖飞舞12 小时前
mysql和redis面试题总结
数据库·redis·mysql
维克兜率天12 小时前
【维克】通道突破:画出趋势的“轨道线“
前端·数据库·人工智能
ao-weilai12 小时前
MySQL数据库:复合查询
android·数据库·mysql
Omics Pro12 小时前
北理工李荣华:AI虚拟细胞证据多模态推理基准
数据库·人工智能·算法·机器学习·自然语言处理
一个天蝎座 白勺 程序猿12 小时前
IoTDB集群扩容:从慌乱到从容的经验分享
数据库·wpf·时序数据库·iotdb
FYKJ_201012 小时前
SpringSecurity教室智能预约系统73972-计算机课程设计、毕业设计
java·vue.js·spring boot·python·mysql·spring·django
写后端的胖头鱼12 小时前
详解索引下推
数据库·索引失效·索引·icp·索引下推