“MYSQL ---CURD”看到这个标题我就绷不住了——兄弟这个词八成是想写 CRUDCreate、Read、Update、Delete也就是对数据库里数据的增、删、改、查。字母顺序调换一下意思完全变了。不过话说回来这个拼写问题反倒让我觉得有必要认真聊聊在实际开发里CRUD 这四个操作几乎占据了一个业务系统 80% 以上的数据访问逻辑但越基础的东西越容易被轻视。我见过不少项目前期建表随意、SQL 裸拼、事务不设等数据量上来之后慢查询、死锁、脏数据一个接一个往外冒最后只能半夜爬起来救火。这篇文章我就围绕 MySQL 的增删改查把我整理过的完整写法、参数化方案、事务处理、索引与字段设计的思路以及踩坑记录全部摊开讲。适合刚接触后端开发的新手也适合写了两三年业务代码但没系统梳理过 CRUD 细节的同学。你不需要一次性全看懂可以先收藏等敲到对应场景再回来查。1. CRUD 的整体设计与思路拆解1.1 为什么 CRUD 没有想象中那么简单先明确一点CRUD 不是四个孤立的 SQL 语句它是一套围绕数据的“生命周期管理”方案。Create数据从无到有要解决的是“唯一性”和“完整性”问题。谁来生成主键哪些字段必须有值并发插入时会不会产生重复数据Read数据从存储到展示要解决的是“检索效率”和“结果准确性”问题。查询走不走索引要不要分页多表关联怎么避免笛卡尔积Update数据状态发生变更要解决的是“变更范围”和“并发安全”问题。不小心把 UPDATE 语句的 WHERE 条件写漏会直接酿成事故高并发下多个事务同时改同一行又容易产生覆盖写。Delete数据从存在到移除要解决的是“永久删除”和“可恢复性”之间的权衡问题。直接 DELETE 还是软删除TRUNCATE 和 DELETE 在行为上有本质区别。我在模拟项目 X 的数据访问层设计阶段会先把这四个操作分别拆成“单条操作”和“批量操作”两条线。单条操作优先保证准确性和安全性批量操作优先保证执行效率和事务边界。这套思路的后端逻辑很简单单条 SQL 出错影响面窄、排查快批量 SQL 效率高但容错要求严不能一说批量就无脑拼一个几百条 VALUES 的 INSERT要知道 MySQL 对单条 SQL 有 max_allowed_packet 大小限制拼过头了直接报错。1.2 为什么选择 MySQL 作为核心存储市面上可以选的数据库很多PostgreSQL、SQL Server、Oracle、SQLite 都有各自定位。MySQL 之所以在绝大多数中小型乃至中大型业务系统中被作为默认选择理由很实际部署和维护成本低。单机安装几分钟搞定主从复制、读写分离的配套方案非常成熟。社区生态极其庞大。无论你用什么语言Java、Python、Go、PHP都有稳定成熟的驱动和 ORM 框架出问题的排查资料也是全网最多的。InnoDB 存储引擎在事务支持、行级锁、崩溃恢复方面足够可靠。MySQL 5.7 和 8.0 之后的版本性能稳定性大幅提升8.0 还引入了窗口函数、CTE 这些以前要绕行的能力。和云数据库产品的兼容度极高。业务量起来之后可以无缝迁移到云上的托管实例不用重写 SQL。但不代表 MySQL 没有问题。它对复杂查询的优化不如 PostgreSQL 那么激进深分页场景容易变慢DDL 操作在大型表上有一定代价8.0 后的 INSTANT 算法改善了部分场景。因此用 MySQL 做业务存储要求开发者在设计表结构时想得更清楚而不是把所有压力都丢给数据库去“优化”。在动手写 CRUD 之前我的习惯是先把表结构彻底定下来。不是凭感觉建表而是先用一句业务描述定义清楚“这张表保存谁的什么数据哪些数据必须唯一哪些字段会被频繁检索。”想清楚这三点表设计基本不会跑偏。然后才是 CREATE TABLE 语句这里面有两个让新手最容易栽跟头的细节字符集和排序规则。字符集业务表统一用 utf8mb4。utf8mb4 是 utf8 的超集能存 emoji 和生僻字。如果你的表用了 utf8某些 App 昵称里带了表情符号插入时直接报 “Incorrect string value”这坑我踩过不止一次。排序规则MySQL 8.0 之前默认 utf8mb4_general_ci8.0 之后默认 utf8mb4_0900_ai_ci。排序规则决定了字符串比较方式比如 a 和 A 是否相等中文按拼音还是按二进制排序。一般业务表随手用默认规则就行但如果你要存用户名且希望校验时区分大小写就得把排序规则改成 utf8mb4_bin。主键选择方面优先用自增 BIGINT。它写入有序、索引占用空间小、性能稳定。如果因为分布式环境必须用雪花 ID那在 InnoDB 下最好把主键设计成 BIGINT 类型并用 UUID_SHORT() 这类有序生成方案避免随机 UUID 导致的页分裂和索引碎片。字段类型上有一张我常用的速查表数据类型使用场景注意事项TINYINT状态位、枚举值范围 -128~127定义字段时建议写明注释INT / BIGINT普通整数、自增主键根据业务量选不要全部无脑 BIGINTDECIMAL(10,2)金额类禁止用 FLOAT/DOUBLE 存钱否则精度会出问题VARCHAR(32)用户名、昵称、状态名长度要留有业务余量但别超大CHAR(固定长度)定长编码如手机号相比 VARCHAR 检索更高效但占用固定空间DATETIME业务时间无时区问题推荐默认使用TIMESTAMP时间戳字段受时区影响且范围到 2038 年TEXT / JSON大文本、复杂结构无法直接建索引可配合虚拟列或拆表你可能会问为什么 CREATE TABLE 要花这么多篇幅因为后边的每一个 SELECT、UPDATE、DELETE 都依赖这张表的结构。字段类型选错索引建错哪怕你 CRUD 写得再工整依旧该慢的慢、该错的错。CRUD 的质量上限在表设计那一刻就锁死了。3. CRUD 核心语法与实操细节3.1 CREATE插入数据的四种正确姿势新增数据是业务写入的入口也是干净数据的源头。这里说的“干净”指的就是入库的每条记录都符合业务规则、不重复、不冗余。基础的 INSERT 语句如下INSERT INTO user (username, email, age, created_at) VALUES (张三, zhangsanexample.com, 25, NOW());这段语句本身没有任何难度真正需要讲究的是下面几个特殊场景。第一需要跳过已存在的数据时使用INSERT IGNORE。它根据主键或唯一索引判断如果数据已存在本次插入直接忽略不报错也不中断。适合跑批导入场景比如每天同步一次外部数据源同一用户重复出现在文件里用 INSERT IGNORE 可以自动去重。第二需要“存在就更新不存在就插入”时使用INSERT ... ON DUPLICATE KEY UPDATE。这个语法非常实用典型场景是记录用户最后一次登录时间或者做计数累加。要注意的是它判断冲突的依据同样是主键或唯一索引而不是业务主键以外的任意字段。INSERT INTO user_login_log (user_id, login_count, last_login_at) VALUES (1001, 1, NOW()) ON DUPLICATE KEY UPDATE login_count login_count 1, last_login_at NOW();第三批量插入时不要一条一条执行而应该用多值插入。一次插入十条记录比循环插入十条快一个数量级原因是减少了网络往返和 SQL 解析开销。INSERT INTO user (username, age) VALUES (A, 20), (B, 21), (C, 22);这里有个硬性限制单条 SQL 不能无限长。受max_allowed_packet参数控制我一般建议每次批量 500~1000 条数据量再大就分批。而且批量插入一旦中间某条数据违反约束整批都会被回滚。第四插入之后需要拿到自增主键。这里不同驱动处理方式不太一样比如在 JDBC 里使用PreparedStatement.RETURN_GENERATED_KEYSPython 的 pymysql 里用cursor.lastrowidORM 框架则会自动回填到实体对象。你要记住的原则是主键是插入数据的“收据”一定要能拿回来否则后续关联写操作会非常尴尬。3.2 READ查询语句的执行顺序与分页优化SELECT 是 CRUD 里使用频率最高的一项也是优化空间最大的一项。几乎所有慢查询问题都能追溯到一条糟糕的 SELECT。一条看似简单的 SQLMySQL 内部实际上是按照固定顺序执行的FROM - WHERE - GROUP BY - HAVING - SELECT 字段 - ORDER BY - LIMIT理解这个顺序非常关键否则很容易写出“看似正确实则低效”的查询。比如 HAVING 是在分组之后过滤的所以能写在 WHERE 里的条件就不要放到 HAVING 里WHERE 可以先缩小参与分组的数据量。分页是查询中最容易出现性能事故的环节。最典型的写法是以偏移量分页SELECT * FROM orders ORDER BY id DESC LIMIT 10000, 20;这条语句在数据量小的阶段毫无压力但当偏移量来到百万级MySQL 需要先扫描并排序前一百万条记录再丢弃性能断崖式下跌。推荐的做法是“游标分页”把偏移量换成上一次结果集最后一条记录的 IDSELECT * FROM orders WHERE id 100860 ORDER BY id DESC LIMIT 20;应用层把上一页返回的最后一条 id 存起来下一页直接带过去整个过程几乎不浪费扫描。很多资深开发不写 LIMIT 偏移只用这种方式原因就在于此OFFSET 是“位置”而 ID 是“数据”按数据路标记位置永远比数据位置更可靠。JOIN 查询在业务中也绕不开。多表关联时一定要清楚内连接INNER JOIN、左连接LEFT JOIN在语义上的区别INNER JOIN 只保留两表匹配上的行LEFT JOIN 保留左表全部行右表匹配不上的字段以 NULL 填充。实际开发中我反复强调一个原则能用 INNER JOIN 就别用 LEFT JOIN。左连接如果使用不当很容易因为右表有多条匹配记录而把左表数据放大成笛卡尔积导致结果集膨胀。3.3 UPDATE有限更新与影响行数的坑UPDATE 语句是所有 CRUD 里最容易闯祸的。闯祸原因永远是同一个漏掉 WHERE 条件或者 WHERE 条件不够精确。UPDATE orders SET status PAID WHERE order_id 1024;上面这条是安全写法只更新目标行。但如果你写成UPDATE orders SET status PAID;MySQL 不会问你“确定吗”它直接执行。等反应过来所有订单都变成了已支付。虽然可以把sql_safe_updates打开来阻止不带 WHERE 的 UPDATE 和 DELETE但最可靠的防线永远是写 SQL 的人自己。我的习惯是UPDATE 之前先写一条 SELECT 验证 WHERE 条件选中的数据行数和范围确认没错再改写成 UPDATE 执行。UPDATE 语句还有一个常见认知误区MySQL 返回的“影响行数”并不等于你试图更新的行数。默认情况下如果某一行更新前后的值没有变化MySQL 会把这次更新判断为“没有变化”影响行数为 0而不是 1。也就是说affect rows 0未必代表“没找到数据”也可能是“找到但值没变”。这在做乐观锁判断时尤其要注意不能依赖影响行数直接判断数据存在与否。一次性更新多条记录时可以考虑用CASE WHEN拼接成一条 UPDATE减少 SQL 执行次数。但可读性和维护成本会上升所以建议只用在数据量较大且更新规则固定不变的场景。3.4 DELETE物理删除与软删除的选择DELETE 是 CRUD 四兄弟里最需要谨慎对待的操作。我先说结论绝大多数业务系统都不要物理删除数据而是通过“软删除”的方式——给表加deleted字段通常为 TINYINT删除操作本质上是 UPDATE。-- 物理删除危险 DELETE FROM user WHERE id 1001; -- 软删除安全可回溯 UPDATE user SET deleted 1 WHERE id 1001;选择软删除的理由很实际线上事故一旦发生数据删了就回不来了即使有备份恢复也耗时耗力。而软删除只是打一个标记随时可以恢复审计时也能查清楚“这条数据到底是不是被删过”。代价是每条查询都要记得带上WHERE deleted 0这可以通过建立视图或者在 ORM 层统一处理。如果确实需要物理删除旧数据也要注意 DELETE 和 TRUNCATE 的区别。DELETE 是逐条删除、返回影响行数、可带事务回滚TRUNCATE 是重建表、重置自增主键计数、不可回滚。业务数据清理宁可分批 DELETE也不要图快用 TRUNCATE。大表清理还容易踩另一个坑DELETE 大量数据会长时间持有行锁阻塞其他业务写入。我建议的做法是分批删除每次删 1000 条、SLEEP 一小段时间再删下一批把锁持有时间摊平降低对在线业务的影响。4. 参数化查询与防注入实操4.1 字符串拼接 SQL 是万恶之源我先问一个直击灵魂的问题你写 CRUD 时是怎么把用户输入的内容拼进 SQL 的如果你还在用这样直接拼接的写法# 反例严禁在生产环境使用 user_input request.get_param(username) sql SELECT * FROM user WHERE username user_input 那等于把你的数据库大门敞开了。举个最简单的例子用户在输入框里提交 OR 11拼接后的 SQL 变成了SELECT * FROM user WHERE username OR 11因为11恒为真这一条查询直接返回整个 user 表的数据。更狠的变体甚至可以拼接 DROP TABLE、UPDATE 权限范围内的其他表数据。SQL 注入不是远古漏洞它在今天的真实互联网上仍然疯狂存在着而且绝大多数是因为开发者在 CRUD 里贪图省事直接用了字符串拼接。4.2 Prepared Statement 为什么能防注入要理解防注入的原理得先知道 SQL 在 MySQL 里的执行流程先做词法解析、语法检查、生成执行计划再绑定参数执行。传统的字符串拼接写法是把用户输入“直接成为 SQL 句子的一部分”MySQL 解析的时候会认为输入就是命令而 Prepared Statement预编译语句把 SQL 结构提前发送给数据库端完成解析之后用户输入只是“参数值”不参与 SQL 语法解析。用 JDBC 写一个标准安全的插入String sql INSERT INTO user (username, email) VALUES (?, ?); try (PreparedStatement ps connection.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, email); ps.executeUpdate(); }这里的?是占位符username 和 email 无论传什么内容进来在数据库看来都只是字符串值不可能变成新的 SQL 命令。Python 的 pymysql 也提供了对应的参数化接口sql INSERT INTO user (username, email) VALUES (%s, %s) cursor.execute(sql, (username, email))注意不同驱动的占位符样式有差异JDBC 用?Python 的 pymysql 用%sGo 的 database/sql 也用?。这就引出另一个易错点不要把?和%s混为一谈规则跟随你使用的驱动而定。4.3 正确绑定参数类型避免隐式转换参数化查询的另一个好处是能显式指定参数类型避免隐式类型转换带来的索引失效问题。很多慢查询事故不是 SQL 写得差而是 WHERE 条件的字段类型和传入参数类型不匹配。举例来说user 表的phone字段是 VARCHAR 类型索引也建在 phone 上但如果你在 WHERE 里传了一个整数SELECT * FROM user WHERE phone 13800138000MySQL 会把 phone 字段隐式转换为数字再比较导致索引失效变成全表扫描。使用参数化写法后通过setString明确把 phone 按字符串处理就不会触发这种问题。每次写 SQL 都问一下自己这个字段在表里是什么类型我传的参数和它是一伙的吗统一类型是防止索引失效最廉价且有效的方式。提示任何由外部输入参与拼装的 SQL 片段都必须走参数化。不管是查询、新增、更新还是删除没有例外。5. 事务与并发控制关键点5.1 事务能让 CRUD 真正可靠单一一条 SQL 成功与否很好判断但真实业务往往需要多个步骤一起成功。比如转账操作A 账户扣钱是一条 UPDATEB 账户加钱是另一条 UPDATE。如果第一条成功、第二条失败钱就凭空消失了。把多条 CRUD 放进同一个事务里保证“要么全部成功要么全部回滚”是数据库层面最基础的一致性保障。事务的四个特性 ACID——原子性、一致性、隔离性、持久性用生活类比来说就是转账操作像一个整体包裹要发就整体发出要退就整体退回快递在中途不允许被人拆开改内容一旦包裹签收物流记录就不能丢。MySQL 的 InnoDB 引擎默认开启自动提交也就是每一条单独的 SQL 自带事务。多语句事务的正确写法是先去设置SET autocommit 0然后在代码里显式调用 commit 或 rollback。以 JDBC 为例Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); // 执行多条 CRUD conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); }注意一个容易被忽略的问题请把事务范围控制在真正需要一致性的操作上事务内部不要夹带远程调用、大量计算、批量导入。事务不是越大越安全事务越大持锁时间越长死锁概率越高。5.2 隔离级别与脏读幻读当多个事务并发执行时隔离级别决定了一个事务能看到另一个事务的什么状态。MySQL 默认的隔离级别是REPEATABLE READ也就是可重复读。四个级别的差异如下隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不会可能可能REPEATABLE READ不会不会可能SERIALIZABLE不会不会不会理解这三个现象可以这样判断脏读是读到别人未提交的数据这种数据可能下一秒就被回滚不可重复读是同一事务内两次读取同一行结果不同原因是其他事务已经提交了更新幻读是同一事务内两次范围查询结果行数不同原因是其他事务插入了新行。MySQL 的 InnoDB 在 REPEATABLE READ 级别下通过 MVCC 和间隙锁解决了部分幻读问题但不代表完全免疫。对于极高一致性要求的业务可以考虑使用 SERIALIZABLE代价是并发能力大幅下降。5.3 并发更新悲观锁与乐观锁CRUD 中的并发更新是事故高发地。典型场景是库存扣减两个请求同时读到库存剩 10 件各自都执行UPDATE stock SET count count - 1但两次都基于同一个初始值“10”可能最终库存变成 9 而不是 8。解决思路有两种。悲观锁思路先锁定目标行再执行更新。BEGIN; SELECT * FROM stock WHERE product_id 88 FOR UPDATE; -- 业务计算 UPDATE stock SET count count - 1 WHERE product_id 88; COMMIT;FOR UPDATE是行级排他锁锁住后其他事务要更新同一行必须阻塞等待这能保证当前事务对这条数据具备独占权。使用时要小心锁必须在事务内生效写完立刻提交避免长时间持锁。乐观锁思路不锁数据库行而是用版本号控制更新条件。UPDATE stock SET count count - 1, version version 1 WHERE product_id 88 AND version 5;如果更新影响行数为 0说明 version 已经发生变化本次更新失败由应用层决定重试或报错。乐观锁适合读多写少、冲突概率低的业务成本低、性能好。两者没有谁绝对更好关键看业务场景秒杀扣库存的高冲突场景适合悲观锁或 Redis 限流前置普通业务更新适合乐观锁。5.4 死锁排查是一线开发必备技能两个事务各自持有对方需要的锁就会出现死锁。场景很常见事务 A先更新订单再更新用户。事务 B先更新用户再更新订单。二者都等对方先释放锁互相卡死。MySQL 检测到死锁后会自动回滚其中一个事务报错信息形如“Deadlock found when trying to get lock”。死锁的排查思路要固定成套路第一步看错误日志确认是哪两条 SQL 参与第二步检查这两条 SQL 涉及的表的锁等待情况用SHOW ENGINE INNODB STATUS查看最近一次死锁现场第三步调整代码访问顺序让所有事务都按同一顺序更新表第四步尽量缩小事务范围减少锁的持有时间。6. 常见问题与排查技巧实录6.1 慢查询与索引失效排查这里列一个我在实际调试中反复见到的现象速查表每一条都来自生产环境实踩现象常见原因解决思路查询越来越慢数据量增长索引缺或失效用 EXPLAIN 分析执行计划WHERE 条件有索引却不走函数包裹字段、隐式类型转换去掉函数或用类型一致参数深分页很慢用了 LIMIT 大偏移量改用游标分页关联查询慢被驱动表关联字段无索引给 ON 条件的字段建索引COUNT 全表统计慢数据量大且无过滤条件使用计数表或从汇总表读取索引失效的几个雷电挨个说。第一在索引列上使用函数SELECT * FROM user WHERE DATE(created_at) 2024-01-01这会导致 created_at 上的索引失效因为 MySQL 每次都要对每行数据先执行 DATE 函数再比较。正确写法是范围查询SELECT * FROM user WHERE created_at 2024-01-01 AND created_at 2024-01-02第二前导通配符SELECT * FROM user WHERE username LIKE %zhang%以%开头的模糊查询无法走索引。如果业务确实需要考虑使用全文索引或外部搜索引擎。第三OR 条件里有一侧没有索引。比如WHERE id 1 OR age 20id 有索引但 age 没有MySQL 可能转全表扫描。改成 UNION ALL 或确保 OR 两侧字段都有索引。检查慢 SQL 的工具方面先在 MySQL 里开启慢查询日志定位到具体 SQL再使用EXPLAIN查看执行计划。看执行计划时重点关注 type 列从好到坏依次是 system、const、eq_ref、ref、range、index、ALL。如果看到 ALL意味着全表扫描必须赶紧查索引。6.2 时区问题TIMESTAMP 还是 DATETIME时区问题经常在 CRUD 里表现得非常诡异。用户明明存了一个时间查出来却发现差了 8 个小时年底一核对全是错的。原因要追溯到字段类型TIMESTAMP 在存储时会根据数据库服务器时区转换成 UTC读取时再转换回当前时区而 DATETIME 不做任何转换存什么就是什么。解决方案很简单也很统一业务字段统一使用 DATETIME并在插入时使用数据库函数 NOW() 或由应用层生成统一标准的日期格式字符串写入。如果在分布式系统里应用服务器和数据库服务器不在同一时区更要避免使用 TIMESTAMP 当作业务时间。TIMESTAMP 还有个隐藏限制只能存储到 2038 年很多业务系统在推进“百年会员”之类的需求时会撞上这个边界。6.3 整数溢出与 BIGINT 边界MySQL 的 INT 是 32 位有符号整数最大值为 2147483647。如果一张表的数据量或自增主键超过这个上限继续插入会直接报“Out of range value”错误。主键溢出这个问题集中出现在快速发展的业务上上线初期表数据量不大用 INT 觉得够用两年后日增百万条数据主键逼近上限才想起来要做迁移操作成本和时间成本都非常高。我的建议是所有自增主键一律从第一天就用 BIGINT。虽然 BIGINT 占用的存储稍大一点但可取值范围到 922 亿亿级别几乎不可能耗尽。不要因为初期数据少就选 INT变更主键类型的成本远超那一点点存储成本。类似情况还有金额字段。千万别用 FLOAT 或 DOUBLE 存金额这类浮点类型在二进制表示上不精确0.1 0.2 可能等于 0.30000000000000004。金额要么用 DECIMAL要么用整数存“分”应用层再转换。这不是洁癖是金融业务的基本要求。6.4 SELECT * 的隐形危害很多初学者习惯写SELECT *因为省事。但它带来的问题在 CRUD 层面非常明显多查了不需要的列增大了网络传输和内存占用数据量一大性能差距就很明显。SELECT *覆盖索引失效的风险高。如果表里有个很大的 TEXT 字段查询时也不得不把这个大字段拉出来完全无法利用覆盖索引优化。表结构变更时SELECT *的字段顺序和数量会同步改变代码里按位置取字段的逻辑很容易被悄然破坏。正确的做法是显式列出需要的字段SELECT id, username, email, created_at FROM user WHERE id 1001;这样一来执行计划能利用覆盖索引、网络传输量更小、字段变化时也不会轻易破坏代码逻辑。CRUD 看起来只是“取数”实际上少查一列就是少一分风险。注意DELETE 和 UPDATE 操作前务必先执行同条件 SELECT 确认命中范围。生产环境的“手滑”事故九成以上是少了这一步确认。7. 结语与我的实操习惯这套 CRUD 的完整思维链从上到下应该是这样先设计表再写插入再写查询然后考虑更新和删除每次访问数据都要将通过参数化防止注入跨多步操作包上事务最后用索引和 EXPLAIN 保障查询性能。把它练成肌肉记忆数据库层面的稳定性就成功了一大半。我个人这几年最受用的一条习惯是在所有涉及数据变更的操作里强制加上“先查后改”的步骤。不管代码写得多熟练执行 UPDATE 和 DELETE 前永远先跑一条 SELECT用同样的 WHERE 条件确认影响范围再加事务、再执行。这个习惯帮我挡掉了至少三次线上事故。另一个想特别提一下的小技巧是数据库连接和驱动层的统一管理。不要每写一个接口就自己 new 一个连接而是由连接池统一分配。连接池的最小空闲连接数、最大连接数、最大等待时间要结合业务并发量和数据库性能基线来配置避免连接数耗尽或者空闲连接不合理占用资源。CRUD 是后端开发的地基地基不牢上层业务做得再花哨也撑不住。如果你想系统性把 MySQL 搞扎实可以顺着表设计、索引原理、事务隔离、慢查询优化这几条线继续往深挖。但不管学到哪一步先把最常用的这四个动作练到不出错、不埋雷才是真正的本手。