SQL 实战项目
学了 12 篇基础,是时候把它们串起来解决点真实问题。这一章给三个完整、可运行的项目——博客数据库设计、电商统计查询、数据迁移清洗,覆盖 SQL 工程师日常最核心的三类任务:建库建表(数据建模)、分析查询(JOIN / 聚合 / 窗口函数)、数据治理(迁移 / 清洗 / 校验)。每段 SQL 都能直接粘到 sqliteonline.com 或本地 MySQL 跑通。建议你边读边敲,把每条 SELECT 的结果对照注释看,亲手验证。
实战 1:博客数据库设计(CREATE TABLE + 外键 + 索引)
需求:为一个博客系统设计核心数据库。三类实体——用户、文章、评论——之间的关系是:一个用户可以发多篇文章,一篇文章可以有多条评论。要求关系正确、查询高效、删除有规则。
涉及知识:主键 PRIMARY KEY + AUTO_INCREMENT、唯一约束 UNIQUE、非空 NOT NULL、默认值 DEFAULT、外键 FOREIGN KEY + ON DELETE CASCADE、二级索引 INDEX、ON UPDATE CURRENT_TIMESTAMP 自动维护时间。
-- ============================================================
-- 项目 1:博客数据库设计
-- 三张主表:users(用户)、posts(文章)、comments(评论)
-- 关系:一个用户发多篇文章,一篇文章有多条评论
-- ============================================================
-- 用户表
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE, -- 登录名,唯一
email VARCHAR(100) NOT NULL UNIQUE, -- 邮箱,唯一
nickname VARCHAR(50), -- 昵称,可空
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 文章表:作者 id 通过外键关联 users
CREATE TABLE posts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
views INT NOT NULL DEFAULT 0, -- 阅读量
published TINYINT(1) NOT NULL DEFAULT 0, -- 0=草稿,1=已发布
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
-- 外键约束:作者必须存在于 users 表;删用户连带删文章
CONSTRAINT fk_posts_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE,
-- 索引:按用户/时间查询是最常见场景,加上加速
INDEX idx_posts_user (user_id),
INDEX idx_posts_created (created_at)
);
-- 评论表:同时关联文章和用户
CREATE TABLE comments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
post_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
content VARCHAR(500) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_comments_post FOREIGN KEY (post_id)
REFERENCES posts(id) ON DELETE CASCADE,
CONSTRAINT fk_comments_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_comments_post (post_id)
);
-- ============================================================
-- 插入测试数据,后续查询要用
-- ============================================================
INSERT INTO users (username, email, nickname) VALUES
('alice', 'alice@example.com', '爱丽丝'),
('bob', 'bob@example.com', '鲍勃');
INSERT INTO posts (user_id, title, content, views, published) VALUES
(1, 'Vue 3 入门', 'Composition API 是...', 120, 1),
(1, 'SQL 索引详解', 'B+ 树如何加速...', 350, 1),
(2, '我的第一篇博客', 'Hello World!', 12, 0);
INSERT INTO comments (post_id, user_id, content) VALUES
(1, 2, '写得真清楚!'),
(1, 1, '感谢支持~'),
(2, 2, '终于搞懂索引了');
-- 验证:删掉 alice 应该连带删掉她的全部文章和评论(CASCADE)
-- DELETE FROM users WHERE username = 'alice'; -- 慎用!要点讲解:
- 主键设计:每张表都用
BIGINT AUTO_INCREMENT自增 id 当主键。避免用 username / email 当主键——它们可能改,改主键代价极大。 UNIQUEvsINDEX:UNIQUE既是约束(防重复)也是索引(加速查找);普通INDEX只加速不防重。username / email 这种"业务上唯一"的字段必须加UNIQUE。- 外键关系:
posts.user_id指向users.id,确保"一篇文章必然属于某个真实存在的用户"。ON DELETE CASCADE表示删用户时连带删他的文章评论——简单粗暴,生产环境有时改用RESTRICT(禁止删)或SET NULL(保留文章标为无作者)。 - 索引加在哪:加在经常当查询条件的列上。这里
user_id(查某用户的文章)、created_at(按时间排序)是高频条件,加索引收益最大;主键自带索引无需重复加。 ON UPDATE:updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP让 MySQL 在任何 UPDATE 时自动刷新这列——再也不用应用层记得手动改时间。- 过度设计警告:博客初期别建几十张表。三张主表 + 关系清楚就够了,后续随业务加(标签、分类、点赞),不要一次到位。
实战 2:电商查询实战(JOIN + GROUP BY + 子查询 + 窗口函数)
需求:给定商品、订单、订单明细三张表,完成四个典型统计——品类销售汇总、消费 Top 用户、每类销冠商品、每日累计成交额。一个比一个难,循序把 JOIN / 聚合 / 子查询 / 窗口函数都用上。
涉及知识:INNER JOIN 多表连接、GROUP BY + SUM 聚合、子查询当临时表、ROW_NUMBER() OVER (PARTITION BY ...) 分组排名、SUM() OVER (ORDER BY ...) 累计窗口。
-- ============================================================
-- 项目 2:电商查询实战
-- 表:products(商品)、orders(订单)、order_items(订单明细)
-- 演示 JOIN / GROUP BY / 子查询 / 窗口函数 四类典型场景
-- ============================================================
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(50),
category VARCHAR(20),
price DECIMAL(10,2)
);
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
status VARCHAR(20), -- paid/shipped/done
created_at DATE
);
CREATE TABLE order_items (
order_id INT,
product_id INT,
quantity INT,
FOREIGN KEY (order_id) REFERENCES orders(id)
);
-- 插入测试数据(实际项目里这些数据已存在)
INSERT INTO products VALUES
(1, 'iPhone', '电子产品', 8000.00),
(2, 'iPad', '电子产品', 4000.00),
(3, 'T恤', '服装', 200.00),
(4, '牛仔裤', '服装', 500.00);
INSERT INTO orders VALUES
(101, 1, 'paid', '2024-06-01'),
(102, 2, 'paid', '2024-06-01'),
(103, 1, 'shipped', '2024-06-02');
INSERT INTO order_items VALUES
(101, 1, 1), -- 订单 101:1 台 iPhone
(101, 3, 2), -- 订单 101:2 件 T 恤
(102, 2, 1), -- 订单 102:1 台 iPad
(103, 4, 3); -- 订单 103:3 条牛仔裤
-- 需求 1:每个品类的总销量和总销售额(JOIN + GROUP BY)
SELECT p.category,
SUM(oi.quantity) AS total_qty,
SUM(oi.quantity * p.price) AS total_amount
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN orders o ON o.id = oi.order_id
WHERE o.status = 'paid'
GROUP BY p.category
ORDER BY total_amount DESC;
-- 结果:
-- category | total_qty | total_amount
-- 电子产品 | 2 | 12000.00
-- 服装 | 2 | 400.00
-- 需求 2:消费最高的用户(子查询 + 聚合)
SELECT user_id, SUM(amount) AS total_paid
FROM (
-- 先把每条明细算成金额,作为内层子查询
SELECT o.user_id, oi.quantity * p.price AS amount
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.status = 'paid'
) detail
GROUP BY user_id
ORDER BY total_paid DESC
LIMIT 3;
-- 结果:
-- user_id | total_paid
-- 1 | 8400.00
-- 2 | 4000.00
-- 需求 3:每个品类最畅销的商品(窗口函数 ROW_NUMBER)
SELECT product_name, category, sold_qty
FROM (
SELECT p.name AS product_name,
p.category,
SUM(oi.quantity) AS sold_qty,
-- 按品类分组、按销量降序排名
ROW_NUMBER() OVER (PARTITION BY p.category
ORDER BY SUM(oi.quantity) DESC) AS rn
FROM order_items oi
JOIN products p ON oi.product_id = p.id
GROUP BY p.id, p.name, p.category
) ranked
WHERE rn = 1; -- 只取每组第 1 名
-- 结果:
-- product_name | category | sold_qty
-- iPhone | 电子产品 | 1
-- T恤 | 服装 | 2
-- 需求 4:每日订单的"累计总金额"(窗口函数 SUM OVER)
SELECT created_at,
SUM(daily_total) AS daily_amount,
-- 按日期排序,把之前的金额全部累加起来
SUM(SUM(daily_total)) OVER (ORDER BY created_at) AS running_total
FROM (
SELECT o.created_at,
oi.quantity * p.price AS daily_total
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.status = 'paid'
) per_order
GROUP BY created_at
ORDER BY created_at;
-- 结果:
-- created_at | daily_amount | running_total
-- 2024-06-01 | 8400.00 | 8800.00
-- 2024-06-02 | 1500.00 | 10300.00要点讲解:
- 聚合前先过滤:在
JOIN后、GROUP BY前用WHERE过滤掉未支付订单(status = 'paid'),否则会把"待付款"也算进销售额,口径错乱。 - 子查询当临时表:需求 2 里先把"每条明细的金额"算出来当一张表
detail,再外层GROUP BY user_id。这种"分步思考"的写法比一个巨型 JOIN 更清晰,也好调试。 ROW_NUMBER() OVER:窗口函数的精髓。PARTITION BY category是"按品类分组",ORDER BY SUM(quantity) DESC是"组内按销量降序",ROW_NUMBER()给每组从 1 编号;外层WHERE rn = 1就是"每组销冠"。这种"组内 Top N"用普通 GROUP BY 几乎写不出。SUM() OVER (ORDER BY ...):不带ROWS边界时默认是从第一行累加到当前行——天然就是累计求和,做"增长曲线"指标特别好用。- 窗口函数兼容性:MySQL 8.0+、PostgreSQL、SQL Server、Oracle 都支持;老旧的 MySQL 5.7 不支持,生产环境请确认版本。
- 性能提示:大表上窗口函数可能慢,确保
PARTITION BY/ORDER BY用到的列有索引;实在慢就考虑用物化视图或离线预聚合。
实战 3:数据迁移与清洗(INSERT SELECT + UPDATE + CASE + 校验)
需求:把脏乱的旧表 users_raw 迁移到规范化的新表 users。迁移过程中要清洗字段(大小写统一、去空格)、剔除无效数据(缺姓名、邮箱非法)、去重(重复邮箱只留一条)、回填空值,最后做数据校验确认迁移无误。
涉及知识:INSERT ... SELECT 批量导入、字符串函数 LOWER / TRIM / CONCAT、CASE WHEN 条件分支、COALESCE / NULLIF 空值处理、DELETE + 子查询去重、LEFT JOIN 找差异、HAVING COUNT(*) > 1 找重复。
-- ============================================================
-- 项目 3:数据迁移与清洗
-- 场景:旧表 users_raw 数据脏(大小写不一/重复邮箱/缺昵称)
-- 迁移到规范化新表 users,并在过程中清洗 + 校验
-- ============================================================
-- 旧表(假定已有数据,字段不规范)
CREATE TABLE users_raw (
id INT,
name VARCHAR(50), -- 大小写不统一,如 'Alice' / 'ALICE'
email VARCHAR(100), -- 有重复、有空格、有大小写不一
age INT -- 可能有 NULL 或异常值
);
INSERT INTO users_raw VALUES
(1, 'Alice', 'Alice@Example.com', 25),
(2, 'BOB', 'bob@example.com', 17),
(3, 'alice', 'alice@example.com', 61), -- 与 1 号邮箱重复(大小写)
(4, 'Tom', 'tom@example.com', NULL), -- 年龄缺失
(5, NULL, 'bad@example', 30); -- 姓名缺失且邮箱非法
-- 新表(规范化)
CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL,
nickname VARCHAR(50),
age_group VARCHAR(10),
source VARCHAR(20) DEFAULT 'migrated'
);
-- 需求 1:从旧表批量导入并清洗(INSERT ... SELECT + 函数 + CASE)
INSERT INTO users (id, username, email, nickname, age_group)
SELECT
id,
LOWER(TRIM(name)) AS username, -- 统一小写 + 去空格
LOWER(TRIM(email)) AS email, -- 邮箱同样规范化
name AS nickname, -- 原名留作昵称
CASE
WHEN age < 18 THEN 'minor'
WHEN age BETWEEN 18 AND 59 THEN 'adult'
WHEN age >= 60 THEN 'senior'
ELSE 'unknown' -- NULL 或非法值
END AS age_group
FROM users_raw
WHERE name IS NOT NULL -- 排除姓名缺失
AND email LIKE '%_@_%._%'; -- 粗略过滤非法邮箱
-- 影响 3 行:id=1,2,3 被导入(id=4 缺姓名被剔除?这里只是示例规则)
-- 需求 2:找出重复 email,只保留 id 最小的那条
-- (MySQL 不能直接在 DELETE 里套本表子查询,需再包一层 SELECT)
DELETE FROM users
WHERE email IN (
SELECT email FROM (
SELECT email FROM users GROUP BY email HAVING COUNT(*) > 1
) dup
)
AND id NOT IN (
SELECT min_id FROM (
SELECT MIN(id) AS min_id FROM users GROUP BY email HAVING COUNT(*) > 1
) keep
);
-- 效果:id=3 与 id=1 邮箱重复,3 号被删除,1 号保留
-- 需求 3:批量回填缺失的昵称(UPDATE + COALESCE + CONCAT)
UPDATE users
SET nickname = COALESCE(NULLIF(nickname, ''), username, CONCAT('user_', id))
WHERE nickname IS NULL OR nickname = '';
-- COALESCE 返回第一个非 NULL 的参数:有原名用原名,否则用 username,再否则拼 user_123
-- 需求 4:迁移完成后做数据校验
-- (a) 总数对账:旧表"有效数据"应等于新表条数
SELECT
(SELECT COUNT(*) FROM users_raw WHERE name IS NOT NULL AND email LIKE '%_@_%._%') AS raw_cnt,
(SELECT COUNT(*) FROM users) AS new_cnt;
-- raw_cnt 与 new_cnt 应相等;不等就说明迁移有遗漏或重复
-- (b) 找出"漏迁"的数据:旧表有、新表没有的 id
SELECT r.id, r.name, r.email
FROM users_raw r
LEFT JOIN users u ON r.id = u.id
WHERE u.id IS NULL;
-- 这里会列出 id=4,5(被清洗规则剔除的),确认它们是"应该剔除"还是"漏迁"
-- (c) 唯一性校验:email 不应有重复
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- 期望返回空集;有结果说明去重没成功,需排查
-- (d) 完整性校验:NOT NULL 字段不该有空
SELECT id, username, email FROM users
WHERE username IS NULL OR email IS NULL;
-- 期望返回空集要点讲解:
INSERT ... SELECT:不用导出再导入,一条 SQL 把旧表数据"流"进新表,过程中还能SELECT里做清洗转换——这是数据迁移最常用的姿势。- 清洗在 SELECT 里做:
LOWER(TRIM(name))把'Alice'/'ALICE '都变成'alice'。在写入时就规范化,比迁完再 UPDATE 一遍高效得多。 CASE WHEN分箱:把连续的 age 转成离散的 age_group(minor / adult / senior / unknown),这是数据仓库里"维度建模"的常用手法,便于后续按段统计。- 子查询去重的坑:MySQL 不允许在
DELETE的子查询里直接引用本表,所以子查询要再包一层SELECT ... FROM (SELECT ...) dup绕过限制——这是 MySQL 特有的写法。 COALESCE优先级:返回第一个非 NULL 的参数。COALESCE(nickname, username, CONCAT('user_', id))实现"有昵称用昵称、否则用用户名、再否则兜底 user_123"的回填逻辑,优雅且安全。- 迁移 ≠ 结束:真正的工程化必须有校验。本例给了四种校验:总数对账(旧有效数 = 新表数)、差异找漏(LEFT JOIN 找旧有新无)、唯一性(email 不重复)、非空(关键字段不为 NULL)。任何一个返回非空集都说明迁移有问题,必须排查。
小结
三个项目对应 SQL 工程师三大主力场景:数据建模(建表 + 关系 + 索引)、分析查询(JOIN / 聚合 / 窗口)、数据治理(迁移 / 清洗 / 校验)。它们把前面学的 DDL / DML / WHERE / JOIN / GROUP BY / 子查询 / 索引 / 函数串到了一起——SQL 真正的难点从来不是单条语法,而是把它们组合起来解决业务问题。建议你拿自己工作或学习中的真实数据(订单、流水、日志)按这三个方向各做一遍,做过一遍比刷一百道 LeetCode 都扎实。
恭喜!到这里 SQL 系列就真正完结了。接下来可以深入——性能优化(EXPLAIN / 慢查询调优)、数据库内核(B+ 树 / MVCC / 隔离级别)、NoSQL(Redis / MongoDB)、大数据(Hive / ClickHouse)——方向很多,但核心 SQL 能力你已经具备。
← 上一篇 视图
返回 SQL 教程目录