这篇文章不会有详细的教程,你需要跟大模型一起成长
目标
- 掌握基础的SQL语法
- 能自主编写简单的SQL查询语句
- 能看懂复杂的SQL语句
- 可以跟大模型一起完成复杂的数据查询和分析工作
环境准备
安装VSCode + DuckDB,就可以在本地用SQL操作csv文件了
https://marketplace.visualstudio.com/items?itemName=RandomFractalsInc.duckdb-sql-tools
- users.csv (用户表文本)
user_id,username,phone
1,张三,13800138001
2,李四,13900139002
3,王五,13700137003
- orders.csv (订单表文本)
order_id,user_id,amount,status
1001,1,250.00,已支付
1002,1,88.50,待付款
1003,2,1200.00,已发货
用DuckDB导入这两个文件就可以查询了
SQL基础语法
- 基础数据查询
SELECT username, phone
FROM users
WHERE username = '张三'
- 排序
SELECT * FROM orders ORDER BY amount DESC;
- 关联查询(JOIN)
因为用户信息和订单信息在不同的文件/表里,你需要把它们“拼”起来, 比如下面通过user_id拼起来
SELECT orders.order_id, users.username, orders.amount
FROM orders
LEFT JOIN users ON orders.user_id = users.user_id;
- 统计
求和
SELECT SUM(amount) FROM orders;
分组统计
SELECT user_id, SUM(amount)
FROM orders
GROUP BY user_id;
COUNT(): 统计行数(例如:总共有多少个订单)。
SUM(): 求和(例如:本月总销售额)。
AVG(): 平均值(例如:客单价)。
MAX() / MIN(): 最大/最小值(例如:最高的一笔消费)
- 子查询
你可以把它想象成“套娃”:先通过内层的查询算出一个结果,再把这个结果交给外层的查询去使用。
SELECT * FROM users
WHERE user_id IN (SELECT DISTINCT user_id FROM orders);
- 解决复杂表的办法CTE (Common Table Expression公用表表达式)
简单来说,CTE 就是把一个复杂的子查询“拎出来”定义成一个临时结果集,并给它起个名字。它就像是 SQL 脚本里的临时变量,让你的代码读起来像是在讲故事,而不是在玩套娃。而且可以复用,不用重复写多次
这里我们没用子查询,而是定义了一个临时表order_summary,就不用在select语句写复杂的代码了
WITH order_summary AS (
SELECT DISTINCT user_id
FROM orders
)
SELECT u.* FROM users u
LEFT JOIN order_summary s ON u.user_id = s.user_id;
- UNION(自动去重)/UNION ALL/EXCEPT(差集)/INTERSECT(交集)
| 操作符 | 含义 | 比喻 |
|---|---|---|
| UNION | 并集 | A + B (去重) |
| INTERSECT | 交集 | A 和 B 共同有的部分 |
| EXCEPT | 差集 | A 有但 B 没有的部分 |
找出那些注册了但从来没买过东西的用户
SELECT user_id FROM users
EXCEPT
SELECT user_id FROM orders;
练习题
根据前面的 users / orders 两表,动手写一写下面的题,写完可以交给大模型帮你批改。
基础查询练习
- 查询所有"已支付"状态的订单
- 查询金额大于 100 的订单,并按金额降序排列
- 查出用户"张三"的电话号码
- 用
JOIN查出每一笔订单对应的用户名和金额 - 找出注册了但从未下过单的用户
参考解答:
-- 1
SELECT * FROM orders WHERE status = '已支付';
-- 2
SELECT * FROM orders WHERE amount > 100 ORDER BY amount DESC;
-- 3
SELECT phone FROM users WHERE username = '张三';
-- 4
SELECT orders.order_id, users.username, orders.amount
FROM orders
JOIN users ON orders.user_id = users.user_id;
-- 5
SELECT user_id FROM users
EXCEPT
SELECT user_id FROM orders;
聚合统计练习
- 统计每个用户的下单总金额
- 统计每个订单状态各有多少笔订单
- 找出订单总金额超过 1000 的用户(提示:用
HAVING) - 统计总共有多少笔订单、平均金额是多少
参考解答:
-- 1
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id;
-- 2
SELECT status, COUNT(*) AS order_count
FROM orders
GROUP BY status;
-- 3
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 1000;
-- 4
SELECT COUNT(*) AS order_count, AVG(amount) AS avg_amount
FROM orders;
进阶内容:SQL 面试题
SQL 面试题通常分为语法基础与概念辨析、经典高频手写题以及性能优化与底层原理三大类。
核心语法与概念
WHEREvsHAVING:WHERE在分组(GROUP BY)前过滤行,不能使用聚合函数;HAVING在分组后过滤结果集,可以使用聚合函数。JOIN的类型与区别:INNER JOIN:仅返回两表匹配成功的记录。LEFT JOIN/RIGHT JOIN:返回左/右表的全部记录及关联表的匹配项(未匹配则填NULL)。FULL OUTER JOIN:返回两表的所有记录。CROSS JOIN:笛卡尔积,把左表每行与右表每行都配对一次,结果行数 = 左表行数 × 右表行数(即 m × n)。例如左表 2 个用户、右表 3 个订单,结果就是 2 × 3 = 6 行:
-- users(2行) CROSS JOIN orders(3行) → 6行
SELECT users.username, orders.order_id
FROM users
CROSS JOIN orders;
张三|1001
张三|1002
张三|1003
李四|1001
李四|1002
李四|1003
实际业务中 CROSS JOIN 很少单独使用(数据量会爆炸式增长),常用来生成排列组合,比如商品 × 尺码。
UNIONvsUNION ALL:UNION会对合并后的数据集进行去重并排序,性能相对较低;UNION ALL直接合并所有行,保留重复项,执行速度更快。COUNT(*)、COUNT(1)与COUNT(列名):COUNT(*)和COUNT(1)统计总行数(包含NULL);COUNT(列名)仅统计该列非NULL的行数。DROP、TRUNCATE与DELETE:DELETE:DML 语句,逐行删除,可带WHERE条件,支持回滚,记录详细日志。TRUNCATE:DDL 语句,快速清空整表并重置自增 ID,不可带条件,通常不可回滚。DROP:DDL 语句,直接删除表结构及所有数据。
高频手写题型
排名与分组 Top N
考查窗口函数(ROW_NUMBER() 连续唯一排序、RANK() 跳跃排序、DENSE_RANK() 连续同分排序)。
-- 查询每个部门薪资前 3 名的员工
SELECT department_id, employee_id, salary
FROM (
SELECT department_id, employee_id, salary,
DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) as rk
FROM employees
) t
WHERE rk <= 3;
连续活跃/连续登录问题
经典“日期差值法”,利用 登录日期 - ROW_NUMBER() 日期 得到固定基准日期来分组。比如找出连续登录 ≥ 3 天的用户:
-- 登录表 login_log(user_id, login_date),先按 (user_id, login_date) 去重(一天可能登录多次)
WITH t AS (
SELECT DISTINCT user_id, login_date
FROM login_log
),
base AS (
SELECT user_id, login_date,
login_date - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS base_date
FROM t
)
SELECT user_id, COUNT(*) AS consecutive_days
FROM base
GROUP BY user_id, base_date
HAVING COUNT(*) >= 3;
这里的 base CTE 核心作用是构造一个用于分组的"恒定基准日期"(分组标识),以此把连续的日期归为同一组。
核心原理:等差数列同步增长。本质是利用"日期与连续序号"的差值分组——中间一旦断签,后面的日期相对序号多出来的天数,就会让它自动掉进另一个分组。
- 连续状态:当用户每天连续登录时,
login_date每天增加 1 天,而ROW_NUMBER()序号也刚好递增 1。两者变化步长一致,用login_date - 序号得到的结果就是一个完全相同固定日期。 - 断签状态:一旦中间出现断签(例如隔了 2 天),
login_date跳过了 2 天,而ROW_NUMBER()只递增 1,相减得到的"基准日期"就会发生跳变,自动生成新的组。
直观数据示例——假设用户在 1 号至 3 号连续登录,断签后 6 号、7 号又连续登录:
| login_date | ROW_NUMBER() | 差值计算 (login_date - rn 天) | 归属分组 (base_date) |
|---|---|---|---|
| 2026-01-01 | 1 | 2026-01-01 - 1 天 | 2025-12-31(第 1 段连续) |
| 2026-01-02 | 2 | 2026-01-02 - 2 天 | 2025-12-31(第 1 段连续) |
| 2026-01-03 | 3 | 2026-01-03 - 3 天 | 2025-12-31(第 1 段连续) |
| 断签 | - | - | - |
| 2026-01-06 | 4 | 2026-01-06 - 4 天 | 2026-01-02(第 2 段连续) |
| 2026-01-07 | 5 | 2026-01-07 - 5 天 | 2026-01-02(第 2 段连续) |
后续只需执行 GROUP BY user_id, base_date,再统计 COUNT(*),就能直接算出每一段连续登录的具体天数。
语法提示:不同数据库对日期减整数的语法支持略有不同。在 MySQL 中通常写为
DATE_SUB(login_date, INTERVAL rn DAY),而在 PostgreSQL / DuckDB 中可直接使用login_date - rn或login_date - INTERVAL rn DAY。
留存率计算
次日留存、7 日留存,通常使用用户首日行为表通过 LEFT JOIN 关联后续日期的行为记录。
先理解两个业务概念:
- 次日留存率:在"某天首次使用产品"的用户中,有多少比例的人在第 2 天(即首次日 + 1 天)又回来使用了。比如 1 月 1 日有 100 个新用户,其中 40 人在 1 月 2 日再次活跃,次日留存率就是 40%。它反映产品"第一次用完后,愿不愿意第二天再来"。
- 7 日留存率:同样这批首日用户,有多少比例的人在第 7 天(首次日 + 7 天)回来使用。留存周期更长,通常反映产品的长期粘性与习惯养成。
一句话:留存率 = 第 N 天仍活跃的首日用户数 ÷ 首日新增用户数,用来衡量产品留住用户的能力,是运营和增长分析的核心指标。
从纯粹的指标定义与数据分析视角来看,7 日留存率(Day 7 Retention Rate)业内最标准、最通用的定义就是严格的“第 7 天”。
标准定义:某一天($D_0$)新增或首次活跃的用户中,恰好在第 7 天($D_0 + 7$ 天)当天又有活跃行为的用户占比。
公式:
为什么标准定义一定要卡"第 7 天":
- 消除"留存衰减曲线"的统计重叠:如果把 1~7 天内都算进去,它就退化成了"新手期 7 天累计活跃率",无法刻画出一条平滑、逐日衰减的留存曲线(Day 1 → Day 3 → Day 7 → Day 30)。
- 匹配用户周习惯周期(Weekly Cycle):绝大多数产品存在天然的"工作日 vs 周末"周期波动。取第 7 天恰好对应下周的同一星期几(如上周五新增 → 本周五回访),能天然抵消星期波动带来的周期性偏差。
容易混淆的几个"7 天"指标对比:
| 指标名称 | 英文术语 | 核心时间窗口 | 衡量目的 |
|---|---|---|---|
| 7 日留存率(标准) | Day 7 Retention | 仅看第 7 天当天($D_0 + 7$) | 衡量产品在完整周周期后的习惯养成与粘性 |
| 7 日内留存率 | 7-Day Range Retention | 第 1 天至第 7 天($[D_1, D_7]$)任意一天 | 衡量新手期的整体未流失率(漏斗转化) |
| 周留存率 | Week 1 Retention | 第 2 周(即 8~14 天)内任意一天 | 适用于中低频产品,放宽单日波动的颗粒度 |
-- 活跃表 user_active(user_id, active_date)
WITH first_active AS ( -- 每个用户的首日活跃日期
SELECT user_id, MIN(active_date) AS first_date
FROM user_active
GROUP BY user_id
)
SELECT f.first_date,
COUNT(DISTINCT f.user_id) AS new_users,
COUNT(DISTINCT a1.user_id) * 1.0 / COUNT(DISTINCT f.user_id) AS day1_retention,
COUNT(DISTINCT a7.user_id) * 1.0 / COUNT(DISTINCT f.user_id) AS day7_retention
FROM first_active f
LEFT JOIN user_active a1 ON f.user_id = a1.user_id
AND a1.active_date = f.first_date + INTERVAL 1 DAY
LEFT JOIN user_active a7 ON f.user_id = a7.user_id
AND a7.active_date = f.first_date + INTERVAL 7 DAY
GROUP BY f.first_date;
思路:把首日活跃表当作"基准",分别去 JOIN 第 1 天、第 7 天的活跃记录,JOIN 上了就说明该用户次日/7 日留存,除以首日新增人数即得留存率。上面的 SQL 采用业界标准的严格口径——a7.active_date = first_date + INTERVAL 7 DAY 精确匹配第 7 天当天。MySQL 中写法为 DATE_ADD(f.first_date, INTERVAL 7 DAY)。
查找并删除重复数据
利用 GROUP BY ... HAVING COUNT(*) > 1 或窗口函数标记 ROW_NUMBER() > 1。
-- 用户表 users_dup(user_id, username, email),以 email 判断重复
-- 第一步:找出有重复的 email
SELECT email, COUNT(*) AS cnt
FROM users_dup
GROUP BY email
HAVING COUNT(*) > 1;
-- 第二步:删除重复行,每组保留 user_id 最小的一条
DELETE FROM users_dup
WHERE user_id NOT IN (
SELECT MIN(user_id) FROM users_dup GROUP BY email
);
窗口函数法更适合“先标记看看哪些是重复行”:
SELECT *, ROW_NUMBER() OVER (PARTITION BY email ORDER BY user_id) AS rn
FROM users_dup;
-- rn > 1 的就是重复行
-- 配合 CTE 直接删除重复行
WITH ranked AS (
SELECT user_id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY user_id) AS rn
FROM users_dup
)
DELETE FROM users_dup
WHERE user_id IN (SELECT user_id FROM ranked WHERE rn > 1);
性能优化与底层原理
- SQL 执行顺序:
FROM→ON→JOIN→WHERE→GROUP BY→HAVING→SELECT→DISTINCT→ORDER BY→LIMIT。
索引失效场景:在索引列上使用函数/运算、违背最左前缀法则、隐式类型转换、使用
LIKE '%abc'前缀模糊匹配。一句话理解根因:索引(通常是 B+ 树)的键是按索引列的原值排好序的,数据库靠这个排序做"等值/范围定位"。只要条件没法跟"原值顺序"对上,定位就无从谈起,只能退化成全表扫描。
在索引列上使用函数/运算:
WHERE YEAR(create_time) = 2024。索引里存的是create_time的原值,而你要找的是"函数算完的结果"在树里的位置——每个节点的键都不是结果值,定位不了,只能把每个节点的原值都取出来算一遍。改写为WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31'就能命中索引(或建函数索引INDEX(YEAR(create_time)))。违背最左前缀法则:联合索引
(a, b, c)的排序是"先按 a,再按 b,再按 c",像查字典先按姓氏再按名字。只查询 b 或 c 时,它们在树里只是"分组内有序",并非全局有序,无法定位起点。所以查询条件必须包含最左列 a(a、a+b、a+b+c都行,b、b+c不行)。隐式类型转换:
WHERE phone = 13800138001,phone 是VARCHAR,数据库会把字符串列 CAST 成数字再比较,相当于偷偷给索引列套了个函数,同上失效。保持两边类型一致(字符串就加引号)即可命中。LIKE '%abc'前缀模糊匹配:LIKE 'abc%'前缀确定,能在 B+ 树上做范围扫描;而LIKE '%abc'通配符在最前面,起点不定,只能全表扫。优化思路是避免前缀通配,或改用全文索引/搜索引擎。
深分页优化(Deep Paging):
LIMIT 1000000, 10扫描成本极高,通常通过“子查询延迟关联主键”或“记录上一页最大 ID(游标分页)”来优化。