01 · 表设计与字段类型
金额用 DECIMAL,不要用 FLOAT;时间点常用 DATETIME,自动时间戳可用 TIMESTAMP。字符串长度可控时用 VARCHAR,大文本再用 TEXT。主键通常使用无业务含义的 BIGINT;业务唯一性由 UNIQUE 约束保证。
-- 查所有列 SELECT * FROM users; -- 指定列 + 条件 + 排序 SELECT name, age FROM users WHERE age >= 18 ORDER BY age DESC;
INSERT INTO users (name, age, email) VALUES ('张三', 25, 'zs@mail.com');
WHERE,不然更新整张表。UPDATE users SET age = 26, email = 'new@mail.com' WHERE id = 1;
-- 删一行 DELETE FROM users WHERE id = 1; -- 清空表(快,但不可回滚,重置自增ID) TRUNCATE TABLE users;
-- 比较 WHERE price > 100 WHERE status != 1 -- 也可以写 <> -- 范围 WHERE age BETWEEN 18 AND 30 -- 多值匹配 WHERE status IN (1, 2, 3) -- 模糊搜索 WHERE name LIKE '%张%' -- 包含"张" WHERE email LIKE '%@gmail.com' -- 以 @gmail.com 结尾 -- NULL 判断(不能用 = NULL) WHERE phone IS NULL WHERE phone IS NOT NULL -- 组合条件 WHERE age > 18 AND status = 1 WHERE role = 'admin' OR role = 'superadmin'
OFFSET = (页码 - 1) × 每页条数
-- 第 1 页,每页 10 条 SELECT * FROM products LIMIT 10 OFFSET 0; -- 第 2 页 SELECT * FROM products LIMIT 10 OFFSET 10; -- 第 5 页 SELECT * FROM products LIMIT 10 OFFSET 40; -- 简写形式(注意:LIMIT 偏移量, 条数 — 顺序相反!) SELECT * FROM products LIMIT 40, 10;
users 存用户信息,orders 存订单信息。
你想查「每个用户的订单」就需要把两张表连起来。这就是 JOIN。
| users 表 | ||
| id | name | city |
|---|---|---|
| 1 | 张三 | 北京 |
| 2 | 李四 | 上海 |
| 3 | 王五 | 广州 |
| 4 | 赵六 | 深圳 |
| orders 表 | ||
| id | user_id | product |
|---|---|---|
| 101 | 1 | iPhone |
| 102 | 1 | AirPods |
| 103 | 2 | MacBook |
| 104 | 99 | iPad |
SELECT u.name, o.product FROM users u INNER JOIN orders o ON u.id = o.user_id;
| name | product |
|---|---|
| 张三 | iPhone |
| 张三 | AirPods |
| 李四 | MacBook |
SELECT u.name, o.product FROM users u -- ← 这是左表 LEFT JOIN orders o ON u.id = o.user_id;
| name | product |
|---|---|
| 张三 | iPhone |
| 张三 | AirPods |
| 李四 | MacBook |
| 王五 | NULL |
| 赵六 | NULL |
LEFT JOIN ... WHERE o.id IS NULL
SELECT u.name, o.product FROM users u RIGHT JOIN orders o ON u.id = o.user_id; -- ← orders 是右表
| name | product |
|---|---|
| 张三 | iPhone |
| 张三 | AirPods |
| 李四 | MacBook |
| NULL | iPad |
-- 没下过单的用户 SELECT u.* FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL;
-- 每个用户的订单数(包括 0 单的用户) SELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name;
COUNT(o.id) 而不是 COUNT(*),因为 NULL 不会被 COUNT 计入,所以没下单的用户会正确显示 0。-- 每个部门有多少人、平均工资 SELECT department, COUNT(*) AS headcount, AVG(salary) AS avg_salary FROM employees GROUP BY department;
COUNT(*) -- 总行数 COUNT(column) -- 非 NULL 的行数 SUM(column) -- 求和 AVG(column) -- 平均值 MAX(column) -- 最大值 MIN(column) -- 最小值
在分组之前过滤行
不能用聚合函数
在分组之后过滤组
可以用聚合函数
-- WHERE 先过滤掉离职员工,再按部门分组,HAVING 只要人数 > 5 的部门 SELECT department, COUNT(*) AS cnt FROM employees WHERE status = 'active' GROUP BY department HAVING COUNT(*) > 5;
-- 工资高于公司平均的员工 SELECT * FROM employees WHERE salary > (SELECT AVG(salary) FROM employees); -- 买过 iPhone 的用户 SELECT * FROM users WHERE id IN ( SELECT user_id FROM orders WHERE product = 'iPhone' );
1. FROM / JOIN → 确定数据源、连表 2. WHERE → 过滤行 3. GROUP BY → 分组 4. HAVING → 过滤组 5. SELECT → 选列 / 计算 / 起别名 6. DISTINCT → 去重 7. ORDER BY → 排序(可以用别名,因为 SELECT 已执行) 8. LIMIT / OFFSET → 分页
这一页不出题,只负责快速唤醒记忆。先用十分钟扫完核心概念,再阅读下面的业务写法;看到陌生点时,再回到“教学”或“练习”标签页深入。
金额用 DECIMAL,不要用 FLOAT;时间点常用 DATETIME,自动时间戳可用 TIMESTAMP。字符串长度可控时用 VARCHAR,大文本再用 TEXT。主键通常使用无业务含义的 BIGINT;业务唯一性由 UNIQUE 约束保证。
NULL 表示未知,不等于空字符串或 0。判断必须写 IS NULL。聚合时 COUNT(*) 统计行,COUNT(col) 忽略 NULL;计算结果可能为 NULL 时,用 COALESCE 提供默认值。
记住 FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT。WHERE 过滤原始行,HAVING 过滤分组;SELECT 的别名通常不能在 WHERE 中使用。
INNER JOIN 只保留匹配数据;LEFT JOIN 保留左表全集。想找“没有订单的用户”,使用 LEFT JOIN 后判断右表主键 IS NULL。右表过滤条件放错到 WHERE,可能会把 LEFT JOIN 变成 INNER JOIN。
索引适合高选择性的查询、关联、排序字段。联合索引遵守最左前缀;等值条件通常放前面,范围和排序放后面。索引不是越多越好,它会增加写入成本与磁盘占用。
先看 type,通常 const/ref/range 优于 ALL;再看 key 是否使用预期索引、rows 预计扫描量、Extra 是否出现 Using filesort 或 Using temporary。EXPLAIN ANALYZE 能看到实际耗时与行数。
事务满足 ACID。InnoDB 默认隔离级别是 REPEATABLE READ。普通 SELECT 是一致性读;SELECT ... FOR UPDATE 会锁定匹配记录。事务要短,更新顺序要一致,降低死锁概率。
分页必须有稳定 ORDER BY。深分页 OFFSET 会扫描并丢弃大量记录;数据量大时改用“上一页最后一个排序键”的游标分页,并为排序键建立合适索引。
MySQL 8 可用 WITH 拆分复杂查询。ROW_NUMBER 适合分组取 Top N,SUM(...) OVER 适合累计值。窗口函数保留明细行,GROUP BY 会把多行压成一行。
UPDATE、DELETE 先用同样的 WHERE 执行 SELECT 确认范围;关键写入放进事务。幂等写入可依赖唯一键配合 INSERT ... ON DUPLICATE KEY UPDATE,而不是先查再插。
先确认 SQL 与参数,再看执行计划、扫描行数和索引;随后检查锁等待、返回数据量与网络。不要一上来就加索引,也不要只看单次测试,真实数据分布会影响执行计划。
users(id, name) 和 orders(id, user_id, amount)。先记住基本查询骨架,再依次复习筛选、分页、连接、聚合和数据修改。
SELECT 列名 FROM 表名 WHERE 行筛选条件 GROUP BY 分组列 HAVING 分组筛选条件 ORDER BY 排序列 DESC LIMIT 条数 OFFSET 跳过条数;
SELECT * FROM users; -- 所有列 SELECT id, name FROM users; -- 指定列 SELECT DISTINCT name FROM users; -- 去重 SELECT * FROM users WHERE id = 3; -- 筛选 SELECT * FROM users WHERE name LIKE '张%'; -- 以“张”开头 SELECT * FROM users WHERE id IN (1, 2, 3); -- 多个值 SELECT * FROM users WHERE name IS NULL; -- 判断空值 SELECT * FROM users ORDER BY id DESC; -- 降序
判断 NULL 要用 IS NULL,不能写 = NULL。
-- 第 1 页,每页 10 条 SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 0; -- 第 3 页,每页 10 条:跳过 (3 - 1) × 10 = 20 条 SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 20;
MySQL 也支持 LIMIT 20, 10,含义是“跳过 20 条,取 10 条”。分页要配合 ORDER BY,否则每页的顺序不可靠。
-- 保留所有用户;没有订单的用户也会出现,订单列为 NULL SELECT u.name, o.amount FROM users AS u LEFT JOIN orders AS o ON o.user_id = u.id; -- 保留所有订单;找不到对应用户的订单也会出现,用户列为 NULL SELECT u.name, o.amount FROM users AS u RIGHT JOIN orders AS o ON o.user_id = u.id;
LEFT JOIN 保留左表全部行,RIGHT JOIN 保留右表全部行;匹配不上的另一侧填 NULL。调换表的书写顺序,右连接通常可以改写成左连接。
INNER JOIN 只返回两边能够匹配的行。连接多张表时,每个 JOIN 都写清楚关联条件,并始终给表设置简短别名,避免同名字段含义不清。
-- 查询有订单的用户及其订单金额 SELECT u.id, u.name, o.id AS order_id, o.amount FROM users AS u INNER JOIN orders AS o ON o.user_id = u.id ORDER BY o.id DESC; -- 统计每位下过单的用户的订单数和总金额 SELECT u.id, u.name, COUNT(o.id) AS order_count, SUM(o.amount) AS total_amount FROM users AS u JOIN orders AS o ON o.user_id = u.id GROUP BY u.id, u.name;
JOIN 默认就是 INNER JOIN。一对多连接会让用户行重复,这是正常结果;需要“一位用户一行”时,再使用 GROUP BY 聚合。
快速自测:只查询订单金额大于 100 的用户和订单,应把 o.amount > 100 放在哪里?答案:放在 WHERE 中。
子查询把一个查询的结果交给另一个查询使用。返回单个值时可以直接比较;只关心“是否存在”时优先使用 EXISTS,数据库找到第一条匹配记录后即可停止继续查找。
-- 查询订单金额高于平均订单金额的订单 SELECT id, user_id, amount FROM orders WHERE amount > (SELECT AVG(amount) FROM orders); -- 查询至少下过一单的用户 SELECT u.id, u.name FROM users AS u WHERE EXISTS ( SELECT 1 FROM orders AS o WHERE o.user_id = u.id ); -- 查询从未下过订单的用户 SELECT u.id, u.name FROM users AS u WHERE NOT EXISTS ( SELECT 1 FROM orders AS o WHERE o.user_id = u.id );
NOT IN 的子查询结果一旦包含 NULL,判断可能全部变成未知;表达“没有关联记录”时,NOT EXISTS 通常更直观稳妥。
快速自测:查询至少有一笔金额大于 500 的订单的用户,只需在 EXISTS 子查询中再添加 AND o.amount > 500。
CASE 可以在查询中完成分类、状态翻译和条件统计。它不会修改原数据,只负责根据条件计算当前结果列。
-- 按金额给订单分级 SELECT id, amount, CASE WHEN amount >= 1000 THEN '大额订单' WHEN amount >= 100 THEN '普通订单' ELSE '小额订单' END AS amount_level FROM orders; -- 一次查询统计不同金额区间 SELECT COUNT(*) AS total_orders, SUM(CASE WHEN amount >= 1000 THEN 1 ELSE 0 END) AS large_orders, SUM(CASE WHEN amount < 1000 THEN amount ELSE 0 END) AS other_amount FROM orders;
多个 WHEN 按书写顺序判断,匹配第一项后就停止。范围条件应从更具体或更高的阈值开始,否则前面的宽泛条件会提前截获数据。
快速自测:把没有订单的用户显示为“未消费”,可以对左连接后的 o.id 写 CASE WHEN o.id IS NULL THEN '未消费' ELSE '已消费' END。
假设 orders 再增加 created_at 和 remark 字段。业务查询经常需要处理时间范围、拼接展示字段和提供默认值;筛选索引列时,应尽量让原字段保持不变。
-- 查询 2026 年 9 月的订单:使用左闭右开区间 SELECT id, user_id, amount FROM orders WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'; -- 格式化日期、拼接说明,并给 NULL 设置默认值 SELECT id, DATE_FORMAT(created_at, '%Y-%m-%d') AS order_date, CONCAT('订单金额:', amount) AS summary, COALESCE(remark, '无备注') AS remark FROM orders;
筛选时间时,created_at >= 起点 AND created_at < 终点 能覆盖带时分秒的数据。避免写 DATE(created_at) = ...,对索引列套函数可能让普通索引无法直接用于范围定位。
快速自测:查询最近 7 天订单,可以写 created_at >= CURRENT_DATE - INTERVAL 7 DAY。
索引帮助 MySQL 少扫描数据。设计联合索引时,从实际查询出发,把常用等值条件放在前面,再考虑范围、排序和需要覆盖的列。
-- 支持“某用户按订单 id 倒序查看订单”
CREATE INDEX idx_orders_user_id_id
ON orders (user_id, id);
EXPLAIN
SELECT id, amount
FROM orders
WHERE user_id = 10
ORDER BY id DESC
LIMIT 20;
阅读执行计划时先看 type 是否为 ALL,再看 key 是否命中预期索引、rows 预计扫描多少行,以及 Extra 中是否出现 Using filesort 或 Using temporary。索引越多,写入维护成本也越高。
-- MySQL 8:实际执行查询并展示每个步骤的真实耗时和行数
EXPLAIN ANALYZE
SELECT id, amount FROM orders WHERE user_id = 10;
快速自测:联合索引是 (user_id, id) 时,只按 id 查询能否稳定使用它?不一定,因为跳过了最左列 user_id。
事务把多条 SQL 视为一个整体:全部成功就 COMMIT,任何一步失败就 ROLLBACK。需要“先读取、再根据结果修改”时,可以用 FOR UPDATE 锁住将要修改的记录。
START TRANSACTION; -- 锁定订单 1001,其他事务暂时不能修改这条记录 SELECT id, amount FROM orders WHERE id = 1001 FOR UPDATE; UPDATE orders SET amount = amount + 20 WHERE id = 1001; COMMIT; -- 发生异常时执行 ROLLBACK;
FOR UPDATE 必须在事务中使用才有实际意义。事务应尽量短,不要在持锁期间等待网络请求或人工操作;多个业务流程按一致顺序锁定记录,可以减少死锁。
快速自测:第二条 UPDATE 失败后,能否继续 COMMIT 第一条修改?通常不应这样做,应捕获异常并执行 ROLLBACK,让业务保持完整。
MySQL 8 可以用 WITH 把复杂查询拆成有名字的步骤。窗口函数在保留每一行的同时完成排名、累计或前后行比较,不会像 GROUP BY 那样把多行合并成一行。
-- 先汇总每位用户的消费,再按总金额排名
WITH user_totals AS (
SELECT u.id, u.name,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.amount), 0) AS total_amount
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.id
GROUP BY u.id, u.name
)
SELECT id, name, order_count, total_amount,
DENSE_RANK() OVER (ORDER BY total_amount DESC) AS amount_rank
FROM user_totals
ORDER BY amount_rank, id;
ROW_NUMBER() 给每行唯一序号;RANK() 遇到并列会跳号;DENSE_RANK() 遇到并列不会跳号。窗口中的 PARTITION BY 可以让排名在每个分组内重新开始。
快速自测:要计算每位用户自己的订单金额累计值,可写 SUM(amount) OVER (PARTITION BY user_id ORDER BY id)。
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total FROM orders GROUP BY user_id HAVING COUNT(*) >= 2 ORDER BY total DESC;
WHERE 筛选分组之前的行,HAVING 筛选分组之后的结果。常用统计函数有 COUNT()、SUM()、AVG()、MAX()、MIN()。
INSERT INTO users (name) VALUES ('小王');
UPDATE users SET name = '王同学' WHERE id = 3;
DELETE FROM users WHERE id = 3;
执行 UPDATE 和 DELETE 前要仔细检查 WHERE;省略它会影响整张表。生产环境建议先用相同条件执行 SELECT,确认影响范围。
思路是保留全部用户,再筛选右表没有匹配成功的行。
SELECT u.* FROM users AS u LEFT JOIN orders AS o ON o.user_id = u.id WHERE o.id IS NULL;
按创建时间倒序加载下一页;created_at 相同时用 id 保证顺序稳定。
SELECT id, order_no, user_id, pay_amount, status, created_at
FROM orders
WHERE user_id = ?
AND status IN ('PAID', 'SHIPPED', 'FINISHED')
AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;
(user_id, created_at, id),让范围扫描与排序复用索引,status 再做过滤。第一页不传游标条件,下一页传上一页最后一条记录的 created_at 与 id。防止“先查库存再扣减”产生并发超卖。
UPDATE sku_stock
SET available_stock = available_stock - ?,
version = version + 1,
updated_at = NOW()
WHERE sku_id = ?
AND available_stock >= ?;
-- 应用层检查 affected_rows:1 表示成功,0 表示库存不足或商品不存在
同一个第三方交易号可能被重复通知,数据库必须保证只入账一次。
INSERT INTO payment_records (payment_no, order_id, channel_trade_no, amount, status, paid_at) VALUES (?, ?, ?, ?, 'SUCCESS', ?) AS incoming ON DUPLICATE KEY UPDATE status = IF(status = 'SUCCESS', status, incoming.status), paid_at = COALESCE(paid_at, incoming.paid_at);
channel_trade_no 建 UNIQUE 索引。幂等性的最后防线应落在数据库约束上。同一次扫描里计算订单量、付费用户数、实收和退款额。
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month,
COUNT(*) AS order_count,
COUNT(DISTINCT CASE WHEN status IN ('PAID','FINISHED') THEN user_id END)
AS paid_user_count,
SUM(CASE WHEN status IN ('PAID','FINISHED') THEN pay_amount ELSE 0 END)
AS paid_amount,
SUM(CASE WHEN status = 'REFUNDED' THEN refund_amount ELSE 0 END)
AS refund_amount
FROM orders
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01'
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month;
找出注册满 30 天,并且最近 90 天没有有效订单的用户。
SELECT u.id, u.mobile, u.created_at
FROM users u
WHERE u.status = 'ACTIVE'
AND u.created_at < NOW() - INTERVAL 30 DAY
AND NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
AND o.status IN ('PAID', 'FINISHED')
AND o.created_at >= NOW() - INTERVAL 90 DAY
);
(user_id, status, created_at)。先汇总商品销量,再在每个品类内部排名。
WITH product_sales AS (
SELECT p.category_id, p.id AS product_id, p.name,
SUM(oi.quantity) AS sales_qty
FROM order_items oi
JOIN orders o ON o.id = oi.order_id AND o.status = 'FINISHED'
JOIN products p ON p.id = oi.product_id
WHERE o.created_at >= CURRENT_DATE - INTERVAL 30 DAY
GROUP BY p.category_id, p.id, p.name
), ranked AS (
SELECT product_sales.*,
ROW_NUMBER() OVER (
PARTITION BY category_id ORDER BY sales_qty DESC, product_id
) AS rn
FROM product_sales
)
SELECT category_id, product_id, name, sales_qty
FROM ranked
WHERE rn <= 3
ORDER BY category_id, rn;
手机号重复时保留 id 最大的记录,删除其余重复行。
DELETE u_old FROM users u_old JOIN users u_new ON u_old.mobile = u_new.mobile AND u_old.id < u_new.id WHERE u_old.mobile IS NOT NULL; -- 清理完成后立即补上约束 ALTER TABLE users ADD UNIQUE KEY uk_users_mobile (mobile);
锁定双方账户后再校验余额和更新,所有并发请求采用相同的账号排序。
START TRANSACTION; SELECT account_id, balance FROM accounts WHERE account_id IN (?, ?) ORDER BY account_id FOR UPDATE; UPDATE accounts SET balance = balance - ? WHERE account_id = ?; UPDATE accounts SET balance = balance + ? WHERE account_id = ?; INSERT INTO transfer_logs(request_no, from_id, to_id, amount) VALUES (?, ?, ?, ?); COMMIT;
request_no 应有 UNIQUE 索引用于防重。真实代码还要在余额不足、影响行数异常或写流水失败时 ROLLBACK。CREATE DATABASE IF NOT EXISTS edu_platform; USE edu_platform; -- 用户表 CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, nickname VARCHAR(50) NOT NULL, email VARCHAR(100) UNIQUE, role ENUM('student', 'teacher', 'admin') DEFAULT 'student', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 课程表 CREATE TABLE courses ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, teacher_id INT, category VARCHAR(30), price DECIMAL(10,2) DEFAULT 0, FOREIGN KEY (teacher_id) REFERENCES users(id) ); -- 选课/购买记录表 CREATE TABLE enrollments ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, course_id INT NOT NULL, paid_amount DECIMAL(10,2), status ENUM('active', 'refunded', 'expired') DEFAULT 'active', enrolled_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (course_id) REFERENCES courses(id) ); -- 学习记录表 CREATE TABLE study_logs ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, course_id INT NOT NULL, duration INT DEFAULT 0, -- 学习时长(秒) logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (course_id) REFERENCES courses(id) ); -- ── 插入示例数据 ── INSERT INTO users (nickname, email, role) VALUES ('小明', 'ming@edu.com', 'student'), ('小红', 'hong@edu.com', 'student'), ('张老师', 'zhang@edu.com', 'teacher'), ('李老师', 'li@edu.com', 'teacher'), ('王同学', 'wang@edu.com', 'student'), ('刘同学', 'liu@edu.com', 'student'), ('赵管理', 'zhao@edu.com', 'admin'), ('新用户', 'new@edu.com', 'student'); INSERT INTO courses (title, teacher_id, category, price) VALUES ('MySQL 入门到精通', 3, '数据库', 99.00), ('Redis 实战', 3, '数据库', 149.00), ('Spring Boot 开发', 4, '后端', 199.00), ('Vue3 全家桶', 4, '前端', 169.00), ('算法面试通关', 3, '算法', 299.00), ('Docker 入门', NULL, '运维', 0.00); INSERT INTO enrollments (user_id, course_id, paid_amount, status, enrolled_at) VALUES (1, 1, 99.00, 'active', '2024-03-01 10:00:00'), (1, 3, 199.00, 'active', '2024-03-15 14:00:00'), (2, 1, 99.00, 'active', '2024-04-01 09:00:00'), (2, 4, 169.00, 'refunded', '2024-04-10 11:00:00'), (5, 1, 99.00, 'active', '2024-05-01 08:00:00'), (5, 2, 149.00, 'active', '2024-05-10 16:00:00'), (5, 5, 299.00, 'expired', '2024-01-15 20:00:00'), (6, 3, 199.00, 'active', '2024-06-01 12:00:00'); INSERT INTO study_logs (user_id, course_id, duration, logged_at) VALUES (1, 1, 3600, '2024-03-02 10:00:00'), (1, 1, 5400, '2024-03-03 14:00:00'), (1, 3, 1800, '2024-03-16 10:00:00'), (2, 1, 7200, '2024-04-02 09:00:00'), (5, 1, 2700, '2024-05-02 10:00:00'), (5, 2, 4500, '2024-05-11 16:00:00');
users.id ──< courses.teacher_id 老师 → 课程 users.id ──< enrollments.user_id 学生 → 选课记录 courses.id ──< enrollments.course_id 课程 → 选课记录 users.id ──< study_logs.user_id 学生 → 学习记录 courses.id ──< study_logs.course_id 课程 → 学习记录