多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MySQL DDL、DML、DQL实战:从表结构设计到查询优化

MySQL DDL、DML、DQL实战:从表结构设计到查询优化 我最早学MySQL的时候就是把DDL、DML、DQL当成三个缩写硬背下来的。直到后来在项目里反复被表结构变更、批量数据修复、慢查询优化这些事折磨之后才真正意识到——这三个分类不是一个考试考点而是你在数据库上动手做事的三种完全不同的操作姿态。今天这篇笔记我结合自己这几年用MySQL的实际经历把DDL、DML、DQL三类语句从原理到实战重新梳理一遍顺带聊聊怎么利用AI工具把这套东西学得更快、记得更牢。内容适合正在学数据库的初学者也适合已经写了几年SQL、但想系统补一补底层逻辑的开发者。很多人学MySQL都是直接从SELECT开始的毕竟查询是最高频的操作。但等你真正要交付一个功能、维护一套线上数据的时候你会发现自己最需要的反而是DDL和DML的功底——表结构怎么设计、字段怎么改、数据怎么安全地更新和回滚。这一篇我就按DDL、DML、DQL的顺序把每一类语句的常见场景、关键坑点、以及我在实际项目里踩过的问题一次讲透。1. 为什么学MySQL要先分清DDL、DML、DQL1.1 三层语句分类的底层逻辑SQL语句按功能划分本质上是按操作对象和作用范围来分的这个逻辑搞清楚了比背多少个命令都重要。DDLData Definition Language操作的是结构——库、表、索引、视图。它的特点是一旦执行影响的是表本身的定义而且多数操作隐式提交无法回滚。你执行一条DROP TABLE表瞬间就没了这跟DML的DELETE完全不是一个量级的事故。DMLData Manipulation Language操作的是数据——增、删、改。它是在已有结构的前提下对表里的记录做变更。它的特点是可以被事务控制可以通过ROLLBACK回滚。这也是为什么我说DML的胆子可以大一点但前提是你得用对事务。DQLData Query Language操作的是查询——SELECT。它不改变任何数据只是把数据按你想要的形态取出来。但别小看它SELECT写得好不好决定了你的系统扛不扛得住流量、报表出不出得来、排查问题快不快。这三层的区别最直观的理解方式是把数据库比作一栋楼DDL是打地基、砌墙、改户型DML是往房间里搬家具、挪东西、扔垃圾DQL是站在楼里到处看、数东西、做统计。你建楼的时候乱来后面住进去天天难受你搬家具不守规矩房间乱成一团你会不会看、怎么看决定你能不能发现问题。我见过不少开发者刚上手就盯着SELECT怎么写结果连ALTER TABLE都没碰过。直到线上要加字段才慌慌张张去查语法然后被锁表问题搞到半夜。顺序学反了后面全是在补救。1.2 DDL、DML、DQL在实际工作中的边界理解了分类逻辑之后真正干活的时候还有一个边界问题什么时候该用哪一类语句这直接关系到你的操作是否安全、是否高效。举几个我实际遇到的例子。加字段这种需求属于标准的DDL。但在线加字段不是随便一条ALTER TABLE就能搞定的——如果表数据量很大直接执行会锁住整个表线上读写全被卡住。这时候你得评估用ALGORITHMINPLACE还是COPY甚至要考虑用专门的Online DDL工具。这属于DDL范畴内更深一层的选型问题。修数据这种需求属于标准的DML。比如线上有一批订单的状态字段错了需要批量更新。这种操作看着简单实际上有几个问题必须先回答影响多少行要不要先备份事务怎么开如果更新到一半发现条件写错了怎么回滚这些全是DML的必修课。查数据这种需求属于标准的DQL。比如排查某个用户为什么下单失败你要把订单、支付、库存几张表关联起来查。这考验的是你对表结构的理解、对索引的运用、对查询计划的分析能力。同样是写SELECT老手写出来可能比新手快几倍甚至几十倍区别就在于对执行计划敏感不敏感。所以我给初学者的建议是先把DDL吃透因为你所有的数据操作都建立在结构之上再学DML因为你真正上线之后每天都在跟数据变更打交道最后练DQL因为查询优化是伴随整个职业生涯的进阶课题。2. DDL实战表结构设计的那些坑2.1 从建表语句看数据类型选型的常见误区DDL的第一步是建表。我在面试候选人、帮同事Review建表语句的时候发现最集中的问题不是语法不会而是数据类型选得随意。先看一个最常见的反面案例CREATE TABLE user_info ( id INT NOT NULL AUTO_INCREMENT, user_name VARCHAR(200) NOT NULL, age VARCHAR(20) DEFAULT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个表看起来没毛病但细节全是问题。user_name设成VARCHAR(200)就过头了。用户名一般32个字符以内基本够用VARCHAR虽然变长但索引长度、排序开销都会随长度增加。age用字符串存就更离谱了——它应该用TINYINT UNSIGNED一个字节就够。用VARCHAR(20)存年龄浪费空间不说还能存进去负数、小数、字母数据校验完全失控。id用INT还是BIGINT也得想清楚。如果业务上线前就能预见到数据量会过亿那INT的43亿上限看着够用但等你真到那个量级再改主键类型代价大到难以想象。我会在任何可能沉淀大量数据的核心表上直接默认BIGINT UNSIGNED。字符集用utf8mb4是对的但要注意的是MySQL的utf8mb4才是真正的完整UTF-8支持utf8在MySQL里反而是一套不完整的历史实现存储不了emoji。凡是涉及用户输入内容的表我都会坚持utf8mb4。建表还有一个很容易忽略的点ON UPDATE。比如update_time字段如果你希望它自动跟随行变更而更新可以这样写update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP这个看着不起眼但能在很大程度上避免业务代码里忘了维护更新时间字段的问题。2.2 ALTER TABLE修改结构时的锁表问题建表只是起点随着业务演进改表才是常态。而改表最大的坑就是锁。早期MySQL版本执行ALTER TABLE大多会锁表期间该表的读写全部阻塞。虽然有了Online DDL之后情况好很多但并不是所有操作都能在线完成。具体能不能在线取决于你用的ALGORITHM和LOCK选项。我在一个千万级订单表上加字段的时候直接执行ALTER TABLE orders ADD COLUMN settle_status TINYINT NOT NULL DEFAULT 0;结果在低峰期执行也花了好几十秒期间不少写请求堆积超时。后来我长记性了凡是核心大表的DDL都会先确认两点第一ALGORITHMINPLACE是否支持当前操作第二LOCKNONE是否允许。如果加字段这种默认能在线执行的我就在业务低峰期直接跑如果是需要重建表的操作我会用第三方工具比如gh-ost、pt-online-schema-change来做避免长时间锁表。另外一个改表的高频需求是修改字段类型或长度。这里有一个反直觉的点把VARCHAR(50)改成VARCHAR(100)看起来只是拉长但InnoDB底层可能仍然需要重建表代价并不小。所以前期设计时对长度留有余地比后期频繁ALTER TABLE要省太多事。2.3 索引创建的时机与原则索引是DDL里最影响性能的部分。我见过不少表把所有能想到的字段都建了索引结果写入慢、占用空间大、查询还没变快多少。也见过一些表主键之外一个索引都没有查询全靠全表扫描。我对索引的理解可以用一句话概括索引是给查询走的捷径而不是给表贴的金片。你建索引之前必须先问自己哪条查询需要它怎么建立索引结构才能最大化减少扫描的行数核心原则有三条。第一最左前缀原则。联合索引(a, b, c)可以高效匹配a、ab、abc三种查询但如果你跳过a直接查b索引就失效了。设计联合索引时最常用的查询条件要放在最左边。第二覆盖索引的收益非常高。如果一个查询的所有字段都在索引里MySQL可以直接用索引返回结果连回表都省了。我在设计报表查询时会刻意把常用查询字段塞进联合索引里效果立竿见影。第三区分度高的字段放前面。比如性别字段区分度极低放索引前面基本没有筛选价值。而订单号、用户ID这类区分度高的字段放在前面才能真正把扫面范围缩小。我举一个具体的场景。业务上有个高频查询条件是根据用户ID查最近一个月的订单。建下面的索引就比较合理ALTER TABLE orders ADD INDEX idx_user_create (user_id, create_time);这样走索引时先定位到用户再按创建时间有序排列直接可以快速取出最近记录。如果你只建了user_id的单列索引MySQL还得额外做一次排序性能就差一截。关于索引我还想强调一个点删除无用索引也是DDL优化的一部分。我经常在巡检时发现一张表上有一堆从没被用过的冗余索引。这些索引每个都在增加写入成本。用sys.schema_unused_indexes视图可以查出来确认后就可以通过DROP INDEX清理掉。3. DML实战数据变更的原子性与恢复3.1 UPDATE的安全写法与事务边界DML里最常用也最危险的就是UPDATE。说它危险是因为很多人写UPDATE时不带WHERE或者WHERE条件写得不严谨导致全表数据被误改。我给自己定的规则是UPDATE之前先看影响行数。在MySQL客户端里执行UPDATE之前可以先跑同条件的SELECT COUNT(*)确认影响范围。比如你要更新一批订单状态SELECT COUNT(*) FROM orders WHERE order_status 1 AND created_at 2024-06-01;确认好数量之后再执行更新。更稳妥的做法是把更新放在一个显式事务里先更新核对结果没问题再COMMIT有问题直接ROLLBACKSTART TRANSACTION; UPDATE orders SET order_status 5 WHERE order_status 1 AND created_at 2024-06-01; -- 这里手动检查一下更新的行数和数据样本 SELECT * FROM orders WHERE order_status 5 LIMIT 10; -- 确认无误后提交 COMMIT;这里有一个关键的认知点InnoDB的DML操作默认是自动提交的每条语句一个事务。如果你不显式开启事务一旦UPDATE执行完想回退就晚了。尤其是生产环境数据修复我会坚持先开事务、后改数据、确认提交这个三步走原则。3.2 锁的分类与死锁排查思路DML写多了必然会碰到锁的问题。MySQL的锁可以按粒度分表锁、页锁、行锁也可以按类型分共享锁S锁、排他锁X锁。InnoDB默认用行级锁这是它并发性能好的根基。行级锁里最值得关注的是两把锁记录锁和间隙锁。记录锁是锁住具体的某一行比如你SELECT ... FOR UPDATE时就是加记录锁精确匹配时。间隙锁锁的是索引记录之间的空隙主要出现在范围查询和RR可重复读隔离级别下。间隙锁的设计是为了防止幻读但也最容易引发死锁。我在一个库存扣减的场景里就遇到过死锁。逻辑是这样两个用户同时下单都要先去查库存、再扣减库存。代码里先SELECT加锁再UPDATE结果两个事务都持有了一部分行的锁然后互相等对方释放死锁就发生了。排查死锁最快的方式是执行SHOW ENGINE INNODB STATUS;输出里会有LATEST DETECTED DEADLOCK段落里面详细记录了死锁涉及的事务、SQL语句、持有和等待的锁。根据这些信息通常能定位到是锁顺序不一致导致的。解决死锁的常见思路有三个一是调整加锁顺序保证多个事务以相同顺序访问资源二是缩短事务时间减少持锁窗口三是合理设计索引让更新走索引而不是全表扫描避免锁大量行。记住一个口诀InnoDB的锁是加在索引上的。如果你的UPDATE条件没走索引InnoDB会先锁住所有扫描到的记录这在并发场景下是灾难。3.3 存储过程批处理数据的注意事项DML的进阶操作是借助存储过程做批处理。很多人在业务里一次性更新几十万条数据直接一个UPDATE扔过去锁表时间过长造成线上大量阻塞。正确的姿势是写存储过程分批提交每批之间加适当休眠。我常用的一个批处理模板是这样的DELIMITER $$ CREATE PROCEDURE batch_update_status() BEGIN DECLARE v_count INT DEFAULT 1; DECLARE v_batch_size INT DEFAULT 1000; WHILE v_count 0 DO UPDATE orders SET order_status 9 WHERE order_status 1 AND id IN ( SELECT id FROM ( SELECT id FROM orders WHERE order_status 1 LIMIT v_batch_size ) AS tmp ); SET v_count ROW_COUNT(); COMMIT; DO SLEEP(0.5); END WHILE; END$$ DELIMITER ;这个存储过程有一个值得注意的点MySQL不允许在UPDATE的WHERE里直接对同一张表做子查询所以需要先包一层临时表派生查询。ROW_COUNT()拿到的就是本次更新行数当返回值是0时循环结束。每次COMMIT目的是释放锁SLEEP(0.5)是给其他事务留出执行窗口。实际运行前可以先在测试环境跑一遍观察耗时和锁等待情况。生产环境我会配合监控看有没有长时间的Lock wait timeout告警。批处理这种事宁可慢一点不能造成事故。4. DQL实战查询优化的核心路径4.1 排序、分页与索引失效很多人以为SELECT写出来能出结果就完事了但同样的数据走不走索引性能差别可能是天壤之别。我最常遇见的查询性能问题集中在排序和分页上。先看排序。一条查询如果带了ORDER BYMySQL需要把结果集排序。如果排序字段上没有合适的索引就会走filesort——这意味着MySQL要把数据先查出来再用临时文件排序效率很低。但如果ORDER BY的字段刚好在索引里并且索引顺序与排序方向一致MySQL就可以直接按索引顺序扫描输出省掉排序这一步。举个例子查询最近一个月的订单并按创建时间排序SELECT * FROM orders WHERE user_id 10086 AND created_at 2024-05-01 ORDER BY created_at LIMIT 20;如果索引是(user_id, created_at)MySQL可以用索引完成筛选加排序整体性能非常好。再看分页。无数人踩过LIMIT深分页的坑SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;这里的逻辑是MySQL把前1000020行全部扫出来然后丢弃前1000000行只返回最后20行。越往后翻页越慢到千万级数据时这条语句基本就跑不动了。解决办法是用延迟关联或基于游标的分页。基于游标的分页方案是记住上一页最后一条记录的ID下一页直接查ID大于它的SELECT * FROM orders WHERE id 1000020 ORDER BY id LIMIT 20;由于主键索引的有序性这种写法即使翻到很深的页也只扫描20条记录性能非常稳定。我的经验是凡是列表页能基于游标分页就别用LIMIT深分页。关于索引失效还有一个高频触发点在索引字段上做函数运算。比如SELECT * FROM orders WHERE DATE(created_at) 2024-06-01;这句看着很正常但对created_at加了DATE()函数之后索引就失效了MySQL只能全表扫描。正确写法是范围比较SELECT * FROM orders WHERE created_at 2024-06-01 00:00:00 AND created_at 2024-06-02 00:00:00;这一类问题解释起来一句话别在索引列上做运算做运算就别想走索引。4.2 多表关联的驱动表选择多表JOIN是查询里最容易出性能问题的地方。很多人以为LEFT JOIN的时候左边就是驱动表右边是被驱动表这种理解不准确。MySQL优化器会根据统计信息决定先访问哪张表而不是机械地按书写顺序来走。驱动表的选择直接影响关联查询的效率。一个常见的误区是把大表放左边认为LEFT JOIN能保住大表的全部数据结果查询反而慢。实际上驱动表最好是筛选后结果集更小的那张表被驱动表的关联字段一定要有索引。我举个例子查订单表关联用户表SELECT o.order_no, u.user_name FROM orders o LEFT JOIN user_info u ON o.user_id u.id WHERE o.created_at 2024-06-01 AND o.created_at 2024-07-01;这里orders是范围筛选后的结果集通常比全表小所以作为驱动表是合理的。user_info的id是主键被驱动表走主键索引关联效率很高。反过来如果你在WHERE里对驱动表的字段做了条件过滤但对应索引没建上MySQL优化器可能就只能用Block Nested-Loop相当于扫描驱动表每一行都去全表查一次被驱动表那是真正的性能灾难。排查手段也很简单用EXPLAIN看执行计划。重点看type列和rows列。type从const、ref、range到ALL依次变差。如果出现ALL就要检查是不是索引没建对。4.3 面试中高频出现的SQL写法总结DQL这块我积累了一些面试中反复出现的SQL题目。把这些题目吃透对日常工作的查询能力提升也很有帮助。TopN问题查出每个用户最近的一笔订单。思路是用窗口函数ROW_NUMBER()SELECT t.user_id, t.order_no, t.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 orders ) t WHERE t.rn 1;连续出现N次的问题比如查出连续三天有登录记录的用户。这类问题可以借助DATE_SUB配合ROW_NUMBER把连续日期做差形成临时分组SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS cnt FROM ( 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 user_login_log ) t GROUP BY user_id, grp HAVING cnt 3;行转列把多行记录变成一行多列常见于报表需求。思路是用CASE WHEN配合聚合函数SELECT user_id, SUM(CASE WHEN month 2024-05 THEN amount ELSE 0 END) AS may_amount, SUM(CASE WHEN month 2024-06 THEN amount ELSE 0 END) AS jun_amount FROM user_order_stat GROUP BY user_id;这类题的核心不是背答案而是理解SQL的执行顺序FROM到WHERE到GROUP BY到HAVING到SELECT再到ORDER BY。顺序清楚了很多看起来花哨的查询都能自己拆解出来。5. 用AI辅助学习MySQL的正确姿势5.1 AI能帮你做什么不能做什么这两年AI学习工具越来越强身边很多同学问我是怎么用AI学MySQL的。我的观点很明确AI是一个非常好的陪练但不是一个可靠的权威。AI能帮你做的事情我列一下第一解释执行计划。你把一条慢查询的EXPLAIN结果贴给AI它能帮你逐列解读告诉你哪个地方可能走了全表扫描、哪里出现了临时表。这比自己翻文档要快很多。第二生成练习题和场景模拟。你可以让AI扮演面试官出SQL题目从简单到复杂按你的水平动态调整。我练窗口函数的时候就是让AI反复出分组取TopN的变种题练到形成肌肉记忆。第三翻译业务需求成SQL。比如你跟AI说查出每个品类下销量前三的商品它能帮你写出窗口函数版本的SQL还附上解释。AI不能做的事情更需要注意第一AI给出的SQL不一定最优。它经常写出逻辑正确但性能糟糕的查询——比如在索引列上用函数、在小结果集上做深分页。你必须保持自己的判断力用EXPLAIN验证。第二AI对线上环境不了解。它不知道你的表有多少数据、有哪些索引、锁等待情况如何。在关键变更上不能把AI的答案当成生产环境的操作凭据。第三AI的记忆和上下文有限。一个复杂的表结构可能横跨十几个字段AI很容易在长对话中混淆字段名。我在实践中的做法是把核心表结构SHOW CREATE TABLE的结果贴给AI让它在确定的上下文里帮我分析。5.2 一个可复用的AI学习工作流我自己摸索了一个比较有效的AI学习MySQL的工作流分享出来供你参考。第一步建立基线。把你当前掌握的SQL能力做一个自测会写基本的增删改查吗会多表关联吗会窗口函数吗不会的地方就是学习重点。这一步可以用AI出题自测比如让它出一套30道题的SQL练习题按照DDL、DML、DQL分类。第二步场景化学习。不按语法顺序去背命令而是按场景去学。比如我要在订单表加一个支付状态字段是一个场景它涉及到DDL的建表/改表、DML的批量更新、DQL的状态统计查询。一个场景下来三类语句都用上了记忆也深刻。第三步把AI当成代码评审员。你写完SQL之后把SQL和表结构都发给AI让它帮你做Code Review。让它指出索引利用是否合理、有没有隐含的类型转换、有没有更优的写法。这一步收益最大因为这相当于有人帮你反复打磨查询习惯。第四步动手验证。AI给的任何优化建议都要在本地MySQL环境里跑一遍用EXPLAIN和实际执行时间验证。没有验证的建议一律当作参考不直接上线。我举一个真实的例子。我最近在处理一个慢查询是统计每个城市的订单金额。我一开始写的SQL在city字段上做了LEFT(city, 2)截取再分组结果全表扫描。我把执行计划贴给AIAI立刻指出了索引失效的问题建议我单独维护一个省份冗余字段或者用SUBSTRING时配合生成列。我在测试库验证之后确实从全表扫描优化成了索引范围扫描查询从3秒降到了0.1秒。这个过程中AI是提效工具真正的验证还是靠自己。写在最后的一点体会MySQL的DDL、DML、DQL说到底是三类不同性质的动作。DDL决定了数据的容器长什么样DML决定了容器里的内容怎么变化DQL决定了你如何从容器里获取价值。把这三类语句的边界、语法、优化思路都理清楚你写SQL的时候就会有一种一切尽在掌握的感觉——知道一条语句执行下去会锁什么、改什么、返回什么这种确定性非常重要。我最后再分享一个小技巧给自己建一个sql_notes的测试库专门用来做各类实验。DDL的锁表现象、DML的事务回滚、DQL的索引使用都可以在这个库里用真实的数据量去验证。纸上得来终觉浅这种事多动手做几次比看十篇教程都管用。
返回列表