MySQL 数据增删改查
搞了这么多年数据库,我越来越觉得一个反直觉的真相:真正让线上系统出问题的,往往不是那些花哨的复杂查询,而是最基础的增删改查没写明白。前两天帮一个团队排查线上告警,一个普通的 UPDATE 语句把整个订单表锁了将近三分钟,业务直接阻塞,后台日志刷了一屏又一屏的超时报错。查到最后原因也不复杂——更新条件没走索引,触发了全表扫描,行锁升级成了表锁,加上事务里还夹着两次远程调用迟迟不提交,锁持有时间被无限拉长。这哪儿是SQL的问题,是对MySQL底层机制的理解不到位。
所以这篇内容我不打算整那些虚头巴脑的"三分钟精通",而是从一个实战角度,把增删改查这条主线彻底掰开揉碎——包括每个操作背后的原理、真实场景下的正确写法、以及我踩过无数次坑之后的排查思路。无论你是刚开始学MySQL的新手,还是已经写过不少业务代码但总感觉哪里不对的开发,这篇文章应该都能让你有所收获。
1. 先搞清楚:增删改查为什么值得你花时间
很多初学者觉得"增删改查不就是四个单词嘛,INSERT、SELECT、UPDATE、DELETE,背下来就完了"。但实际上,CRUD是理解整个关系型数据库世界的一把钥匙。你写出的每一条SQL,背后都会经过解析、优化、执行、日志记录、锁管理、缓冲池交互一整条链路,任何一个环节出了问题,轻则SQL跑得慢,重则拖垮整个数据库。
1.1 一条SQL引发的"蝴蝶效应"
我先说个真实案例。去年有个客户做电商节大促,运营那边一边发优惠券一边要求清点过期数据,开发小哥直接在业务高峰期跑了一条大范围 DELETE,删了大概一百多万行。结果是什么呢?MySQL为了支持回滚,先把所有要删的数据写进了undo log,然后每一行删除都要记录binlog,主从同步直接落后了好几分钟。前台用户看到订单状态迟迟不更新,还以为是支付出了问题,客服电话都被打爆了。这条DELETE单独看没什么问题,语法完全正确,但它忽视了"大批量删除在高峰期的连锁反应"这个关键点。
这个案例说明,增删改查远不止"把数据写进去、读出来"这么简单。你的业务场景、数据规模、并发情况、索引设计,都会直接影响一条简单语句的表现。
1.2 四个操作的"业务本质"
用大白话拆解一下,增删改查对应的其实是业务的四类需求:
- SELECT(查):消费数据。用户看到的信息、报表统计的指标、系统的判断逻辑,全靠查询支撑。查得好不好,直接决定用户体验和系统性能。
- INSERT(增):产生数据。用户注册、下单、发帖、上传日志,都是新的数据产生。插入的姿势不对,后续查询和更新都会跟着遭殃。
- UPDATE(改):校准数据。订单状态流转、库存扣减、余额变动、资料修改,本质上是业务状态的变更记录。更新是最容易踩坑的操作,因为并发场景下"改错"和"改慢了"的代价都非常大。
- DELETE(删):清理数据。逻辑删除、物理删除、归档清理,是保证数据不会无限膨胀的最后防线。删得不对,不是丢数据就是锁表。
这四个操作不是孤立的。你在设计一张表的时候,就必须想好它将来会被怎么查、怎么改、怎么删。这就是为什么很多老DBA总说"建表五分钟,设计两小时"。表结构设计不好,后面的增删改查全是坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四大操作逐层拆解:从语法到业务落地
为了让你看得更直观,我建一张贯穿全文的示例表。假设我们在做一个学生成绩管理系统。
sql复制CREATE TABLE students (
id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '学号',
name VARCHAR(50) NOT NULL COMMENT '姓名',
age TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '年龄',
class_no VARCHAR(20) NOT NULL COMMENT '班级',
score DECIMAL(5,2) NOT NULL DEFAULT 0 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),
KEY idx_class_no (class_no),
KEY idx_score (score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生成绩表';
这张表里我故意加上了主键、普通索引、DECIMAL字段、时间字段的默认值设置,这些都是日常开发中最常见的设计,后面讲解的时候会用到。
2.1 SELECT:不只是SELECT * FROM
查询是CRUD里最复杂的操作,没有之一。很多人写查询就是 SELECT * FROM students WHERE id = 1,能用,但远远不够。
查询的核心原则是"只取所需"。这在业务开发中特别重要——你不能每次为了拿一个字段就把整行几十个字段全捞出来。网卡带宽、内存占用、SQL层的传输开销,这些都是成本。更严重的是,SELECT * 在有覆盖索引的情况下会让优化器放弃索引覆盖扫描,回表查询代价高不少。
常见的查询形态我列几个:
sql复制-- 基础条件查询
SELECT name, score FROM students WHERE class_no = '高三(1)班' AND score >= 600;
-- 聚合统计
SELECT class_no, COUNT(*) AS student_count, AVG(score) AS avg_score
FROM students
GROUP BY class_no
HAVING AVG(score) > 500
ORDER BY avg_score DESC;
-- 分页查询
SELECT id, name, score FROM students ORDER BY score DESC LIMIT 20, 10;
这里有个重要的点:WHERE和HAVING的区别。WHERE是在分组之前进行行级过滤,HAVING是在分组之后对聚合结果进行过滤。写错了,轻则语义不对,重则查询结果完全错误。
还有 LIMIT 分页。很多同学写分页都是 LIMIT 10000, 20,意思是跳过前面一万条,拿后面二十条。但MySQL实际执行时,是把前一万条全部读出来丢掉再拿后面二十条,数据量大了之后会越翻越慢。正确做法是用游标式分页:
sql复制-- 记录上一次查询的最大id,用id > 上次的id来翻页
SELECT id, name, score FROM students
WHERE id > 10086
ORDER BY id ASC
LIMIT 20;
这种方式能利用主键索引快速定位,不会因为翻页深度增加而线性变慢。
2.2 INSERT:插入的边界与陷阱
插入数据看起来最简单,但有几个细节非常关键。
首先是多值插入。一次性插入多行,比循环执行单条INSERT快很多,因为减少了客户端和服务器之间的网络往返次数,也减少了日志刷盘次数:
sql复制INSERT INTO students (name, age, class_no, score) VALUES
('张三', 18, '高三(1)班', 655),
('李四', 17, '高三(2)班', 623),
('王五', 18, '高三(1)班', 598);
其次是插入冲突处理。业务上经常遇到"没这条记录就插入,有这条记录就更新"的场景。你可以用:
sql复制INSERT INTO students (id, name, score) VALUES (1001, '张三', 660)
ON DUPLICATE KEY UPDATE score = VALUES(score);
这条语法在MySQL里叫"upsert",主键或唯一索引冲突时自动转为更新,不会报错。注意,VALUES() 函数在8.0.20版本后已经被标记为废弃,官方推荐用别名语法:
sql复制INSERT INTO students (id, name, score) VALUES (1001, '张三', 660) AS new
ON DUPLICATE KEY UPDATE score = new.score;
上面这种写法在MySQL 8.0.20及以上版本更安全,提前适应没坏处。
还有一个经常被忽略的点:插入的数据要显式指定字段列表。如果你写 INSERT INTO students VALUES (1, '张三', 18, ...),完全依赖字段顺序,一旦表结构调整,SQL直接崩。所以实际开发中永远写清楚字段名。
2.3 UPDATE:更新不是"改个值"那么简单
UPDATE大概是四类操作里出事故最多的一类。最常见的惨案是忘了WHERE条件,把全表数据都改了。这种错误在每个公司都会发生,区别只是影响范围和有没有人发现。
正确的UPDATE姿势要考虑三件事:条件是否走索引、是否需要加锁、是否要在事务中执行。
sql复制-- 普通更新
UPDATE students SET score = 660 WHERE id = 1001;
-- 扣减库存的原子操作
UPDATE inventory SET stock = stock - 1 WHERE goods_id = 88 AND stock > 0;
第二个例子我特别说一下。减库存是典型的高并发场景,如果不加 stock > 0 这个条件,当库存已经为0时,并发请求依然会把库存减成负数。加上这个条件后,更新影响行数为0时,就说明库存不足,需要在业务层做相应的处理。这是利用数据库行锁特性实现的原子操作,比先查库存再更新要安全得多。
MySQL还有一种更新写法容易被人忽略——多表关联更新:
sql复制UPDATE students s
JOIN class_info c ON s.class_no = c.class_no
SET s.score = s.score + 5
WHERE c.grade = '高三';
这种语法在做批量业务调整时非常有用,比如给某一年级统一加分、给某些用户统一发送某种状态等。
但注意,UPDATE多表关联时执行计划可能和SELECT不一样,涉及多张表的锁顺序问题,在并发高峰容易引发死锁。实际操作中我会尽量拆成两步:先用SELECT把目标主键查出来,再用主键批量更新。
2.4 DELETE:删数据之前先想清楚
DELETE同样有"忘了WHERE"的灾难场景,而且比UPDATE更致命——UPDATE至少还能通过binlog恢复,DELETE直接把数据干没了,如果备份策略再出问题,那就真的凉了。
关于DELETE,有几个实战经验必须知道:
第一,DELETE不会释放磁盘空间。InnoDB存储引擎删除记录时,只是在记录上打了一个删除标记,物理空间并不会立刻归还给操作系统。所以你删了一堆数据后,可能会发现表文件的大小没有变化。这是正常的,后续INSERT新数据时会复用这些空间。如果确实需要缩小表空间,要执行 OPTIMIZE TABLE students 或重建表。
第二,删除大表数据不要一次性删。我在前面提到过大批量DELETE引发的连锁反应。正确的做法是分批删除:
sql复制-- 每次只删除1000条,循环执行直到没有数据为止
DELETE FROM students WHERE score < 100 LIMIT 1000;
注意,MySQL的DELETE并不支持LIMIT直接用在所有场景下,但单表删除时是可以和LIMIT配合使用的。
第三,TRUNCATE和DELETE有本质区别。
| 对比项 | DELETE | TRUNCATE |
|---|---|---|
| 条件删除 | 支持WHERE | 不支持,只能全表清空 |
| 事务回滚 | 支持 | 不支持(隐式提交) |
| 重置自增ID | 不会 | 会 |
| 触发触发器 | 会 | 不会 |
| 执行速度 | 慢(逐行删) | 快(直接重建表) |
TRUNCATE本质上是"删除表的所有数据并重新创建表",速度非常快,但代价是没法回滚。如果有误操作的风险,请务必先备份。
第四,优先考虑逻辑删除。在真实业务中,尤其是订单、用户这类核心数据,"删"往往不是一个好主意。因为历史数据可能涉及审计、统计、对账等场景。所以很多系统会在表中加一个 is_deleted 字段,删除操作实际上是UPDATE,把状态位改成1。查询时统一加一个 WHERE is_deleted = 0 的条件。这样既保留了数据,又避免了物理删除带来的各种副作用。
3. 增删改查背后的锁与事务:为什么两条SQL会互相等待
如果你想在增删改查上突破"增删改查"这个境界,事务和锁是你绕不开的两座山。很多线上问题,本质上是这两块理解不透导致的。MySQL作为关系型数据库能保证数据一致性,靠的就是ACID特性,而其中隔离性和一致性,正是通过锁机制实现的。
3.1 事务的四个隔离级别
MySQL的InnoDB引擎支持四种事务隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 几乎不用 |
| READ COMMITTED | 不会 | 可能 | 可能 | 大多数数据库默认 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB已解决) | MySQL默认 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 并发性能极差 |
MySQL默认的隔离级别是REPEATABLE READ。注意,MySQL在这个级别下通过间隙锁机制已经解决了大部分幻读问题,所以很多生产环境直接用它。但如果你的系统允许且业务逻辑对"读到最新已提交数据"有要求,也可以改成READ COMMITTED,并发性能和死锁概率都会有所优化。
隔离级别的设置:
sql复制-- 查看当前事务隔离级别
SELECT @@transaction_isolation;
-- 会话级别设置
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
3.2 InnoDB锁的基本盘
InnoDB的锁分为共享锁(S锁)和排他锁(X锁)两大类:
- 共享锁:读锁,多个事务可以同时持有。
SELECT ... LOCK IN SHARE MODE - 排他锁:写锁,只能一个事务持有。
SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT都会加排他锁。
判断依据就是:两个锁能否共存,取决于它们是否冲突。共享锁和共享锁不冲突,排他锁和任何锁都冲突。
在InnoDB的行锁之上,还有两种进阶概念:
- 间隙锁(Gap Lock):锁的是两条记录之间的间隙,防止其他事务在间隙中插入数据。它覆盖的是"范围内不存在但可能被插入"的记录。
- 临键锁(Next-Key Lock):行锁和间隙锁的组合,左开右闭区间。InnoDB在REPEATABLE READ级别下,默认使用临键锁来避免幻读。
这里有一句非常实用的经验:在InnoDB中,UPDATE和DELETE不一定是立即锁住目标行,而是先通过索引定位到候选记录,再对范围内可能满足条件的记录加锁。如果WHERE条件没有索引,InnoDB会锁住整张表的所有记录(本质上退化为表锁),这就是我开头提到的那个事故的根因。
3.3 一个真实的锁等待排错过程
有一次线上业务告警,某个订单状态更新接口的耗时从平均50毫秒飙到了3秒多。我登陆数据库一看,进程列表里一堆 UPDATE orders SET status = 'paid' WHERE order_no = 'xxxx' 都在 Waiting for lock。
排查链路是这样的:
第一步,看当前有哪些事务在跑:
sql复制SELECT * FROM information_schema.INNODB_TRX\G;
这个视图会显示正在执行的事务、事务开始时间、执行的具体SQL。我找到了一大堆长时间未提交的事务。
第二步,看锁等待关系:
sql复制SELECT * FROM sys.innodb_lock_waits\G;
这个视图能直接告诉你,谁在等谁的锁。
第三步,分析锁等待源头。
最终发现,有一个定时任务在批量更新一张大表的某个字段,更新条件没有走索引,导致这张表的部分记录加了大量间隙锁。而业务侧的事务在更新订单状态时,恰好落在了间隙锁覆盖的范围内,于是全部阻塞。
解决方案就是给那个更新条件的字段加上索引,然后把大批量更新改成小批量提交。锁等待瞬间消失,接口耗时恢复正常。
这个案例里的教训是:锁问题往往是"慢SQL + 没走索引"的连锁反应。SQL执行得越快,锁持有的时间越短,对其他事务的影响就越小。
3.4 死锁:两个事务互相等待的僵局
死锁是另一个经典问题。MySQL检测到死锁后,会自动回滚其中一个事务(通常选择回滚代价较小的一方),然后抛出 Deadlock found when trying to get lock 的报错。
最常见的死锁场景是两个事务以不同顺序更新多张表。比如事务A先更新订单表再更新库存表,事务B先更新库存表再更新订单表。当两个事务同时执行时,就可能出现A拿着订单表的锁等库存表,B拿着库存表的锁等订单表的僵局。
避免死锁的方案很朴素:所有事务都按相同的顺序访问表和行。比如约定"先订单后库存",事务B也先更新订单表再更新库存表,就不会出现循环等待了。
4. 从单表走向复杂场景:JOIN、聚合与排序的正确姿势
单表CRUD只是基本功,真实业务里你很快就会面对多表查询。学生表、课程表、成绩表、班级表,一张表根本装不下完整的业务信息,这时候就要使用JOIN了。
4.1 SQL的执行顺序先搞懂
很多人在写复杂SQL时,会把SQL的语法顺序和执行顺序搞混。语法上,SQL的书写顺序是:
code复制SELECT -> FROM -> WHERE -> GROUP BY -> HAVING -> ORDER BY -> LIMIT
但MySQL实际的执行顺序是:
code复制FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT
这个顺序非常重要。它解释了为什么WHERE条件里不能用SELECT中定义的别名(因为SELECT还没执行),也解释了为什么GROUP BY之后,SELECT中只能出现分组字段和聚合函数(因为其他字段已经被合并掉了)。
4.2 JOIN类型对比
JOIN的类型有四种,我列个表一次讲清楚:
| 类型 | 语义 | 数据特征 |
|---|---|---|
| INNER JOIN | 内连接 | 只返回两边都匹配的记录 |
| LEFT JOIN | 左连接 | 返回左表全部记录,右表无匹配则补NULL |
| RIGHT JOIN | 右连接 | 返回右表全部记录,左表无匹配则补NULL |
| CROSS JOIN | 交叉连接 | 返回笛卡尔积,一般用于生成组合数据 |
实际业务中,用的最多的是INNER JOIN和LEFT JOIN。RIGHT JOIN很少直接用,因为只要把表顺序调换一下,RIGHT JOIN就能变成LEFT JOIN,写出RIGHT JOIN的SQL可读性不如LEFT JOIN。
一个常见的坑是:使用LEFT JOIN时,如果把右表的条件写在WHERE里,左连接就悄悄变成了内连接。比如:
sql复制-- 这个查询想统计每个班级人数(包括没有学生的空班级)
SELECT c.class_no, COUNT(s.id) AS student_count
FROM class_info c
LEFT JOIN students s ON c.class_no = s.class_no
WHERE s.score > 500;
这里WHERE里加了 s.score > 500,如果某个班级没有学生,s.score是NULL,这行记录就被过滤掉了。最终结果等于INNER JOIN的效果。如果想保留空班级,条件应该写在ON里面:
sql复制SELECT c.class_no, COUNT(s.id) AS student_count
FROM class_info c
LEFT JOIN students s ON c.class_no = s.class_no AND s.score > 500;
这细微的差别,恰恰是面试里常考、实际开发里男生踩坑的点。
4.3 聚合与排序的进阶玩法
GROUP BY做统计的时候,有个小技巧经常被忽略。假设你要查"每个班级分数最高的学生",一种写法是:
sql复制SELECT class_no, MAX(score) AS max_score
FROM students
GROUP BY class_no;
但如果你还想看到这个最高分学生的名字,那就不能直接加 name 字段,因为"非分组字段 + 非聚合字段"在SQL标准里是不允许的。MySQL的ONLY_FULL_GROUP_BY模式默认开启,会直接报错。你可以用子查询:
sql复制SELECT s.class_no, s.name, s.score
FROM students s
JOIN (
SELECT class_no, MAX(score) AS max_score
FROM students
GROUP BY class_no
) t ON s.class_no = t.class_no AND s.score = t.max_score;
ORDER BY排序方面,最核心的优化是:让排序走索引。比如索引是 (class_no, score),你执行 ORDER BY class_no, score 时,MySQL可以直接按索引顺序读取,不需要额外的文件排序。但如果你的排序字段和索引顺序不一致,或者排序字段上有函数操作,索引就会失效。
4.4 EXISTS vs IN,以及那个"OR不能去重"的问题
很多人在热词里搜"mysql的or能去重吗",这个问题确实值得说清楚。OR 本身没有去重能力,因为SQL查询的集合语义就是不重复的。你可能在 UNION 中看到过去重效果:UNION 默认去重,UNION ALL 不去重。但如果你的业务要求"用OR把符合条件的记录合并展示且不重复",那查询结果本身就不会重复,因为每一行记录在主键上唯一。用OR拼接条件并不会带来重复行,只是多个条件取并集。
再说说EXISTS和IN的选择。对于小表驱动大表,两者差异不大;但如果子查询的结果集很大,IN可能会因为临时表缓存导致性能问题。EXISTS更擅长于"只要存在就返回"的场景。在实际业务里,我会记住一个简单的判断原则:
- 子查询的结果集小,用IN;
- 外层表的数据量小、子查询数据量巨大,用EXISTS;
- 子查询结果集可能需要多次复用,用JOIN。
5. 真实环境中的高频踩坑清单
这一节我纯分享,每一条都是一个真实事故浓缩出来的经验。你读完之后也许不会立刻用到,但下次遇到类似问题,至少知道往哪个方向排查。
5.1 忘记WHERE条件,全表遭殃
这是增删改查领域最经典的事故,没有之一。我见过一个测试环境误删生产库的案例,原因是开发在测试环境写了一条 DELETE FROM users,本想连的都是测试库,结果连接串指向了生产库,一条语句把所有用户数据全删了。后来复盘发现,如果当时采用逻辑删除方案,这种事故至少可以缓解一半。
我的建议:
- 生产库禁用不带WHERE的DELETE和UPDATE。可以在应用层做拦截,也可以在MySQL层面通过触发器或
sql_safe_updates参数来强制约束。 - 把
sql_safe_updates设置为ON,这样不带WHERE条件的DELETE和UPDATE会被拒绝执行。
sql复制SET sql_safe_updates = ON;
5.2 int+5溢出问题
热词里有"mysql中int+5",这其实是个非常具体的场景。如果你在更新语句里直接对INT字段做加法,比如:
sql复制UPDATE students SET age = age + 5 WHERE id = 1001;
当 age 是 TINYINT(取值范围-128到127)时,如果当前值是125,加5后就变成了130,超出范围,MySQL会报错,而且事务回滚。如果用的是 INT(最大21亿左右),一般不容易溢出,但如果你在做计数器,比如 UPDATE counter SET count = count + 1,当数值超过INT上限时同样会出问题。
正确的姿势是:预估字段的数据量级,把增量字段设计成 BIGINT 或者无符号INT,必要时用 DECIMAL 处理涉及金额的精确计算。还有一个更隐蔽的坑是:UPDATE语句中的 age + 5 并不会走索引,因为age字段上加了函数运算。如果这个更新经常执行,需要评估是否要改成"先查出来,再在应用层算好,用常量条件更新"。
5.3 NULL带来的认知偏差
NULL是数据库世界里最让人头疼的概念之一。它既不是0,也不是空字符串,而是"未知"。这导致了很多查询结果与直觉不符。
最经典的坑是:NOT IN子查询遇到NULL,返回空结果。
sql复制-- 如果要查"不在高三(1)班的学生"
SELECT name FROM students
WHERE class_no NOT IN ('高三(1)班', NULL);
这条SQL的结果是空集。因为NULL意味着"未知",class_no NOT IN ('高三(1)班', NULL) 在逻辑上等价于 class_no != '高三(1)班' AND class_no != NULL,而 class_no != NULL 的结果是NULL(未知),所以在WHERE里被当作FALSE过滤掉了。正确写法是避免在IN列表中放NULL,或者用NOT EXISTS。
另一个NULL相关的坑是聚合函数自动忽略NULL。比如 AVG(score) 不会把NULL计入分母,如果你希望NULL按0分处理,需要先用 COALESCE(score, 0) 转换。
5.4 MySQL 8.0的认证协议兼容问题
热词里那个 "firedac phys mysql client does not support authentication protocol requested" 是老生常谈的兼容性问题。MySQL 8.0默认的认证插件是 caching_sha2_password,而一些老客户端(比如部分Delphi的FireDAC组件、旧版PHP mysqlnd、某些老版本JDBC驱动)只支持 mysql_native_password,于是连接时报错。
解决方案有两个:
方案一:在数据库端创建兼容的账号:
sql复制CREATE USER 'app_user'@'%' IDENTIFIED WITH mysql_native_password BY 'your_password';
GRANT ALL PRIVILEGES ON your_db.* TO 'app_user'@'%';
方案二:修改全局默认认证插件(不推荐,降低了安全性):
ini复制# my.cnf
[mysqld]
default-authentication-plugin=mysql_native_password
我个人的建议是:能升级客户端就升级客户端,不能升级再考虑创建兼容账号,不要全局降低认证强度。
5.5 中文乱码问题
MySQL中文乱码的根源几乎只有一个:字符集不统一。数据库、表、连接三层的字符集必须一致。连接层的字符集容易被忽略,比如你在代码里用JDBC连接MySQL,没有显式指定characterEncoding,那写入的数据可能按照latin1处理,读出来自然就是乱码。
最佳实践是存储层全链路使用 utf8mb4,因为它兼容所有Unicode字符,包括emoji。建库时直接指定:
sql复制CREATE DATABASE your_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
连接串里也显式加上:
code复制jdbc:mysql://localhost:3306/your_db?useUnicode=true&characterEncoding=utf8mb4
6. 一条SQL的旅程:从客户端到存储引擎
最后我想聊聊底层机制。理解一条SQL在MySQL内部是怎么跑起来的,能帮你从根本上理解增删改查为什么有时快有时慢。
6.1 执行链路拆解
一条SQL的执行链路大概是:
客户端连接器 -> 查询缓存(8.0已移除) -> 解析器 -> 优化器 -> 执行器 -> 存储引擎
具体来说:
- 连接器:负责和客户端建立连接、鉴权、管理连接状态。这也是为什么连接串里的用户名、密码、host不能有误。
- 解析器:对SQL语句做词法分析和语法分析,生成语法树。如果你的SQL写错了,这里就会报语法错误。
- 优化器:这是最核心的一步。它决定SQL怎么执行,比如选择哪个索引、用什么连接顺序、是否用临时表。优化器的决策直接决定了SQL的性能。你可以用
EXPLAIN查看它最终选择的执行计划。 - 执行器:根据优化器生成的执行计划,调用存储引擎的接口去读写数据。
- 存储引擎:真正操作磁盘和内存的组件,InnoDB负责行锁、事务、MVCC、缓冲池等机制。
MySQL 8.0移除了查询缓存,原因很明显:查询缓存的失效太频繁,只要表上有任何更新,该表的所有缓存全部失效。在高并发写入场景下,查询缓存反而成了性能瓶颈。
6.2 用EXPLAIN验证你的SQL
无论你写的是增、删、改、查哪种语句,如果想确认它有没有走索引,最直接的方法就是 EXPLAIN:
sql复制EXPLAIN SELECT * FROM students WHERE class_no = '高三(1)班' AND score > 500;
重点关注几个字段:
- type:从好到坏依次是
system > const > eq_ref > ref > range > index > ALL。看到ALL就要警惕,说明全表扫描了。 - key:实际用到的索引。如果为NULL,说明没走索引。
- rows:预估需要读取的行数。越小越好。
- Extra:出现
Using filesort表示排序没有走索引;出现Using temporary表示使用了临时表,通常需要优化。
常用的排查方法是:先用EXPLAIN分析慢SQL,再结合业务场景优化索引,最后验证执行计划的变化。
6.3 常见优化思路总览
增删改查的优化可以总结成一句话:让每一行SQL尽量少地触碰数据。
- 查询:善用覆盖索引,避免
SELECT *,避免在索引列上做函数运算。 - 插入:批量插入代替逐条插入,合理设置事务大小,大事务拆小。
- 更新:能用主键或唯一索引条件就别用普通字段,减少锁范围。
- 删除:分批删除,低峰期执行,优先考虑逻辑删除。
慢查询日志也是一个非常有用的调优入口。打开慢查询日志,定位那些执行时间超过阈值(比如1秒)的SQL,一个个去分析,通常能发现绝大多数性能瓶颈。
sql复制-- 查看慢查询日志状态
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
把 long_query_time 设置成1秒(生产环境可以根据实际情况调整),让MySQL把慢SQL记录下来,定期分析,数据库会越跑越健康。
我自己平时维护数据库时有个习惯:每周抽个固定时间,把慢查询日志里的TOP SQL拉出来过一遍,看执行计划,看索引命中率,看锁等待时间。这个过程并不复杂,但长期坚持下来,线上数据库的稳定性会有非常明显的提升。增删改查看似基础,但能把基础操作写对、写快、写安全的人,放在任何团队里都是值得信赖的。
