1. 为什么人人都要死磕表操作与查询做开发的,几乎没人能绕开数据库,而 MySQL 又是用得最广的那一个。不管你是写后端接口、做数据分析,还是维护老项目,每天打交道最多的就是三件事:建表、改表、查数据。很多人刚入门时喜欢先背各种命令,结果真到了项目里,连一个JOIN都写得战战兢兢,更别提看懂别人建的表为什么这么设计了。说实话,MySQL 的表操作和查询,是区分“会写 SQL”和“写好 SQL”的分水岭。同样一份数据,有人用三条子查询嵌套糊出来,有人一条LEFT JOIN加个聚合函数就把问题解决了,跑出来的效率差着数量级。这篇文章我想从实际开发的角度,把表结构设计、字段类型选择、SQL 查询的核心逻辑和执行顺序这些事一次讲透,顺带把我这些年踩过的坑、试过的好用方案都分享出来,不管你是刚入行的新人,还是写了两三年 SQL 但总觉得差点火候的开发者,都有值得看的东西。2. 表操作:建表之前,先想清楚这五件事很多新手拿到需求就开干,CREATE TABLE一敲就完事,结果上线没两个月就开始改表结构,凌晨三点在工位上ALTER TABLE还被锁表锁到崩溃。建表这事,真不是敲个 SQL 那么简单。2.1 存储引擎怎么选才不后悔MySQL 的存储引擎不止一种,但绝大多数业务场景只有两个选项:InnoDB和MyISAM。我见到过不少老项目用了 MyISAM,理由是“查询快”,但那也就快在读多写少的纯静态场景下。MyISAM 不支持事务,不支持外键,用的是表级锁,一旦并发写一上来,整个表都被锁住,其他查询全部排队,那种体验真的酸爽。如果没有特别理由,一律用 InnoDB。它支持事务、行级锁、崩溃恢复,虽然单条写入性能比 MyISAM 稍慢一点,但在真实业务里,这点差距远远抵不上并发安全带来的收益。MySQL 8.0 已经彻底移除了 MyISAM 的新建支持,所以新版选默认的 InnoDB 就好。提示:如果只是偶尔做统计报表、读多写极少的场景,可以考虑用 ARCHIVE 引擎压缩存储,但不建议作为主要业务的存储引擎。建表语法其实很简单,但关键是建表语句后面的每个选项都有含义:CREATE TABLE user_info ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', username VARCHAR(50) NOT NULL COMMENT '用户名', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用', 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_username (username), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信息表';这看起来简单,但每个字段的选择背后都有讲究。2.2 字段类型选错了,后面全是泪字段类型的坑,是表操作里最容易被忽略的。先说字符集,从 MySQL 5.7 开始我就强烈建议一律使用utf8mb4,而不是utf8。原因很简单:utf8mb4 才是真正的四字节 UTF-8,能存储 emoji 表情和一些生僻汉字。用 utf8 的话,用户昵称里带个表情符号,插入直接报错,这种线上事故我遇到不止一次了。再说数值类型。INT是 4 字节,BIGINT是 8 字节,主键用BIGINT UNSIGNED是稳妥的选择,尤其数据量以后可能破亿的表。TINYINT是 1 字节,存状态、类型这类取值很小的字段非常合适。有个老坑要提醒:INT(11)这种写法里的 11 只是显示宽度,不影响取值范围,MySQL 8.0.17 以上版本已经弃用了显示宽度,别再纠结这个了。还有金额字段。我见过有人用FLOAT存价格,结果做个加减法都能对不上账,这个教训太痛了。金额、折扣这类精确计算结果,必须用DECIMAL,比如DECIMAL(10, 2)表示整数部分 8 位、小数部分 2 位。说个实际的坑:有两个商家账户,一个存了 99.09,一个存了 99.11,用FLOAT查出来俩余额显示都是 99.1,但加起来却等于 198.19999999,对账怎么都对不上。换成DECIMAL(10,2)存储和参与计算,问题当场消失。时间字段选用也有讲究。DATETIME不依赖时区设置,存的是表面时间;TIMESTAMP存储的是 UTC 时间戳,查询时和会话时区做换算。日常业务推荐DATETIME,它的范围从 1000 年到 9999 年,而TIMESTAMP到了 2038 年会溢出的老问题,虽然新版本扩展了范围,但运维上还是多一事不如少一事。另外,DATETIME记得设置默认值,尽量在建表时就给一个合理的默认,避免插入时遗漏导致报错。关于字段是否允许NULL,很多新手的理解是“NULL 和空串差不多”,大错特错。NULL 在索引、聚合、比较运算里都有特殊含义,COUNT(column)会忽略 NULL,WHERE column = NULL永远查不到数据(必须用IS NULL),而且 NULL 字段在 InnoDB 里会额外占用一个字节标记。能用NOT NULL DEFAULT表达清楚的就别用 NULL。比如status TINYINT NOT NULL DEFAULT 1,语义清晰,查询也方便。2.3 主键、唯一键、普通索引的区别搞清楚主键选什么,直接决定表的写入性能和查询效率。自增主键是最通用的方案,因为在 InnoDB 聚簇索引的存储结构下,数据按主键顺序排列,自增主键能做到顺序写入,减少页分裂。但要注意自增主键不要用VARCHAR,比如用户表用手机号做主键,这不仅是存储空间浪费,还会让每次插入都触发 B+ 树的大量节点分裂,插入性能下降明显。唯一键和普通索引的区别在于约束。唯一键保证字段值不重复,适合手机号、邮箱这类业务上有唯一性要求的字段,比如上文里的UNIQUE KEY uk_username (username)。普通索引则纯粹为了加速查询,像状态位这种区分度不高的字段,加索引的直接收益有限,但如果查询里经常要按状态过滤,也能用上覆盖索引减少回表。我建议的索引策略是:主键选自增 BIGINT;业务唯一字段加唯一索引;高频筛选字段加普通索引;能用联合索引就用联合索引,不用为每个字段单独建索引,否则索引膨胀反而拖慢写入。2.4 表设计里的硬伤:别浪费时间在过度设计上建表的时候,很多人容易陷入两个极端:一个是什么约束都不加,能跑就行;另一个是过度设计,建得跟数据仓库的宽表一样,每个字段都加索引、每个细节都要考虑。实际上,业务层和存储层要分工协作。约束是数据库的底线,比如必填字段要NOT NULL、唯一性字段要建唯一索引,这些该下有就要下。但联合查询、冗余字段这类东西,可以等真实性能瓶颈出现后再考虑。举个例子,做订单表时,order_no(订单编号)加了个唯一索引;user_id加了个普通索引;status因为报表统计经常按状态过滤,后来还真派上用场了。但某个字段 vnbranch_code(渠道码)当时随手加了索引,一年后发现这个字段压根没进过 WHERE 条件,白白占了存储空间还拉了写入性能。所以索引宁缺毋滥,别凭直觉加。2.5 改表操作:线上表不能随便 ALTER改表这事儿,我经历过几次惊心动魄的场面。早年在一个老系统里,有一张几百万行的核心表,某天需求要加一个字段,我直接ALTER TABLE ADD COLUMN就执行了,结果 InnoDB 在拷贝表数据的过程中把整张表锁住,业务写入足足停了七八分钟,被运营投诉到怀疑人生。所以线上改表,一定要评估锁和时间。MySQL 8.0 用ALGORITHM=INPLACE和LOCK=NONE可以在线加索引、加字段,但如果字段改到了表结构里的关键位置,或者有默认值需要重算,依然可能触发锁表。经验之谈:改动前先看表和索引的数据量,预估执行时间;改动用低峰期;尽量一条 SQL 完成多个改动,不要分多次执行;如果确实大表改动,考虑用工具来做在线变更。3. 查询(DQL):从会敲到敲得明白如果说建表是打地基,那查询就是每天都要碰的日常。很多人对 SELECT 的理解停留在“会写”层面,但遇到稍微复杂的业务就一头雾水,或者写出来的 SQL 跑得奇慢无比。说到底,是没有真正理解 SQL 的执行顺序和各个子句的作用。3.1 SELECT 完整执行顺序,这可能是很多人的知识盲区先记住这条 SQL 书写顺序和底层执行顺序的不同:SELECT column_name, COUNT(*) FROM table_name WHERE condition GROUP BY column_name HAVING group_condition ORDER BY column_name LIMIT offset, count;看起来这就是一条标准查询,但 MySQL 真正执行的顺序是:先FROM找到表,再WHERE过滤行,然后GROUP BY分组,接着HAVING过滤分组,然后SELECT投影出要的列,之后ORDER BY排序,最后LIMIT截取条数。为什么这个顺序重要?因为很多人分不清WHERE和HAVING的区别就栽在这里。WHERE是在分组之前对每一行过滤,不能使用聚合函数;HAVING是在分组之后对分组过滤,可以使用COUNT、SUM这些聚合函数。但凡搞清楚执行顺序,这个疑惑自然就解开了。# 正确示例:找出下单次数超过 5 次的用户 SELECT user_id, COUNT(*) AS order_count FROM orders WHERE status = 'paid' GROUP BY user_id HAVING COUNT(*) > 5 ORDER BY order_count DESC LIMIT 10;这个查询里,WHERE先过滤掉未支付的订单,GROUP BY按用户分组,HAVING筛出下单超过 5 次的组,最后排序取前 10。每一步都符合执行顺序的逻辑。3.2 WHERE、JOIN、聚合,常见面试题背后的真实业务逻辑JOIN是另一个让人头疼的地方。INNER JOIN、LEFT JOIN、RIGHT JOIN、CROSS JOIN的区别,其实一句话就能说清:INNER 取交集,LEFT 保留左表全部,RIGHT 保留右表全部,CROSS 是笛卡尔积。但真到业务里,很多人搞不清到底该用哪个。举个例子:要查每个用户的订单数量和总金额,用LEFT JOIN就能保证没有下单的用户也出现在结果里(订单量显示为 0);但如果只关心有订单的用户,INNER JOIN更合适。# 左连接:查出所有用户及其订单统计,没下过单的用户订单数为 0 SELECT u.id, u.username, COUNT(o.id) AS order_count, COALESCE(SUM(o.amount), 0) AS total_amount FROM user_info u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.username;注意这里统计COUNT(o.id)而不是COUNT(*),是因为 LEFT JOIN 之后,没有订单的用户会在订单列上得到 NULL,COUNT(o.id)会自动忽略 NULL;如果用了COUNT(*),这些用户会被统计成 1,就成了错误结果。这是实战中非常容易踩的坑。聚合函数的使用也有讲究。COUNT(*)、COUNT(1)、COUNT(column)三者性能差别其实很小,但含义不同。COUNT(column)统计的是非 NULL 值的行数,而COUNT(*)统计所有行数。如果你要统计“有多少 user_id 不为空的订单”,就可以用COUNT(user_id);如果只是想知道总行数,用COUNT(*)就好。另外SUM、AVG同样会忽略 NULL,所以在统计前最好用IFNULL或COALESCE把 NULL 兜住,避免结果里莫名出现 NULL 造成的展示问题。3.3 子查询和 JOIN 怎么选,IN 与 EXISTS 的性能奥义子查询能做的事情 JOIN 也基本都能做,但两者的执行逻辑不同。从 MySQL 5.6 开始,优化器对子查询有了改进,不再像以前那么无脑,但一些场景下,重新写成 JOIN 仍然会更快。例一,查“有订单的用户”:-- 子查询写法 SELECT id, username FROM user_info WHERE id IN (SELECT DISTINCT user_id FROM orders); -- JOIN 写法 SELECT DISTINCT u.id, u.username FROM user_info u INNER JOIN orders o ON u.id = o.user_id;大部分情况下 JOIN 更清晰,尤其在两张表数据量都大的时候。不过 MySQL 优化器会把简单的 IN 子查询改写成 semi-join,所以性能差距并没有传说中那么大。真正需要留意的是NOT IN和NOT EXISTS的情况,NOT IN遇到子查询结果里有 NULL 时会直接导致整个查询结果为空,这是经典的坑:-- 危险写法:如果 orders.user_id 有任何 NULL,这个查询会返回空结果 SELECT id, username FROM user_info WHERE id NOT IN (SELECT user_id FROM orders); -- 安全写法:一定要加上 IS NOT NULL SELECT id, username FROM user_info WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL); -- 或者在子查询里用 COALESCE SELECT id, username FROM user_info WHERE id NOT IN (SELECT COALESCE(user_id, 0) FROM orders);所以我在代码审查里看到NOT IN都会多留个心眼。至于 IN 和 EXISTS 怎么选,经验法则:外层表小、内层表大时,用 IN 通常更高效;外层表大、内层表小时,EXISTS 借助索引可能更占优势。但我建议,先看执行计划,别靠猜,因为 MySQL 8.0 的优化器已经能自动做转换,实际性能差异往往取决于索引情况,而不是写法本身。-- EXISTS 写法示例 SELECT id, username FROM user_info u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);有人说SELECT 1是从查询计划优化的角度来写的,因为子查询只需要判断是否存在,不需要返回具体列。虽然现在的优化器基本不会在乎你写的是SELECT 1还是SELECT *,但这种写法在语义上更清晰——你只关心有没有,不关心记录内容。3.4 LIMIT 分页的隐藏陷阱:别让深分页拖垮数据库分页是业务系统里最常见的需求之一,但很多人只是机械地LIMIT offset, count,数据量小的时候没问题,数据量一大就出事。比如LIMIT 1000000, 20,MySQL 前面 100 万行是要一条条找到并丢弃的,然后在第 100 万零一行开始取 20 行,这代价完全是在“白跑”。优化的思路是用主键或覆盖索引来加速定位。比如按主键排序的场景:-- 普通写法(深分页时很慢) SELECT id, name, status FROM user_info ORDER BY id LIMIT 1000000, 20; -- 优化写法:延迟关联,先只查主键,再用主键回表取其他字段 SELECT u.id, u.name, u.status FROM user_info u INNER JOIN (SELECT id FROM user_info ORDER BY id LIMIT 1000000, 20) t ON u.id = t.id;延迟关联的思路是:在子查询里用索引覆盖着把主键找出来,再通过主键去关联原表拿全字段,避开大范围回表。这招在单表数据千万级、深分页场景下极好用。如果条件允许,更优雅的方式是“基于游标的分页”,也就是记录上一页最后一条的 id,下一页直接用WHERE id > 上一个id来取:SELECT id, name, status FROM user_info WHERE id > 1000010 ORDER BY id LIMIT 20;这样每一页查询都走主键索引的范围扫描,根本不存在“跳过多少行”的问题,页数越深优势越明显。不过有个前提:排序字段必须稳定唯一,通常用主键或唯一键。4. 查询优化:执行计划怎么看,索引怎么用才高效处理一个慢查询,第一件事不是去改 SQL 语句,而是先看它为什么慢。EXPLAIN就是 MySQL 给我们的体检报告,读懂它,很多问题的答案自己就出来了。4.1 EXPLAIN 关键列的一次说透EXPLAIN加上 SQL 就能看执行计划,核心信息集中在type、key、rows、Extra这几列上。type是访问类型,从好到差大概是:system>const>eq_ref>ref>range>index>all。const和eq_ref出现在主键或唯一索引等值查询时,速度最快;ref是普通索引等值查询;range是索引范围扫描;index是全索引扫描;all则是全表扫描,这基本就是慢查询的代名词。真正常见的问题都出在all上。key表示实际用到的索引,如果为 NULL,就意味着没走索引。rows是预估扫描行数,这个数字越大越值得警惕。Extra里出现Using filesort意味着排序没走索引,出现Using temporary则意味着使用了临时表,这两者都是性能隐患信号。比如你ORDER BY的字段没建索引,或者GROUP BY的字段和 JOIN 条件不一致,就会出现这两个标志。举个看执行计划的例子:EXPLAIN SELECT u.id, u.username, COUNT(o.id) AS order_count FROM user_info u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = 1 GROUP BY u.id, u.username ORDER BY order_count DESC LIMIT 10;如果执行计划里user_info的 type 是ref、key 是idx_status,那 WHERE 的效率就没问题;但如果orders的访问方式变成了all,说明orders.user_id缺索引,JOIN 就要做全表扫描了,这是最常见的 JOIN 慢的原因。此时给orders.user_id加上索引,效果立竿见影。4.2 联合索引的前缀法则和最左前缀原则联合索引是最能体现优化水平的地方。所谓最左前缀原则,就是你在(a, b, c)三个字段上建了联合索引,那么查询a = ?、a = ? AND b = ?、a = ? AND b = ? AND c = ?都能用到这个索引,但直接b = ?却用不上。这个原则的本质是:联合索引底层是按从左到右的字段顺序构建一棵 B+ 树的。比如(user_id, status, created_at)这个索引,先按 user_id 排序,user_id 相同再按 status,再相同才按 created_at。所以能用到索引的查询条件,必须从最左边字段开始连续匹配。一个常见的优化案例:订单表高频查询是 “按用户查最近订单”。那么建联合索引(user_id, created_at)就很合适,因为WHERE user_id = ? ORDER BY created_at DESC LIMIT 10可以直接通过索引完成定位和排序,不需要额外 filesort。如果建成(created_at, user_id),排序条件无法命中索引顺序,性能就差一截。还有个容易忽视的点:去掉冗余索引。如果已经有联合索引(a, b),单列索引(a)其实是被包含的,可以删掉。每多一个索引,写入时就要多维护一棵 B+ 树,代价是实实在在的。我在一些老项目里见过三四个重叠索引,删掉之后写入性能明显回升。4.3 索引失效的场景,老生常谈但总有人踩索引不是建了就一定能用上,有好几个经典场景会让索引直接失效,而这些问题排查起来并不复杂,关键是要心里有数。第一,对索引列做函数运算或隐式类型转换。比如WHERE DATE(created_at) = '2025-01-01'会让 created_at 索引失效,正确做法是改成范围查询:WHERE created_at >= '2025-01-01' AND created_at < '2025-01-02'。再比如WHERE phone = 13800138000,如果 phone 是 VARCHAR 类型,这里的数字会被隐式转成字符串来比较,可能导致索引失效,应该写成字符串形式。第二,LIKE 模糊匹配以%开头时索引失效。LIKE '%abc'无法用索引,LIKE 'abc%'可以。如果你确实要做包含匹配,可以考虑全文索引,或者把数据量控制在一定范围,或者干脆用搜索引擎方案。第三,索引列参与计算。比如WHERE price * 2 > 100,这种写法索引直接失效,正确姿势是WHERE price > 100 / 2,把计算挪到等号另一边。但这几年我越来越觉得,与其背这些失效规则,不如养成看执行计划的习惯。写一条 SQL 后顺手EXPLAIN一下,看 type 是不是 range 以上、key 是不是停在了预期索引上、Extra 有没有告警,比凭感觉优化靠谱得多。5. 常见问题与调优实战案例前面讲了很多原理,最后分享几个我在实际项目里遇到的真实问题。这些问题最常见的共同点,就是业务在快速增长时,SQL 性能突然成为瓶颈,排查和修复的过程挺有代表性。5.1 案例一:深分页导致接口超时某个管理系统后台有一个“全部用户订单”列表,数据量大概 800 万行,运营点最后一页时接口直接超时。查了一下日志,SQL 是ORDER BY id DESC LIMIT 7000000, 20,执行计划里 rows 预估接近全表,耗时 6 秒多。我用延迟关联改写后,耗时降到 90 毫秒不到。后来进一步让前端改成“加载更多”的交互模式,用基于主键的游标分页替换页码跳转,就再也没有出现过超时了。有兴趣的话,你也可以在自己本地造个几十万行数据,分别跑普通写法和延迟关联写法对比一下耗时,感受会很直观。5.2 案例二:LEFT JOIN 驱动表过大另一个项目是报表统计,一张几千万行的订单表去关联几百行的商品类型表,执行计划里 order 表走全表扫描,每次跑报表都巨慢。最后给订单表的goods_type字段建了联合索引,并调整查询条件让优化器先选小表做驱动表,再把大表上的关联字段索引建好,整个查询从 10 秒级别降到 0.3 秒级别。这里要补充一点:JOIN 的驱动表选择其实是由优化器决定的,它会倾向于选择小表驱动大表,但优化器的判断依赖统计信息,有时它的选择并不理想。分析执行计划时,如果发现驱动表选错,可以尝试调整 FROM 顺序,或者通过STRAIGHT_JOIN强制指定顺序,但这属于偏方,常规项目不推荐滥用,先检查索引和统计信息更稳。5.3 常见问题速查表为了方便回看,我把这些经验整理成一张小表:问题现象可能原因排查思路解决对策查询时全表扫描,type=all缺索引或索引失效EXPLAIN 看 key 是否为 NULL检查 WHERE/ORDER BY 字段,合理建索引排序慢,Extra 有 Using filesortORDER BY 字段未命中索引看排序字段顺序和索引顺序是否一致调整索引顺序或改用联合索引覆盖排序JOIN 查询巨慢被驱动表关联字段无索引EXPLAIN 看关联表的 type 是否为 all给被驱动表的 ON 字段加索引深分页越翻越慢offset 过大导致扫描大量行看 SQL 的 LIMIT offset改游标分页或延迟关联NOT IN 返回空结果子查询里有 NULL检查子查询字段是否为 NULL子查询里加 IS NOT NULL 或用 NOT EXISTS数据错乱不对账金额字段用了 FLOAT看字段类型统一改为 DECIMAL存不了 emoji 或生僻字表字符集是 utf8SHOW CREATE TABLE 看字符集改为 utf8mb4连接查询结果重复JOIN 条件不唯一,导致一对多膨胀检查关联字段是否唯一先 DISTINCT 或按业务语义改写查询加了索引但没走LIKE 以 % 开头、函数计算、隐式转换看 SQL 写法是否破坏索引改写 SQL,计算移到右值,函数去掉5.4 表字段追加与重命名的正确姿势业务迭代过程中,改表不可避免。我总结的稳妥流程是:先SHOW CREATE TABLE看当前结构;再评估数据量;改动尽量合并到一条 ALTER 语句;上线前先在测试环境用同样结构压一遍,几十万行以下可以直接改,上千万行的表则要考虑在线变更方案。现场如果担心操作风险,最后先做一次全量备份,能省很多不必要的麻烦。注意:线上环境重命名表或改字段名时,任何一点疏忽都可能影响依赖这个表的老代码。改完后要立刻检查代码仓库里有没有对旧字段名的引用,尤其要注意那些拼错字段名也不报错的 ORM 框架(部分映射方式是编译期不检查的),这点吃过亏的同行应该不少。6. 一点真心话写了这么多,想以个人的实际体会收个尾。我见过太多人把 SQL 当成“理所当然该会但懒得深学”的技能,直到线上出问题才回来补课。其实表操作和查询是数据库最基础却也最见功底的部分,花一晚上把执行计划、索引原理、执行顺序这几个点啃透,之后半年能省下无数排查问题的时间。另外想分享一个小技巧:每写一条复杂度上来的查询,都习惯性丢进EXPLAIN看一眼,不要依赖“直觉”。有时候你以为没问题的写法,执行计划里一行Using filesort就暴露了性能隐患。形成这个习惯之后,你的 SQL 水平提升真的会非常快,并且很少再被线上慢查询搞得措手不及。如果这篇文章能帮你在走 SQL 这条路上少踩几个坑,我就很开心了。
1. 为什么人人都要死磕表操作与查询做开发的,几乎没人能绕开数据库,而 MySQL 又是用得最广的那一个。不管你是写后端接口、做数据分析,还是维护老项目,每天打交道最多的就是三件事:建表、改表、查数据。很多人刚入门时喜欢先背各种命令,结果真到了项目里,连一个JOIN都写得战战兢兢,更别提看懂别人建的表为什么这么设计了。说实话,MySQL 的表操作和查询,是区分“会写 SQL”和“写好 SQL”的分水岭。同样一份数据,有人用三条子查询嵌套糊出来,有人一条LEFT JOIN加个聚合函数就把问题解决了,跑出来的效率差着数量级。这篇文章我想从实际开发的角度,把表结构设计、字段类型选择、SQL 查询的核心逻辑和执行顺序这些事一次讲透,顺带把我这些年踩过的坑、试过的好用方案都分享出来,不管你是刚入行的新人,还是写了两三年 SQL 但总觉得差点火候的开发者,都有值得看的东西。2. 表操作:建表之前,先想清楚这五件事很多新手拿到需求就开干,CREATE TABLE一敲就完事,结果上线没两个月就开始改表结构,凌晨三点在工位上ALTER TABLE还被锁表锁到崩溃。建表这事,真不是敲个 SQL 那么简单。2.1 存储引擎怎么选才不后悔MySQL 的存储引擎不止一种,但绝大多数业务场景只有两个选项:InnoDB和MyISAM。我见到过不少老项目用了 MyISAM,理由是“查询快”,但那也就快在读多写少的纯静态场景下。MyISAM 不支持事务,不支持外键,用的是表级锁,一旦并发写一上来,整个表都被锁住,其他查询全部排队,那种体验真的酸爽。如果没有特别理由,一律用 InnoDB。它支持事务、行级锁、崩溃恢复,虽然单条写入性能比 MyISAM 稍慢一点,但在真实业务里,这点差距远远抵不上并发安全带来的收益。MySQL 8.0 已经彻底移除了 MyISAM 的新建支持,所以新版选默认的 InnoDB 就好。提示:如果只是偶尔做统计报表、读多写极少的场景,可以考虑用 ARCHIVE 引擎压缩存储,但不建议作为主要业务的存储引擎。建表语法其实很简单,但关键是建表语句后面的每个选项都有含义:CREATE TABLE user_info ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', username VARCHAR(50) NOT NULL COMMENT '用户名', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用', 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_username (username), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信息表';这看起来简单,但每个字段的选择背后都有讲究。2.2 字段类型选错了,后面全是泪字段类型的坑,是表操作里最容易被忽略的。先说字符集,从 MySQL 5.7 开始我就强烈建议一律使用utf8mb4,而不是utf8。原因很简单:utf8mb4 才是真正的四字节 UTF-8,能存储 emoji 表情和一些生僻汉字。用 utf8 的话,用户昵称里带个表情符号,插入直接报错,这种线上事故我遇到不止一次了。再说数值类型。INT是 4 字节,BIGINT是 8 字节,主键用BIGINT UNSIGNED是稳妥的选择,尤其数据量以后可能破亿的表。TINYINT是 1 字节,存状态、类型这类取值很小的字段非常合适。有个老坑要提醒:INT(11)这种写法里的 11 只是显示宽度,不影响取值范围,MySQL 8.0.17 以上版本已经弃用了显示宽度,别再纠结这个了。还有金额字段。我见过有人用FLOAT存价格,结果做个加减法都能对不上账,这个教训太痛了。金额、折扣这类精确计算结果,必须用DECIMAL,比如DECIMAL(10, 2)表示整数部分 8 位、小数部分 2 位。说个实际的坑:有两个商家账户,一个存了 99.09,一个存了 99.11,用FLOAT查出来俩余额显示都是 99.1,但加起来却等于 198.19999999,对账怎么都对不上。换成DECIMAL(10,2)存储和参与计算,问题当场消失。时间字段选用也有讲究。DATETIME不依赖时区设置,存的是表面时间;TIMESTAMP存储的是 UTC 时间戳,查询时和会话时区做换算。日常业务推荐DATETIME,它的范围从 1000 年到 9999 年,而TIMESTAMP到了 2038 年会溢出的老问题,虽然新版本扩展了范围,但运维上还是多一事不如少一事。另外,DATETIME记得设置默认值,尽量在建表时就给一个合理的默认,避免插入时遗漏导致报错。关于字段是否允许NULL,很多新手的理解是“NULL 和空串差不多”,大错特错。NULL 在索引、聚合、比较运算里都有特殊含义,COUNT(column)会忽略 NULL,WHERE column = NULL永远查不到数据(必须用IS NULL),而且 NULL 字段在 InnoDB 里会额外占用一个字节标记。能用NOT NULL DEFAULT表达清楚的就别用 NULL。比如status TINYINT NOT NULL DEFAULT 1,语义清晰,查询也方便。2.3 主键、唯一键、普通索引的区别搞清楚主键选什么,直接决定表的写入性能和查询效率。自增主键是最通用的方案,因为在 InnoDB 聚簇索引的存储结构下,数据按主键顺序排列,自增主键能做到顺序写入,减少页分裂。但要注意自增主键不要用VARCHAR,比如用户表用手机号做主键,这不仅是存储空间浪费,还会让每次插入都触发 B+ 树的大量节点分裂,插入性能下降明显。唯一键和普通索引的区别在于约束。唯一键保证字段值不重复,适合手机号、邮箱这类业务上有唯一性要求的字段,比如上文里的UNIQUE KEY uk_username (username)。普通索引则纯粹为了加速查询,像状态位这种区分度不高的字段,加索引的直接收益有限,但如果查询里经常要按状态过滤,也能用上覆盖索引减少回表。我建议的索引策略是:主键选自增 BIGINT;业务唯一字段加唯一索引;高频筛选字段加普通索引;能用联合索引就用联合索引,不用为每个字段单独建索引,否则索引膨胀反而拖慢写入。2.4 表设计里的硬伤:别浪费时间在过度设计上建表的时候,很多人容易陷入两个极端:一个是什么约束都不加,能跑就行;另一个是过度设计,建得跟数据仓库的宽表一样,每个字段都加索引、每个细节都要考虑。实际上,业务层和存储层要分工协作。约束是数据库的底线,比如必填字段要NOT NULL、唯一性字段要建唯一索引,这些该下有就要下。但联合查询、冗余字段这类东西,可以等真实性能瓶颈出现后再考虑。举个例子,做订单表时,order_no(订单编号)加了个唯一索引;user_id加了个普通索引;status因为报表统计经常按状态过滤,后来还真派上用场了。但某个字段 vnbranch_code(渠道码)当时随手加了索引,一年后发现这个字段压根没进过 WHERE 条件,白白占了存储空间还拉了写入性能。所以索引宁缺毋滥,别凭直觉加。2.5 改表操作:线上表不能随便 ALTER改表这事儿,我经历过几次惊心动魄的场面。早年在一个老系统里,有一张几百万行的核心表,某天需求要加一个字段,我直接ALTER TABLE ADD COLUMN就执行了,结果 InnoDB 在拷贝表数据的过程中把整张表锁住,业务写入足足停了七八分钟,被运营投诉到怀疑人生。所以线上改表,一定要评估锁和时间。MySQL 8.0 用ALGORITHM=INPLACE和LOCK=NONE可以在线加索引、加字段,但如果字段改到了表结构里的关键位置,或者有默认值需要重算,依然可能触发锁表。经验之谈:改动前先看表和索引的数据量,预估执行时间;改动用低峰期;尽量一条 SQL 完成多个改动,不要分多次执行;如果确实大表改动,考虑用工具来做在线变更。3. 查询(DQL):从会敲到敲得明白如果说建表是打地基,那查询就是每天都要碰的日常。很多人对 SELECT 的理解停留在“会写”层面,但遇到稍微复杂的业务就一头雾水,或者写出来的 SQL 跑得奇慢无比。说到底,是没有真正理解 SQL 的执行顺序和各个子句的作用。3.1 SELECT 完整执行顺序,这可能是很多人的知识盲区先记住这条 SQL 书写顺序和底层执行顺序的不同:SELECT column_name, COUNT(*) FROM table_name WHERE condition GROUP BY column_name HAVING group_condition ORDER BY column_name LIMIT offset, count;看起来这就是一条标准查询,但 MySQL 真正执行的顺序是:先FROM找到表,再WHERE过滤行,然后GROUP BY分组,接着HAVING过滤分组,然后SELECT投影出要的列,之后ORDER BY排序,最后LIMIT截取条数。为什么这个顺序重要?因为很多人分不清WHERE和HAVING的区别就栽在这里。WHERE是在分组之前对每一行过滤,不能使用聚合函数;HAVING是在分组之后对分组过滤,可以使用COUNT、SUM这些聚合函数。但凡搞清楚执行顺序,这个疑惑自然就解开了。# 正确示例:找出下单次数超过 5 次的用户 SELECT user_id, COUNT(*) AS order_count FROM orders WHERE status = 'paid' GROUP BY user_id HAVING COUNT(*) > 5 ORDER BY order_count DESC LIMIT 10;这个查询里,WHERE先过滤掉未支付的订单,GROUP BY按用户分组,HAVING筛出下单超过 5 次的组,最后排序取前 10。每一步都符合执行顺序的逻辑。3.2 WHERE、JOIN、聚合,常见面试题背后的真实业务逻辑JOIN是另一个让人头疼的地方。INNER JOIN、LEFT JOIN、RIGHT JOIN、CROSS JOIN的区别,其实一句话就能说清:INNER 取交集,LEFT 保留左表全部,RIGHT 保留右表全部,CROSS 是笛卡尔积。但真到业务里,很多人搞不清到底该用哪个。举个例子:要查每个用户的订单数量和总金额,用LEFT JOIN就能保证没有下单的用户也出现在结果里(订单量显示为 0);但如果只关心有订单的用户,INNER JOIN更合适。# 左连接:查出所有用户及其订单统计,没下过单的用户订单数为 0 SELECT u.id, u.username, COUNT(o.id) AS order_count, COALESCE(SUM(o.amount), 0) AS total_amount FROM user_info u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.username;注意这里统计COUNT(o.id)而不是COUNT(*),是因为 LEFT JOIN 之后,没有订单的用户会在订单列上得到 NULL,COUNT(o.id)会自动忽略 NULL;如果用了COUNT(*),这些用户会被统计成 1,就成了错误结果。这是实战中非常容易踩的坑。聚合函数的使用也有讲究。COUNT(*)、COUNT(1)、COUNT(column)三者性能差别其实很小,但含义不同。COUNT(column)统计的是非 NULL 值的行数,而COUNT(*)统计所有行数。如果你要统计“有多少 user_id 不为空的订单”,就可以用COUNT(user_id);如果只是想知道总行数,用COUNT(*)就好。另外SUM、AVG同样会忽略 NULL,所以在统计前最好用IFNULL或COALESCE把 NULL 兜住,避免结果里莫名出现 NULL 造成的展示问题。3.3 子查询和 JOIN 怎么选,IN 与 EXISTS 的性能奥义子查询能做的事情 JOIN 也基本都能做,但两者的执行逻辑不同。从 MySQL 5.6 开始,优化器对子查询有了改进,不再像以前那么无脑,但一些场景下,重新写成 JOIN 仍然会更快。例一,查“有订单的用户”:-- 子查询写法 SELECT id, username FROM user_info WHERE id IN (SELECT DISTINCT user_id FROM orders); -- JOIN 写法 SELECT DISTINCT u.id, u.username FROM user_info u INNER JOIN orders o ON u.id = o.user_id;大部分情况下 JOIN 更清晰,尤其在两张表数据量都大的时候。不过 MySQL 优化器会把简单的 IN 子查询改写成 semi-join,所以性能差距并没有传说中那么大。真正需要留意的是NOT IN和NOT EXISTS的情况,NOT IN遇到子查询结果里有 NULL 时会直接导致整个查询结果为空,这是经典的坑:-- 危险写法:如果 orders.user_id 有任何 NULL,这个查询会返回空结果 SELECT id, username FROM user_info WHERE id NOT IN (SELECT user_id FROM orders); -- 安全写法:一定要加上 IS NOT NULL SELECT id, username FROM user_info WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL); -- 或者在子查询里用 COALESCE SELECT id, username FROM user_info WHERE id NOT IN (SELECT COALESCE(user_id, 0) FROM orders);所以我在代码审查里看到NOT IN都会多留个心眼。至于 IN 和 EXISTS 怎么选,经验法则:外层表小、内层表大时,用 IN 通常更高效;外层表大、内层表小时,EXISTS 借助索引可能更占优势。但我建议,先看执行计划,别靠猜,因为 MySQL 8.0 的优化器已经能自动做转换,实际性能差异往往取决于索引情况,而不是写法本身。-- EXISTS 写法示例 SELECT id, username FROM user_info u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);有人说SELECT 1是从查询计划优化的角度来写的,因为子查询只需要判断是否存在,不需要返回具体列。虽然现在的优化器基本不会在乎你写的是SELECT 1还是SELECT *,但这种写法在语义上更清晰——你只关心有没有,不关心记录内容。3.4 LIMIT 分页的隐藏陷阱:别让深分页拖垮数据库分页是业务系统里最常见的需求之一,但很多人只是机械地LIMIT offset, count,数据量小的时候没问题,数据量一大就出事。比如LIMIT 1000000, 20,MySQL 前面 100 万行是要一条条找到并丢弃的,然后在第 100 万零一行开始取 20 行,这代价完全是在“白跑”。优化的思路是用主键或覆盖索引来加速定位。比如按主键排序的场景:-- 普通写法(深分页时很慢) SELECT id, name, status FROM user_info ORDER BY id LIMIT 1000000, 20; -- 优化写法:延迟关联,先只查主键,再用主键回表取其他字段 SELECT u.id, u.name, u.status FROM user_info u INNER JOIN (SELECT id FROM user_info ORDER BY id LIMIT 1000000, 20) t ON u.id = t.id;延迟关联的思路是:在子查询里用索引覆盖着把主键找出来,再通过主键去关联原表拿全字段,避开大范围回表。这招在单表数据千万级、深分页场景下极好用。如果条件允许,更优雅的方式是“基于游标的分页”,也就是记录上一页最后一条的 id,下一页直接用WHERE id > 上一个id来取:SELECT id, name, status FROM user_info WHERE id > 1000010 ORDER BY id LIMIT 20;这样每一页查询都走主键索引的范围扫描,根本不存在“跳过多少行”的问题,页数越深优势越明显。不过有个前提:排序字段必须稳定唯一,通常用主键或唯一键。4. 查询优化:执行计划怎么看,索引怎么用才高效处理一个慢查询,第一件事不是去改 SQL 语句,而是先看它为什么慢。EXPLAIN就是 MySQL 给我们的体检报告,读懂它,很多问题的答案自己就出来了。4.1 EXPLAIN 关键列的一次说透EXPLAIN加上 SQL 就能看执行计划,核心信息集中在type、key、rows、Extra这几列上。type是访问类型,从好到差大概是:system>const>eq_ref>ref>range>index>all。const和eq_ref出现在主键或唯一索引等值查询时,速度最快;ref是普通索引等值查询;range是索引范围扫描;index是全索引扫描;all则是全表扫描,这基本就是慢查询的代名词。真正常见的问题都出在all上。key表示实际用到的索引,如果为 NULL,就意味着没走索引。rows是预估扫描行数,这个数字越大越值得警惕。Extra里出现Using filesort意味着排序没走索引,出现Using temporary则意味着使用了临时表,这两者都是性能隐患信号。比如你ORDER BY的字段没建索引,或者GROUP BY的字段和 JOIN 条件不一致,就会出现这两个标志。举个看执行计划的例子:EXPLAIN SELECT u.id, u.username, COUNT(o.id) AS order_count FROM user_info u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = 1 GROUP BY u.id, u.username ORDER BY order_count DESC LIMIT 10;如果执行计划里user_info的 type 是ref、key 是idx_status,那 WHERE 的效率就没问题;但如果orders的访问方式变成了all,说明orders.user_id缺索引,JOIN 就要做全表扫描了,这是最常见的 JOIN 慢的原因。此时给orders.user_id加上索引,效果立竿见影。4.2 联合索引的前缀法则和最左前缀原则联合索引是最能体现优化水平的地方。所谓最左前缀原则,就是你在(a, b, c)三个字段上建了联合索引,那么查询a = ?、a = ? AND b = ?、a = ? AND b = ? AND c = ?都能用到这个索引,但直接b = ?却用不上。这个原则的本质是:联合索引底层是按从左到右的字段顺序构建一棵 B+ 树的。比如(user_id, status, created_at)这个索引,先按 user_id 排序,user_id 相同再按 status,再相同才按 created_at。所以能用到索引的查询条件,必须从最左边字段开始连续匹配。一个常见的优化案例:订单表高频查询是 “按用户查最近订单”。那么建联合索引(user_id, created_at)就很合适,因为WHERE user_id = ? ORDER BY created_at DESC LIMIT 10可以直接通过索引完成定位和排序,不需要额外 filesort。如果建成(created_at, user_id),排序条件无法命中索引顺序,性能就差一截。还有个容易忽视的点:去掉冗余索引。如果已经有联合索引(a, b),单列索引(a)其实是被包含的,可以删掉。每多一个索引,写入时就要多维护一棵 B+ 树,代价是实实在在的。我在一些老项目里见过三四个重叠索引,删掉之后写入性能明显回升。4.3 索引失效的场景,老生常谈但总有人踩索引不是建了就一定能用上,有好几个经典场景会让索引直接失效,而这些问题排查起来并不复杂,关键是要心里有数。第一,对索引列做函数运算或隐式类型转换。比如WHERE DATE(created_at) = '2025-01-01'会让 created_at 索引失效,正确做法是改成范围查询:WHERE created_at >= '2025-01-01' AND created_at < '2025-01-02'。再比如WHERE phone = 13800138000,如果 phone 是 VARCHAR 类型,这里的数字会被隐式转成字符串来比较,可能导致索引失效,应该写成字符串形式。第二,LIKE 模糊匹配以%开头时索引失效。LIKE '%abc'无法用索引,LIKE 'abc%'可以。如果你确实要做包含匹配,可以考虑全文索引,或者把数据量控制在一定范围,或者干脆用搜索引擎方案。第三,索引列参与计算。比如WHERE price * 2 > 100,这种写法索引直接失效,正确姿势是WHERE price > 100 / 2,把计算挪到等号另一边。但这几年我越来越觉得,与其背这些失效规则,不如养成看执行计划的习惯。写一条 SQL 后顺手EXPLAIN一下,看 type 是不是 range 以上、key 是不是停在了预期索引上、Extra 有没有告警,比凭感觉优化靠谱得多。5. 常见问题与调优实战案例前面讲了很多原理,最后分享几个我在实际项目里遇到的真实问题。这些问题最常见的共同点,就是业务在快速增长时,SQL 性能突然成为瓶颈,排查和修复的过程挺有代表性。5.1 案例一:深分页导致接口超时某个管理系统后台有一个“全部用户订单”列表,数据量大概 800 万行,运营点最后一页时接口直接超时。查了一下日志,SQL 是ORDER BY id DESC LIMIT 7000000, 20,执行计划里 rows 预估接近全表,耗时 6 秒多。我用延迟关联改写后,耗时降到 90 毫秒不到。后来进一步让前端改成“加载更多”的交互模式,用基于主键的游标分页替换页码跳转,就再也没有出现过超时了。有兴趣的话,你也可以在自己本地造个几十万行数据,分别跑普通写法和延迟关联写法对比一下耗时,感受会很直观。5.2 案例二:LEFT JOIN 驱动表过大另一个项目是报表统计,一张几千万行的订单表去关联几百行的商品类型表,执行计划里 order 表走全表扫描,每次跑报表都巨慢。最后给订单表的goods_type字段建了联合索引,并调整查询条件让优化器先选小表做驱动表,再把大表上的关联字段索引建好,整个查询从 10 秒级别降到 0.3 秒级别。这里要补充一点:JOIN 的驱动表选择其实是由优化器决定的,它会倾向于选择小表驱动大表,但优化器的判断依赖统计信息,有时它的选择并不理想。分析执行计划时,如果发现驱动表选错,可以尝试调整 FROM 顺序,或者通过STRAIGHT_JOIN强制指定顺序,但这属于偏方,常规项目不推荐滥用,先检查索引和统计信息更稳。5.3 常见问题速查表为了方便回看,我把这些经验整理成一张小表:问题现象可能原因排查思路解决对策查询时全表扫描,type=all缺索引或索引失效EXPLAIN 看 key 是否为 NULL检查 WHERE/ORDER BY 字段,合理建索引排序慢,Extra 有 Using filesortORDER BY 字段未命中索引看排序字段顺序和索引顺序是否一致调整索引顺序或改用联合索引覆盖排序JOIN 查询巨慢被驱动表关联字段无索引EXPLAIN 看关联表的 type 是否为 all给被驱动表的 ON 字段加索引深分页越翻越慢offset 过大导致扫描大量行看 SQL 的 LIMIT offset改游标分页或延迟关联NOT IN 返回空结果子查询里有 NULL检查子查询字段是否为 NULL子查询里加 IS NOT NULL 或用 NOT EXISTS数据错乱不对账金额字段用了 FLOAT看字段类型统一改为 DECIMAL存不了 emoji 或生僻字表字符集是 utf8SHOW CREATE TABLE 看字符集改为 utf8mb4连接查询结果重复JOIN 条件不唯一,导致一对多膨胀检查关联字段是否唯一先 DISTINCT 或按业务语义改写查询加了索引但没走LIKE 以 % 开头、函数计算、隐式转换看 SQL 写法是否破坏索引改写 SQL,计算移到右值,函数去掉5.4 表字段追加与重命名的正确姿势业务迭代过程中,改表不可避免。我总结的稳妥流程是:先SHOW CREATE TABLE看当前结构;再评估数据量;改动尽量合并到一条 ALTER 语句;上线前先在测试环境用同样结构压一遍,几十万行以下可以直接改,上千万行的表则要考虑在线变更方案。现场如果担心操作风险,最后先做一次全量备份,能省很多不必要的麻烦。注意:线上环境重命名表或改字段名时,任何一点疏忽都可能影响依赖这个表的老代码。改完后要立刻检查代码仓库里有没有对旧字段名的引用,尤其要注意那些拼错字段名也不报错的 ORM 框架(部分映射方式是编译期不检查的),这点吃过亏的同行应该不少。6. 一点真心话写了这么多,想以个人的实际体会收个尾。我见过太多人把 SQL 当成“理所当然该会但懒得深学”的技能,直到线上出问题才回来补课。其实表操作和查询是数据库最基础却也最见功底的部分,花一晚上把执行计划、索引原理、执行顺序这几个点啃透,之后半年能省下无数排查问题的时间。另外想分享一个小技巧:每写一条复杂度上来的查询,都习惯性丢进EXPLAIN看一眼,不要依赖“直觉”。有时候你以为没问题的写法,执行计划里一行Using filesort就暴露了性能隐患。形成这个习惯之后,你的 SQL 水平提升真的会非常快,并且很少再被线上慢查询搞得措手不及。如果这篇文章能帮你在走 SQL 这条路上少踩几个坑,我就很开心了。