Skip to content

MySQL SQL 查询面试题与答案

这套题专门训练“给出表结构,现场编写 SQL”。题目统一使用电商、员工和登录行为数据模型,避免每道题的字段语义不一致。

建议练习方式:

  1. 先只看题目,限时 5~15 分钟手写。
  2. 自己准备边界数据运行,不要只凭肉眼判断。
  3. 再展开参考答案,比较结果语义而不是只比较 SQL 文本。
  4. 最后用 EXPLAINEXPLAIN ANALYZE 检查执行方式,并说明需要什么索引。

参考答案主要使用 MySQL 8.0。使用窗口函数或递归 CTE 的题目会标注版本,并在部分高频题中给出 MySQL 5.7 思路。

统一数据模型

用户、商品和订单

sql
create table users (
  id bigint primary key comment '用户ID',
  username varchar(64) not null comment '用户名',
  city varchar(64) comment '所在城市,未填写时为NULL',
  register_time datetime not null comment '注册时间',
  unique key uk_username(username),
  key idx_users_city(city)
) engine = InnoDB default charset = utf8mb4 comment = '用户表';

create table categories (
  id bigint primary key comment '商品分类ID',
  category_name varchar(64) not null comment '商品分类名称',
  unique key uk_category_name(category_name)
) engine = InnoDB default charset = utf8mb4 comment = '商品分类表';

create table products (
  id bigint primary key comment '商品ID',
  product_name varchar(128) not null comment '商品名称',
  category_id bigint not null comment '商品分类ID',
  price decimal(18, 2) not null comment '商品当前单价',
  status varchar(20) not null comment '商品状态:ON_SALE-在售,OFF_SHELF-下架',
  key idx_products_category(category_id),
  constraint fk_products_category
    foreign key(category_id) references categories(id),
  constraint chk_products_price check(price >= 0)
) engine = InnoDB default charset = utf8mb4 comment = '商品表';

create table orders (
  id bigint primary key comment '订单ID',
  user_id bigint not null comment '下单用户ID',
  status varchar(20) not null comment '订单状态:PENDING-待支付,PAID-已支付,CLOSED-已关闭',
  total_amount decimal(18, 2) not null comment '订单总金额',
  created_at datetime not null comment '订单创建时间',
  key idx_orders_user_created(user_id, created_at desc, id desc),
  key idx_orders_status_created(status, created_at),
  constraint fk_orders_user
    foreign key(user_id) references users(id)
) engine = InnoDB default charset = utf8mb4 comment = '订单表';

create table order_items (
  id bigint primary key comment '订单明细ID',
  order_id bigint not null comment '订单ID',
  product_id bigint not null comment '商品ID',
  quantity int not null comment '购买数量',
  unit_price decimal(18, 2) not null comment '下单时商品单价',
  key idx_items_order(order_id),
  key idx_items_product(product_id),
  constraint fk_items_order
    foreign key(order_id) references orders(id),
  constraint fk_items_product
    foreign key(product_id) references products(id),
  constraint chk_items_quantity check(quantity > 0)
) engine = InnoDB default charset = utf8mb4 comment = '订单明细表';

员工和登录记录

sql
create table departments (
  id bigint primary key comment '部门ID',
  department_name varchar(64) not null comment '部门名称',
  unique key uk_department_name(department_name)
) engine = InnoDB default charset = utf8mb4 comment = '部门表';

create table employees (
  id bigint primary key comment '员工ID',
  employee_name varchar(64) not null comment '员工姓名',
  department_id bigint not null comment '所属部门ID',
  manager_id bigint comment '直属上级员工ID,最高负责人为NULL',
  salary decimal(18, 2) not null comment '当前薪资',
  hire_date date not null comment '入职日期',
  key idx_employees_department_salary(department_id, salary desc, id),
  key idx_employees_manager(manager_id),
  constraint fk_employees_department
    foreign key(department_id) references departments(id),
  constraint fk_employees_manager
    foreign key(manager_id) references employees(id)
) engine = InnoDB default charset = utf8mb4 comment = '员工表';

create table login_records (
  id bigint primary key comment '登录记录ID',
  user_id bigint not null comment '登录用户ID',
  login_date date not null comment '登录日期,同一用户每天最多一条',
  unique key uk_login_user_date(user_id, login_date),
  key idx_login_date_user(login_date, user_id),
  constraint fk_login_user
    foreign key(user_id) references users(id)
) engine = InnoDB default charset = utf8mb4 comment = '用户每日登录记录表';

数据模型关系速查

业务域表名中文名称核心关系
用户users用户表一个用户可以有多张订单、多个登录日期
商品categories商品分类表一个分类可以包含多个商品
商品products商品表每个商品属于一个分类,可以出现在多条订单明细中
交易orders订单表每张订单属于一个用户,可以包含多条订单明细
交易order_items订单明细表连接订单和商品,保存购买数量及下单时单价
组织departments部门表一个部门可以包含多个员工
组织employees员工表通过 manager_id 自关联形成上下级关系
行为login_records用户每日登录记录表一个用户同一天最多保留一条登录记录

字段中文速查

用户、分类和商品

表名字段类型可空中文含义与口径
usersidbigint用户 ID,主键
usersusernamevarchar(64)用户名,全局唯一
userscityvarchar(64)所在城市,未填写时为 NULL
usersregister_timedatetime用户注册时间
categoriesidbigint商品分类 ID,主键
categoriescategory_namevarchar(64)商品分类名称,全局唯一
productsidbigint商品 ID,主键
productsproduct_namevarchar(128)商品名称
productscategory_idbigint商品分类 ID,关联 categories.id
productspricedecimal(18,2)商品当前单价,不代表历史下单价格
productsstatusvarchar(20)商品状态:ON_SALE 在售,OFF_SHELF 下架

订单和订单明细

表名字段类型可空中文含义与口径
ordersidbigint订单 ID,主键
ordersuser_idbigint下单用户 ID,关联 users.id
ordersstatusvarchar(20)订单状态:PENDING 待支付、PAID 已支付、CLOSED 已关闭
orderstotal_amountdecimal(18,2)订单总金额
orderscreated_atdatetime订单创建时间
order_itemsidbigint订单明细 ID,主键
order_itemsorder_idbigint所属订单 ID,关联 orders.id
order_itemsproduct_idbigint商品 ID,关联 products.id
order_itemsquantityint购买数量,必须大于 0
order_itemsunit_pricedecimal(18,2)下单时商品单价,不随商品当前价格变化

部门、员工和登录记录

表名字段类型可空中文含义与口径
departmentsidbigint部门 ID,主键
departmentsdepartment_namevarchar(64)部门名称,全局唯一
employeesidbigint员工 ID,主键
employeesemployee_namevarchar(64)员工姓名
employeesdepartment_idbigint所属部门 ID,关联 departments.id
employeesmanager_idbigint直属上级员工 ID,自关联 employees.id;最高负责人为 NULL
employeessalarydecimal(18,2)员工当前薪资
employeeshire_datedate入职日期
login_recordsidbigint登录记录 ID,主键
login_recordsuser_idbigint登录用户 ID,关联 users.id
login_recordslogin_datedate登录日期;user_id + login_date 唯一

SQL 题通用约定

  • 已支付订单统一使用 status = 'PAID'
  • 已关闭订单使用 status = 'CLOSED',待支付使用 PENDING
  • 金额以 decimal 保存,不使用浮点数。
  • 日期区间统一使用左闭右开,例如 [2026-08-01, 2026-09-01)
  • “第二高工资”默认指第二个不同的工资值,而不是排序后的第二行。
  • “最近一笔订单”在时间相同时使用 id desc 作为稳定排序。
  • 除非题目明确允许,否则不能依赖无 order by 的返回顺序。

第一组:基础查询

题目 1:查询指定月份的已支付订单

查询 2026 年 8 月创建的已支付订单,返回订单 ID、用户 ID、金额和创建时间,按创建时间倒序;时间相同按订单 ID 倒序。

参考答案
sql
select id,
       user_id,
       total_amount,
       created_at
from orders
where status = 'PAID'
  and created_at >= '2026-08-01 00:00:00'
  and created_at <  '2026-09-01 00:00:00'
order by created_at desc, id desc;

不要写 date_format(created_at, '%Y-%m') = '2026-08',它可能让普通时间索引无法直接完成范围定位。左闭右开区间也不会遗漏毫秒或微秒。

题目 2:查询城市并正确处理 NULL

查询城市为北京或者城市信息未填写的用户。

参考答案
sql
select id, username, city
from users
where city = '北京'
   or city is null;

不能使用 city = null。SQL 中 NULL 要使用 is nullis not null 判断。

题目 3:查询用户来自哪些城市

返回非空城市列表,去重并按城市名升序排列。

参考答案
sql
select distinct city
from users
where city is not null
order by city;

排序结果受列的字符集和 collation 影响。

题目 4:使用 CASE 对订单金额分级

金额大于等于 1000 为 HIGH,大于等于 100 且小于 1000 为 MEDIUM,其他为 LOW

参考答案
sql
select id,
       total_amount,
       case
         when total_amount >= 1000 then 'HIGH'
         when total_amount >= 100 then 'MEDIUM'
         else 'LOW'
       end as amount_level
from orders;

case 按顺序匹配,所以应该先判断较高阈值。

题目 5:找出名称中包含 MySQL 的商品

不区分大小写的前提由列 collation 决定。

参考答案
sql
select id, product_name, price
from products
where product_name like '%MySQL%';

前导 % 通常无法使用普通 B+Tree 做前缀定位。数据量大、检索复杂时应评估全文索引或搜索引擎,而不是只增加普通索引。

题目 6:查询第 2 页订单

每页 20 条,按 created_at desc, id desc 排序。

参考答案
sql
select id, user_id, status, total_amount, created_at
from orders
order by created_at desc, id desc
limit 20 offset 20;

页码较深时 offset 分页需要扫描并丢弃大量记录。线上大表更推荐游标分页:

sql
select id, user_id, status, total_amount, created_at
from orders
where created_at < ?
   or (created_at = ? and id < ?)
order by created_at desc, id desc
limit 20;

题目 7:查询从未设置城市的注册用户数

参考答案
sql
select count(*) as user_count
from users
where city is null;

这里使用 count(*) 是统计满足条件的行。count(city) 会忽略 NULL,结果反而为 0。

题目 8:查询价格处于分类平均价格之上的商品

返回商品 ID、名称、分类 ID、商品价格和所在分类平均价格。

参考答案
sql
select p.id,
       p.product_name,
       p.category_id,
       p.price,
       a.avg_price
from products p
join (
  select category_id, avg(price) as avg_price
  from products
  group by category_id
) a on a.category_id = p.category_id
where p.price > a.avg_price;

MySQL 8 也可以使用窗口函数:

sql
with priced as (
  select p.*,
         avg(price) over(partition by category_id) as avg_price
  from products p
)
select id, product_name, category_id, price, avg_price
from priced
where price > avg_price;

第二组:聚合、Join 与子查询

题目 9:统计每个用户的已支付订单

返回所有用户,包括没有已支付订单的用户;输出订单数和支付总金额,没有订单时显示 0。

参考答案
sql
select u.id,
       u.username,
       count(o.id) as paid_order_count,
       coalesce(sum(o.total_amount), 0) as paid_amount
from users u
left join orders o
  on o.user_id = u.id
 and o.status = 'PAID'
group by u.id, u.username;

右表状态条件必须放在 on 中。如果放到 where o.status = 'PAID',没有订单的用户会被过滤,left join 实际退化为内连接语义。不能使用 count(*),否则无订单用户也会因为左表占位行被统计为 1。

题目 10:查询从未下单的用户

分别用 NOT EXISTSLEFT JOIN 编写。

参考答案

not exists

sql
select u.id, u.username
from users u
where not exists (
  select 1
  from orders o
  where o.user_id = u.id
);

left join

sql
select u.id, u.username
from users u
left join orders o on o.user_id = u.id
where o.id is null;

不要机械使用 not in。如果子查询结果包含 NULL,会受到三值逻辑影响。

题目 11:统计每个月的订单指标

统计每月订单总数、支付订单数、支付金额,按月份升序。

参考答案
sql
select date_format(created_at, '%Y-%m') as order_month,
       count(*) as order_count,
       sum(case when status = 'PAID' then 1 else 0 end) as paid_count,
       sum(case when status = 'PAID' then total_amount else 0 end) as paid_amount
from orders
group by date_format(created_at, '%Y-%m')
order by order_month;

这是报表聚合写法。如果只统计一个固定时间范围,应同时在 where 使用原始时间列范围,先减少扫描数据:

sql
where created_at >= ? and created_at < ?

题目 12:查询支付金额超过 10000 的用户

只统计已支付订单,返回用户 ID、用户名和支付金额,按金额倒序。

参考答案
sql
select u.id,
       u.username,
       sum(o.total_amount) as paid_amount
from users u
join orders o on o.user_id = u.id
where o.status = 'PAID'
group by u.id, u.username
having sum(o.total_amount) > 10000
order by paid_amount desc, u.id;

where 过滤分组前的订单行,having 过滤聚合后的用户组。

题目 13:查询从未被购买的商品

“购买”只统计已支付订单中的订单明细。

参考答案
sql
select p.id, p.product_name
from products p
where not exists (
  select 1
  from order_items oi
  join orders o on o.id = oi.order_id
  where oi.product_id = p.id
    and o.status = 'PAID'
);

如果只判断 order_items 是否存在,会把待支付和已关闭订单也当作已购买。

题目 14:查询第二高的不同工资

没有第二高工资时返回 NULL。

参考答案

MySQL 5.7 和 8.0 都可用:

sql
select max(salary) as second_highest_salary
from employees
where salary < (
  select max(salary)
  from employees
);

MySQL 8 窗口函数写法:

sql
select max(case when salary_rank = 2 then salary end) as second_highest_salary
from (
  select salary,
         dense_rank() over(order by salary desc) as salary_rank
  from employees
) ranked;

题目要求的是第二个不同工资值,所以不能简单 order by salary desc limit 1 offset 1,重复最高工资会产生错误。

题目 15:查询工资高于本部门平均值的员工

返回部门名、员工名、工资和部门平均工资。

参考答案
sql
select d.department_name,
       e.employee_name,
       e.salary,
       a.avg_salary
from employees e
join departments d on d.id = e.department_id
join (
  select department_id, avg(salary) as avg_salary
  from employees
  group by department_id
) a on a.department_id = e.department_id
where e.salary > a.avg_salary
order by d.id, e.salary desc, e.id;

MySQL 8 也可以使用 avg(salary) over(partition by department_id),再在外层过滤。

题目 16:查询订单表头金额与明细金额不一致的订单

明细金额为 quantity * unit_price 之和,金额比较保留两位小数。

参考答案
sql
select o.id,
       o.total_amount,
       coalesce(sum(oi.quantity * oi.unit_price), 0) as item_amount
from orders o
left join order_items oi on oi.order_id = o.id
group by o.id, o.total_amount
having round(o.total_amount, 2)
    <> round(coalesce(sum(oi.quantity * oi.unit_price), 0), 2);

金额字段应使用 decimal。真实业务还要明确优惠、运费、税费、退款和舍入发生在行级还是订单级,不能假定表头金额一定等于商品原价之和。

第三组:MySQL 8 窗口函数

题目 17:查询每个部门工资最高的三名员工

同工资共享名次,并允许同一部门返回超过三行。

参考答案
sql
with ranked as (
  select e.*,
         dense_rank() over(
           partition by department_id
           order by salary desc
         ) as salary_rank
  from employees e
)
select d.department_name,
       r.employee_name,
       r.salary,
       r.salary_rank
from ranked r
join departments d on d.id = r.department_id
where r.salary_rank <= 3
order by d.id, r.salary_rank, r.id;

如果题目要求每个部门严格返回三行,应使用 row_number 并增加稳定排序;如果同工资共享名次,应使用 dense_rank 或按题意使用 rank

题目 18:查询每个用户最近一笔订单

时间相同选择 ID 最大的订单。

参考答案

MySQL 8:

sql
with ranked as (
  select o.*,
         row_number() over(
           partition by user_id
           order by created_at desc, id desc
         ) as rn
  from orders o
)
select id, user_id, status, total_amount, created_at
from ranked
where rn = 1;

MySQL 5.7 可使用 not exists

sql
select o.*
from orders o
where not exists (
  select 1
  from orders newer
  where newer.user_id = o.user_id
    and (
      newer.created_at > o.created_at
      or (newer.created_at = o.created_at and newer.id > o.id)
    )
);

推荐索引:(user_id, created_at desc, id desc)

题目 19:计算用户订单累计支付金额

只统计已支付订单,按时间和订单 ID 累计。

参考答案
sql
select user_id,
       id as order_id,
       created_at,
       total_amount,
       sum(total_amount) over(
         partition by user_id
         order by created_at, id
         rows between unbounded preceding and current row
       ) as running_paid_amount
from orders
where status = 'PAID'
order by user_id, created_at, id;

显式使用 rows 框架,避免相同排序值下默认 range 框架把同值行一起纳入。

题目 20:计算每笔订单与上一笔订单的金额差

按同一用户的订单时间排序。

参考答案
sql
with compared as (
  select user_id,
         id as order_id,
         created_at,
         total_amount,
         lag(total_amount) over(
           partition by user_id
           order by created_at, id
         ) as previous_amount
  from orders
)
select user_id,
       order_id,
       created_at,
       total_amount,
       previous_amount,
       total_amount - previous_amount as amount_diff
from compared;

用户第一笔订单没有上一笔,差值为 NULL。不要无条件转成零,除非业务明确这样定义。

题目 21:计算每个用户的支付金额占比

返回订单金额、用户支付总金额和订单占该用户支付金额的比例。

参考答案
sql
select user_id,
       id as order_id,
       total_amount,
       sum(total_amount) over(partition by user_id) as user_paid_amount,
       total_amount
         / nullif(sum(total_amount) over(partition by user_id), 0)
         as amount_ratio
from orders
where status = 'PAID';

nullif(..., 0) 防止除零。展示为百分比时可以在应用层格式化,避免过早把数值转成字符串。

题目 22:计算月支付金额和环比增长率

环比为 (本月金额 - 上月金额) / 上月金额

参考答案
sql
with monthly as (
  select date_format(created_at, '%Y-%m-01') as month_start,
         sum(total_amount) as paid_amount
  from orders
  where status = 'PAID'
  group by date_format(created_at, '%Y-%m-01')
), compared as (
  select month_start,
         paid_amount,
         lag(paid_amount) over(order by month_start) as previous_amount
  from monthly
)
select month_start,
       paid_amount,
       previous_amount,
       (paid_amount - previous_amount) / nullif(previous_amount, 0)
         as growth_rate
from compared
order by month_start;

这个答案只显示有订单的月份。如果题目要求没有订单的月份也显示零,需要先生成完整月份日历,再左连接月度聚合。

题目 23:计算员工工资排名和与部门最高工资的差值

参考答案
sql
select department_id,
       id,
       employee_name,
       salary,
       dense_rank() over(
         partition by department_id
         order by salary desc
       ) as salary_rank,
       max(salary) over(partition by department_id) - salary
         as gap_to_department_max
from employees
order by department_id, salary_rank, id;

一个窗口负责排名,另一个窗口计算部门最高工资,不需要把员工表重复 Join 两次。

第四组:连续行为、留存与递归查询

题目 24:查询连续登录至少 3 天的用户

login_records 已通过唯一键保证同一用户一天只有一条记录。

参考答案

使用“日期减去行号得到同组固定值”的 Gaps and Islands 方法:

sql
with numbered as (
  select user_id,
         login_date,
         row_number() over(
           partition by user_id
           order by login_date
         ) as rn
  from login_records
), grouped as (
  select user_id,
         login_date,
         date_sub(login_date, interval rn day) as group_key
  from numbered
), streaks as (
  select user_id,
         min(login_date) as start_date,
         max(login_date) as end_date,
         count(*) as consecutive_days
  from grouped
  group by user_id, group_key
)
select user_id, start_date, end_date, consecutive_days
from streaks
where consecutive_days >= 3
order by user_id, start_date;

如果原表同一天可能登录多次,必须先 select distinct user_id, date(login_time) 去重,否则行号会把同一天重复记录误当成连续天数。

题目 25:查询每个用户最长连续登录天数

参考答案
sql
with numbered as (
  select user_id,
         login_date,
         row_number() over(
           partition by user_id
           order by login_date
         ) as rn
  from login_records
), grouped as (
  select user_id,
         date_sub(login_date, interval rn day) as group_key
  from numbered
), streaks as (
  select user_id,
         group_key,
         count(*) as consecutive_days
  from grouped
  group by user_id, group_key
)
select user_id,
       max(consecutive_days) as max_consecutive_days
from streaks
group by user_id;

这是连续区间题的通用模型:先排序编号,再构造连续区间分组键,最后对每个区间聚合。

题目 26:计算次日留存率

以用户注册日期为基准,计算每个注册日的注册人数、次日登录人数和次日留存率。

参考答案
sql
select date(u.register_time) as register_date,
       count(*) as registered_users,
       count(l.user_id) as next_day_login_users,
       count(l.user_id) / nullif(count(*), 0) as next_day_retention
from users u
left join login_records l
  on l.user_id = u.id
 and l.login_date = date(u.register_time) + interval 1 day
group by date(u.register_time)
order by register_date;

login_records 的唯一键保证每个用户每天最多一行,所以 count(l.user_id) 不会重复统计。如果没有唯一约束,应在关联前按用户和日期去重。

大表中 date(u.register_time) 主要用于分组;如果限制注册范围,仍应额外使用原始时间列区间过滤。

题目 27:查询复购用户

定义为至少有两个不同日期的已支付订单。

参考答案
sql
select user_id,
       count(*) as paid_order_count,
       count(distinct date(created_at)) as paid_days
from orders
where status = 'PAID'
group by user_id
having count(distinct date(created_at)) >= 2;

这里题目明确要求不同日期,所以不能只使用 count(*) >= 2。真实复购口径还可能排除退款、测试订单或要求间隔超过一定时间。

题目 28:查询买过分类下所有在售商品的用户

即“关系除法”问题:用户必须购买目标分类中每一个 status = 'ACTIVE' 的商品。

参考答案

假设目标分类 ID 为 ?

sql
select o.user_id
from orders o
join order_items oi on oi.order_id = o.id
join products p on p.id = oi.product_id
where o.status = 'PAID'
  and p.category_id = ?
  and p.status = 'ACTIVE'
group by o.user_id
having count(distinct p.id) = (
  select count(*)
  from products target
  where target.category_id = ?
    and target.status = 'ACTIVE'
);

需要明确空集合语义:如果该分类当前没有在售商品,是否所有用户都算“买过全部商品”?上面的 SQL 不会返回用户。业务定义不同,SQL 也要调整。

题目 29:递归查询某经理的全部下属

返回直接和间接下属,并显示层级。要求 MySQL 8。

参考答案
sql
with recursive subordinate_tree as (
  select id,
         employee_name,
         manager_id,
         1 as depth
  from employees
  where manager_id = ?

  union all

  select e.id,
         e.employee_name,
         e.manager_id,
         t.depth + 1
  from employees e
  join subordinate_tree t on e.manager_id = t.id
  where t.depth < 20
)
select id, employee_name, manager_id, depth
from subordinate_tree
order by depth, id;

employees(manager_id) 需要索引。生产还应阻止组织数据形成环;这里只用深度限制避免异常数据无限递归,并不能识别所有环。

题目 30:找出每个分类销量最高的商品

只统计已支付订单,销量相同的商品全部返回。

参考答案
sql
with product_sales as (
  select p.category_id,
         p.id as product_id,
         p.product_name,
         sum(oi.quantity) as sold_quantity
  from orders o
  join order_items oi on oi.order_id = o.id
  join products p on p.id = oi.product_id
  where o.status = 'PAID'
  group by p.category_id, p.id, p.product_name
), ranked as (
  select ps.*,
         dense_rank() over(
           partition by category_id
           order by sold_quantity desc
         ) as sales_rank
  from product_sales ps
)
select c.category_name,
       r.product_id,
       r.product_name,
       r.sold_quantity
from ranked r
join categories c on c.id = r.category_id
where r.sales_rank = 1
order by c.id, r.product_id;

必须先按商品聚合销量,再对聚合结果排名。不能在原始订单明细上直接排名,否则排名的是某一行明细数量,不是商品总销量。

第五组:加试题,只给思路不直接给完整答案

完成前 30 题后,再独立实现以下题目:

  1. 找出工资高于直属经理的员工。
  2. 查询每个部门入职时间最早的员工,时间相同全部返回。
  3. 查询每个用户第一次和第二次已支付订单的间隔天数。
  4. 计算每个注册月份用户在注册后 30 天内的付费转化率。
  5. 查询连续 3 个月都有已支付订单的用户。
  6. 查询每个商品连续无销量的最长天数,需要先生成日期日历。
  7. 找出购买商品集合完全相同的用户对。
  8. 查询同一用户 10 分钟内连续创建的疑似重复订单。
  9. 计算商品每 7 天移动平均销量。
  10. 查询订单金额中位数,分别考虑总行数为奇数和偶数。

这些题没有唯一写法。至少说明:结果粒度、去重规则、NULL、并列值、稳定排序、日期边界和建议索引。

面试写 SQL 的检查顺序

拿到题目后不要立刻写代码,先确认:

  1. 结果一行代表什么:用户、订单、商品还是用户月份。
  2. 关联是一对一还是一对多:Join 后是否放大行数。
  3. 统计口径:是否只算已支付、是否排除退款、是否按不同日期去重。
  4. 并列规则:Top 3 是严格三行还是同分共享名次。
  5. NULL 语义:没有订单要不要显示,金额要显示 NULL 还是 0。
  6. 时间边界:使用左闭右开,时区是什么。
  7. 稳定排序:时间相同用哪个唯一键打破平局。
  8. MySQL 版本:能否使用窗口函数和递归 CTE。
  9. 性能:过滤能否提前,Join Key 和高频条件是否有索引。
  10. 验证数据:空表、重复值、同一时间、NULL、并列第一、跨月和无明细订单。

常见错误

错误典型后果
left join 右表条件写进 where无匹配左表行被过滤,语义变内连接
左连接后使用 count(*)无明细对象也被统计为 1
一对多 Join 后直接 sum 表头金额同一订单金额被明细行重复累加
第二高工资直接 offset 1最高工资重复时结果错误
Top N 没有定义并列规则row_numberrankdense_rank 选错
只按时间排序时间相同结果不稳定,分页重复或漏数据
not in 子查询可能含 NULL结果因三值逻辑异常
日期列套函数过滤普通时间索引可能无法范围定位
月环比不补月份没数据月份消失,上一行不等于上个月
连续登录前不按天去重一天多次登录被误算为多天
各分片平均值再次平均全局平均值错误,应汇总总和与总数
没有 order by 却依赖返回顺序数据量或执行计划变化后顺序改变

索引思考题

给每道 SQL 再回答一次“为什么需要这个索引”:

查询候选索引目标
用户最近订单orders(user_id, created_at desc, id desc)过滤、排序和 limit 连续完成
状态时间范围orders(status, created_at)等值状态后按时间范围扫描
订单明细汇总order_items(order_id)快速定位订单的全部明细
商品销量order_items(product_id)按商品关联明细
部门工资排名employees(department_id, salary desc, id)按部门访问并减少排序成本的可能性
组织树employees(manager_id)每轮递归定位直接下属
用户登录日login_records(user_id, login_date)用户内按日期顺序访问

窗口函数有索引也不一定完全避免排序,是否使用、扫描多少和是否产生临时表仍应通过执行计划验证。

自测评分

每题 5 分:

  • 1 分:结果粒度和统计口径正确。
  • 1 分:Join、NULL、去重和并列语义正确。
  • 1 分:时间和排序边界正确。
  • 1 分:SQL 能在目标 MySQL 版本运行。
  • 1 分:能说明索引、执行计划和大数据量风险。

前 30 题总分 150:

  • 120 分以上:具备常规面试 SQL 编写能力。
  • 135 分以上:能较稳定应对追问和边界条件。
  • 145 分以上:继续做加试题,并用百万级测试数据分析执行计划。

关联知识点

本章小结

SQL 面试真正考的是把业务语言转换成确定的数据粒度、关联关系和统计口径。写出结果只是第一步,还要处理 NULL、重复、并列、时间边界和版本差异,并能说明数据量扩大后如何通过索引和执行计划验证。建议先完成前 30 题,再做 10 道无答案加试题,最后对慢查询使用真实数据做 EXPLAIN ANALYZE