SQL 基础:执行顺序、增删改查到窗口函数
MySQL 8.4 LTS 基线下的 SQL 日常主力用法:DDL/DML/DQL/DCL 的核心语法、容易想当然的规则(别名可见性、NULL 语义、LEFT JOIN 过滤位置),以及 8.0 之后真正值得用的现代特性(CTE、窗口函数)。索引与执行计划细节在 08-EXPLAIN与查询优化.md,事务与锁在 05-事务与锁.md,本章遇到只点到为止。
贯穿本章的示例表
一套贴近业务的电商表,后文所有 SQL 都基于它们:
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
username VARCHAR(64) NOT NULL COMMENT '用户名',
email VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',
city VARCHAR(32) COMMENT '所在城市(可空,用于演示 NULL 语义)',
age SMALLINT NOT NULL DEFAULT 0 COMMENT '年龄',
referrer_id BIGINT UNSIGNED COMMENT '邀请人ID(可空)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
PRIMARY KEY (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户表';
CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(128) NOT NULL COMMENT '商品名',
category VARCHAR(32) NOT NULL DEFAULT '' COMMENT '分类',
price DECIMAL(10, 2) NOT NULL DEFAULT 0.00 COMMENT '售价',
stock INT NOT NULL DEFAULT 0 COMMENT '库存',
PRIMARY KEY (id),
UNIQUE KEY uk_name (name)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '商品表';
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '订单号',
user_id BIGINT UNSIGNED NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待付款 1已付款 2已发货 3完成 4退款',
amount DECIMAL(10, 2) NOT NULL DEFAULT 0.00 COMMENT '订单金额',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
KEY idx_user_time (user_id, created_at)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单表';
CREATE TABLE order_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
quantity SMALLINT NOT NULL DEFAULT 1,
price DECIMAL(10, 2) NOT NULL DEFAULT 0.00 COMMENT '成交单价',
PRIMARY KEY (id),
KEY idx_order (order_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单明细表';建表时的类型怎么选、规范怎么定,展开讲在 03-数据类型与建表.md。
一、SQL 执行顺序:一切规则的源头
书写顺序是 SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ... LIMIT,但逻辑求值顺序完全不同——这是绝大多数"语法直觉"翻车现场的根源:
FROM / JOIN ... ON -- 先确定数据来源和连接
WHERE -- 在原始行上过滤
GROUP BY -- 分组
HAVING -- 在分组结果上过滤
SELECT(含表达式、聚合函数) -- 这时才计算投影和别名
DISTINCT
ORDER BY -- 排序时别名已可见
LIMIT -- 最后截取由此推出 MySQL 的别名可见性规则:
-- WHERE 在 SELECT 之前求值,别名还不存在,报错:
SELECT city AS c FROM users WHERE c = '杭州';
-- ERROR 1054: Unknown column 'c' in 'where clause'
-- GROUP BY / HAVING / ORDER BY 在 SELECT 之后(或同时)求值,MySQL 允许引用别名:
SELECT city AS c, COUNT(*) AS cnt
FROM users
GROUP BY c
HAVING cnt > 10
ORDER BY cnt DESC;别名规则速记:WHERE 里不能用 SELECT 别名(标准 SQL 如此,MySQL 无例外);GROUP BY/HAVING/ORDER BY 里能用(MySQL 扩展,追求跨库可移植时少依赖);窗口函数和聚合表达式不能出现在 WHERE 里,要过滤它们的成果必须套外层查询(见窗口函数一节)。
注意
ORDER BY引用别名时排的是"计算后的结果";ORDER BY 2这种按 SELECT 列表序号排序虽然合法,可读性差且列一移动就排错序,生产 SQL 里避免。
二、DDL:库、表与三兄弟删除
建库与字符集
CREATE DATABASE IF NOT EXISTS shop
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_0900_ai_ci;
DROP DATABASE IF EXISTS shop_test; -- 库名不给引号,删库前先 USE + SHOW TABLES 确认字符集和排序规则在库级定调,表/列不显式指定时逐层继承(服务器→库→表→列)。utf8mb4 和排序规则的选型纠葛(emoji、1267 报错)详见 03-数据类型与建表.md。
CREATE TABLE 要素回顾
一条 CREATE TABLE 由六块组成:列定义(类型 + NOT NULL + DEFAULT + COMMENT)、主键、唯一键、普通索引、表选项(ENGINE/CHARSET/COLLATE)、表级 COMMENT。上面示例表就是标准模板;每一块怎么选都有讲究,细节全部指向 03-数据类型与建表.md,本章不重复。
ALTER TABLE 常用改法
-- 加列(8.0 起追加到表尾通常是 INSTANT,秒级完成)
ALTER TABLE orders ADD COLUMN remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注';
-- 改列:注意 MODIFY 是"整列定义重写"
ALTER TABLE orders MODIFY COLUMN amount DECIMAL(12, 2) NOT NULL DEFAULT 0.00 COMMENT '订单金额';
-- 索引与表选项(改列名用 RENAME COLUMN,改类型见下方注意事项)
ALTER TABLE orders ADD KEY idx_status (status), DROP KEY idx_user_time;
ALTER TABLE users RENAME TO user_info;注意
MODIFY/CHANGE必须把列的完整定义写全:只写MODIFY amount DECIMAL(12,2)而不带NOT NULL DEFAULT ... COMMENT ...,缺失的部分会回落到默认值——列悄悄变可空、注释丢失,是线上最常见的 ALTER 事故之一。改列前先SHOW CREATE TABLE抄原定义。
注意 改数据类型(如 INT 改 BIGINT、VARCHAR 加长跨 256 字节线)在某些组合下会触发整表 COPY 重建并长时间持锁;大表加列/加索引/改类型的锁行为分级与工具选择(ALGORITHM=INSTANT/INPLACE、gh-ost/pt-osc)详见
09-Online-DDL与大表变更.md,动手前必读。
DROP / TRUNCATE / DELETE:删表、清数据、删行的分界
| 维度 | DROP TABLE | TRUNCATE TABLE | DELETE |
|---|---|---|---|
| 归类 | DDL | DDL | DML |
| 删什么 | 整表(结构+数据+索引) | 全部数据,保留结构 | 按 WHERE 删行,不写 WHERE 删全部 |
| WHERE | 无 | 无 | 有 |
| 触发器 | 无关 | 不触发 | 逐行触发 |
| 自增计数器 | 随表消失 | 重置为 1 | 不重置 |
| 事务回滚 | 隐式提交,不能回滚 | 隐式提交,不能回滚 | 可回滚 |
| 速度 | 快 | 最快(直接释放页) | 逐行删、写 undo/binlog,慢 |
TRUNCATE 看似"DELETE 全表的快捷方式",实际差别很大:它隐式提交、不走触发器、重置自增值,因此在事务里执行 TRUNCATE 后再 ROLLBACK 是回不来的。误删找回的演练和备份策略见 11-备份恢复与性能调优.md。
三、INSERT 家族
基础形态
-- 单行:列清单显式写全,别依赖列顺序
INSERT INTO orders (order_no, user_id, status, amount)
VALUES ('A20261020001', 101, 0, 199.00);
-- 多行:一条语句批量插入,比循环单条快得多(网络往返、事务开销都省)
INSERT INTO order_items (order_id, product_id, quantity, price) VALUES
(1001, 7, 2, 59.00),
(1001, 9, 1, 81.00),
(1002, 7, 1, 59.00);
-- INSERT ... SELECT:跨表搬运
INSERT INTO order_items_archive (order_id, product_id, quantity, price)
SELECT order_id, product_id, quantity, price FROM order_items WHERE order_id < 900000;
-- 未列出的列取 DEFAULT;无默认值且 NOT NULL 则报错(严格模式)ON DUPLICATE KEY UPDATE:存在即更新
靠主键或唯一键冲突来判断"重复",一条语句完成插入或更新,秒杀"先 SELECT 再决定"的竞态写法:
-- 老写法(VALUES 函数,8.0.20 起弃用):
INSERT INTO products (name, category, price, stock) VALUES ('键盘K80', '外设', 199.00, 50)
ON DUPLICATE KEY UPDATE stock = stock + VALUES(stock);
-- 8.0.19 起的推荐写法:给待插入行起别名,用别名引用新值
INSERT INTO products (name, category, price, stock) VALUES ('键盘K80', '外设', 199.00, 50) AS new
ON DUPLICATE KEY UPDATE stock = stock + new.stock, price = new.price;等号右侧不带限定符的列名指已存在行的旧值,new.xxx 指待插入行的新值。affected rows 语义是面试常踩点:
- 插入新行返回 1;更新已有行返回 2;命中已有行但所有值都没变化返回 0。
- 所以用"affected rows > 0"判断"写过数据"时,更新场景的 2 完全正常,别当 bug 报。
注意 ODKU 同样会消耗自增值(即使命中更新分支)、在语句复制模式下有主从隐患;它只解决"冲突后怎么办",不能替代应用层的业务唯一性设计。自增空洞问题见
13-常见陷阱与最佳实践.md。
INSERT IGNORE 与 REPLACE:副作用比便利多
INSERT IGNORE INTO users (id, username) VALUES (5, 'bob');
REPLACE INTO users (id, username, email) VALUES (5, 'bobby', 'b@x.com');- INSERT IGNORE:把错误降级为 warning——注意它忽略的不只是重复键(1062),数据截断、NOT NULL 违约等也会被静默吞掉,脏数据无声入库;被忽略的行仍会执行 BEFORE INSERT 触发器、仍消耗自增值(出现空洞)。
- REPLACE:冲突时是"先 DELETE 旧行再 INSERT 新行"。未在新语句中列出的列会丢失旧值(回到默认值);DELETE+INSERT 会触发触发器、消耗新自增值、若配了外键还会引发级联。用 ODKU 能解决的场景不要用 REPLACE。
这两位的完整事故清单(自增跳变、误删、复制安全)在 13-常见陷阱与最佳实践.md 汇总。
四、UPDATE / DELETE 与"忘写 WHERE"的自保
UPDATE orders SET status = 1, amount = amount - 10.00
WHERE id = 1001 AND status = 0; -- 带上 status=0 做乐观校验,防止重复支付标记
DELETE FROM order_items WHERE order_id = 1002 AND quantity < 0;
-- 分批处理:UPDATE/DELETE 支持 ORDER BY + LIMIT(按主键推进,避免一次锁几十万行)
UPDATE orders SET status = 4
WHERE status = 0 AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY id LIMIT 1000; -- 循环执行直到 affected rows 为 0忘写 WHERE 是全行业通用的名场面,三层自保:
- 客户端开启安全更新:
SET SESSION SQL_SAFE_UPDATES = 1;后,WHERE 里没有索引列(key 列)且没带 LIMIT 的 UPDATE/DELETE 会直接报错 ERROR 1175,逼你补条件。 - 先 SELECT 再改:同一套 WHERE 先跑
SELECT COUNT(*)或抽样,确认命中行数符合预期,再把这段原样粘进 UPDATE——改的永远只是粘贴后的动词部分。 - 事务兜底:
BEGIN;执行 UPDATE,SELECT验证结果,对了才COMMIT,错了ROLLBACK(配合第 1 条,MySQL 事务细节见05-事务与锁.md)。
五、WHERE / ORDER BY / LIMIT:检索与分页
-- WHERE 常用谓词
SELECT * FROM orders
WHERE status IN (1, 2) -- 枚举
AND amount BETWEEN 100 AND 500 -- 闭区间
AND created_at >= '2026-10-01' -- 时间左闭右开更防歧义
AND city IS NULL -- 判空只能用 IS NULL(见 NULL 一节)
ORDER BY amount DESC, id ASC -- 排序列加 id 兜底,保证结果确定
LIMIT 10 OFFSET 20; -- 第 3 页:每页 10 条,偏移从 0 计LIMIT n 等价 LIMIT 0, n;LIMIT offset, size 与 LIMIT size OFFSET offset 两种方言 MySQL 都认。页码换算永远是 (页码-1) * 每页数。
注意 深分页
LIMIT 500000, 10的实现是"扫描并丢弃前 50 万行",页码越深越慢;书签式改写(WHERE id > 上次末尾 ORDER BY id LIMIT 10)与延迟关联等方案在08-EXPLAIN与查询优化.md展开。另外分页总数用独立 COUNT 查询拿,别SQL_CALC_FOUND_ROWS(8.0 已弃用)。
ORDER BY 的默认规则补充两点:MySQL 中 NULL 视为最小值(ASC 时排最前);ORDER BY 列若走了索引可免 filesort,这决定了联合索引里排序列放哪,预告 04-索引原理与实战.md。
六、聚合与 GROUP BY
SELECT city, COUNT(*) AS user_cnt, AVG(age) AS avg_age,
MAX(created_at) AS last_join
FROM users
GROUP BY city;ONLY_FULL_GROUP_BY 的来龙去脉
MySQL 5.7 起默认开启 ONLY_FULL_GROUP_BY,很多老项目迁移时报错:
Expression #1 of SELECT list is not in GROUP BY clause and contains
nonaggregated column 'shop.users.email' which is not functionally
dependent on columns in the GROUP BY clause它挡的是真问题:SELECT city, username, COUNT(*) FROM users GROUP BY city 里,一个城市对应多个 username,返回哪一个是未定义行为——老 MySQL 随机取一列,应用却当"最新那个"用,数据错得悄无声息。
正确解法按优先级:
- 非聚合列全部进 GROUP BY:
GROUP BY city, username; - 确实"组内任选一个就行"时,用 8.0 的
ANY_VALUE(city)显式表达意图; - 想要"每组最新那条"的完整行——这是每组 TopN 问题,正确工具是窗口函数(第十二节)。
改 sql_mode 把它关掉是把报警器电池抠了,默认值含义见 03-数据类型与建表.md 的 sql_mode 一节。
GROUP_CONCAT 的 1024 截断
SELECT order_id,
GROUP_CONCAT(product_id ORDER BY product_id SEPARATOR ',') AS skus
FROM order_items GROUP BY order_id;GROUP_CONCAT 支持组内 ORDER BY、DISTINCT、SEPARATOR,但结果受 group_concat_max_len 限制,默认只有 1024 字节,超长静默截断(仅 warning)——“我的 SKU 列表怎么少了几个"多半是它。会话内调大:SET SESSION group_concat_max_len = 65535;(上限 max_allowed_packet)。
七、JOIN 全谱
INNER / LEFT / RIGHT / CROSS / 自连接
-- INNER JOIN:两边都能匹配上的才留下
SELECT o.order_no, u.username
FROM orders o INNER JOIN users u ON u.id = o.user_id;
-- LEFT JOIN:左表全保留,右表配不上的补 NULL
SELECT u.username, COUNT(o.id) AS order_cnt
FROM users u LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.username; -- COUNT(o.id) 数的是右表非 NULL,即 0 单用户得 0
-- RIGHT JOIN:和 LEFT 镜像,习惯上把左表换成另一张重写,少用
-- CROSS JOIN:笛卡尔积,报表补零/枚举全组合时才有正当用途
SELECT c.city, p.name
FROM (SELECT DISTINCT city FROM users) c
CROSS JOIN products p;
-- 自连接:同一张表扮两个角色
SELECT a.username AS u1, b.username AS u2, a.city
FROM users a JOIN users b ON a.city = b.city AND a.id < b.id;
-- a.id < b.id 去掉自配对和 (b,a) 重复对LEFT JOIN 的过滤条件写 ON 还是 WHERE(重点)
这是 LEFT JOIN 唯一真正难的地方。一句话结论:右表的过滤条件,写 ON 是"少连一些”,写 WHERE 是"连完再删"——后者会把没匹配上的 NULL 行删光,LEFT JOIN 退化成 INNER JOIN。
-- 需求:所有用户 + 他"已发货(status=2)"的订单
SELECT u.id, u.username, o.order_no
FROM users u LEFT JOIN orders o
ON o.user_id = u.id AND o.status = 2;
-- 没下过发货单的用户仍在结果里,order_no 为 NULL
SELECT u.id, u.username, o.order_no
FROM users u LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 2;
-- 未匹配行的 o.status 是 NULL,NULL = 2 不成立被 WHERE 滤掉,
-- 结果只剩"有发货单的用户"——等价 INNER JOIN,且往往不是想要的记法:左表条件放 WHERE,右表条件放 ON;若对右表的过滤本意就是"最终结果层面筛"(比如只要无单用户 WHERE o.id IS NULL),那写 WHERE 反而正确——“查从未下单用户"的经典写法 LEFT JOIN ... WHERE orders.id IS NULL 正是利用补 NULL 特性做反连接(anti join)。
驱动表选择(谁放左边、优化器怎么排)一句话预告:MySQL 8.0 优化器会重排 JOIN 顺序,但 LEFT JOIN 的外连接语义会限制它能怎么重排;真实执行顺序与代价看 08-EXPLAIN与查询优化.md。
八、子查询与派生表
-- 标量子查询:返回一行一列,能出现在任何单值位置
SELECT username, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_cnt
FROM users u;
-- 列子查询 + IN
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- EXISTS:相关子查询,语义"外层这行是否存在配得上内层的行"
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 1000);
-- 派生表:FROM 里的子查询必须有别名(MySQL 硬性要求)
SELECT t.city, t.cnt FROM (
SELECT city, COUNT(*) AS cnt FROM users GROUP BY city
) t WHERE t.cnt > 10;IN vs EXISTS 怎么选(8.0 语境下):
- 历史口诀"外层小用 IN、外层大用 EXISTS"在 5.6+ 优化器把 IN 子查询改写成半连接(semi-join)后已不可靠;两句话:非相关子查询、结果集不大且无 NULL,用 IN 更直观;每行外层都要探测大表、内层是主键/索引点查,用 EXISTS;
NOT IN换成NOT EXISTS(防 NULL,见第十一节)。最终用EXPLAIN验证,08-EXPLAIN与查询优化.md。 - 相关子查询(引用外层列)在语义上对每行外层重跑一次,写在 SELECT 列里的标量子查询最费:百万行外层就百万次点查,优先考虑改 JOIN 或 CTE。
- 8.0 对派生表/视图的两条优化:合并(谓词推进外层,避免物化)与物化一次供多引用;优化器自动二选一,别为"子查询一定物化"这种过时结论做设计。
CTE(WITH)本质是"有名字、可引用的派生表/标量”,第十三位展开。
九、UNION / UNION ALL
SELECT id, username AS name, 'user' AS src FROM users WHERE city = '杭州'
UNION ALL
SELECT id, name AS name, 'product' AS src FROM products WHERE category = '外设';- 各段列数量相同、类型可对齐(结果列名与类型取第一段);顺序不保证,除非写 ORDER BY。
UNION(不带 ALL)= 合并后去重:内部要建临时表 + 排序去重,两段各 50 万行时是实打实的代价。99% 的场景要么天然无重复(两个不同来源表)、要么你要的就是全量——默认写 UNION ALL,确有去重需求再换 UNION。- ORDER BY/LIMIT 作用于合并后的整体;想对单个 SELECT 排序再 UNION,必须给它加括号并写成
SELECT ... ORDER BY x LIMIT 10的派生表形式,否则语法按整体解析。
十、CASE 与常用函数速查
CASE WHEN:报表行转列
两种形态:简单 CASE(CASE expr WHEN v1 THEN ...)与搜索 CASE(CASE WHEN cond THEN ...)。经典行转列:
SELECT DATE(created_at) AS d,
SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) AS pending_cnt,
SUM(CASE WHEN status = 1 THEN amount ELSE 0 END) AS paid_amount,
SUM(CASE WHEN status = 4 THEN amount ELSE 0 END) AS refund_amount
FROM orders
GROUP BY d;CASE 是表达式不是语句:可嵌套、可用于 SELECT/ORDER BY/GROUP BY。ORDER BY FIELD(status, 2, 1, 3) 配合 CASE 可实现自定义枚举排序。
条件与空值函数
SELECT IF(status = 1, '已付', '未付') AS s, -- 三元
IFNULL(email, '-') AS e, -- a 为 NULL 取 b
NULLIF(stock, 0) AS s2, -- a=b 时返回 NULL,防除零利器
COALESCE(remark, memo, '无') AS note -- 从左到右第一个非 NULL
FROM orders;NULLIF(x, 0) 是比率计算的标准防具:cnt / NULLIF(total, 0),分母为 0 时结果安静地变 NULL 而不是报错/无穷。
字符串函数
SELECT CONCAT('u_', id) AS k, -- 任一参数为 NULL 结果即 NULL(传染)
CONCAT_WS(',', city, username) AS tag, -- 分隔符版:自动跳过 NULL,拼 LIKE 模糊也用这风格
LOCATE('key', name) AS p, -- 子串位置(1 起始),参数顺序是 (子串, 串),和多数语言反着
SUBSTRING(name, 1, 5), LEFT(name, 5), RIGHT(name, 3), -- 按字符计,中文安全;LENGTH 才是字节
TRIM(BOTH ' ' FROM name), -- 还有 LTRIM/RTRIM;去指定字符 TRIM(BOTH 'x' FROM ...)
REPLACE(name, '旧', '新');LIKE 通配符:% 任意长度、_ 单字符;WHERE name LIKE '100\%%' ESCAPE '\\' 匹配以"100%“开头的商品名(转义写法见官方文档,注意 \% 是模式转义而非字符串转义)。前导 % 无法用索引;FIND_IN_SET('外设', category_list) 处理逗号字段时完全走不了索引,而且逗号字段本身就是反范式,改桥接表(见 13-常见陷阱与最佳实践.md)。
日期函数
SELECT NOW(), CURDATE(), SYSDATE(); -- NOW=语句开始时刻,SYSDATE=调用瞬间(区别在长语句)
SELECT DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s'); -- %i 才是分钟,%m 是月份——写混了是高频事故
SELECT STR_TO_DATE('2026/10/20 13:05', '%Y/%m/%d %H:%i');
SELECT DATE_ADD(NOW(), INTERVAL 1 MONTH), -- 还有 DATE_SUB、加 (6,'2026-02-01') 形式的 INTERVAL
TIMESTAMPDIFF(HOUR, created_at, NOW()), -- 注意方向:第二个减第一个,返回 end-start
DATEDIFF(CURDATE(), created_at); -- 天数差:DATEDIFF(早的放后? 不是——是 expr1 - expr2,以天为单位截断)TIMESTAMPDIFF(MINUTE|HOUR|DAY|..., begin, end) 结果是 end - begin,别把参数顺序凭直觉写反;DATEDIFF(a, b) = a - b 的整天数。
注意 对索引列套函数 = 索引失效:
WHERE DATE(created_at) = '2026-10-20'每行都要算函数,优化器只能全表扫。改写为区间:created_at >= '2026-10-20' AND created_at < '2026-10-21'。原理与更多等价改写见08-EXPLAIN与查询优化.md。
类型转换
SELECT CAST('123' AS UNSIGNED) + 1, -- 124
CONVERT(0.565, DECIMAL(10, 2)); -- 0.57:浮点转 DECIMAL 四舍六入五看偶数策略,敏感计算实测
SELECT CONVERT(name USING latin1); -- 另一种 CONVERT:字符集转换浮点转整、字符串转数字的隐式规则在 03-数据类型与建表.md 隐式转换一节。
十一、NULL:三件必须钉死的事
1. NULL 与任何东西比较结果都是 unknown,WHERE 只放行 TRUE。
SELECT * FROM users WHERE city = NULL; -- 永远 0 行,判空必须 IS NULL / IS NOT NULL
SELECT * FROM users WHERE city <> '杭州'; -- 也会漏掉 city 为 NULL 的人!
SELECT * FROM users WHERE city <> '杭州' OR city IS NULL; -- 才完整2. NOT IN 的子查询里只要有一个 NULL,整个结果集为空。
SELECT * FROM users
WHERE referrer_id NOT IN (SELECT id FROM users WHERE city = '北京');
-- 若子查询结果含 NULL:对任意行,"x NOT IN (..., NULL)" 里对 NULL 的比较是 unknown,
-- AND 链里出现 unknown 整体不为 TRUE → 所有行被过滤,返回空集,还以为是数据问题修法三选一:NOT EXISTS (...)(推荐,天然免疫 NULL)、子查询加 WHERE id IS NOT NULL、或业务上保证该列 NOT NULL。
3. COUNT 家族数的是什么。
SELECT COUNT(*), COUNT(1), COUNT(city), COUNT(DISTINCT city) FROM users;COUNT(*)/COUNT(1) 统计所有行(含 city 为 NULL 的行);COUNT(city) 只数 city 非 NULL 的行;COUNT(DISTINCT city) 同样不计 NULL。InnoDB 里 COUNT(*) 与 COUNT(1) 性能无实质差别(优化器自动选最小的可用索引扫),差别全在 NULL 语义。推论:AVG(age) 与 SUM(age)/COUNT(*) 不等价——AVG 的分母是非 NULL 行数(等价 COUNT(age)),手算平均时这是隐性 bug 高发点。
十二、窗口函数实战
MySQL 8.0(8.0.2 起)支持窗口函数。语法骨架:
函数() OVER (
[PARTITION BY 分组列] -- 按值切窗口
[ORDER BY 排序列] -- 窗口内排序
[帧:ROWS / RANGE ...] -- 取数范围,可省
)排名三兄弟:ROW_NUMBER / RANK / DENSE_RANK
对同一组金额 1000、1000、900:
ROW_NUMBER():1、2、3 —— 强行编号,并列也分先后(ORDER BY 需补决胜列如 id,否则并列时编号不稳定);RANK():1、1、3 —— 并列跳号;DENSE_RANK():1、1、2 —— 并列不跳号。
-- 每个用户金额 Top3 的订单(每组 TopN 标准模板)
WITH ranked AS (
SELECT user_id, id AS order_id, order_no, amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC, id) AS rn
FROM orders
)
SELECT * FROM ranked WHERE rn <= 3;窗口函数不能直接写在 WHERE/HAVING 里(它们在 SELECT 阶段求值,而 WHERE 更早),必须套 CTE 或派生表过滤——上面这个"WITH + rn<=3"是生产里用得最多的形状。
聚合 OVER 与帧
SELECT user_id, created_at, amount,
SUM(amount) OVER (PARTITION BY user_id) AS user_total, -- 不写 ORDER BY:整分区一个值
SUM(amount) OVER (PARTITION BY user_id ORDER BY created_at) AS running_sum, -- 累计
AVG(amount) OVER (PARTITION BY user_id ORDER BY created_at
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS avg_recent3 -- 滚动窗口
FROM orders;帧的默认规则容易记错:写了 ORDER BY 时默认帧是 RANGE UNBOUNDED PRECEDING AND CURRENT ROW——RANGE 按"值"算范围,排序值相同的行(peer)会一起算进去,累计到并列行时可能"提前拿到后面行的金额”;要严格按物理行滚动就显式写 ROWS BETWEEN ...。SUM(...) OVER w 还能配 WINDOW w AS (...) 给窗口命名复用。
LAG / LEAD:同比环比
-- 按月销售额,环比与同比
WITH monthly AS (
SELECT DATE_FORMAT(created_at, '%Y-%m') AS ym, SUM(amount) AS sales
FROM orders
GROUP BY ym
)
SELECT ym, sales,
LAG(sales, 1) OVER (ORDER BY ym) AS prev_month,
ROUND((sales - LAG(sales, 1) OVER (ORDER BY ym))
/ NULLIF(LAG(sales, 1) OVER (ORDER BY ym), 0) * 100, 2) AS mom_pct,
LAG(sales, 12) OVER (ORDER BY ym) AS yoy_base
FROM monthly
ORDER BY ym;LAG(列, 偏移, 默认值) 取前 N 行、LEAD 取后 N 行,第三参数补首行 NULL。同比基数为空时用 NULLIF 防除零(呼应第十节)。
连续区间:连续登录(gap-and-island)
经典题:找连续下单 ≥3 天的用户。思路——同一个用户的 日期 - 序号 在同一段连续区间内恒定不变,据此分组:
WITH d AS ( -- 登录日志换成订单去重到天
SELECT DISTINCT user_id, DATE(created_at) AS login_date FROM orders
),
g AS (
SELECT user_id, login_date,
DATE_SUB(login_date,
INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) DAY) AS grp
FROM d
)
SELECT user_id, MIN(login_date) AS streak_start, MAX(login_date) AS streak_end,
COUNT(*) AS days
FROM g
GROUP BY user_id, grp
HAVING COUNT(*) >= 3;“相减消漂移"的套路同样适用于连续签到、连续通话、库存断档识别。
十三、CTE 与递归 CTE
WITH 把长查询拆成命名步骤,可读性和"一次物化多处引用"两全:
WITH big_users AS (
SELECT user_id FROM orders WHERE amount > 5000 GROUP BY user_id
),
big_buyer_detail AS (
SELECT u.id, u.username FROM big_users b JOIN users u ON u.id = b.user_id
)
SELECT * FROM big_buyer_detail; -- CTE 可互相引用(后者用前者),也可传参引用多次递归 CTE:树形结构
MySQL 8.0 起支持,语法固定为"锚点 + UNION [ALL] + 递归体”:
-- 表 departments(id INT 主键, name VARCHAR(64), parent_id INT NOT NULL DEFAULT 0,根节点 parent_id=0)
WITH RECURSIVE tree AS (
-- 锚点:根节点
SELECT id, name, parent_id, 1 AS depth,
CAST(name AS CHAR(512)) AS path
FROM departments WHERE parent_id = 0
UNION ALL
-- 递归体:用上一轮 tree 找孩子
SELECT d.id, d.name, d.parent_id, t.depth + 1,
CONCAT(t.path, ' > ', d.name)
FROM departments d JOIN tree t ON d.parent_id = t.id
)
SELECT * FROM tree ORDER BY path;
-- depth 输出层级,path 输出"总公司 > 华东 > 上海一组"式面包屑评论盖楼(parent_id 指向楼层根或上一条)同构可解:锚点换根评论、递归体 JOIN 子评论即可;若要"从某条评论向上追根",把 JOIN 方向反过来逐层向上即可。
要点:
- 初始列宽度记得用
CAST撑开(如 path 的 CHAR(512))——递归追加会超锚点列宽,报 “Incorrect string size”; - 递归深度上限由
cte_max_recursion_depth控制,默认 1000,超限报错 “Recursive query aborted”;脏数据成环时靠调小这个参数或UNION(去重,自带防环但慢)兜底; - MySQL 不支持两个 CTE 互相递归引用(A 引 B 再回 A);
- 被多次引用的 CTE 优化器可能只算一次(物化),复杂查询里 CTE 链比 20 行派生表嵌套好维护得多,放心用。
十四、DCL 与权限快速上手
CREATE USER 'app_rw'@'10.20.%' IDENTIFIED BY 'Fv9#tR2x'; -- host 决定哪些来源可登,% 通配
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'app_rw'@'10.20.%';
REVOKE DELETE ON shop.* FROM 'app_rw'@'10.20.%';
SHOW GRANTS FOR 'app_rw'@'10.20.%';
DROP USER 'app_ro'@'localhost';权限位按需给:库级 ON shop.*、表级 ON shop.orders;ALL PRIVILEGES 只给 DBA 账号。8.0 默认认证插件是 caching_sha2_password(客户端不支持时才会要求改 mysql_native_password,8.4 已默认禁用后者),ALTER USER ... IDENTIFIED WITH ... 切换;GRANT 即时生效,无需 FLUSH PRIVILEGES(那是改系统表手工授权时代的历史包袱)。
一句话心法:应用账号永远不给 DDL(ALTER/DROP),删列删表走变更流程,防止应用 bug 直接毁表。账号体系、最小权限矩阵、密码策略详见 11-备份恢复与性能调优.md。
十五、预处理与注入防护
注入的唯一正解是参数化:SQL 模板先编译、值永远走占位符,不进语句文本。MySQL 预处理协议用 ?:
PREPARE stmt FROM 'SELECT id, username FROM users WHERE city = ? AND age > ?';
SET @c = '杭州', @a = 18;
EXECUTE stmt USING @c, @a;
DEALLOCATE PREPARE stmt;应用层(以 Go database/sql 为例)同样形态,驱动自动走预处理协议:
rows, err := db.Query(
"SELECT id, username FROM users WHERE city = ? AND age > ?",
city, minAge,
)
// LIKE 模糊匹配把 % 放进参数或用 CONCAT,不要把用户输入拼进语句:
db.Query("SELECT id FROM users WHERE username LIKE CONCAT('%', ?, '%')", keyword)要拼的动态部分(表名、列名、ORDER BY 方向)占位符解决不了(占位符只能代表值),必须走白名单映射校验。任何"用 fmt.Sprintf 把用户输入拼进 SQL"的写法都视同注入事故现场。
十六、锁相关语句预告
SELECT stock FROM products WHERE id = 7 FOR UPDATE; -- 加行级排他锁,扣库存前锁定
SELECT * FROM orders WHERE id = 1001 LOCK IN SHARE MODE; -- 等价 FOR SHARE,共享读锁
SELECT ... FOR UPDATE SKIP LOCKED; -- 8.0:跳过被锁行,适合任务队列领取
SELECT ... FOR UPDATE NOWAIT; -- 碰锁立刻报错而不是等这些语句什么时候用、锁住的是行还是间隙、和隔离级别怎么互动,05-事务与锁.md 全章展开。
实战建议
- 动笔前先想求值顺序:WHERE 用不了别名和窗口函数、LEFT JOIN 右表过滤写 ON 还是 WHERE——这两类问题都是"以为 SQL 按书写顺序执行"造成的。
- 批量写用 INSERT 多行和 ODKU,新代码统一
AS new别名写法,别再用弃用的 VALUES();INSERT IGNORE 和 REPLACE 能不用就不用。 - 生产会话默认开 SQL_SAFE_UPDATES,改数据走"先 SELECT 验行、事务里改、确认再 COMMIT"三步;DELETE/TRUNCATE/DROP 前核对影响行数。
- ONLY_FULL_GROUP_BY 报错的正确解法是补 GROUP BY 或 ANY_VALUE,不是关 sql_mode;GROUP_CONCAT 记得
group_concat_max_len默认 1024。 - 分页一律 ORDER BY 唯一列兜底 + 预估深页改书签式;UNION 默认写 ALL。
- NULL 三定律贴键盘边:比较即 unknown、NOT IN 遇 NULL 全空、COUNT(col) 不含 NULL。
- 8.0+ 项目里每组 TopN、同比环比、连续区间直接用 CTE + 窗口函数,别再拼自连接和 GROUP_CONCAT 模拟;递归 CTE 记得
cte_max_recursion_depth与环防护。 - 所有对外 SQL 走参数化,动态标识符用白名单;权限走 DCL 最小化,应用账号禁 DDL。