
说出来可能有点暴露年龄的感觉这些 MySQL 笔记还是我早年间一点点攒出来的当时刚接触数据库什么都不懂全靠着翻文档、看日志、拿生产环境的慢查询一遍遍试才把这些基础中的基础磨透。这么多年过去了MySQL 版本从 5.x 一路走到 8.x但核心的基础语法、索引逻辑、事务机制这一层几乎没有变过。所以我一直觉得把这部分内容整理出来对刚入门的同学来说比去追那些花里胡哨的“新特性”要有用得多。这篇文章定位是“基础篇三”跟前两篇是连续的关系主要覆盖查询进阶、多表连接、子查询、索引初步、事务概念和常用函数这几个板块。不夸张地说业务开发里 90% 的 SQL 都绕不开这些知识点。无论你是刚接触数据库的应届生还是写了两年 CRUD 想系统补一下基础的后端开发这篇文章都可以直接当手册用。1. 查询进阶分组、聚合与结果过滤1.1 聚合函数从行到列的归纳MySQL 最常用的聚合函数有五个COUNT()、SUM()、AVG()、MAX()、MIN()。如果你以前只拿它们做过简单的统计那这节可以帮你把这些函数放进更复杂的查询场景里理解。聚合函数做的事情本质上就是把“多行数据”归纳成一个数值。比如现在有一张员工表employees字段是emp_id, emp_name, dept_id, salary, hire_date我想知道公司平均工资SELECT AVG(salary) FROM employees;这个很好理解。但要注意一个细节COUNT(*)和COUNT(column)的结果可能不一样。COUNT(*)统计的是行数只要这一行存在就算COUNT(column)统计的是该列非 NULL 值的个数。我当年就因为在统计订单数时用了COUNT(order_remark)结果漏掉了一堆没有备注的订单导致报表数据对不上。排查半天最后发现是 NULL 值的坑。这一点务必记住。SUM()也要注意 NULL 的问题。NULL 参与任何算术运算结果都是 NULL。如果某一列的某些行的值是 NULLSUM()会自动忽略它们这倒没什么问题。但如果你在应用层拿这个结果再去做加减乘除就有可能出现“意外的 NULL”。1.2 GROUP BY分组统计的基石GROUP BY是聚合查询的灵魂。它的语义是把数据按某个字段的值分成若干组然后对每一组做聚合统计。举个例子统计每个部门的平均工资SELECT dept_id, AVG(salary) AS avg_salary FROM employees GROUP BY dept_id;执行逻辑是先按dept_id分组同部门的员工归为一组然后每组算一个平均值。最终返回的结果每组一行。这里有个 SQL 初学者最容易踩的坑SELECT子句里出现的非聚合字段必须出现在GROUP BY里面。什么意思呢看这句SELECT emp_name, dept_id, AVG(salary) FROM employees GROUP BY dept_id;emp_name既不在聚合函数里也不在GROUP BY里这在 MySQL 里到底能不能执行分情况。如果sql_mode没有开启ONLY_FULL_GROUP_BYMySQL 会允许它执行但返回的emp_name是这一组里随机某一条的员工姓名没有任何业务含义。如果你在生产环境遇到“这个 SQL 跑出来的结果怎么每次都不一样”八成就是这个原因。我的建议是从第一天学 SQL 开始就养成好习惯SELECT中的字段要么放在GROUP BY里要么被聚合函数包裹不要在分组查询里选一个“孤立”的普通字段。MySQL 8.0 默认开启ONLY_FULL_GROUP_BY这种写法会直接报错这也是好事逼着你写规范的 SQL。1.3 HAVING分组后的过滤条件WHERE和HAVING的区别是我面试别人时必问的一道题。一句话记住WHERE在分组之前过滤行HAVING在分组之后过滤组。举个例子找出平均工资高于 10000 的部门SELECT dept_id, AVG(salary) AS avg_salary FROM employees WHERE salary 5000 GROUP BY dept_id HAVING avg_salary 10000;先通过WHERE salary 5000把月薪低于 5000 的员工过滤掉剩下的人按部门分组最后HAVING avg_salary 10000把平均工资不达标的部门过滤掉。WHERE针对的是原始行HAVING针对的是分组后的结果集。很多人问HAVING里能不能用WHERE的条件不能因为WHERE在分组前就执行完了。反过来WHERE里不能用聚合函数比如WHERE AVG(salary) 10000这写法就是错的因为执行WHERE的时候聚合计算根本没开始。这个执行顺序问题理解清楚了写 SQL 就很少会逻辑混乱。1.4 通用执行顺序把这一节的知识串起来看SQL 的逻辑执行顺序是这样的FROM确定数据源WHERE对原始行做过滤GROUP BY分组HAVING过滤分组SELECT投影出需要的列计算聚合表达式ORDER BY排序LIMIT限制行数记住这个顺序非常有用。你写的每条查询无论看起来多复杂都是按这个顺序在脑子或者数据库引擎里走一遍的。我刷题、调 SQL 的时候遇到“为什么结果不对”的问题第一件事就是把 SQL 按执行顺序拆开逐步排查效率非常高。2. 多表查询连接的艺术2.1 为什么需要连接真实业务里的数据几乎不会放在一张表里。用户信息在users表订单在orders表商品在products表。要把“用户买了什么商品”查出来就必须把多张表的数据按照某种关系拼接在一起。多表查询就是解决这个问题的。核心是“连接条件”也就是表与表之间通过哪个字段关联起来。最常见的关联关系是主外键关系比如orders.user_id对应users.id。2.2 INNER JOIN只留能对上的内连接的语义是只返回两张表中满足连接条件的行。SELECT u.user_name, o.order_id, o.amount FROM users u INNER JOIN orders o ON u.id o.user_id;这条 SQL 返回的是“有订单的用户”的订单记录。如果一个用户一条订单都没有这个用户在结果里根本不会出现。理解内连接的关键是它能自动过滤掉没有匹配关系的数据。INNER JOIN和旧式的WHERE多表写法在功能上是等价的。比如上面的 SQL 也可以写成SELECT u.user_name, o.order_id, o.amount FROM users u, orders o WHERE u.id o.user_id;但我强烈建议你直接用JOIN ... ON的写法。理由很简单显式的连接条件好维护加过滤条件时不用在 WHERE 里堆一堆“连接条件 过滤条件”的混杂物读 SQL 的时候脑子里就能自动分层。2.3 LEFT JOIN左表全保留LEFT OUTER JOIN简称LEFT JOIN的语义是左表的每一行都保留右表如果有匹配的行则拼接上如果没有右表的字段以 NULL 填充。业务里最常见的场景查所有用户及其订单没买过东西的用户也要列出来订单字段留空。SELECT u.user_name, o.order_id, o.amount FROM users u LEFT JOIN orders o ON u.id o.user_id;这个结果里没有订单的用户也会出现只是order_id和amount是 NULL。做菜单列表、用户列表这类“主表数据必须完整展示”的场景LEFT JOIN使用频率极高。说一个使用LEFT JOIN时特别容易踩的坑如果你在WHERE里对右表字段加了过滤条件LEFT JOIN就可能会退化成内连接的效果。SELECT u.user_name, o.order_id, o.amount FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.amount 100;这语句的意图可能是“所有用户及其金额大于 100 的订单”但实际效果是没有订单的用户因为o.amount为 NULLWHERE o.amount 100判断为 FALSE直接就被过滤掉了。这跟你用INNER JOIN没区别。正确的写法应该是把过滤条件放到ON后面SELECT u.user_name, o.order_id, o.amount FROM users u LEFT JOIN orders o ON u.id o.user_id AND o.amount 100;这样语义就是“左表全保留右表只取金额大于 100 的订单来匹配匹配不上就置 NULL”。这个细节非常关键我在代码评审里看到过太多次因为这个写法导致的数据异常。2.4 自连接一张表自己连自己自连接就是一张表跟自己连接。听起来有点绕但实际场景不少。最典型的例子是员工表和经理的关系一个部门里员工有emp_id和manager_idmanager_id指向的是同一张表里的其他员工的emp_id。比如我想查出每个员工和他的经理姓名SELECT e.emp_name AS employee_name, m.emp_name AS manager_name FROM employees e LEFT JOIN employees m ON e.manager_id m.emp_id;这里的关键是给同一张表起两个不同的别名e和m在逻辑上把它当成两张独立的表来用。自连接用LEFT JOIN而不是INNER JOIN通常是为了保留没有经理的员工记录比如老板本人。自连接理解起来不难真正的问题是性能。如果表数据量很大而连接字段没有索引自连接的效率会非常低。所以实际使用中务必确保manager_id这类自引用字段有索引。2.5 连接条件与过滤条件分离写多表查询的时候我有一个坚持了很多年的习惯ON子句只写连接条件WHERE子句只写过滤条件。两者混在一起写短期内没什么问题一旦 SQL 变复杂或者需要改成LEFT JOIN逻辑就很容易出问题。举个混写的反面案例SELECT u.user_name, o.order_id FROM users u LEFT JOIN orders o ON u.id o.user_id AND o.status 1;这里的o.status 1写在ON里意味着右表只匹配状态为 1 的订单匹配不上的用户记录依然保留只是订单字段为 NULL。如果把这个条件挪到WHERE里SELECT u.user_name, o.order_id FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.status 1;那效果就是“只保留有有效订单的用户”左表不再全保留。两种写法业务语义完全不同。所以写连接查询时心里时刻要有一根弦ON决定“怎么连”WHERE决定“留下谁”。3. 子查询嵌套的思路3.1 标量子查询结果是一个值子查询就是嵌套在另一个查询里的查询。最简单的子查询类型是标量子查询返回单个值一行一列可以放在SELECT、WHERE、HAVING等位置。比如查询工资高于公司平均工资的员工SELECT emp_name, salary FROM employees WHERE salary (SELECT AVG(salary) FROM employees);括号里的查询先算出一个平均工资然后外层查询用它做比较。执行过程是“先内后外”子查询执行一次结果被外层复用。标量子查询也可以放在SELECT里比如查询每个员工的工资并附带展示全公司平均工资SELECT emp_name, salary, (SELECT AVG(salary) FROM employees) AS avg_salary FROM employees;这种写法在报表里很常见。但要注意如果子查询返回多行MySQL 会直接报错Subquery returns more than 1 row。所以标量子查询的条件一定要能保证唯一。3.2 IN 与 EXISTS两种集合判断IN子查询和EXISTS子查询是高频考点很多人搞不清两者的区别。简单说IN外层记录的值是否在子查询的结果集里。EXISTS子查询是否能查出数据不关心查出的具体值。举个例子查询有订单的用户SELECT user_name FROM users WHERE id IN (SELECT user_id FROM orders);用EXISTS写法SELECT user_name FROM users WHERE EXISTS (SELECT 1 FROM orders WHERE orders.user_id users.id);注意EXISTS子查询里的关联条件orders.user_id users.id它和外层的users表建立了关联这种叫“相关子查询”。它的执行思路是对外层每一行都去执行一遍子查询只要子查询能查出任何一条记录就返回 TRUE。对IN和EXISTS的性能对比MySQL 的优化器在不同版本里处理方式差异很大。以前经常听到“小表驱动大表用 IN大表驱动小表用 EXISTS”的说法这个原则在 5.6 之前的版本里确实很重要。但到了 MySQL 5.6 以后的优化器会自动做半连接semi-join优化很多场景下两者的执行计划已经很像了。我的建议是初期把两者语义搞明白先保证结果正确性能问题交给EXPLAIN来判断。3.3 ANY 与 ALL比较的延伸ANY和ALL需要配合比较运算符使用理解起来比IN和EXISTS略绕。 ANY(...)大于子查询结果集中的任意一个值也就是大于最小值。 ALL(...)大于子查询结果集中的所有值也就是大于最大值。查工资比部门 10 里任意一个员工都高的员工SELECT emp_name, salary FROM employees WHERE salary ANY (SELECT salary FROM employees WHERE dept_id 10);换成 ALL就是比部门 10 里所有员工都高。 ANY(...)等价于IN(...) ALL(...)等价于NOT IN(...)。理解了这一层遇到别人写的这种看起来很吓人的 SQL 就不会发怵了。3.4 FROM 子查询派生表子查询的结果可以当作一张临时表使用放在FROM后面。这种子查询也叫派生表。每个派生表都必须有别名。一个典型场景先对明细数据做聚合再把聚合结果和其他表连接。比如统计每个部门的人数再找出人数超过 100 人的部门名称SELECT d.dept_name, t.cnt FROM ( SELECT dept_id, COUNT(*) AS cnt FROM employees GROUP BY dept_id ) t INNER JOIN departments d ON t.dept_id d.dept_id WHERE t.cnt 100;这里的关键是派生表t把分组聚合的结果先算出来它本质上是内存里的一张临时结果集然后再和部门表做连接。使用派生表时SQL 可读性会提高很多逻辑一层套一层清晰明了。不过要注意派生表在 MySQL 5.7 之前是没有索引的连接性能可能不理想。5.7 之后优化器支持了“派生表合并”和“延迟物化”优化情况好很多。但无论如何派生表里的数据结果集别搞太大该过滤的提前过滤。4. 索引查询提速的核心4.1 索引的本质与数据结构索引这个东西看起来神秘本质就是一份“有序的目录”。没有索引时MySQL 要找一个数据只能一条一条扫全表这叫全表扫描full table scan。有了索引就能像查字典一样通过目录直接定位效率提升几个数量级。MyISAM 时代索引用得最多的数据结构是 B 树InnoDB 也沿用了 B 树。B 树的特点是叶子节点存储数据或指向数据的指针非叶子节点只存索引键值数据有序排列同时叶子节点之间有指针串联非常适合范围查询和排序。为什么不用哈希索引呢哈希索引适合等值查询一条记录 O(1) 定位但对范围查找无能为力也不支持排序。而 B 树在等值、范围、排序、前缀匹配等场景下都能发挥作用。所以 InnoDB 的默认索引结构是 B 树而不是哈希。4.2 聚簇索引与二级索引InnoDB 的心法InnoDB 里每个表都有一个聚簇索引这个索引的叶子节点直接存整行数据。聚簇索引的选择规则是优先用主键如果没有主键选一个非空的唯一键如果连唯一键都没有InnoDB 会隐式生成一个 6 字节的row_id作为聚簇索引。聚簇索引之外的所有索引都叫二级索引也叫辅助索引它们的叶子节点存的不再是整行数据而是主键值。所以通过二级索引查数据时先找到主键值再回聚簇索引里查完整行这个过程叫“回表”。理解这个机制很重要因为很多优化的本质就是“减少回表次数”。如果查询需要的所有字段都包含在二级索引里就不用回表这种索引叫覆盖索引。比如SELECT emp_name, dept_id FROM employees WHERE dept_id 100;如果建立一个(dept_id, emp_name)的联合索引那么这个查询所需字段dept_id和emp_name都在索引里直接返回索引数据就行完全不用回表。这就是覆盖索引的威力。4.3 联合索引的最左前缀原则联合索引是指多个字段组成的索引比如idx(user_id, status, create_time)。这个索引的匹配规则是“最左前缀”只有从第一个字段开始连续匹配时索引才生效。以下查询能用上索引WHERE user_id 100; WHERE user_id 100 AND status 1; WHERE user_id 100 AND status 1 AND create_time 2024-01-01;以下查询用不上WHERE status 1; WHERE create_time 2024-01-01; WHERE status 1 AND create_time 2024-01-01;因为status和create_time缺少左侧的user_id作为前缀B 树没法确定从哪个位置开始扫描。字段顺序对联合索引影响巨大。设计联合索引的时候核心原则是区分度高的字段放前面、常用等值条件的字段放前面、范围条件字段放后面。最经典的一个例子一个订单表查询高频条件是user_id status另一高频条件是user_id create_time那你建立(user_id, status, create_time)这个联合索引两个查询都能覆盖比分开建两个索引要省空间、省维护成本。4.4 索引失效的常见场景这一点可以说是实战中最有价值的内容。索引建了但用不上SQL 还是慢这是新手阶段最头疼的问题。我梳理了几个最常见的索引失效场景第一种在索引列上做函数运算。比如WHERE DATE(create_time) 2024-01-01这个查询对create_time用了DATE()函数B 树无法直接比较索引失效。正确写法是范围匹配WHERE create_time 2024-01-01 AND create_time 2024-01-02第二种隐式类型转换。如果user_id是字符串类型你写WHERE user_id 100MySQL 会把字段转换成数字再比较索引失效。解决办法是保持类型一致。第三种LIKE 以通配符开头。WHERE name LIKE %张%无法使用索引WHERE name LIKE 张%可以。前缀匹配是 B 树能做到的中间和结尾匹配做不到。第四种OR连接的条件。如果OR两侧的字段只有一部分有索引整个条件可能都用不上索引。尽量把OR改成UNION或者保证所有参与OR的字段都有索引。还有一个很容易忽略的点索引列上做算术运算比如WHERE salary * 2 10000同样会导致索引失效。把运算从列上挪到常量侧写成WHERE salary 5000索引就能用上了。4.5 EXPLAIN读懂执行计划EXPLAIN是 MySQL 里最重要的调优工具没有之一。在 SQL 前面加上EXPLAINMySQL 会输出这个查询的执行计划而不是真正执行它。重点关注这几个字段type访问类型。从好到差依次是systemconsteq_refrefrangeindexALL。出现ALL全表扫描就要警惕了。range 及以上都是能接受的。key实际用到的索引名。如果为 NULL说明没走索引。rows预计扫描的行数越小越好。Extra如果出现Using filesort说明排序没走索引需要优化出现Using temporary说明用了临时表通常跟GROUP BY或DISTINCT有关出现Using index说明覆盖索引生效是全绿状态。我的习惯是每条慢查询必跑一次EXPLAIN看type是不是ALL看rows是不是很大看Extra有没有filesort。三秒钟扫一眼问题基本就能定位个七七八八。5. 事务保障数据一致性的防线5.1 ACID 与 InnoDB 的实现事务是数据库执行逻辑的最小工作单元包含四条特性原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。原子性保证事务里的操作要么全部成功要么全部回滚。InnoDB 通过 undo log 实现事务修改数据前先把旧值写入 undo log一旦需要回滚就根据 undo log 把数据恢复回去。一致性是最终目标事务执行前后数据库的完整性约束没有被破坏。它依赖原子性、隔离性和应用层的逻辑共同保证。隔离性是多个事务并发执行时彼此之间互不干扰的程度。InnoDB 通过锁和 MVCC多版本并发控制实现。持久性保证事务一旦提交数据就不会丢。InnoDB 用 redo log 实现事务提交时先把变更写入 redo log磁盘再异步刷数据页。即使数据库崩溃也能通过 redo log 重放恢复。5.2 四种隔离级别SQL 标准定义了四种隔离级别从低到高分别是读未提交READ UNCOMMITTED事务能读到其他事务还没提交的数据会出现脏读。实际生产中没人用。读已提交READ COMMITTED只读已提交的数据解决脏读但可能出现不可重复读同一个事务里两次查询同一行数据结果不同。可重复读REPEATABLE READ同一个事务里多次读取同一行数据结果一致。解决不可重复读但可能出现幻读同一个事务里两次范围查询结果集不同。这是 MySQL InnoDB 的默认级别。串行化SERIALIZABLE事务串行执行隔离性最高并发性能最差。Oracle 默认是读已提交MySQL 默认是可重复读。MySQL 用自己的方式解决了大部分幻读问题通过间隙锁gap lock和MVCC配合在可重复读级别下已经能做到某种程度上的“防幻读”。注意MySQL 的可重复读只是对快照读防幻读对当前读SELECT ... FOR UPDATE这类加锁读还是依赖间隙锁来处理的。5.3 事务的开启与提交显式使用事务很简单START TRANSACTION; UPDATE accounts SET balance balance - 100 WHERE id 1; UPDATE accounts SET balance balance 100 WHERE id 2; COMMIT;如果有任何一步执行失败可以ROLLBACK回滚。我见过不少团队业务代码里每次都自动提交只有极少数场景才用事务。其实只要涉及多张表的数据一致性修改都应该考虑加事务。比如下单操作扣库存、扣余额、生成订单、写流水这些操作必须在一个事务里完成否则中间断电宕机钱扣了订单没生成就出大问题了。一些老生常谈但重要的注意点事务里不要执行外部接口调用避免长事务锁住大量资源。不要在一个事务里做太多无关操作事务越短越好锁释放得越快。事务内尽量一次性提交避免交互式输入。5.4 MVCC多版本控制的底层逻辑MVCC 是 InnoDB 实现隔离级别的关键机制。核心思路是每一行记录可以有多个历史版本读操作通过版本链找到自己可见的版本不需要加锁。InnoDB 在每行数据后面隐藏了两个字段trx_id最近修改这行数据的事务 ID和roll_pointer指向这行数据在 undo log 里的上一个版本。事务执行时会生成一个read view里面记录了当时活跃事务的列表。读数据时根据read view判断当前行版本对当前事务是否可见。这个机制的好处是读操作不会阻塞写操作写操作也不会阻塞读操作。这在高并发场景下意义重大。理解了 MVCC再回头看可重复读和读已提交的区别就清楚多了读已提交每次读都会生成新的read view可重复读只在第一次读的时候生成read view之后的读复用同一个。6. 常用函数与实用技巧6.1 字符串函数随手记MySQL 的字符串函数在日常开发里用得非常多挑几个最常用的CONCAT(str1, str2, ...)字符串拼接。SUBSTRING(str, pos, len)截取子串。REPLACE(str, from_str, to_str)替换字符串。UPPER()/LOWER()大小写转换。LENGTH()返回字节长度注意一个中文在 utf8mb4 编码下占 3 个字节。CHAR_LENGTH()返回字符长度中文按一个字符算。TRIM()去除首尾空格。GROUP_CONCAT()把分组内多行的值拼成一个字符串经常在报表中使用。举个例子把员工姓名和部门 ID 拼接输出SELECT CONCAT(emp_name, -, dept_id) AS info FROM employees;把同一个部门的所有员工姓名拼在一起SELECT dept_id, GROUP_CONCAT(emp_name ORDER BY emp_id SEPARATOR 、) FROM employees GROUP BY dept_id;这里GROUP_CONCAT的排序和分隔符都可以自定义非常灵活。6.2 日期函数与时间处理日期处理是另一个高频需求。常用的有NOW()当前日期时间。CURDATE()当前日期。DATE_ADD(date, INTERVAL n unit)日期加法。DATE_SUB(date, INTERVAL n unit)日期减法。DATEDIFF(date1, date2)两个日期相差天数。DATE_FORMAT(date, format)日期格式化。查询最近 7 天创建的订单SELECT order_id, create_time FROM orders WHERE create_time DATE_SUB(NOW(), INTERVAL 7 DAY);按天统计订单数SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, COUNT(*) FROM orders GROUP BY day ORDER BY day DESC;注意对日期字段进行DATE_FORMAT操作后该列上的索引可能会失效。如果数据量大建议直接按create_time的范围条件分组。6.3 条件逻辑CASE WHENCASE WHEN是 SQL 里做条件分支的语法类似于编程语言里的if-else。它有两个写法简单格式SELECT emp_name, CASE dept_id WHEN 1 THEN 技术部 WHEN 2 THEN 市场部 ELSE 其他 END AS dept_name FROM employees;搜索格式SELECT emp_name, salary, CASE WHEN salary 5000 THEN 低 WHEN salary 10000 THEN 中 ELSE 高 END AS salary_level FROM employees;CASE WHEN在统计报表里特别有用可以对数据做分段统计。比如统计每个工资区间的员工数SELECT CASE WHEN salary 5000 THEN 0-5k WHEN salary 10000 THEN 5-10k ELSE 10k END AS salary_range, COUNT(*) AS cnt FROM employees GROUP BY salary_range;这个写法比在应用层做分段处理要高效得多一条 SQL 就能出结果。6.4 IFNULL 与 NULL 处理NULL 是 SQL 世界最需要小心的坑之一。三值逻辑里NULL 既不是 TRUE 也不是 FALSE而是 UNKNOWN。所以NULL NULL的结果不是 TRUE而是 NULL。查 NULL 必须用IS NULL或用NULL 安全等于运算符。IFNULL(expr1, expr2)的语义是如果 expr1 为 NULL返回 expr2否则返回 expr1。这个函数在输出结果时非常常用有效防止结果集里出现大量 NULL 值。SELECT emp_name, IFNULL(phone, 未登记) AS phone FROM employees;6.5 LIMIT 与分页优化分页查询是后端开发每天都要写的东西。简单写法SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 40;等价于SELECT * FROM orders ORDER BY id LIMIT 40, 20;数据量小的时候这没什么问题。但数据量变大以后深分页页码靠后的分页会有严重的性能问题。为什么因为 MySQL 需要先把前 40 条找出来再丢弃掉只返回 20 条。也就是说页码越靠后MySQL 扫描的数据越多速度越慢。优化深分页的常见手段是“延迟关联”或“基于游标”。延迟关联的写法是SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY id LIMIT 100000, 20 ) t ON o.id t.id;子查询只查主键走了覆盖索引速度很快再用主键关联回表查完整数据。基于游标的分页更常用记住上一页最后一条记录的排序值下一页直接从这个值往后取。SELECT * FROM orders WHERE id 100020 ORDER BY id LIMIT 20;这种方式在数据量大的场景下性能稳定但要求排序字段是唯一的、连续可比较的实际应用中通常用id或带唯一索引的时间戳字段。7. 典型报错与排查方法速查写 SQL 过程中报错是最折磨人的。我把几个高频报错的排查思路整理出来直接用就行。Unknown column xxx in where clause字段名打错了或者表别名没写对。先确认表结构再检查别名特别是多表连接时字段名必须带上正确的表别名前缀。You cant specify target table for update in FROM clauseMySQL 不允许在更新一张表时子查询里直接查询同一张表。解决办法是把子查询再包一层让 MySQL 认为它查询的是派生表。经典思路UPDATE employees SET salary salary * 1.1 WHERE dept_id ( SELECT dept_id FROM (SELECT dept_id FROM employees GROUP BY dept_id HAVING AVG(salary) 5000) t );Subquery returns more than 1 row子查询使用了比较但子查询返回了多行。改用IN、ANY、ALL或者优化子查询条件保证唯一。Data too long for column xxx插入或更新的数据长度超过列定义长度。需要核实字段长度定义或者检查字符串拼接逻辑是否超出预期。Deadlock found when trying to get lock并发事务互相持有对方需要的锁造成死锁。InnoDB 会自动检测并回滚其中一个事务应用层需要捕获这个异常并重试。注意保持事务内加锁顺序一致缩小事务范围。8. 沉淀这些基础如何反哺实际项目学完这些基础回到真实项目里你会发现它们无处不在。接口性能优化绕不开索引设计和执行计划分析订单系统的数据准确性靠的是事务和隔离级别的合理设置报表统计的灵活性依赖分组聚合和条件逻辑的灵活组合。我自己的一个习惯是所有写出来的查询语句上线前必须过一遍EXPLAIN有慢查询风险的 SQL 一律重写。规则不复杂先看type有没有到ALL再看rows扫描行数最后盯一下Extra里的filesort和temporary。这三步走完绝大多数隐患都能提前发现。翻出这些多年前整理的笔记最让我感慨的是数据库知识虽然迭代速度没有前端框架那么快但“吃透基础”四个字的价值从来没有变过。当时花几个月啃下来的这些概念到现在写代码做优化时依然是我判断问题最底层的依据。MySQL 版本会变工具会变但数据存储、检索、一致性这三大问题的解决思路会一直在那里。