前几天有个同事跑来找我,说他照着网上的教程装 MySQL,折腾了一整天,最后net start mysql弹出来的还是“服务无法启动”。我过去一看,他连my.ini里的basedir和datadir都写反了,数据目录是空的,服务当然起不来。这件事让我意识到,很多人学 MySQL 最大的障碍不是 SQL 语法,而是对整个数据库的运行机制缺乏整体感知。这篇文章就是我的 MySQL 学习笔记,我把这些年踩过的坑、想明白的原理、以及几个真正救过场的排查思路都梳理在这里,希望能帮你少走一些弯路。
1. 学MySQL之前,先想清楚它到底解决什么问题
1.1 从一次“照着教程装MySQL”翻车说起
同事翻车那天,他从网上复制了一堆命令,什么mysqld --install、net start mysql,照着敲完就报错。我帮他排查时发现,他根本没有创建data目录,也没有初始化数据字典,my.ini里的路径还写成了中文目录。这些错误在官方文档里都有明确说明,但新手往往只顾着“跑命令”,忽略了配置文件和服务之间的关系。
我后来总结出一句话:装 MySQL 本质上是装一个“服务”,不是装一个“软件”。Windows 下它是一个 Windows 服务,Linux 下它是一个守护进程,Docker 下它是一个容器。只有理解了服务这一层,你才知道为什么改了my.ini要重启服务,为什么初始化数据目录后服务才能启动。很多人卡在安装这一步,就是因为把数据库当成了普通应用程序。
1.2 关系型数据库的四个核心使命
先跳出安装的坑,聊聊更根本的问题:MySQL 到底在解决什么?
- 持久化:数据不能因为断电、宕机就丢了,必须落盘。
- 并发控制:多个用户同时读写同一条数据时,结果要正确。
- 故障恢复:崩溃之后,已提交的事务不能丢,未提交的事务要回滚。
- 数据一致性:业务规则如“账户余额不能为负”必须被强制执行。
你可以把 MySQL 想象成一个带规则的快递柜,而不是一个塞满东西的仓库。仓库只有一个门,大家一拥而上就会乱;快递柜有格子、有锁、有登记,每个人取放都有记录和校验。事务、锁、索引这些概念,本质上都是为了维护这个快递柜的秩序。
1.3 建议的学习顺序:先把地图铺开
我见过不少人一上来就背SELECT * FROM table,背了几个月只会在测试库里面玩玩具数据,遇到真实问题照样懵。我自己的学习路径是:SQL 基础 → 索引与事务 → 锁与隔离级别 → 备份恢复 → 性能调优 → 数据同步。每一步都要有个验收标准,比如“SQL 基础”学完后,你应该能独立写出一条带JOIN、GROUP BY、HAVING的查询,并解释每一步的执行顺序。
另外,MySQL 的版本差异非常大。5.7 和 8.0 在身份认证、索引、优化器上都有明显区别,8.0 后又推出了 8.4 LTS 长期支持版本。新项目我个人建议直接用 8.0 以上版本,5.7 已经停止更新,没必要在新环境里重新踩老坑。
2. 安装配置里的那些坑,希望你别再踩一遍
2.1 Windows:服务无法启动、SSL连接错误、e0434352
Windows 上装 MySQL 8.0,常见方式有两种:下载 MSI 安装包,或者下载 ZIP 包手动配置。MSI 看起来省事,但如果你是为了给 JavaWeb 项目做本地库,我更推荐 ZIP 方式,因为你能清楚知道每个文件被放到了哪里。
ZIP 方式的完整流程是:解压到C:\mysql-8.0,在根目录新建my.ini,至少写上:
[mysqld] basedir=C:/mysql-8.0 datadir=C:/mysql-8.0/data port=3306 character-set-server=utf8mb4然后以管理员身份打开命令行,执行:
mysqld --initialize-insecure mysqld --install net start mysqlmysqld --initialize-insecure会创建一个 data 目录,并生成一个初始 root 账号。很多人漏掉这一步,直接启动服务,就会遇到“服务无法启动”。
如果服务确实启动了,但登录时提示 SSL 连接错误,常见原因有三个:服务器端 SSL 证书文件缺失或过期、客户端使用的 TLS 版本不匹配、连接串里没有正确配置 SSL 参数。排查时先看服务端状态:
SHOW VARIABLES LIKE '%ssl%';如果have_ssl是DISABLED,那是服务端就没开启 SSL;如果已开启,客户端连接串可以尝试显式指定useSSL=true&requireSSL=false。需要提醒一句:不要为了省事永久关闭 SSL,本地测试无所谓,生产环境别有这种习惯。
还有一个 Windows 特有的报错[ERROR] [MY-014060]或者系统层弹出 e0434352 之类错误码。e0434352 从技术层面看是 .NET 运行时抛出的未处理异常,常见于系统中缺少 Visual C++ 运行库或 .NET 组件。我的建议是别在那一堆“报错修复工具”里浪费时间,先把微软官方的 Visual C++ Redistributable 装齐,再把 MySQL 的 MSI 安装日志完整贴出来排查。别忘了,MySQL 8.0 的 ODBC 驱动也依赖 VC++ 2015-2022 运行库,很多人连 ODBC 连不上也是同一个原因。
2.2 Linux:RPM包依赖和初始化密码
Linux 下装 MySQL,最容易被老教程带到 RPM 路线上。CentOS 上装 5.7 时经典报错就是缺依赖:
error: Failed dependencies: libaio.so.1()(64bit) is needed by mysql-community-server-... perl(Data::Dumper) is needed by ...解决办法是提前安装依赖:
yum install -y libaio perl装完后,MySQL 8.0 的初始化方式和 5.7 有些差别,5.7 需要手动执行mysqld --initialize --user=mysql,8.0 的 RPM 包通常会自动初始化。初始密码会写在日志里:
grep 'temporary password' /var/log/mysqld.log如果你在 CentOS 9 上给 Zabbix 7.0 LTS 配 MySQL 8.0 后端,建议别用系统自带的 MariaDB,也别乱改 MySQL 的密码策略,先把validate_password组件的规则搞清楚再设置,否则一条建库命令都会因为密码强度不够而失败。
2.3 Docker:一句命令跑起来,数据却可能瞬间蒸发
Docker 是很多人现在装 MySQL 的首选,一条命令就能起一个实例:
docker run -d --name mysql8 -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 mysql:8.0但不挂数据卷的话,容器删掉,数据全部蒸发。
正确做法是把数据目录和配置文件都挂出来:
docker run -d --name mysql8 -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ -v /opt/mysql/data:/var/lib/mysql \ -v /opt/mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf \ mysql:8.0Docker 安装失败最常见的原因,一是宿主机 3306 端口已经被占用,二是 MySQL 容器初始化需要十几秒,你刚docker run完就急着连,当然连不上。另外,在 ARM 架构的机器上拉镜像时要注意平台版本,直接docker pull mysql:8.0有时候会拉到 arm64 镜像,导致一些老驱动连不上。
2.4 客户端工具的选型建议
Navicat 功能确实全,但它是付费软件。网上那些“Navicat for MySQL 破解安装”的教程,我强烈不建议碰。你的数据库里将来存的是客户信息、订单数据、甚至身份证号,用一个来路不明的破解工具连生产库,等于把钥匙交给陌生人。
我推荐 DBeaver,开源免费,支持 MySQL、PostgreSQL、ClickHouse、TDengine 等几乎所有常用数据库。有同事抱怨 DBeaver 离线装 MySQL 驱动很麻烦,其实是因为 DBeaver 默认会在线下载驱动包,内网环境失败后,手动下载mysql-connector-java.jar,在“数据库驱动管理器”里指定本地路径即可。C++ 项目连 MySQL 也类似,需要把libmysql.dll或mysql-connector-c++放对位置,本质上都是驱动问题。
3. 日常SQL里那些“看起来对,实际上坑人”的细节
3.1 排序乱跳:ORDER BY背后的“不稳定”
先说一个真实案例。分页接口第一页返回了一条记录,第二页又返回了同一条记录,用户以为数据错乱。原因很简单:ORDER BY created_at LIMIT 20里,created_at并不是唯一值。当排序字段相同时,MySQL 返回顺序是不确定的,可能每次执行都不一样。
正确写法是加一个唯一字段作为次级排序:
SELECT * FROM orders ORDER BY created_at, id LIMIT 20 OFFSET 0;另一个排序坑是字符集排序规则。MySQL 的utf8mb4_general_ci和utf8mb4_unicode_ci对中文、英文、拼音的排序结果不同。如果建表时一张表用了utf8mb4_general_ci,另一张用了utf8mb4_unicode_ci,JOIN 时可能连索引都用不上。排序之前,先确认 collation。
还有一点容易被忽略:ORDER BY字段如果无法走索引,EXPLAIN 里会出现Using filesort,数据量大的时候会生成临时文件排序,非常慢。优化办法有两种,一是让排序字段包含在联合索引里,二是减少排序行数,比如先用 WHERE 把数据过滤到很小的范围。
3.2 改表结构:ALTER TABLE不是加个字段那么简单
热搜词里有“mysql数据库修改结构”,这个需求几乎每个项目都会遇到,但很多人对 ALTER TABLE 的代价没有概念。
在 MySQL 8.0 之前,大部分 ALTER TABLE 操作会锁表,期间业务无法写入。即使 8.0 支持了ALGORITHM=INSTANT,也只对“在末尾添加列”这类操作有效。比如:
ALTER TABLE orders ADD COLUMN status INT NOT NULL DEFAULT 0;这条语句在 8.0.12 之后,如果status列加在表末尾,可以 INSTANT 完成。但如果加在中间位置,MySQL 仍然要重建表,数据量大时会在凌晨把 CPU 打满。
线上改表的标准操作是:低峰期执行,先用pt-online-schema-check评估,或者直接用 Percona Toolkit 的pt-online-schema-change做在线变更。没有条件用工具时,至少先看表大小:
SELECT table_name, round(((data_length + index_length) / 1024 / 1024), 2) AS size_mb FROM information_schema.tables WHERE table_schema = 'yourdb';几百 MB 的表随便改,几百 GB 的表就要非常谨慎。
3.3 默认值0:一个很小但影响深远的建模习惯
“mysql设置默认值为0”这个热搜来自很多建表需求。为什么大家喜欢默认值 0?因为 NULL 在 SQL 里的行为很反直觉。
COUNT(column)不会统计 NULL 值。SUM(column)遇到 NULL 会直接忽略。WHERE column = NULL永远不成立,必须用IS NULL。- 两个 NULL 不相等,
NULL = NULL结果是 NULL,不是 TRUE。
所以对于数值型业务字段,我一般建议NOT NULL DEFAULT 0。但这不是万能药。比如订单状态,如果业务上有“未开始”和“进行中”两种状态,用一个默认 0 表示“待处理”没问题;如果状态本身允许“未知”,那用 0 和用 NULL 的语义就不一样了,将来排查问题时你根本分不清“从未设置”和“被设置为0”有什么区别。设计字段默认值时,先问自己一句话:这个默认值在业务上到底代表什么意思?
3.4 误更新数据后的恢复思路:先预防,再补救
热搜里有个词叫“mysql update 还原”,一看就是有人做了后悔操作。先给结论:MySQL 没有一键还原 UPDATE 的命令,恢复要靠备份和 binlog。
最可靠的预防手段只有三条:
- 生产环境禁止不带 WHERE 的 UPDATE 和 DELETE,可以用
--safe-updates选项保护。 - 执行影响行数很小的 UPDATE 前,先 SELECT 一遍同样的 WHERE 条件,确认影响范围。
- 定期 mysqldump 全量备份,并开启 binlog。
真的误操作了,恢复思路是:找到误操作前的 binlog 位点,用mysqlbinlog解析日志,提取出误操作那一刻之前的事件,重放到一个临时库,然后导出被覆盖的数据。这个操作很繁琐,但确实可行。前提是 binlog 格式是ROW,如果是STATEMENT格式,解析会非常困难。所以我在新部署任何 MySQL 时,都会把binlog_format=ROW写进配置,这是给未来的自己留一条后路。
4. 事务、锁、索引:事故现场的真相,不是面试八股
4.1 隔离级别:从脏读到幻读,每个级别都在解决一个真实问题
学习事务,我特别大的体会是:这些概念不是发明出来考人的,每一个都对应着真实世界里出现过的数据错乱。
MySQL 的隔离级别从低到高有四档:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB下大部分场景可避免) |
| SERIALIZABLE | 不会 | 不会 | 不会 |
给你一个容易理解的例子:
事务 A 查订单表里金额为 100 的订单,事务 B 同时把其中一条订单金额改成 200 并提交。此时事务 A 再去查,如果它看到的是 200,那就是“不可重复读”,因为同一个事务内两次读取同一条件的结果不一样了。如果事务 B 插入了一条新订单,事务 A 再查时多出一行,那就是“幻读”。
MySQL InnoDB 引擎默认是 REPEATABLE READ,通过 MVCC(多版本并发控制)让普通 SELECT 读取的是快照,从而避免了大部分幻读。但如果是当前读(比如SELECT ... FOR UPDATE),在特定条件下仍然可能出问题。所以不要以为“MySQL 默认就不会幻读”,面试时把 MVCC 和当前读、快照读这两条线讲清楚,比单纯背隔离级别要加分得多。
4.2 锁的世界:共享锁、排他锁、意向锁、间隙锁
锁是面试常客,也是线上死锁的根源。我按自己的理解给你捋一遍:
- 共享锁(S锁):读锁,多个事务可以同时加共享锁,但别人不能加排他锁。对应
SELECT ... LOCK IN SHARE MODE。 - 排他锁(X锁):写锁,一旦加上,其他事务不能再加任何锁。对应
UPDATE、DELETE,以及SELECT ... FOR UPDATE。 - 意向锁:表级别的锁。事务准备给某行加锁时,先在表上加上意向锁,告诉别人“这张表里可能有些行被锁了”,这样其他事务要加表锁时就不用逐行扫描判断。
- 间隙锁:REPEATABLE READ 下,在索引范围查询时,MySQL 会把不存在的记录区间也锁住,防止其他事务在区间里插入数据,从而避免幻读。
死锁的经典场景是:事务 A 先更新订单表,再更新用户表;事务 B 先更新用户表,再更新订单表。两个事务互相等对方释放锁,MySQL 检测到死锁后会回滚其中一个事务。我在业务代码里规范所有事务的更新顺序都按“用户表→订单表”执行,这个问题就再也没有出现过。
线上死锁发生后,别急着改代码,先看现场:
SHOW ENGINE INNODB STATUS;这段输出里会标注LATEST DETECTED DEADLOCK,里面有两张表的加锁顺序和持有锁的情况,能帮你快速定位问题。注意,这个命令需要 SUPER 或 PROCESS 权限。
4.3 索引失效场景:为什么加了索引还是全表扫描
“mysql创建索引”这个关键词天天有人搜,但我发现很多人的困境不是“不会建”,而是“建了没用”。下面这几个场景,是我实际排查中见到的典型索引失效原因:
隐式类型转换。字段是字符串,查询条件是数字:
SELECT * FROM user WHERE phone = 13800138000;phone是VARCHAR,MySQL 会把字段转成数字再比较,索引直接失效。正确写法是phone = '13800138000'。
对索引列使用函数:
SELECT * FROM orders WHERE DATE(created_at) = '2025-01-01';即使created_at有索引,也无法使用,因为索引存储的是原始值,不是DATE()函数结果。正确写法是范围查询:
SELECT * FROM orders WHERE created_at >= '2025-01-01' AND created_at < '2025-01-02';LIKE 前缀模糊:LIKE '%关键字'用不上索引,但LIKE '关键字%'可以。
联合索引不满足最左前缀:联合索引idx_user_status(user_id, status),查询条件只写status,用不上索引。所以联合索引的字段顺序设计要与业务查询的 WHERE 条件对齐。
加索引之前还要想明白一个问题:索引不是越多越好。每个索引都要占磁盘、都要在写入时更新,索引太多会导致写入变慢。我通常的做法是先用慢查询日志找到高频慢 SQL,再针对它建索引,而不是一上来就给所有字段都建索引。
4.4 存储过程:能用,但请不要把业务逻辑全塞进去
热搜里“mysql存储过程”的搜索量不小。存储过程在批量处理、定时任务、报表统计这类场景里确实方便,比如一个月度对账存储过程,直接在数据库里聚合完再输出结果,省去应用层和大表之间的数据传输。
但我不建议把核心业务逻辑写进存储过程。原因有三:
- 存储过程不好调试,MySQL 里断点调试远不如 Java/C++ 的 IDE 方便。
- 存储过程难以做版本管理,应用代码能进 Git 仓库做 Code Review,存储过程往往只存在于某个环境的数据库里。
- 存储过程会把计算压力集中在数据库服务器上,而数据库是系统里最难水平扩展的组件。
如果你确实要用,至少学会错误处理。存储过程里常见的坑是 SQL 异常后继续执行,导致结果莫名其妙。处理方法是声明异常处理器:
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;这样任何 SQL 错误都会触发回滚,并重新抛出错误信息给客户端。
5. 数据同步与迁移:MySQL不应该是唯一的数据底座
5.1 用Flink把MySQL同步到ClickHouse的常见做法
业务跑了一段时间后,你会发现 MySQL 很吃力:报表查询几十秒都出不来,导出大表直接卡死在线事务。这时候很多人会把分析型数据放到 ClickHouse,热搜里“使用flink 实现mysql同步到clickhouse”就是典型需求。
整体链路一般是:
MySQL binlog -> Canal/Flink CDC -> Kafka -> Flink -> ClickHouse我实际用的比较多的是 Flink CDC 方案,它可以直接订阅 binlog,省掉 Canal 那一层代理。前提是 MySQL 要开启 binlog 且格式是 ROW:
[mysqld] server-id=1 log-bin=mysql-bin binlog_format=ROW给同步账号授权时,除了 SELECT 权限,还要加上:
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'flink_user'@'%';这个方案里踩过的坑有两个。第一个是 ClickHouse 不擅长高频 UPDATE/DELETE,MySQL 同步过来的数据如果经常变更,建议把 ClickHouse 表建为 ReplacingMergeTree,用更新时间字段去重。这能保证同一主键只保留最新版本。第二个坑是 DDL 同步。MySQL 表加了一个列,ClickHouse 那边不会自动加,同步任务很容易挂掉。所以上线前要和业务确认表结构冻结周期,或者写一个监控 DDL 的告警脚本。
5.2 把MySQL表结构迁移到TDengine:超级表与子表的设计
TDengine 是时序数据库,建模方式和 MySQL 完全不同。它有一个“超级表(STABLE)”的概念,超级表定义的是数据模型,每个设备的数据放在子表(TABLE)里。
比如 MySQL 里有一张设备温度表:
CREATE TABLE device_temperature ( id BIGINT PRIMARY KEY, device_id VARCHAR(32), region VARCHAR(32), ts DATETIME, temperature FLOAT, humidity FLOAT );切换到 TDengine 时,设计思路是:
ts作为时间主列,必须是 timestamp 类型。temperature、humidity是采集的数值,作为 field。device_id、region是标签 tag,用于分组过滤。
建表语句类似:
CREATE STABLE temperature_stable ( ts TIMESTAMP, temperature FLOAT, humidity FLOAT ) TAGS (device_id BINARY(32), region BINARY(32));然后每个设备插入数据时自动创建子表:
CREATE TABLE temp_device_001 USING temperature_stable TAGS ('device_001', '华东');我写过一个小工具,用 Python 读取 MySQL 的information_schema.COLUMNS,自动生成上面的 TDengine 建表语句。核心就三步:读取列名和类型 → 把数值列分到 field、把维度列分到 tag → 生成 STABLE 和子表语句。如果表里还有字符串类型,TDengine 里要用BINARY或NCHAR,而且长度需要显式指定,不能像 MySQL 那样随便一个TEXT就完事。
5.3 排查Sqoop连不上MySQL的顺序
Sqoop 这种老牌工具虽然生态在老化,但还是有不少存量系统在用。“sqoop连接不上mysql”这个问题,按下面顺序排查,基本十分钟内能定位:
- 驱动 jar 包是否就位。
mysql-connector-java.jar要放到$SQOOP_HOME/lib或者 Hadoop 的 share 目录,注意 MySQL 8 需要 8.0 版本的驱动,5.7 用的老驱动会报Communications link failure。 - 连接串是否完整。MySQL 8 必须带上
useSSL=false&serverTimezone=Asia/Shanghai,否则也容易出现 SSL 连接错误。 - 认证插件是否兼容。MySQL 8 默认用户认证插件是
caching_sha2_password,Sqoop 依赖的老驱动不认识,需要要么升级驱动,要么给同步账号单独用mysql_native_password。 - 网络和权限。看 MySQL 用户能不能从 Sqoop 所在主机登录,
user表里的 host 是不是%,防火墙是否放行 3306 端口。
6. 调优与面试:把学习笔记变成解决问题的能力
6.1 定位慢SQL:EXPLAIN是永远的第一站
性能调优面试题里最常出现的工具就是 EXPLAIN。但真正要解决线上问题,你得先找到慢 SQL。我一般按这个顺序操作:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1;然后再用:
mysqldumpslow -s at /var/lib/mysql/*slow.log找到高频慢 SQL 后,EXPLAIN一条语句看重点字段:
| 字段 | 关注点 |
|---|---|
| type | 从好到差依次是 const、eq_ref、ref、range、index、ALL |
| key | 实际使用了哪个索引,NULL 表示没走索引 |
| rows | 预估扫描行数,和实际延迟基本成正比 |
| Extra | 出现 Using filesort、Using temporary 就要警惕 |
调优顺序是:先解决全表扫描,再解决排序和临时表,最后才是 SQL 重写或加索引。不要一上来就加索引,先看原来的索引为什么没生效,这和我前面讲的索引失效场景是同一个思路。
6.2 连接池参数别拍脑袋:先看症状再调参
“mysql的数据库连接池”这个热搜词背后,其实就是连接池参数怎么设。
一个误区是连接池越大越好。每个 MySQL 连接都是一个线程,占用内存和上下文切换成本。一个 Java 应用里设置 200 个连接,数据库可能直接就扛不住了。我见过一个合理的推荐公式是核心线程数 * 2 + 有效磁盘数,但这只是起点,不是标准答案。
更实际的做法是先看症状:
- 应用日志里大量
Connection wait timeout,说明连接池被占满,先看慢 SQL,别急着加连接数。 - 数据库 CPU 不高,但连接数很高,可能是连接泄漏,应用层连接用完没归还。
- 连接池最小空闲太小,突发流量会导致频繁建连。
HikariCP 的默认maximumPoolSize是 10,这个值对大多数中小项目是够用的。真正到了需要把连接数加到 50、100 的时候,首先要考虑的是拆分数据源或者引入读写分离,而不是继续堆连接。
6.3 面试题的背后,考察的是系统化思维
每次有人让我押“mysql面试题”,我都会说,面试题是多种多样的,但出题人考察的东西其实就那几样:你理不理解事务?你理不理解索引?你遇到线上问题时有没有自己的排查方法论?
比如“为什么 InnoDB 用 B+ 树而不是哈希索引”,考察的不只是数据结构,而是你有没有想过:B+ 树能支持范围查询,叶子节点用双向链表串联,天然适合磁盘页存储;哈希索引只适合等值查询,一旦你要做>、<、BETWEEN,哈希立刻失效。
再比如“MySQL 的隔离级别有哪几种”,如果你只是把所有概念背下来,面试官再追问一句“为什么 MySQL 默认用 REPEATABLE READ 而不用 READ COMMITTED”,很多人就卡住了。其实这涉及 MySQL 早期主从复制和 binlog 逻辑的兼容问题,也涉及 MVCC 的实现方式。能把这个背景讲清楚,比记住几个概念有用得多。
我建议你准备一个真实的案例,比如“我们线上有一条 SQL 很慢,我用 EXPLAIN 发现它走了全表扫描,原因是隐式类型转换,最后怎么改”。这种经历比任何八股文都更打动人。
最后分享一个我自己的小习惯:每学一个新知识点,我都会在本地库写一个最小复现例子,把原理变成可见的现象。比如想理解间隙锁,就开两个会话,一个SELECT ... FOR UPDATE,另一个尝试 INSERT,亲眼看到它被锁住。笔记可以抄别人的,但这样的“亲眼所见”,才能真正变成你自己的经验。MySQL 学习就是这样,没有捷径,但踩过的每个坑,都会在未来某个深夜帮你少花几个小时。