前几天团队里一个刚转正的同学跑过来问我:“我INSERT和UPDATE都会写,为什么线上一个慢查询把我难住了?”这个问题问得很好,因为增删查改这四个字看起来谁都会,但把它放到真实的生产环境里,背后牵扯的是数据类型选择、约束设计、索引命中、事务隔离、锁竞争这一整条链路。作为一个和MySQL打了多年交道的开发者,我想借这篇内容把数据库增删查改这件事从头到尾拆开讲透,不仅讲语法,更讲语法背后的原理和那些文档里不会写的坑。
这篇文章适合刚入门数据库的初学者,也适合写过一段时间SQL但总在细节上踩坑的开发者。通篇我会用实际的SQL语句、真实场景和踩坑记录来展开,你可以直接照着操作,也可以把它当作一份随时查阅的备忘录。不管你用的是Windows、Linux还是Docker里的MySQL,核心内容都通用。
1. 动手写第一条INSERT前,先把这几件事确认清楚
1.1 建表与字段类型怎么选才不后悔
增删查改的第一步不是写INSERT,而是建一张结构合理的表。很多新手上来就用最简单的写法,结果后面改字段类型、加约束的时候痛不欲生。我见过最典型的例子,是用VARCHAR存金额,用FLOAT存价格,等到做统计汇总的时候发现数据对不上,追查半天才意识到是浮点精度问题。
以一张用户表为例,我常用的建表语句长这样:
sql复制CREATE TABLE `user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`age` TINYINT UNSIGNED DEFAULT 0 COMMENT '年龄',
`balance` DECIMAL(10,2) NOT NULL DEFAULT 0.00 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_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';
这里有几个关键选择值得展开说说:
- id用BIGINT UNSIGNED而不是INT:INT有符号最大才21亿出头,对用户表、订单表这种高增长表来说,几年就顶到上限了。用UNSIGNED可以把正数范围翻倍,而BIGINT几乎不用考虑溢出问题。用AUTO_INCREMENT做主键,后续做分库分表或者数据迁移时也方便。
- 金额用DECIMAL而不是FLOAT/DOUBLE:FLOAT和DOUBLE是浮点数,二进制存储时无法精确表达0.1这样的十进制小数。DECIMAL是字符串存储的定点数,精度完全可控。DECIMAL(10,2)表示总位数10位、小数位2位,最大可存99999999.99,一般业务足够用了。
- DATETIME而不是TIMESTAMP:TIMESTAMP有2038年问题,而且会随时区变化,历史数据的时间可能对不上。DATETIME没有时区概念,存进去是什么就是什么,排查问题时更省心。
- CHARSET用utf8mb4而不是utf8:MySQL的utf8最多支持3字节,存不了emoji和一些生僻字。utf8mb4是完整的UTF-8实现,是MySQL 8.0的默认字符集,直接用就对了。
注意:测试环境建表可以随意,但生产环境改表结构很痛苦。MySQL 8.0里很多DDL操作虽然支持ONLINE执行,但大表上仍然可能产生锁等待或主从延迟。所以建表时花十分钟想清楚字段类型,能帮你省掉后面十个小时的迁移成本。
1.2 INSERT的几种姿势:单条、批量与忽略冲突
建完表就可以插数据了。最基础的INSERT写法有两种,一种是显式指定列名,一种是不指定列名全量插入:
sql复制-- 指定列名插入(推荐)
INSERT INTO `user` (`username`, `email`, `age`, `balance`)
VALUES ('zhangsan', 'zhangsan@example.com', 25, 100.00);
-- 不指定列名,必须按表结构顺序提供所有字段的值
INSERT INTO `user` VALUES (1, 'lisi', 'lisi@example.com', 30, 200.00, NOW(), NOW());
第二种写法看着省事,但隐患很大。一旦表结构调整,比如新增了一个字段,这条SQL就报错了。更重要的是,可读性差,别人看代码根本不知道每个值对应哪个列。我强烈建议在任何项目里都使用第一种写法,虽然多敲几个字符,但维护性提升一个量级。
插入多条数据时,使用单条INSERT搭配多组VALUES是效率最高的方式:
sql复制INSERT INTO `user` (`username`, `email`, `age`, `balance`) VALUES
('wangwu', 'wangwu@example.com', 28, 300.00),
('zhaoliu', 'zhaoliu@example.com', 22, 50.50),
('sunqi', 'sunqi@example.com', 35, 0.00);
MySQL执行单条多VALUES插入时,只需要解析一次SQL、写入一次日志,比逐条INSERT快很多,尤其是插入几百上千条数据时,性能差距可以达到数倍。但是要注意,单条SQL不要无限长,建议每次控制在几百条到一千条以内,否则网络传输和binlog写入的负担也会变大。
还有一个高频场景:插入的数据可能和已有记录的主键或唯一键冲突。比如用户名已经存在,需求是"没有就插入,有就更新余额"。这种场景用INSERT ... ON DUPLICATE KEY UPDATE非常合适:
sql复制INSERT INTO `user` (`username`, `email`, `age`, `balance`)
VALUES ('zhangsan', 'zhangsan_new@example.com', 26, 150.00)
ON DUPLICATE KEY UPDATE
`email` = VALUES(`email`),
`age` = VALUES(`age`),
`balance` = VALUES(`balance`);
这条SQL遇到唯一键冲突时,会自动把email、age、balance更新为新值。比先SELECT再判断再UPDATE的写法高效得多,也避免了并发下"判断时没有、插入时冲突"的竞态问题。
1.3 自增主键的行为与字符集陷阱
每次INSERT之后,如果需要拿到新插入记录的自增ID,MySQL提供了LAST_INSERT_ID()函数:
sql复制INSERT INTO `user` (`username`, `email`) VALUES ('zhangsan', 'zhangsan@example.com');
SELECT LAST_INSERT_ID();
注意,LAST_INSERT_ID()是会话级别的,不会受到其他连接插入数据的影响,所以可以放心使用。不要把LAST_INSERT_ID()和MAX(id)混用,MAX(id)在多线程并发插入时会拿到别人的ID,这在业务上是严重的逻辑错误。
关于自增ID还有两个重要认知:
- 自增ID不回退:即使删除了最大的几行,再插入时也不会复用被删除的ID。这个设计是为了避免主键冲突和索引分裂,属于正常行为,不要试图去"修复"它。
- 自增ID不是连续的:事务回滚后,已经分配的自增ID也不会回收。所以表里出现ID空洞是正常的,千万不要为了追求连续ID去做DELETE或UPDATE操作。
字符集是另一个容易被忽略的坑。如果你的连接字符集和表的字符集不一致,插入中文或表情符号时可能报错或乱码。可以在连接建立后执行:
sql复制SET NAMES utf8mb4;
这个命令一次性设置了客户端、连接、返回结果三层的字符集。在代码里连接数据库时,也要确保连接参数里指定了characterEncoding=utf8mb4或者等效配置。我排查过很多乱码问题,最终根因都是"表是utf8mb4,连接却是latin1",数据进去就变成了问号。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 查询不只是SELECT *,过滤与排序才是核心功力
2.1 WHERE条件的执行逻辑与NULL陷阱
查询是CRUD里使用频率最高的操作,也是最容易写差的。很多初学者习惯用SELECT *,把整张表所有字段捞出来,然后在代码里过滤。这个习惯在数据量小的时候没问题,数据量一上来就会拖垮数据库和带宽。
先看一个常规的查询语句:
sql复制SELECT `id`, `username`, `email`, `balance`
FROM `user`
WHERE `balance` > 100.00
ORDER BY `created_at` DESC
LIMIT 20;
这里的WHERE子句是查询的核心过滤条件。MySQL会先去找到满足balance > 100.00的行,然后按created_at排序,最后取前20条返回。如果你想查用户名等于某个值的记录,写成:
sql复制SELECT `id`, `username` FROM `user` WHERE `username` = 'zhangsan';
WHERE后面可以接多个条件,用AND和OR连接。AND的优先级高于OR,所以当条件混用时要加括号明确语义:
sql复制SELECT `id`, `username` FROM `user`
WHERE (age > 18 AND balance > 100) OR username = 'admin';
NULL是查询里最容易出错的地方。age = NULL永远查不出结果,因为NULL不等于任何值,包括它自己。判断NULL要用IS NULL或IS NOT NULL:
sql复制SELECT `id`, `username` FROM `user` WHERE `email` IS NULL;
相反,如果想查"邮箱不为空"的记录,除了IS NOT NULL,还要考虑是否需要排除空字符串。业务上"未填写邮箱"可能表现为NULL,也可能表现为'',这取决于写入时怎么处理的。写查询之前先确认数据质量,是排查NULL相关问题的第一步。
2.2 ORDER BY排序与LIMIT分页的深层问题
排序用ORDER BY,默认升序ASC,降序加DESC。多字段排序时,按照从左到右的优先级依次排序:
sql复制SELECT `id`, `username`, `balance`, `created_at`
FROM `user`
ORDER BY `balance` DESC, `created_at` ASC;
这条SQL先按balance从高到低排,balance相同的记录再按created_at从早到晚排。多字段排序在排行榜、报表场景中非常常见,优先级颠倒可能导致结果完全不一样。
分页是另一个高频操作。最基础的分页写法是:
sql复制SELECT `id`, `username` FROM `user` ORDER BY `id` LIMIT 20 OFFSET 40;
OFFSET表示跳过的行数,LIMIT表示本次返回的行数。上面这条SQL意思是跳过前40行,返回第41到60行。
这个写法的隐含问题在数据量大时非常明显:OFFSET越大,MySQL需要扫描并丢弃越多的行。比如LIMIT 10 OFFSET 100000,MySQL要先扫描十万零十行,再丢掉前十万行,效率极低。
优化深分页的常用手段是"游标分页"——用上一页最后一条记录的ID作为查询条件:
sql复制-- 第一页
SELECT `id`, `username` FROM `user` ORDER BY `id` LIMIT 20;
-- 第二页:以第一页最后一条记录的id为起点
SELECT `id`, `username`
FROM `user`
WHERE `id` > 10020
ORDER BY `id`
LIMIT 20;
这种方式不需要扫描被跳过的行,查询速度基本恒定。前提是你有一个可以比较大小的排序字段,通常就是自增主键id。如果业务上必须用OFFSET做页面跳转,且表数据量超过百万行,我建议在分页查询的WHERE条件里加一个范围过滤(比如时间范围),尽量减少扫描行数。
2.3 聚合统计与GROUP BY的正确打开方式
查询不只是取数,还经常要做统计。拿用户表来说,想知道平均余额、总余额、最大最小年龄:
sql复制SELECT
COUNT(*) AS total_count,
AVG(`balance`) AS avg_balance,
SUM(`balance`) AS total_balance,
MAX(`age`) AS max_age,
MIN(`age`) AS min_age
FROM `user`;
COUNT(*)、SUM()、AVG()、MAX()、MIN()是五大聚合函数。这里有两个细节:
- COUNT(*)统计的是行数,包括值为NULL的行;COUNT(column)统计的是该列非NULL的行数。如果想知道某字段有多少个非空值,用COUNT(column)更准确。
- SUM和AVG会自动忽略NULL值。但如果所有都是NULL,SUM返回NULL而不是0。业务上如果期待返回0,需要用IFNULL或COALESCE包裹:
SELECT COALESCE(SUM(balance), 0) FROM user;
GROUP BY用于分组统计。比如按年龄段分组统计人数和平均余额:
sql复制SELECT
`age`,
COUNT(*) AS user_count,
AVG(`balance`) AS avg_balance
FROM `user`
GROUP BY `age`
ORDER BY `age` ASC;
这里要注意,SELECT后面出现的非聚合列,原则上必须出现在GROUP BY子句中。虽然MySQL有个ONLY_FULL_GROUP_BY模式(8.0默认开启)会强制校验,但很多人在5.7关闭了严格模式,导致写出来的SQL跑在别的环境上直接报错。规范做法是:GROUP BY后面有什么列,SELECT里就只放什么列加聚合函数。
如果要对分组后的结果做过滤,用HAVING而不是WHERE。WHERE是在分组前过滤原始行,HAVING是在分组后过滤聚合结果:
sql复制SELECT
`age`,
COUNT(*) AS user_count
FROM `user`
WHERE `created_at` >= '2024-01-01'
GROUP BY `age`
HAVING COUNT(*) > 5
ORDER BY user_count DESC;
这条SQL的逻辑是:先筛选2024年之后创建的用户,按年龄分组统计人数,最后只保留人数大于5的年龄组。把HAVING的条件误写成WHERE,是执行报错或结果错误的常见原因。
3. UPDATE没有WHERE就是事故,锁与影响行数都要心里有数
3.1 UPDATE的基础语法与安全门禁
UPDATE的作用是修改已有记录,基本语法:
sql复制UPDATE `user`
SET `balance` = 200.00
WHERE `id` = 1;
SET后面可以写多个字段,用逗号分隔:
sql复制UPDATE `user`
SET `balance` = `balance` + 50.00, `updated_at` = NOW()
WHERE `id` = 1;
把balance在原值基础上加50,这种写法是原子操作,在并发场景下不会丢更新。相比之下,先SELECT出来在代码里加完再UPDATE回去,在并发下会互相覆盖。
UPDATE最危险的地方在于:不写WHERE就是全表更新。我听说过不止一起生产事故,是因为写UPDATE时忘了加WHERE条件,把整张表的字段全部重置了。这种事故在MySQL里没有后悔药,只能靠备份恢复。所以我的习惯是:
- 先写SELECT确认WHERE条件命中的是全表还是结果集:
sql复制SELECT COUNT(*) FROM `user` WHERE `id` = 1;
- 确认无误后,把SELECT替换成UPDATE,保留同样的WHERE条件。
- UPDATE执行后用ROW_COUNT()确认影响行数是否符合预期。
另外,MySQL的客户端工具(如mysql命令行、Navicat)默认可能开启了自动提交,一条UPDATE执行完立即生效。如果担心误操作,可以在执行前先手动开启一个事务:
sql复制START TRANSACTION;
UPDATE `user` SET `balance` = 200.00 WHERE `id` = 1;
-- 检查影响行数,确认无误
COMMIT;
-- 如果发现不对
ROLLBACK;
用事务包住UPDATE的好处是,发现影响行数不对可以立刻回滚。这也是生产环境操作数据时的标准姿势。
3.2 行锁与并发更新:为什么你的UPDATE卡住了
MySQL的InnoDB引擎默认使用行锁。执行UPDATE时,WHERE条件命中的记录会被加排他锁,其他事务想更新同一条记录时会被阻塞,直到当前事务提交或回滚。
举个例子,两个会话同时执行:
sql复制-- 会话A
START TRANSACTION;
UPDATE `user` SET `balance` = 300.00 WHERE `id` = 1;
-- 会话B(此时会被阻塞)
UPDATE `user` SET `balance` = 500.00 WHERE `id` = 1;
会话B会一直等待会话A提交或回滚。如果会话A迟迟不提交,会话B最终可能报错:Lock wait timeout exceeded; try restarting transaction。这个超时时间默认是50秒,由参数innodb_lock_wait_timeout控制。
锁等待出现时,第一反应不是去杀进程,而是搞清楚谁占了锁。可以用下面的SQL查看当前有哪些事务在运行:
sql复制SELECT * FROM information_schema.INNODB_TRX\G
再用下面的SQL查看锁等待关系:
sql复制SELECT * FROM sys.innodb_lock_waits\G
日常开发中最常见的锁等待原因是长事务。一个事务里做了好几条UPDATE,中间还穿插了RPC调用或业务计算,整个过程持续几十秒甚至几分钟,其他事务只能干等。解决思路是:事务里只放必要的数据库操作,别把外部调用塞进去;UPDATE的WHERE条件尽量用到索引,避免全表扫描时锁住大量记录。
还有一个容易被忽视的坑:UPDATE没走索引时,InnoDB会对扫描到的每一行加锁,相当于把整张表都锁住了。所以UPDATE的WHERE条件字段必须有索引,这是性能和并发安全的基本保障。
3.3 UPDATE的隐式类型转换陷阱
隐式类型转换是UPDATE里最容易踩的坑,但SELECT同样会遇到。如果字段类型是VARCHAR,WHERE条件里写成数字:
sql复制UPDATE `user` SET `age` = 26 WHERE `username` = 12345;
如果username列存的是'12345'这种纯数字字符串,MySQL会尝试把列值转换成数字再比较。问题在于,这种转换可能导致索引失效,查询变成全表扫描。更严重的是,当转换规则不满足预期时,可能更新到错误的记录。
再看一个更隐蔽的例子。字段类型是CHAR或VARCHAR,存的值是'00123',你用WHERE username = 123去匹配,MySQL会先把'00123'转成123再比较,结果是可以匹配上的。但如果你想把用户名是123的更新成456,写成:
sql复制UPDATE `user` SET `username` = '456' WHERE `username` = 123;
这条SQL在MySQL 8.0的严格模式下可能直接报错,因为'00123'和'123'都转成123导致匹配范围扩大,更新多条记录。为了避免这类问题,字符串比较必须加引号,这是写SQL的基本素养。
4. DELETE与TRUNCATE,删数据前先想清楚这几点
4.1 DELETE的条件与影响行数确认
DELETE是最需要敬畏的SQL,因为数据一旦删除,如果没有备份就彻底没了。基础语法:
sql复制DELETE FROM `user` WHERE `id` = 10;
DELETE同样可以不写WHERE,那就会清空整张表。这里再次强调:任何DELETE都必须先SELECT确认范围,最好再包一层事务:
sql复制START TRANSACTION;
DELETE FROM `user` WHERE `id` = 10;
-- 确认影响行数是1
COMMIT;
DELETE涉及外键约束时更要小心。如果其他表有外键引用user表的记录,删除user表记录时可能因为外键约束报错,或者触发级联删除。MySQL默认的FOREIGN_KEY_CHECKS是开启的,被引用时直接DELETE会报错:
code复制Cannot delete or update a parent row: a foreign key constraint fails
听到这个报错先别慌,这是数据库在保护数据完整性。正确的做法是:先删除引用表中的子记录,再删除父表记录。千万不要为了省事执行SET FOREIGN_KEY_CHECKS=0然后删数据,这样会留下大量孤儿记录,后续全表扫描和统计都会出问题。
4.2 DELETE、TRUNCATE与DROP三兄弟的区别
同样是把数据搞没,DELETE、TRUNCATE、DROP之间天差地别:
| 操作 | 类型 | 是否可回滚 | 是否重置自增ID | 是否释放存储空间 | 是否删除表结构 |
|---|---|---|---|---|---|
| DELETE | DML | 事务内可回滚 | 否 | 否(碎片仍在) | 否 |
| TRUNCATE | DDL | 不可回滚 | 是 | 是 | 否 |
| DROP | DDL | 不可回滚 | 是 | 是 | 是 |
实际上,TRUNCATE在MySQL 8.0中某些场景下隐式提交,一旦执行无法ROLLBACK。而DELETE如果是在事务中执行,可以ROLLBACK恢复。
使用建议:
- 要删除部分行,用DELETE加WHERE。
- 要清空整张表并重置自增ID,用TRUNCATE。TRUNCATE速度远快于DELETE全表。
- 连表结构一起不要了,用DROP。
一个细节是:TRUNCATE之后,自增ID从1重新开始;DELETE全表后自增ID不会重置。我之前处理过一张临时表,每次跑批前需要清空数据,用TRUNCATE是最高效的,执行完ID从头开始,省去了显式指定ID的麻烦。
4.3 误删恢复与日常备份的底线思维
误删数据是所有开发者的噩梦,但噩梦是可以被制度化的备份习惯挡住的。MySQL最常用的备份工具是mysqldump,使用方式如下:
bash复制# 备份单个数据库
mysqldump -u root -p --single-transaction --quick --routines --triggers testdb > testdb_backup.sql
# 指定编码
mysqldump -u root -p --default-character-set=utf8mb4 testdb > testdb_backup.sql
--single-transaction参数对InnoDB表很重要,它通过事务快照实现一致性备份,不会锁表,不会影响线上业务。--routines和--triggers分别备份存储过程和触发器。
恢复数据:
bash复制mysql -u root -p testdb < testdb_backup.sql
为了把恢复粒度做到更细,生产环境还需要开启binlog。binlog里记录的是所有数据变更操作,可以配合备份把数据库恢复到任意时间点。检查binlog是否开启:
sql复制SHOW VARIABLES LIKE 'log_bin';
如果线上已经开启binlog,误删后的恢复流程大致是:先用最近的备份恢复全量数据,再用binlog回放到误删操作之前的时间点。这个过程很考验运维能力,但至少给数据留下了一线生机。
另外,在删除动作本身加上"软删除"策略也是业界常见做法。给表加一个deleted_at字段,删除时执行UPDATE而不是DELETE:
sql复制UPDATE `user` SET `deleted_at` = NOW() WHERE `id` = 10;
查询时统一过滤WHERE deleted_at IS NULL。这种方式可以随时找回误删数据,代价是查询语句多一个条件、数据量会持续增长。对于中小型业务来说,这个代价完全值得。
5. 事务与索引:CRUD从"能跑"到"跑得好"的进阶之路
5.1 事务的ACID与转账场景实战
增删查改如果只停留在单条SQL层面,根本体现不出数据库真正的能力。所有复杂的业务操作,比如下单、转账、库存扣减,都是由多条SQL组合完成的,它们必须保证"要么全部成功,要么全部失败",这就是事务存在的意义。InnoDB是支持事务的存储引擎,MyISAM不支持,这也是为什么从MySQL 5.5开始官方推荐使用InnoDB。
事务的四大特性ACID——原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),核心是让多条SQL变成一个不可分割的整体。用转账场景举例:
sql复制START TRANSACTION;
UPDATE `account` SET `balance` = `balance` - 100.00 WHERE `user_id` = 1;
UPDATE `account` SET `balance` = `balance` + 100.00 WHERE `user_id` = 2;
-- 检查扣款方余额是否足够,足够则提交,否则回滚
COMMIT;
如果不使用事务,第一条UPDATE成功了、第二条UPDATE失败,钱就凭空消失了。用事务包住,要么两条都成功,要么两条都回滚。COMMIT之前如果发现逻辑不对,随时可以ROLLBACK。
事务隔离级别也是CRUD进阶的必修课。MySQL默认的隔离级别是REPEATABLE READ(可重复读),它保证同一个事务内多次SELECT结果一致。每种隔离级别解决的问题不同:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB间隙锁可避免) |
| SERIALIZABLE | 不会 | 不会 | 不会 |
对大多数业务来说,保持默认的REPEATABLE READ就好。如果要追求更高的并发性能,可以从REPEATABLE READ降到READ COMMITTED,比如某些报表查询场景。但改了隔离级别之后,同一个事务内多次SELECT结果可能不同,业务代码要能接受这种变化。
5.2 索引:查询加速的核心机制
再好的查询语句,没有索引也是白搭。索引的原理可以类比书的目录:没有目录,你要一页页翻;有目录,直接翻到对应页码。InnoDB的索引底层是B+Tree,叶子节点存储了整行数据,非叶子节点只存索引列的值和指向子节点的指针。
创建索引的语法:
sql复制-- 普通索引
CREATE INDEX idx_username ON `user` (`username`);
-- 唯一索引
CREATE UNIQUE INDEX uk_email ON `user` (`email`);
-- 复合索引
CREATE INDEX idx_age_balance ON `user` (`age`, `balance`);
索引不是越多越好。每个索引都会占用存储空间,插入、更新、删除时都要同步维护索引,索引太多反而拖慢写操作。我的经验是:只在WHERE条件高频出现、JOIN关联字段、ORDER BY排序字段上建索引。
复合索引有个重要概念叫"最左前缀原则"。索引idx_age_balance建立在(age, balance)上,查询条件必须从age开始才能用上这个索引。以下SQL可以用到这个索引:
sql复制WHERE age = 25
WHERE age = 25 AND balance > 100
但下面的SQL用不上:
sql复制WHERE balance > 100
因为在复合索引中,先按age排序,再按balance排序。跳过age直接用balance,索引就没有意义了。
判断一条SQL有没有用上索引,使用EXPLAIN:
sql复制EXPLAIN SELECT `id`, `username` FROM `user` WHERE `username` = 'zhangsan';
EXPLAIN输出中的type列是关键参考。从好到差大致是:const、eq_ref、ref、range、index、ALL。其中ALL表示全表扫描,是性能最差的;range表示索引范围扫描,通常可以接受。还有一个细节是key列,显示实际用到的索引名。如果key是NULL,说明没有命中任何索引。
5.3 生产环境CRUD性能优化清单
整理一份我平时做SQL Review时会逐条检查的清单,每一条都是真实踩坑后的沉淀:
- **禁止SELECT ***:只取业务需要的列,减少网络传输和内存占用。这点在宽表上效果尤其明显。
- WHERE条件里的列不要套函数:比如
WHERE DATE(created_at) = '2024-01-01'会让索引失效,因为MySQL必须先计算再比较。正确写法是WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'。 - LIKE模糊匹配的%别放开头:
LIKE '%zhang'用不上索引,LIKE 'zhang%'可以走索引。 - 分页别用大OFFSET:前文提到的游标分页替代OFFSET,在千万级数据上是质的差别。
- 批量操作不是越猛越好:批量INSERT或UPDATE数量控制在500到1000条左右,兼顾性能与日志压力。
- 多表关联时先确认连接字段有索引:两个大表JOIN时,如果没有索引,MySQL可能要做嵌套循环全表扫描,慢到让你怀疑服务器宕机了。
- 定期用慢查询日志找出性能杀手:MySQL配置中开启慢查询日志,把执行时间超过1秒的SQL记录下来,定期Review。
慢查询日志开启方式:
sql复制SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
线上环境建议长期开启慢查询日志,配合EXPLAIN分析,基本能覆盖绝大多数性能问题。
6. 实战中遇到的几个高频问题排查记录
6.1 ERROR 2002 (HY000) 连接失败
这个问题在热搜词里出现了,我遇到很多次。完整的报错是:
code复制ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)
这个报错常见于刚安装完MySQL或者系统重启之后。意思是找不到MySQL的socket文件,而socket文件是MySQL启动后才会生成的。排查步骤:
- 确认MySQL服务是否在运行:
bash复制systemctl status mysqld
- 如果服务没启动,启动它:
bash复制systemctl start mysqld
- 如果服务已启动但依然报错,查一下socket文件路径是否和客户端默认路径一致。MySQL 8.0的socket路径可以用:
sql复制SHOW VARIABLES LIKE 'socket';
- 如果路径不一致,连接时显式指定:
bash复制mysql -u root -p -S /var/lib/mysql/mysql.sock
还有一种情况是使用TCP/IP连接但端口不通。检查端口监听状态:
bash复制netstat -anp | grep 3306
如果是云服务器,还要确认安全组规则放行了3306端口。这类网络问题排查起来比较繁琐,耐心从服务状态、socket路径、端口监听、防火墙规则逐层排查,基本都能定位。
6.2 插入数据乱码与字符集三层设置
乱码问题往往不是出在"表结构"一层,而是客户端、连接、服务器三层字符集不一致导致的。排查优先级从高到低:
- 连接后立刻执行
SHOW VARIABLES LIKE 'character_set%';,观察character_set_client、character_set_connection、character_set_results。 - 如果这三者不一致,执行
SET NAMES utf8mb4;。 - 确认表本身的字符集:
sql复制SHOW CREATE TABLE `user`;
- 确认建表时没有残留latin1或utf8mb3的列。
我处理过一个历史遗留系统,表的字符集是latin1,里面已经有大量中文数据。直接ALTER TABLE改成utf8mb4反而会把数据变乱码,因为存储的字节流是latin1编码。正确做法是先备份,然后转成utf8mb4,转换时要注意原来是latin1存储的中文实际上是GBK编码,需要先转成binary再转utf8mb4,过程比较折腾。所以建表时直接选utf8mb4,是最省心的决策。
6.3 Lock wait timeout exceeded:锁等待超时怎么办
前面讲UPDATE时说过锁等待的问题。实际开发中,一条SQL执行爆出Lock wait timeout exceeded,正常的排查链路是:
- 查看当前正在运行的事务:
sql复制SELECT * FROM information_schema.INNODB_TRX\G
- 找占用锁的事务持续时间(trx_started字段),基本可以判断是哪个长事务卡住了锁。
- 查看锁等待明细:
sql复制SELECT * FROM sys.innodb_lock_waits\G
- 如果确认是某个事务卡死,可以手动终止它:
sql复制KILL 线程ID;
从根上解决问题,是找到为什么事务没提交。常见原因有:事务里做了大量耗时操作、代码里忘了COMMIT、事务里调用了外部接口。把事务体量控制在"只操作必要的行"这个原则,锁等待问题能减少一大半。
6.4 INT(M)显示宽度与真实存储范围的误区
热搜词里有"mysql中int+5",这里值得澄清一个老生常谈的误区。INT(5)中的5不是存储长度限制,而是显示宽度。INT类型固定占用4字节存储,存储范围是固定的:
- 有符号INT:-2147483648 到 2147483647
- 无符号INT UNSIGNED:0 到 4294967295
INT(5)配合ZEROFILL属性时,数字不足5位会在前面补零显示,比如5显示为00005。但存储值本身没有任何变化,超过5位的数值照样能存进去。这个显示宽度功能在新版MySQL中已经不推荐使用,ZEROFILL也在MySQL 8.0.17之后被标记为废弃。日常建表直接用INT或INT UNSIGNED就对了,别为了"好看"写INT(11)之类的东西,容易让人误会。
顺手把MySQL常用整数类型的存储范围列出来,方便参考:
| 类型 | 字节数 | 有符号范围 | 无符号范围 |
|---|---|---|---|
| TINYINT | 1 | -128 ~ 127 | 0 ~ 255 |
| SMALLINT | 2 | -32768 ~ 32767 | 0 ~ 65535 |
| MEDIUMINT | 3 | -8388608 ~ 8388607 | 0 ~ 16777215 |
| INT | 4 | -2147483648 ~ 2147483647 | 0 ~ 4294967295 |
| BIGINT | 8 | -2^63 ~ 2^63-1 | 0 ~ 2^64-1 |
建表时根据业务量预估选择合适的整数类型,年龄用TINYINT,主键用BIGINT,中间状态用INT,这是最稳妥的组合。
最后说点个人的体会
带过不少刚入行的同学,也见过很多线上事故,我发现一个规律:能把增删查改写得行云流水的人,后面学事务、索引、优化、分库分表都会顺很多;反过来,总觉得基础语句简单、不爱深究细节的人,迟早会在某个深夜被一条慢查询或锁等待逼到怀疑人生。这次把CRUD相关的核心点、坑点、排查思路整理出来,既是给新人的一份地图,也是我自己多年踩坑后的一次回望。如果你在实际操作中遇到这里没覆盖到的问题,欢迎带着现场报错和表结构来交流,我们下篇再继续聊。
