MySQL数据库这套东西,知识点看起来很散——事务、联合查询、连接查询、子查询、备份与恢复,各自都能写一本书。但我在实际开发里越用越发现,它们其实是一条链上的环节:写一条查询SQL是基础能力,多张表取数要会连接查询和联合查询,复杂业务逻辑要会子查询,多条SQL要么同时成功要么同时失败得靠事务兜底,系统上线之后能不能睡得着觉,取决于备份恢复做得够不够扎实。这篇文章我就把这五个主题串起来讲一遍,重点不是背概念,而是把"为什么这么做"和"上生产之后会遇到什么坑"说清楚,适合刚入门MySQL的开发者,也适合用了一段时间但总在某些细节上含糊其辞的后端同学。
1. 事务的四个特性好背,但隔离级别才是实战的分水岭
1.1 ACID四个特性,到底哪一个才是真正难保证的
事务的四大特性——原子性、一致性、隔离性、持久性,几乎每本教材都会讲。原子性说的是要么全做要么全不做,持久性说的是提交了就别丢,这两个相对好理解,也好实现。真正让很多人在面试和实战里栽跟头的是"一致性"和"隔离性",因为这两个概念不是事务自己就能闭环解决的。
一致性最容易被误解成"数据库帮我保证数据合理"。实际上,一致性是应用程序的责任,数据库只是提供约束工具。转账场景是最经典的解释:A账户扣100,B账户加100,如果只有扣款成功而加款失败,那总金额就对不上——这违反的其实是业务上的一致性,而不是数据库崩溃恢复的一致性。数据库能做的,是通过原子性保证扣款和加款要么都发生要么都不发生,但"账户余额不能为负数"这种业务规则,需要你在SQL里写约束(比如CHECK或应用层判断),数据库不会帮你脑补。
隔离性则是并发场景下的核心战场。多个事务同时读写同一行数据,如果没有隔离机制,就会出现脏读、不可重复读、幻读。MySQL的InnoDB引擎默认隔离级别是可重复读(REPEATABLE READ),这一点和Oracle、PostgreSQL默认用读已提交(READ COMMITTED)不一样,也是很多人跨数据库切换时最容易踩的坑。
1.2 隔离级别与脏读、不可重复读、幻读的真实现象
隔离级别一共四档,从松到严分别是读未提交、读已提交、可重复读、串行化。读未提交几乎没人用,因为它允许一个事务读到另一个事务还没提交的数据——万一对方回滚,你读到的就是凭空捏造的数据,这在金融类业务里是不可接受的。
读已提交解决了脏读,但又带来了不可重复读:同一个事务里,第一次SELECT和第二次SELECT同一行数据,结果可能不一样,因为另一个事务在这期间提交了更新。可重复读保证了同一个事务里多次读取同一行数据结果一致,但按标准定义它仍允许幻读——也就是查询某个范围的数据时,另一个事务插入了一行符合条件的新数据,导致你两次查询"范围行数"不同。
MySQL的InnoDB在可重复读级别下,通过MVCC(多版本并发控制)加间隙锁(Gap Lock)和临键锁(Next-Key Lock),把标准的幻读问题也基本解决了。这也是为什么MySQL敢把默认级别设为可重复读。我做过一个测试:
事务A开启后查询SELECT * FROM orders WHERE status = 'pending',事务B插入一条status为pending的新订单并提交,事务A再次执行同样的查询——在MySQL可重复读下,结果是看不到这条新数据的。这依赖的就是间隙锁对范围内的插入进行锁定。但要注意,这个保护不是万能的,如果你用的是当前读(SELECT ... FOR UPDATE或UPDATE),间隙锁的边界、唯一索引和非唯一索引的行为都不一样,复杂并发场景下照样可能死锁或者出现意想不到的覆盖现象。
1.3 事务注解在Spring里失效的那些隐蔽情况
后端项目里事务大多靠Spring的@Transactional注解触发,很多人以为只要加了注解,方法里的所有SQL就自动享受事务保护。实际上注解只是开启了一个数据库事务,能不能真正生效取决于几个条件,我排过好几次这种错,总结下来最常见的失效场景有四类。
第一类是同类内部调用。ServiceA的methodA调用methodB,两个方法都标了@Transactional,但methodA没走Spring代理,而是直接调了this.methodB(),事务注解完全被跳过。解决办法要么把methodB拆到另一个Bean里,要么用AopContext.currentProxy()拿代理对象再调用。
第二类是方法权限修饰符问题。Spring默认只对public方法做事务代理,protected、private方法标了注解是无效的,而且不报错,只在日志里留一行提示,线上排查起来相当隐蔽。
第三类是异常被吞掉。方法内部用try-catch把异常捕获了,事务感知不到异常,当然不会回滚。正确的做法是catch到异常后手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),或者把异常重新抛出去。
第四类是回滚异常类型。@Transactional默认只对RuntimeException和Error回滚,如果你在方法里抛出的是受检异常(比如业务校验不通过时自定义的BusinessException继承Exception),事务一样不会回滚。这时候需要显式标注rollbackFor = Exception.class。
至于热搜里提到的分布式事务一致性,那是另一层话题。单体数据库事务解决的是单库内的原子性,微服务架构下订单服务和库存服务各自持有库,就涉及跨库事务。常见方案有基于消息队列的最终一致性、Seata的AT/TCC模式等。我的建议是:能不分库就别分库,分布式事务的成本远比想象中高,很多团队引入Seata之后反而因为全局锁、回滚日志等问题导致性能骤降。先看能不能通过业务设计规避掉跨库写操作,比如把订单和库存放到同一个库甚至同一张表里。
1.4 事务提交后的锁:行锁、间隙锁与死锁排查
事务不只是隔离级别的理论,落到数据库里就是锁的获取与释放。一条UPDATE语句会申请行锁直到事务提交或回滚才释放,这个"锁的持有时间"就是很多性能问题的根源。我之前遇到过一起线上事故:一个批量更新的定时任务,每条数据都走了一个完整事务,事务里除了UPDATE还查了好几张表做校验,单条事务耗时200毫秒以上。高峰期一并发,锁等待暴涨,最终导致连接池打满。
排查死锁和锁等待,最简单的方式是执行SHOW ENGINE INNODB STATUS,里面的LATEST DETECTED DEADLOCK段会记录最近一次死锁的事务和持有锁的SQL。更系统的做法是查performance_schema下的data_lock_waits和metadata_locks两张表,能定位到是哪个事务在等哪把锁、已经等了多久。还有一点必须养成习惯:事务里不要查询太多无关数据,把锁的范围控制在最小,提交之前不要做远程调用、发消息等慢操作,这些都会无限拉长锁的持有时间。
2. 连接查询:INNER、LEFT、RIGHT JOIN怎么选,为什么大表关联会慢
2.1 三类JOIN的语义差异,用一个订单示例讲透
连接查询是SQL里出镜率最高的操作,但"结果差多少"很多人是搞不清的。假设有两张表,users用户表有三条记录(id为1、2、3),orders订单表有两条记录(user_id为1、1、4)。INNER JOIN只返回两边能匹配上的记录,等于取交集;LEFT JOIN以左表为主,左表记录全部保留,右表没匹配上的地方填NULL;RIGHT JOIN反过来以右表为主。
实际开发里RIGHT JOIN用得极少。原因很朴素:你把SQL里的表顺序换一下,RIGHT JOIN就能改写成LEFT JOIN,可读性反而更好。我个人有个不成文的习惯——统一用LEFT JOIN,从不写RIGHT JOIN,这样团队review代码时不需要来回切换思维。
还有一个常见争议是CROSS JOIN(笛卡尔积)。它返回两个表的全部组合,3条乘2条就是6条。很多新手写JOIN时忘记带ON条件,数据库就会生成笛卡尔积,数据量一大直接炸掉。曾经有同事在两张10万行的表上执行不带ON的LEFT JOIN,数据库瞬间IO飙升,慢查询日志里全是这条SQL。写连接查询第一件事就是检查ON条件。
2.2 ON和WHERE的过滤逻辑,LEFT JOIN最容易出错的地方
ON和WHERE看起来都是筛选条件,但在LEFT JOIN里二者有天壤之别。ON是在连接阶段决定的,决定右表哪些行能匹配进来;WHERE是在连接完成后对结果集做的过滤。对INNER JOIN来说二者结果一样,对LEFT JOIN来说完全不同。
举个例子,查所有用户及其订单,想排除掉已取消的订单:
SELECT u.id, u.name, o.order_no FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status != 'cancelled';这条SQL返回所有用户,没有有效订单的用户其订单字段为NULL。
如果把条件挪到WHERE里:
SELECT u.id, u.name, o.order_no FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.status != 'cancelled';这条SQL会把没有订单的用户也过滤掉,因为NULL不等于'cancelled',外面再嵌套一层过滤时这些行就没了。线上出现过一次数据报表问题,就是因为工程师想查"全部用户的相关订单数",把过滤条件写进了WHERE,结果没有订单的用户直接被剔除,导致统计口径错误。
2.3 驱动表与被驱动表:为什么小表驱动大表是铁律
连接查询的性能,很大程度上取决于哪张表作为驱动表,哪张作为被驱动表。MySQL执行连接时,会先读取驱动表的数据,再用驱动表的每行去被驱动表匹配。如果驱动表是100万行的大表,被驱动表是100行的小表,匹配次数就是100万次;反过来换成100行驱动100万行,匹配次数就是100次(在有索引的前提下)。
真实执行计划里,优化器会尽量选择小表作为驱动表,但前提是你得提供有效的统计信息和合适的索引。有一条SQL优化原则我一直挂在嘴边:连接条件上的字段一定要建索引,尤其被驱动表的连接字段。如果两张表都很大、连接字段又没有索引,MySQL只能走嵌套循环加全表扫描,那种慢是肉眼可见的——一条SQL跑几十秒算轻的。
我之前优化过一个订单导出功能,orders表和order_items表各有一百多万行,连接字段order_id没建索引,导出一次需要40秒。后来在order_items表的order_id上加了索引,同样的SQL降到0.2秒。这就是连接查询里索引的魔力。
还有一个概念叫join_buffer。如果被驱动表的连接字段没索引,MySQL会使用Block Nested-Loop Join,把驱动表的一部分行放入join_buffer,然后一次性扫描被驱动表。这个buffer默认大小是256KB,可以通过join_buffer_size参数调整。但不要盲目调大,它每个连接都会分配,连接数多了内存消耗很可观。
2.4 多表关联的三种优化思路,别只会建索引
当一张SQL关联四五张表时,即使每张表的连接字段都有索引,性能也可能不尽如人意。原因在于每一层关联都要做随机IO,关联的表越多,IO次数累积越多。
第一种优化思路是反范式设计。把频繁一起查询的字段冗余到一张表里,比如把用户名字段冗余到订单表,这样查询订单时就不需要JOIN用户表了。代价是更新用户姓名时,需要同步更新冗余字段,写操作多了一份维护成本。这种方案特别适合读多写少、实时性要求不高的场景。
第二种是拆分成多个简单查询,在应用层做数据组装。很多ORM框架(比如MyBatis的关联查询、Doctrine的懒加载)默认就是这个思路。它避免了一次复杂JOIN,但是会引入经典的N+1查询问题。规避的办法是批量查询,一次性把多个ID查回来,再在内存里做映射。
第三种是使用汇总表或者宽表。报表类需求尤其适合这种做法:每天晚上跑一次定时任务,把订单、用户、商品的数据汇总到一张分析表,白天的报表查询直接查这张宽表,避免高并发下反复做昂贵的大表连接。我之前维护过一个经营看板,最初所有指标都是实时JOIN多张大表算出来的,高峰期接口超时严重。后来改成每五分钟刷新一次汇总表,接口响应从五六秒下降到两三百毫秒,效果立竿见影。
3. 联合查询:UNION与UNION ALL的区别和分页陷阱
3.1 UNION和UNION ALL,性能差一个量级的原因
联合查询就是拿两个或多个SELECT语句的结果拼成一张表。UNION和UNION ALL的区别,一句话就能说清:UNION会去重,UNION ALL不去重。但就是这个去重操作,让UNION在性能上比UNION ALL慢一个量级。
UNION的去重实现方式,是把两张表的结果合并后放进临时表,再对临时表做唯一性检查。这个操作涉及排序或哈希,数据量一旦超过内存阈值,临时表还会落到磁盘,代价相当高。我之前优化过一个搜索接口,原先用的UNION,两个子查询各自返回几万行结果。业务上其实根本不需要去重,因为两个子查询的维度本来就不一样,改成UNION ALL后接口从1.8秒降到0.3秒。
所以我的建议是:默认优先用UNION ALL。哪怕你觉得可能会有重复数据,也可以先在业务逻辑里判断一下是否真的需要去重——很多时候重复是允许的。
3.2 联合查询里的排序和分页,最容易写错的地方
联合查询有两个和排序分页相关的暗坑,我见不少人踩过。
第一个是全局排序问题。直接这么写:
SELECT id, name FROM users UNION ALL SELECT id, name FROM vip_users ORDER BY name你以为是对整个结果集排序,实际上MySQL只会对最后一条SELECT的查询结果排序。要让联合结果整体排序,必须把联合查询放到子查询里再排序:
SELECT * FROM ( SELECT id, name FROM users UNION ALL SELECT id, name FROM vip_users ) t ORDER BY t.name第二个是分页问题。直接在联合查询外层拼LIMIT,只会对最后一个SELECT生效。正确的做法同样是先联合成临时表,再对临时表分页:
SELECT * FROM ( SELECT id, name FROM users UNION ALL SELECT id, name FROM vip_users ) t ORDER BY t.id LIMIT 20, 10这种写法的性能问题在于:如果两个子查询结果都很大,MySQL会先把所有数据合并成临时表再排序分页,内存和磁盘压力都很大。优化思路通常是缩小每个子查询的返回范围,比如先用WHERE过滤出必要的数据,再合并。
3.3 联合查询的硬性要求:列数、类型、列名
联合查询有几条硬性要求:所有SELECT语句的列数必须一致,对应列的数据类型必须兼容,最终结果集的列名以第一个SELECT的列名为准。
列数不一致直接报错,这个好理解。类型兼容的意思是,第一个查询的某列是VARCHAR,第二个查询对应列是INT,MySQL会自动做类型转换,但转换可能丢失精度或者引发隐式转换,导致索引失效。最好的习惯是所有分支都保持完全相同的字段顺序和类型。
列名以第一个SELECT为准这点也容易造成误解。比如第一个SELECT写的是SELECT id, user_name,第二个写的是SELECT id, nickname,最终结果集的第二列还是叫user_name,如果你在应用层用nickname这个字段名去取数据,就会取不到。
还有一种使用场景需要提醒:联合查询中的每个SELECT都可以有自己的WHERE和GROUP BY,但ORDER BY在单个SELECT内除了配合LIMIT之外基本没意义。我见过有人写SELECT * FROM a ORDER BY id UNION ALL SELECT ...,MySQL直接报警告,因为联合后顺序毫无保证,必须外层统一排序。
4. 子查询:先弄懂执行顺序,再讨论怎么优化
4.1 子查询的三种分类,对应不同的使用场景
子查询本质是嵌套在另一个查询里的完整SELECT。按照返回结果的不同,可以分成标量子查询、列子查询和表子查询。
标量子查询返回单个值,一般用在WHERE比较或SELECT列表里,比如查每个用户的订单金额时,把(SELECT SUM(amount) FROM orders WHERE user_id = users.id)放到SELECT列表里。列子查询返回一列值,配合IN使用,典型的写法是WHERE user_id IN (SELECT user_id FROM vip_list)。表子查询返回一个结果集,通常放在FROM后面当临时表使用,比如SELECT * FROM (SELECT user_id, COUNT(*) cnt FROM orders GROUP BY user_id) t WHERE t.cnt > 5。
在WHERE条件里还有EXISTS、ANY、ALL等操作符。ANY和ALL的使用频率很低,但原理值得理解。WHERE price > ANY (SELECT price FROM product WHERE type = 'B')的意思是价格大于类型B中最小的那个价格;WHERE price > ALL (...)则是大于其中最大的。严格来说这两个操作符都能用聚合函数改写,比如ANY改成> (SELECT MIN(price) ...),ALL改成> (SELECT MAX(price) ...),改写后往往性能更好、可读性更强。
4.2 相关子查询和非相关子查询,执行逻辑完全不同
子查询性能差异的根源,在于它是相关子查询还是非相关子查询。
非相关子查询可以独立执行一次,结果缓存下来供外层查询使用。比如:
SELECT * FROM users WHERE id IN (SELECT user_id FROM vip_users);MySQL可以先执行内层查询,拿到所有vip用户的id列表,再拿这个列表去外层匹配。这个过程可能走物化(Materialization),也就是把子查询结果存到内存临时表并建索引,保证外层匹配效率。
相关子查询就麻烦得多,它引用了外层查询的字段:
SELECT u.id, u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_cnt FROM users u;对于users表的每一行,MySQL都需要执行一次子查询。如果users表有1万行,子查询相当于执行1万次。性能好不好,完全取决于users表数据量和order表连接字段的索引。这种查询模式本质是"N次子查询",在数据量大时极其致命。
优化相关子查询的常用手段,是把它改写为JOIN加GROUP BY。上面那个例子可以改写为:
SELECT u.id, u.name, IFNULL(tmp.cnt, 0) AS order_cnt FROM users u LEFT JOIN ( SELECT user_id, COUNT(*) cnt FROM orders GROUP BY user_id ) tmp ON tmp.user_id = u.id;子查询只执行一次,然后整体做连接,性能通常好很多。但这个改写不是免费的:如果子查询结果集很大,MySQL要物化一个很大的临时表,内存会紧张。这时可以退而求其次,保留相关子查询,但确保被引用的连接字段上有索引——users表遍历1万次,每次通过order表的索引快速定位,而不是全表扫描,性能也能接受。
4.3 IN、EXISTS怎么选,以及NOT IN的坑
IN和EXISTS的选择是经典的面试题。早期MySQL优化器能力有限,EXISTS在某些场景下比IN快,因为IN会把子查询结果全部加载。但现在的MySQL 8.0已经引入了半连接优化(Semi-join),会把IN改写成更高效的执行计划,IN和EXISTS在很多场景下性能没有本质区别了。你可以通过EXPLAIN查看执行计划,如果看到Select tables optimized away或者半连接相关的输出,说明优化器已经在帮你做优化了。
真正需要警惕的是NOT IN。如果子查询结果里包含NULL,NOT IN的结果集很可能为空——这是一个反直觉的坑。比如:
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);如果orders表里有一条user_id为NULL的记录,这条SQL会返回0行。原因是NULL和任何值比较都是NULL(未知),NOT IN (1, 2, NULL) 对每个id来说都是"不等于1且不等于2且不等于NULL",而"不等于NULL"的结果是UNKNOWN,WHERE条件只接受TRUE,所以全部过滤掉。
遇到这种情况,要么在子查询里显式过滤掉NULL:WHERE user_id IS NOT NULL,要么改用NOT EXISTS。我个人倾向于在涉及NULL可能性的场景一律用NOT EXISTS,语义更清晰,也不用担心NULL问题。
4.4 用EXPLAIN定位子查询的性能瓶颈
聊了这么多优化方式,真正动手时第一件事永远是看执行计划。EXPLAIN SELECT ...(MySQL 8.0里更完整的用法是EXPLAIN ANALYZE)会展示每一行的访问类型、扫描行数、使用的索引、Extra信息。
重点看几个字段:type列从好到差依次是system、const、eq_ref、ref、range、index、ALL,见到ALL说明是全表扫描,基本需要优化。rows列是优化器预估的需要扫描的行数,数值大说明数据访问成本高。Extra里的Using temporary表示使用了临时表,Using filesort表示需要文件排序,这两个在子查询和关联查询里出现时,往往就是性能瓶颈所在。
我习惯把EXPLAIN的输出当成"数据库给我的一张体检报告"。之前排查一条慢SQL,外层查用户,子查询查订单金额,EXPLAIN显示子查询的type是index,意味着虽然用了索引,但扫描的是整棵索引树。后来在子查询的WHERE条件上又加了一个组合索引,type变成ref,扫描行数从十万级降到几十条,SQL快了百倍不止。没有EXPLAIN,纯靠猜,可能折腾一整天都定位不到问题。
5. 备份与恢复:mysqldump是基本功,binlog是最后的救命稻草
5.1 逻辑备份和物理备份,什么样的场景选什么样的方案
MySQL备份分为两大类:逻辑备份和物理备份。逻辑备份用mysqldump导出SQL文件,特点是可读性强、跨版本兼容性好,但备份和恢复速度慢;物理备份直接拷贝数据文件(比如用Percona XtraBackup),速度快、粒度细,但要求数据库版本和配置基本一致,恢复时对目录权限等有严格限制。
对大多数中小型项目来说,mysqldump已经足够。它最常用的参数组合是这样的:
mysqldump -u root -p \ --single-transaction \ --master-data=2 \ --routines --triggers --events \ --set-gtid-purged=OFF \ exampledb > exampledb.sql--single-transaction参数尤其重要,它让InnoDB使用一致性快照进行备份,不锁表,在线备份时业务照常读写。--master-data=2会在备份文件里记录当前binlog的文件名和位置,增量恢复时要用。--routines --triggers --events保证存储过程、触发器、事件调度器这些对象也被导出,漏掉任何一项,恢复后功能可能不完整。
如果只想备份部分表,可以用mysqldump 库名 表名1 表名2的方式。不要用--all-databases备份整个实例,除非你确实需要,因为将一份全实例备份恢复到另一台机器时,系统库的表容易冲突。
5.2 恢复的完整链路:全量还原加binlog补增量
备份做得好不好,最终要看恢复链路是否完整。一个标准的恢复流程是先把最近一次全量备份导回去,再用binlog把从备份时间点到故障时间点的增量数据补上。
第一步还原全量文件:
mysql -u root -p exampledb < exampledb.sql第二步用mysqlbinlog解析binlog,指定时间点或位置点。假设昨天凌晨2点做了全量备份,今天早上10点数据库被误删了一批数据,我要恢复到9点59分的状态:
mysqlbinlog --start-datetime="2024-01-15 02:00:00" --stop-datetime="2024-01-15 09:59:00" \ mysql-bin.000023 mysql-bin.000024 mysql-bin.000025 | mysql -u root -p这里容易忽略的是binlog可能分布在多个文件里。如果一个文件还没写满,后续的binlog事件会继续写进同一个文件,所以需要把备份时间点之后产生的所有binlog文件都传给mysqlbinlog,不要只挑某一个。
还有更精细的做法,用--start-position和--stop-position指定binlog的位置偏移量。实际恢复时我通常先解析出一个文本文件:
mysqlbinlog --base64-output=DECODE-ROWS -v mysql-bin.000023 > binlog.sql然后在这个文本文件里搜索误操作的SQL,精确定位到它前后的position,再按position区间做恢复。这样能避免恢复到错误时间点把还没出错的正常数据也回滚掉。
5.3 恢复演练和备份策略,几条让我印象深刻的教训
备份策略的通用公式是"全量加增量"。比如每周日凌晨2点做一次全量备份,每天凌晨2点做一次增量备份(就是备份MySQL的binlog文件)。遵守这个策略的同时,有几条教训是我踩过之后才真正理解的。
第一条是恢复演练必须定期做。我有一次在项目上线前做恢复演练,脚本跑完之后发现恢复出来的orders表只有全量备份的数据,增量部分怎么都补不上去。排查半天,原因是全量备份时用了--master-data=2,但恢复时没有按照文件里记录的binlog位置作为起点的习惯,而是随手指定了备份命令执行的时间点,结果时间差里丢了几百条记录。从那以后我的备份脚本里都强制要求记录两个参数:备份完成时间和备份时的binlog位置,恢复时严格按它们对齐。
第二条是备份文件要异地存储。曾有个客户的服务器磁盘坏了,本地机房的备份文件跟着一起没了,唯一的恢复手段是从对象存储拉取几个星期前的备份。从那次之后,我所有的备份任务都加了自动上传对象存储或者另一台机器的步骤,同时设置保留策略,比如保留最近30天的每日备份和最近12个月的每周备份。
第三条是恢复之前先检查磁盘空间和字符集。恢复一个十几GB的SQL文件时,如果目标实例的磁盘空间不足,写到一半报错,数据库状态会非常尴尬。字符集问题也很常见,备份时是utf8mb4,恢复时连接默认字符集是latin1,中文全部变成乱码。恢复命令里可以显式加--default-character-set=utf8mb4。
5.4 备份恢复中的几个隐蔽坑:GTID、事件、字符集
GTID是MySQL 5.6以后引入的全局事务标识符。如果源库开启了GTID,并且备份时用了--set-gtid-purged=AUTO或ON,恢复时目标库的GTID执行历史会改变。我有一次在测试环境做主从复制,导入一个带GTID的备份文件后,从库一直报主键冲突,查了好久才发现是GTID没有正确跳过已经执行过的事务。
存储过程和触发器也很容易被忽略。如果备份命令漏了--routines和--triggers,恢复的库表面上是好的,但业务一跑就报"PROCEDURE db_name.proc_name does not exist"。这种事情在测试环境能暴露出来,但有些公司只在出故障时才做恢复,一恢复就是生产事故。
另外,mysqldump导出的SQL里,表结构定义带了AUTO_INCREMENT的值,但如果你只导数据不导结构,或者反过来只导结构不导数据,都会造成不一致。我用过最稳妥的方式是结构数据一起导出,然后恢复前在测试库先做一次全量恢复验证,确认无误后再对正式库动手。
关于binlog,还有一点必须强调:binlog不是默认开启的。MySQL 8.0的默认配置里,如果通过发行版安装,binlog通常是关闭的,需要你在my.cnf里显式配置log-bin=mysql-bin和server-id=1。很多小团队根本没有开binlog,等出了故障才想起做增量恢复,结果发现从第一天起就没有打基础。这相当于没买保险就上路,平时感觉不到,出了事就只剩后悔。
6. 把这五个主题串起来,回到一天的日常工作
这套内容讲完,你可能会觉得有点多,但其实它们每天都会在业务代码里出现。早上写接口,查订单列表要JOIN用户表,这是连接查询;合并几个渠道的统计数据,用UNION ALL,这是联合查询;做会员等级判断时嵌套了一层"用户是否在VIP名单里",这是子查询;下单扣库存时要保证扣减库存和生成订单两步操作同时成功,这是事务;晚上定时跑任务备份全库,传送到异地,这是备份与恢复。
我刚工作那会儿,觉得这些知识没什么好学的,会用就行。后来线上出过几次事故——一次是LEFT JOIN加了WHERE导致报表数据少了,一次是长事务锁表把整站拖垮,一次是误删数据没有任何增量备份只能恢复到前一天凌晨的数据。从那以后,我养成了两个习惯:线上SQL先跑EXPLAIN再上线;备份恢复每月做一次演练,至少要在测试环境完整走一遍全量加增量的恢复流程。
这篇文章里的所有示例,基本都来自我实际写过的SQL和排查过的故障。如果你在看完之后想动手练一遍,我建议你拿一个测试库,造几万行数据,然后自己写几条多表JOIN、子查询、UNION查询,用EXPLAIN看执行计划,再分别试试不同索引对性能的影响;事务部分可以开两个MySQL客户端窗口,模拟两个并发会话去更新同一行数据,观察锁等待和隔离级别带来的差异;备份恢复则可以在自己的开发环境真实执行一次mysqldump,然后模拟误删一行数据,再用binlog补回来。这一套流程走下来,比你看十遍文档都有用。