从建表到慢查询优化:MySQL表操作与查询实战指南 先说我观察到的一个现象我见过不少简历上写着“熟练使用 MySQL”的开发者单表查询、连表、子查询、分组聚合这些语法都能写真让他处理一个线上数据问题也能出结果。可一旦问到细节——订单金额字段为什么用 DECIMAL 而不是 FLOAT一条带 GROUP BY 的查询到底先执行哪一步LEFT JOIN 的关联字段没建索引会慢到什么程度——很多人就答不上来了。这不是基础不扎实的问题而是对 MySQL 的表操作和查询停留在“会写”层面没到“写明白”层面。这篇内容就是围绕 MySQL 的表操作和查询来展开的从建表字段设计、表结构调整、增删改实操到 SELECT 执行顺序、JOIN 连接、聚合分组、索引优化一步一步把“会写”变成“写明白”。适合刚入门 MySQL 的初学者建立完整认知也适合写过一段时间 SQL 但总觉得差点火候的开发者对照自查。1. 表结构与字段设计查询性能的分水岭很多人学 MySQL 是从 SELECT 开始的觉得建表简单顺手就CREATE TABLE了。但实际维护过线上系统的人都清楚查询写得再漂亮表结构设计不合理性能一样拉胯。表结构才是查询性能的真正分水岭字段类型选错、约束缺失、字符集混用后面要花几倍精力去补。1.1 字段类型选错后面全是债先看整数类型。MySQL 提供 TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT占用字节分别是 1、2、3、4、8可表示的取值范围差异很大。选型核心原则是够用就好不要盲目往大了选。TINYINT -128 ~ 127或 0 ~ 255 SMALLINT -32768 ~ 32767 MEDIUMINT -8388608 ~ 8388607 INT -2147483648 ~ 2147483647 BIGINT -9223372036854775808 ~ 9223372036854775807比如状态字段就 0、1、2 几个值TINYINT 足够自增主键多数场景 BIGINT 稳妥但只是一个几千行的配置表用 INT 也完全没问题。字段越小InnoDB 数据页能放的行越多扫描效率越高索引树也更矮这个差距在千万级表上会非常明显。浮点数与定点数是最容易踩坑的地方。金额、余额、费率这类数据绝对不要用 FLOAT 或 DOUBLE必须用 DECIMAL。原因是浮点数在二进制表示下不具备精确性0.1 0.2 在计算机内部会得到一个不精确的值存储金额时会出现 0.30000000000000004 这种结果对账根本对不上。DECIMAL 是字符串形式存储的定点数精度可控DECIMAL(10,2)表示最多 10 位数字、其中 2 位小数最大可表示 99999999.99一般业务金额足够用。字符类型也需要在意。CHAR 是定长VARCHAR 是变长适合长度可变的文本但 VARCHAR 的长度不是越大越好VARCHAR(255)和VARCHAR(5000)在存储开销、排序临时表、索引前缀长度上差别很大。TEXT 类型更要注意它不能有默认值生成临时表时可能直接落到磁盘临时表建立索引只能指定前缀长度能用 VARCHAR 的地方尽量别用 TEXT。日期时间上DATE 存日期DATETIME 和 TIMESTAMP 存日期时间。TIMESTAMP 的范围上限是 2038 年而且会随时区转换DATETIME 范围大得多也不受时区影响通常业务系统里我默认选 DATETIME。选字段类型时可以这样问自己这个字段的最大可能值是多少在业务生命周期内会不会超存储空间和查询效率哪个更重要想清楚了再写 DDL。1.2 约束、默认值、字符集一开始定错后面很被动主键选择是个大话题。InnoDB 是聚集索引组织表主键决定了数据的物理存储顺序。自增整数主键插入时是顺序追加页分裂和索引维护代价小而 UUID 字符串做主键插入顺序随机会造成大量随机 IO 和页分裂性能明显下降。业务订单号、用户手机号这类有唯一性要求的字段建议作为 UNIQUE KEY 而不是主键。当然如果业务天然有雪花 ID、有序 ID也可以用 BIGINT 主键来替代自增。NOT NULL 约束也值得重视。我几乎对所有字段都建议NOT NULL同时给默认值。NULL 在 SQL 里有三层含义不是 0、不是空字符串、是“不确定”这会在 WHERE 判断、索引统计、COUNT 计数时带来不少麻烦。比如WHERE col NULL永远不成立必须写IS NULLCOUNT(列名) 会自动忽略 NULL 值联合索引里 NULL 值的处理也有额外开销。能避免就避免。字符集和排序规则更是被忽略的重灾区。MySQL 8.0 默认是utf8mb4而早期版本的utf8其实是 utf8mb3只能存基本多文种平面字符遇到生僻字或者 emoji 就会报错或乱码。所以建表时最好显式声明CREATE TABLE ... DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;排序规则方面_ai_ci表示 accent-insensitive 和 case-insensitive也就是不区分大小写如果业务要求区分比如用户名登录校验就用_bin或_cs。字符集不一致还会导致 JOIN 关联时无法使用索引这个放到后面 JOIN 部分细说。1.3 一张订单表的建表实操拿最常见的订单表举例完整的建表语句长这样CREATE TABLE order_info ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT UNSIGNED NOT NULL COMMENT 用户ID, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 订单总金额, pay_status TINYINT NOT NULL DEFAULT 0 COMMENT 支付状态0待支付1已支付2已退款, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id_created_at (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci COMMENT订单表;逐条拆解设计意图id用自增主键顺序写入友好order_no有唯一业务含义加唯一索引保证不重复total_amount用 DECIMAL(10,2)精确计算金额pay_status用 TINYINT状态值够用且省空间created_at和updated_at使用默认值减少应用层维护时间字段的负担idx_user_id_created_at是联合索引方便按用户查订单并按时间排序。完成后可以用两条命令快速检查设计SHOW CREATE TABLE order_info; SHOW INDEX FROM order_info;前者看表定义是否和意图一致后者确认索引是否建立。1.4 建表之后怎么确认设计是合理的表建完了并不代表就结束了。我会在交付前做一次“设计复审”先列出业务里最高频的 3 到 5 条查询语句模拟一下走索引的情况。如果有一条查询 WHERE 条件和 ORDER BY 字段完全不在任何索引里大概率线上就要慢查询爆发。最朴素也最实用的验证方式是往里灌一批有代表性的测试数据然后执行EXPLAIN SELECT ...看 type 是不是从ALL变成了ref或range。ALL代表全表扫描千万级表上这是灾难级别的信号。注意这里不是要追求所有查询都走索引而是高频查询、核心查询必须能稳定命中索引。表结构设计本来就是为查询服务的脱离了真实的查询模式谈设计都是空谈。2. 表结构调整与数据变更实操中容易踩的坑表一旦上线后续的增删改和结构调整就开始了。这一阶段最容易出问题的是三类ALTER TABLE 的锁表风险、事务与误操作、大数据量变更的性能坍塌。2.1 ALTER TABLE 加字段、改类型时的锁表风险不少开发者以为 ALTER TABLE 只是“执行一下”而已但大表变更可能是灾难。MySQL 8.0 对部分加列操作支持 INSTANT 算法只修改数据字典秒级完成但修改字段类型、加索引等操作仍然可能需要重建表期间锁表、占用磁盘、消耗 IO。举个例子一张几千万行的订单表要加一个索引如果直接执行ALTER TABLE ... ADD INDEX耗时可能是几分钟到几十分钟期间写入会被阻塞业务直接受影响。常规做法是低峰期执行执行前看磁盘空间余量执行中监控线程状态如果有条件用第三方在线改表工具它通过创建新表、增量同步数据、切换表名的方式来减少锁的影响。我自己的习惯是任何大表 DDL 前先拷贝一份表结构做演练预估耗时再决定时机。2.2 UPDATE、DELETE 的事务边界与影响行数默认情况下 MySQL 是自动提交的也就是每条 DML 语句执行完立即提交。批量操作时我建议显式开启事务保证中途失败可以回滚避免一半成功一半失败。但事务也不能太大一次更新几十万行会导致锁范围过大、undo log 膨胀甚至拖垮主库。UPDATE 不带 WHERE 是线上事故的高发原因。早期我在某环境里就见过一条UPDATE user SET status1直接把全表状态改掉的案例好在数据是配置表恢复成本很低。养成习惯高危 UPDATE 和 DELETE 前先用同名 WHERE 条件跑一条 COUNT 或 SELECT确认影响范围再操作。MySQL 客户端返回的Rows matched和Changed含义不同matched 是 WHERE 命中的行数changed 是实际发生值变更的行数。如果 SET 的值和原值相同changed 可能是 0。这点对写脚本做数据订正的人来说很容易误解。2.3 误操作后的自保手段针对误操作最有效的不是祈祷而是提前开好保护开启 binlog定期做全量备份并为关键表保留最近时间点的备份。DDL 语句无法回滚所以大表变更前先备份目标表比如用CREATE TABLE order_info_bak_20240101 AS SELECT * FROM order_info;这种轻量方式。事务模式下的 DML 误操作可以通过 ROLLBACK 救回来但如果已经 COMMIT就只能依赖备份和 binlog 做时间点恢复。这也是我一直强调“先开事务再手滑”的原因。对新手来说最重要的习惯是执行任何影响行数可能很大的语句之前先给自己留一条后路备份永远比后悔药便宜。3. SELECT 执行顺序写对查询的地基SELECT 是 MySQL 里最常用的语句但很多人对它的执行顺序理解是反的。语法书写顺序和逻辑执行顺序并不一致搞清楚这个很多疑难问题都会迎刃而解。3.1 一条完整的 SELECT 是怎么被执行的一条标准查询的逻辑执行顺序大致如下FROM - ON - JOIN - WHERE - GROUP BY - HAVING - SELECT - DISTINCT - ORDER BY - LIMIT注意SELECT 子句在逻辑上排在后面而不是最先执行。也就是说数据库先确定“从哪些表取数据”再做行过滤、分组、聚合最后才做列投影和排序。举一个典型例子SELECT category_id, COUNT(*) AS cnt FROM product WHERE price 100 GROUP BY category_id HAVING COUNT(*) 10 ORDER BY cnt DESC LIMIT 5;执行过程先从 product 表取全部数据WHERE 过滤掉 price 100 的行再按 category_id 分组对每组做 COUNTHAVING 过滤掉计数不大于 10 的组然后 SELECT 投影 category_id 和 cnt最后按 cnt 降序排列取前 5 行。理解了这一点很多语法限制就变得自然了。比如 WHERE 里不能使用聚合函数因为此时聚合还没发生而 ORDER BY 可以使用 SELECT 里的别名因为排序发生在投影之后。另外一个经典误区是分页。LIMIT 200000, 10看着只取 10 条但 MySQL 要先扫描并跳过前 200000 行深翻页非常慢。优化手段是“游标分页”记录上一页最后一条的 ID下一页用WHERE id 上一页最大ID ORDER BY id LIMIT 10直接走主键索引。3.2 WHERE、HAVING、ON 三兄弟的分工这三个过滤条件长得像但作用阶段完全不同条件过滤时机是否可使用聚合函数典型场景WHERE分组之前对原表行过滤不能筛选原始记录HAVING分组之后对分组结果过滤可以筛选满足统计条件的组ONJOIN 连接时对关联匹配做条件不能指定关联关系与连接过滤ON 和 WHERE 的区别在 LEFT JOIN 里尤其容易翻车。比如查所有用户及其订单只想保留已支付订单如果写成SELECT u.user_id, o.order_no FROM user u LEFT JOIN order_info o ON u.user_id o.user_id WHERE o.pay_status 1;这段 SQL 会把没有订单、或只有未支付订单的用户全部过滤掉LEFT JOIN 实际上退化成了 INNER JOIN。正确做法是把支付状态条件放进 ON 里SELECT u.user_id, o.order_no FROM user u LEFT JOIN order_info o ON u.user_id o.user_id AND o.pay_status 1;这样所有用户都在结果集里没有匹配到的用户 order_no 显示为 NULL。这个差异是面试和线上问题排查的高频考点理解一次就不会再忘。3.3 一条复杂查询的分解思路遇到复杂查询我的习惯是先拆成小查询逐步验证再合并。比如“查 2024 年每个分类销量最高的商品”很多人一上来就想写嵌套子查询结果越写越乱。用窗口函数可以写得很干净SELECT category_id, product_id, sales_cnt FROM ( SELECT category_id, product_id, SUM(sales_qty) AS sales_cnt, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY SUM(sales_qty) DESC) AS rn FROM order_detail WHERE created_at 2024-01-01 AND created_at 2025-01-01 GROUP BY category_id, product_id ) t WHERE rn 1;内层先做分组统计外层按分类编号取第一名。如果不想用窗口函数也可以用关联子查询或自连接实现但可读性会差一些。复杂查询的要点是明确每一步的输出是什么把中间结果想象成一张临时表再一层层往上搭。4. JOIN 连接与子查询的选型思路多表查询是 MySQL 使用中绕不开的部分。JOIN 用不对轻则结果错误重则慢查询拖垮库。4.1 四种 JOIN 的语义边界MySQL 支持 INNER JOIN、LEFT JOIN、RIGHT JOIN不支持 FULL OUTER JOIN但可以用 UNION 模拟。JOIN 类型语义结果集特征INNER JOIN取两表匹配的数据只保留两边都满足条件的行LEFT JOIN左表全保留右侧无匹配则填 NULL左表行数一定保留RIGHT JOIN右表全保留左侧无匹配则填 NULL右表行数一定保留FULL OUTER JOIN两表并集无匹配填 NULL左表和右表都全保留实际工作中 RIGHT JOIN 我用得很少总觉得可读性不如调换表的顺序用 LEFT JOIN。FULL OUTER JOIN 可以用这种方式模拟SELECT a.*, b.* FROM table_a a LEFT JOIN table_b b ON a.id b.id UNION SELECT a.*, b.* FROM table_a a RIGHT JOIN table_b b ON a.id b.id;JOIN 时还有一个隐形的坑关联字段如果是 NULLNULL 和任何值用等号匹配都永远不成立关联结果里会直接丢失这些行。设计表时关联字段尽量 NOT NULL也是在给后续 JOIN 省麻烦。4.2 JOIN 和子查询怎么选很多人纠结 JOIN 和子查询到底用哪个。我的判断标准是先看优化器是否能把子查询改写成半连接semi-join再看是否会产生全表匹配。比如“查有订单的用户”可以用 JOIN、IN、EXISTS 三种写法-- 方式一JOIN 后去重 SELECT DISTINCT u.user_id FROM user u JOIN order_info o ON u.user_id o.user_id; -- 方式二IN 子查询 SELECT user_id FROM user WHERE user_id IN (SELECT user_id FROM order_info); -- 方式三EXISTS 子查询 SELECT user_id FROM user u WHERE EXISTS (SELECT 1 FROM order_info o WHERE o.user_id u.user_id);在 MySQL 5.6 之后IN 子查询通常会被优化成半连接性能不一定差。但 EXISTS 这种相关子查询的逻辑更直观“外层行如果存在匹配记录就保留”。如果优化器选择把相关子查询逐行执行外层表很大的时候性能就会急剧下降。我的建议是逻辑等价时优先 JOIN其次是 EXISTSIN 作为备选一旦发现执行计划异常就手动改写再对比。还有一类子查询要警惕SELECT 列表里的标量子查询。比如SELECT user_id, (SELECT MAX(amount) FROM order_info o WHERE o.user_id u.user_id) FROM user u;每行都会执行一次子查询外层几十万行就会执行几十万次这是典型的慢查询写法。可以用 JOIN 聚合先算好结果再关联或者使用窗口函数来优化。4.3 关联字段无索引与字符集不一致导致的慢查询JOIN 性能的核心在于关联字段是否走索引。两表关联时MySQL 以一张表作为驱动表另一张为被驱动表被驱动表的关联字段上必须有索引否则每匹配一行都要全表扫描一次。曾经处理过一个慢查询一张几十万行的用户表和一张几万行的订单表做 LEFT JOIN订单表的 user_id 没建索引查询耗时十几秒。加了一个索引后同样的查询变成毫秒级。这就是索引缺失的典型代价。字符集不一致也会造成 JOIN 无法走索引。A 表字符集是 utf8mb4B 表是 latin1关联时 MySQL 需要做字符集转换索引基本失效。处理这类问题时我一般会统一所有表的字符集为 utf8mb4字段类型尽量一致避免隐式转换。另外驱动表的关联字段如果类型和索引列不一致比如 VARCHAR 对 INT也会导致索引失效。5. 聚合分组与统计查询从明细到结论业务报表、数据分析都离不开聚合查询。GROUP BY、聚合函数、CASE WHEN 组合起来能解决绝大多数统计需求但使用上有几个高频误区。5.1 GROUP BY 的常见误区SELECT 了未分组的列MySQL 5.7 及以上默认开启ONLY_FULL_GROUP_BY下面这条 SQL 会直接报错SELECT user_id, order_no, MAX(created_at) FROM order_info GROUP BY user_id;原因很简单按 user_id 分组后每组可能有多行order_no 取哪一行数据库无法确定所以拒绝执行。正确写法只能是SELECT user_id, MAX(created_at) FROM order_info GROUP BY user_id;那如果我想看“每个用户最近一单的订单号”该怎么办这就是经典的“分组取 TopN”问题。MySQL 8.0 用窗口函数最清晰SELECT user_id, order_no, created_at FROM ( SELECT user_id, order_no, created_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM order_info ) t WHERE rn 1;MySQL 5.7 及以下版本不支持窗口函数可以用“自连接 聚合”或“用户变量”来模拟但复杂度会明显上升。这也是我建议新项目直接用 MySQL 8.0 的原因之一。5.2 COUNT、SUM、AVG 与 NULL 的那些事聚合函数对 NULL 的处理各不相同用错会导致统计数字偏差函数对 NULL 的处理说明COUNT(*)统计所有行数不管某列是否为 NULLCOUNT(列名)忽略 NULL只统计该列非 NULL 的行SUM(列名)忽略 NULL如果全为 NULL结果为 NULLAVG(列名)忽略 NULL除以非 NULL 行数MAX / MIN忽略 NULL常规求最大最小所以统计“实际发货数量”应该用COUNT(发货时间)统计“订单总行数”用COUNT(*)。COUNT(1) 和 COUNT(*) 在当前版本下性能差别可以忽略不必纠结。如果需要把 NULL 当 0 处理用 IFNULL 或 COALESCE 包一层。比如统计人均消费金额时有的用户没有任何订单SUM 结果为 NULL直接除就会得到 NULL正确写法是IFNULL(SUM(amount), 0) / COUNT(*)或者AVG(IFNULL(amount, 0))。5.3 用 CASE WHEN 与聚合实现条件统计CASE WHEN 和聚合函数配合可以在一次扫描里同时统计多个条件避免同一张表被扫多次。比如统计某月订单总数和已支付订单数SELECT COUNT(*) AS total_orders, SUM(CASE WHEN pay_status 1 THEN 1 ELSE 0 END) AS paid_orders, SUM(CASE WHEN pay_status 2 THEN 1 ELSE 0 END) AS refunded_orders FROM order_info WHERE created_at 2024-06-01 AND created_at 2024-07-01;这种方法非常实用尤其是报表类场景一两次全表扫描就能拿到一整组指标。另一个常用技巧是行转列把月份从行变成列方便导出成宽表。例如统计每个分类 1 月和 2 月的销售额SELECT category_id, SUM(CASE WHEN MONTH(created_at) 1 THEN amount ELSE 0 END) AS month_1, SUM(CASE WHEN MONTH(created_at) 2 THEN amount ELSE 0 END) AS month_2 FROM sales WHERE created_at 2024-01-01 AND created_at 2024-03-01 GROUP BY category_id;这种写法的本质是“条件聚合”在数据量可控时比多次查询效率高很多也容易维护。6. 索引、执行计划与慢查询优化实战最后聊查询性能。很多人遇到慢查询第一反应是“加索引”但加索引不是万能药索引加错了反而拖慢写入。学会看执行计划、识别索引失效场景、按流程排查慢查询才是正路。6.1 EXPLAIN 到底在看什么EXPLAIN 是 MySQL 提供的查询执行计划查看工具使用方法是在 SELECT 前加关键字EXPLAIN SELECT * FROM order_info WHERE user_id 10086 ORDER BY created_at DESC LIMIT 10;输出结果里重点看几个字段字段含义关注点type访问类型从好到差system const eq_ref ref range index ALLkey实际使用的索引NULL 代表没走索引rows预估扫描行数和实际返回行数差距越大越可疑Extra附加信息出现 Using filesort、Using temporary 时通常需要优化type 是 ALL 说明全表扫描回到之前的例子如果 order_info 表没有 idx_user_id_created_atWHERE user_id...就是全表扫然后再做文件排序性能极差。有了联合索引后type 会变成 refORDER BY 也能直接利用索引顺序Extra 里不再出现 Using filesort。这类优化通过执行计划就能直观确认。Extra 里的 Using index 表示覆盖索引也就是查询所需列都在索引里不需要回表是理想状态。设计索引时如果高频查询只需要少数几个列可以考虑把这些列放进索引实现覆盖扫描。6.2 索引失效的典型场景索引不是建了就一定被用到。以下是我在实践中经常遇到的失效场景失效场景具体示例应对建议对索引列使用函数WHERE DATE(created_at)2024-01-01改成范围条件created_at ... AND created_at ...隐式类型转换手机号存 VARCHAR条件写WHERE phone13800001111改为字符串WHERE phone13800001111前导模糊查询WHERE name LIKE %张无法走索引考虑全文索引或搜索引擎OR 条件扫全表WHERE status1 OR name张三拆成 UNION ALL或用 UNION 组合两个索引联合索引不满足最左前缀索引 (a,b,c)条件只用 b必须包含 a 才能用到该联合索引低选择性字段性别字段建索引列值只有两种优化器可能选择全表扫描索引没意义有一种情况最容易忽略对索引列做运算比如WHERE price * 2 100索引同样失效应把运算挪到等式另一边写成WHERE price 50。每次排查慢查询时先对照这张表检查写法往往比盲目加索引更有效。6.3 慢查询日志与优化流程慢查询问题的定位可以从开启慢查询日志开始SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL slow_query_log_file /var/log/mysql/slow.log;long_query_time 设为 1 秒然后观察一段时间。拿到慢 SQL 后我推荐按以下流程处理先看 SQL 逻辑确认结果集是否合理是否可以通过业务去重、分页缩小数据范围。用 EXPLAIN 查看执行计划聚焦 type、rows、Extra 三列。判断是否缺少索引索引是否被失效写法挡住。如果索引没问题考虑改写 SQL子查询改写为 JOIN相关子查询改为聚合或窗口函数OR 改为 UNION ALL。小流量验证观察执行耗时和资源消耗确认后再全量生效。以前处理过一条统计报表慢查询单表只有几十万行但 SQL 里用了一个 IN 子查表和两张表 JOIN执行计划里出现了 Using temporary 和 Using filesort耗时三秒。后来把子查询改写为 JOIN GROUP BY临时表和文件排序都消失了耗时降到几十毫秒。很多时候问题不在数据量而在写法让优化器无法发挥。另外补充一点索引不是越多越好。每个索引都会增加写入和存储成本冗余索引还会让优化器选择困难。建索引时应该围绕核心查询路径来设计宁可少而精不要多而杂。我会定期用information_schema里的统计数据找出利用率低的索引在线上去掉冗余索引这比一味加索引更符合长期维护的利益。最后分享一个我保持了很多年的习惯写完一条稍微复杂的查询先看一眼 EXPLAIN再上线。尤其是查询里带了 JOIN、GROUP BY、ORDER BY 的时候执行计划会告诉我索引有没有吃上、有没有隐藏的文件排序。另外一个习惯是表字段设计时尽量选小类型、统一 utf8mb4、能 NOT NULL 就 NOT NULL。这些细节在写第一条查询时看不出差别等数据量上来之后你会感谢当初那个认真设计表结构的自己。