很多人学习 MySQL ,到了建表和索引这一步,可能就觉得掌握了核心。会建表,会写SQL,知道索引能加速,好像就差不多了。
但在实际项目中,真正棘手的问题往往在后面才开始浮现:
- 为什么转账不会只扣钱不加钱?
- 为什么高并发下库存不会轻易扣错?
- 为什么一条已经提交的事务,数据库崩了之后还能恢复?
- 为什么很多公司对“改表”这件事慎之又慎?
- 为什么数据库不是“把数据写进去”就万事大吉?
这些问题,其实都指向同一个核心诉求:数据库不仅要存储数据,更要在并发、异常和持续演进中,尽全力保证数据的正确性。
这篇文章,我们就将这些支撑数据库稳定运行的关键机制串起来讲清楚,包括:
- 事务是什么,解决什么问题
- 锁扮演什么角色
- MVCC 为何是提升并发性能的关键
- Redo Log、Undo Log、Binlog 各自的职责
- 数据库迁移为何不能靠手动操作
- 一次数据库变更到底蕴藏着哪些风险
如果说建表和SQL是数据库的“应用层”,那么这些内容就更接近它的“运行原理层”。它们共同回答了:数据库为什么能稳定工作,而不让数据轻易陷入混乱。
一、从一个最简单的问题开始:为什么数据库需要事务
先看一个经典的业务场景:转账。
假设A给B转100元,这个过程至少包含两个动作:
- A的余额减100
- B的余额加100
如果数据库只执行了第一步,第二步因为程序报错、机器宕机或网络问题而未成功,就会出现严重的数据不一致:A的钱少了,B的钱却没多。
这显然是无法接受的。因此,数据库必须提供一种能力,让这一组操作要么全部成功,要么全部失败,绝不允许只做一半。这就是事务最核心的价值。
你可以把事务朴素地理解为:将一组操作打包成一个不可分割的原子单位。这个单位的要求很明确:要么全部生效,要么全部撤销。正因如此,事务是数据库的基础能力,而非可选功能。
二、事务到底在解决什么问题
学习事务时,我们常被ACID、隔离级别、锁等术语包围。但如果抓住本质,事务主要解决的是两类核心问题:
1. 操作的完整性
像转账、下单扣库存、创建订单并记录支付,这些操作天然就是一个逻辑整体。如果中间只成功了一部分,业务数据就会出错。因此,事务首先解决的是“一组操作不能只执行一半”的完整性问题。
2. 并发的正确性
例如库存只剩1件,两个用户同时下单。如果两个请求都先读到“库存为1”,然后都去执行扣减,就可能发生超卖。因此,事务还要面对“多个操作同时进行时,如何保证数据不乱”的并发正确性问题。
简而言之,事务不仅仅是为了“回滚”,更是为了在复杂的现实场景中,为数据的正确性提供基本保障。
三、ACID:别死背定义,先看它们保护什么
事务最经典的四个特性是ACID。很多资料会直接给出定义,但理解它们各自“保护”什么会更直观。
1. 原子性(Atomicity)
保护的是:这组操作不能只做一半。
例如转账的两个步骤必须一起成败。如果中途失败,数据库必须有能力撤销已经执行的部分。原子性的核心就是将多个操作捆绑为一个不可分割的整体。
2. 一致性(Consistency)
保护的是:事务执行前后,数据都应满足预设的业务规则。
例如账户余额不能无缘无故减少,订单总金额必须与明细总和相等。一致性并非单由数据库保证,它需要数据库的机制与业务逻辑共同维护。
3. 隔离性(Isolation)
保护的是:多个事务并发执行时,它们不应轻易互相干扰。
如果没有隔离性,一个事务读取到另一个事务未提交的中间状态数据,系统就极易出错。
4. 持久性(Durability)
保护的是:事务一旦提交,其结果就必须被可靠地保存下来。
即便数据库崩溃或机器断电,已提交的数据也不能丢失。
ACID这四个特性并非孤立,它们是从不同维度构筑了数据库正确性的基石。
四、为什么并发会让事务变得复杂
如果数据库每次只处理一个请求,理解事务会简单得多。但现实是,数据库几乎总是在同时处理大量并发请求:用户同时下单、接口同时更新信息、任务同时写日志……
一旦出现并发,问题就接踵而至。
五、并发下最经典的几个问题
1. 脏读
指一个事务读到了另一个事务尚未提交的数据。
例如:事务A将余额从100改为50(未提交),事务B读到了这个50,随后事务A回滚。那么事务B读到的50就是一份从未真正存在过的“脏”数据。
2. 不可重复读
指在同一个事务内,前后两次读取同一行数据,得到的结果不一致。
例如:事务A第一次读某商品价格为100,在此期间事务B将价格改为120并提交,事务A第二次再读,价格变成了120。
3. 幻读
指在同一个事务内,前后两次按照相同条件查询,返回的记录条数不一致。
例如:事务A查询“状态为待支付的订单数”得到10条,期间事务B新插入了一条待支付订单并提交,事务A再次查询,结果变成了11条,这多出来的一条如同“幻影”。
这些问题共同揭示了一个事实:如果缺乏控制,并发事务下的数据视图会变得极不稳定。
六、事务隔离级别:一致性与性能的权衡
数据库不可能为了绝对正确而完全牺牲性能。如果所有事务都严格串行执行,虽然最安全,但性能会极其低下。
因此,数据库设计了一套折中方案:事务隔离级别。它本质上是在数据一致性和并发性能之间寻求不同等级的平衡。
MySQL常见的隔离级别有四种:
1. 读未提交(Read Uncommitted)
最低级别。事务可能读取到其他未提交事务的数据,因此可能发生脏读。实际业务中很少使用。
2. 读已提交(Read Committed)
只能读取到已经提交的数据。避免了脏读,但仍可能出现不可重复读。这是许多数据库的默认级别。
3. 可重复读(Repeatable Read)
保证在同一个事务内,多次读取同一行数据时,结果是一致的。避免了脏读和不可重复读,但仍可能发生幻读。MySQL InnoDB存储引擎的默认级别就是它。
4. 串行化(Serializable)
最高级别。事务串行执行,安全性最高,但并发性能也最差。
记住一个基本原则:隔离级别越高,数据一致性越强,但并发性能的代价也越大。
七、锁:为什么数据库必须有它?
当多个事务同时操作同一份数据时,不能指望“自觉”。数据库必须有一套机制来控制访问顺序,这套机制就是锁。
你可以把锁理解为:当一个事务要处理某些数据时,它先获得对这些数据的特定操作权限,其他事务可能需要等待。
例如库存扣减场景:
- 事务A正在修改某商品库存。
- 此时事务B也想修改同一条库存数据。
如果没有锁,两者可能同时读取、计算并覆盖,最终导致数据错误。因此,数据库需要通过锁让一个事务先执行,另一个事务暂时等待。
锁的本质不是为了卡住系统,而是为了避免并发写入导致的数据冲突。
八、最基础的两种锁
1. 共享锁
偏向“读”操作。可以理解为:“我现在要读这份数据,别人也可以读,但请不要随意修改它。”
2. 排他锁
偏向“写”操作。可以理解为:“我现在要修改这份数据,在我完成之前,请不要来读或写它。”
实际的数据库锁机制远比这复杂(如行锁、间隙锁、意向锁等),但对于理解核心思想,只需记住:锁的核心目的是在并发场景下控制访问顺序,防止数据冲突。
九、锁的代价:为什么会导致系统变慢
锁保障了正确性,但也带来了性能开销。一旦加锁,就可能导致其他事务等待,等待增多,系统吞吐量自然会下降。
常见的问题包括:
- 阻塞:一个事务持有锁时间过长,导致其他事务长时间等待。
- 锁等待:大量事务因等待锁而响应变慢。
- 死锁:两个或更多事务互相持有对方所需的资源,导致循环等待,都无法继续执行。
因此,数据库设计不能简单地“一锁了之”,否则性能会严重受损。这时,MVCC机制就显得至关重要。
十、MVCC是什么?为什么它如此重要?
MVCC,全称多版本并发控制。它的名字听起来抽象,但解决的问题很实际:能否让读操作和写操作不要总是互相阻塞?
如果每次读取都去争夺当前数据的最新版本(即通过加锁),数据库的并发性能会非常差。MVCC的核心思想是:让读操作看到一个适合自己事务视角的历史数据版本,而不是必须去抢最新的那份。
这好比一份不断被修订的文档:
- 写作者可以继续编辑最新版本。
- 阅读者可以根据自己的需要,查看某个特定时间点的快照版本。
这样,读和写在很大程度上就可以互不干扰,从而显著提升并发性能。
十一、MVCC如何实现?关键在Undo Log
MVCC并非魔法,其背后依赖一个关键组件:Undo Log。
Undo Log可以理解为“旧版本记录”。例如,一条数据原本余额=100,后被一个事务改为余额=80。此时数据库不能只保留80而丢弃100,因为如果事务需要回滚,或者其他事务(在MVCC机制下)需要看到旧版本,就必须能找回原值。
Undo Log主要做两件事:
- 事务失败回滚时,用它来恢复数据。
- MVCC读取历史版本时,沿着它构建旧的数据视图。
所以,Undo Log既是实现事务原子性(回滚)的关键,也是支撑MVCC机制的基石。
十二、事务、锁、MVCC之间的关系
这些概念虽多,但分工明确:
- 事务:负责将一组操作打包成一个保证原子性的逻辑整体。
- 锁:负责在并发环境下控制写入冲突,确保数据不会被同时乱改。
- MVCC:负责优化读写并发,让大多数读操作无需加锁,从而提升系统吞吐。
它们可以看作数据库并发控制的三层能力:
- 事务解决 “操作整体正确”
- 锁解决 “并发写入冲突”
- MVCC解决 “读写并发效率”
十三、为什么事务提交后,数据库崩溃数据还能恢复?
这就涉及数据库的日志系统了。很多人认为UPDATE执行成功,数据就已经改好了。但实际上,数据库为了性能,通常不会在每次事务提交时都立刻将数据页同步到磁盘的最终位置(那样太慢)。
为了保证“已提交的事务不丢失”,就需要强大的日志机制来兜底。其中最关键的就是 Redo Log。
十四、Redo Log是什么?
Redo Log是一种“重做日志”。它解决的核心问题是:事务已经提交,但对应的数据页可能还没来得及完全写入磁盘;此时若数据库崩溃,如何保证这笔提交不丢?
它的思想很像会计记账:
- 在数据修改发生时,先把“做了什么修改”这条记录可靠地记下来(写Redo Log)。
- 即使系统突然崩溃,重启后也能根据这些记录,把未完成的数据修改重新执行一遍。
因此,Redo Log最核心的职责就是:保证已提交事务的持久性,即ACID中的D(Durability)。
十五、为什么有了数据文件,还需要Redo Log?
因为直接修改磁盘上的数据页(随机IO)成本很高。数据库为了提升性能,会利用内存缓冲池,不会每次提交都立刻刷盘。
如果没有Redo Log,将面临巨大风险:用户看到“提交成功”,但紧接着机器断电,那些还在内存中未落盘的修改就永久丢失了。
Redo Log的作用,就是在数据页之外,先将变更意图以顺序追加的方式快速、可靠地记录下来(顺序IO,速度快)。即使崩溃,重启后也能依据它进行恢复。
所以,Redo Log的策略是:先把“改了什么”记牢,再择机将数据页慢慢刷到磁盘。
十六、Undo Log 与 Redo Log 的区别
这是两个易混淆但职责分明的日志。
- Undo Log:关注 “如何撤回”。
- 主要用于事务回滚、提供旧版本数据以支持MVCC。
- 它像是“后悔药”,负责让事务能安全地退回去。
- Redo Log:关注 “如何重做”。
- 主要用于崩溃恢复,保证已提交事务的持久性。
- 它像是“备忘录”或“补账本”,负责把丢失的进展补回来。
一个负责“撤销”,一个负责“重做”。
十七、Binlog又是什么?与Redo Log有何不同?
如果说Redo Log是存储引擎层面的物理恢复日志,那么Binlog更像是MySQL Server层的逻辑变更日志。
Binlog记录的是数据库的逻辑操作事件,例如:“在表T中插入了哪些数据”、“更新了哪些行的哪些字段”。它的主要用途通常有三个:
- 主从复制:从库通过重放Binlog来同步数据。
- 增量备份与恢复:可以基于某个时间点的备份,通过重放后续的Binlog恢复到指定时间点。
- 审计:追踪数据库的历史变更。
既然有Redo Log,为何还要Binlog?因为它们定位不同:
- Redo Log 解决的是:已提交的数据如何确保不丢(存储引擎内部崩溃恢复)。
- Binlog 解决的是:数据变更如何被复制、追踪和逻辑回放(Server层的数据同步与归档)。
简单理解:Redo Log是InnoDB的“内部日记”,Binlog是MySQL的“公开变更流水账”。
十八、其他常见日志
- 错误日志(Error Log):记录数据库启动、关闭、运行中的异常信息,是故障排查的首要依据。
- 慢查询日志(Slow Query Log):记录执行时间超过阈值的SQL,是性能优化的关键切入点。
日志系统是数据库实现可恢复、可观测、可维护的基础设施。
十九、为什么数据库结构变更不能靠手动修改?
讲完事务和日志,再看数据库迁移就顺理成章了。许多新手项目里,数据库结构变更常常是手工操作:需要加字段就直连生产环境执行SQL,改类型也是随手操作。
在项目初期,这似乎可行。但一旦系统复杂,问题就会爆发:
- 各环境(开发、测试、生产)数据库结构不一致。
- 变更记录缺失,无人清楚历史演进过程。
- 上线失败后,没有可靠的回退方案。
- 新成员无从了解数据库结构的来龙去脉。
因此,数据库结构变更必须脱离“手工操作”的模式。数据库迁移,本质上是将数据库结构的演进,变成一套可记录、可审查、可重复执行、可版本化的工程实践。
二十、数据库迁移到底是什么?
数据库迁移指的是,随着业务发展,对数据库结构和数据进行的计划性演进。例如:
- 新增表或字段
- 修改字段类型或长度
- 新增或删除索引
- 数据格式转换与历史数据补全
- 表结构拆分或合并
只要系统在迭代,数据库迁移就是一项常态化的工程活动。
二十一、为什么数据库迁移风险很高?
很多人低估其风险,认为不过是几条SQL。但实际上,它常常是系统稳定性的高风险点:
- 直接影响线上服务:大表加索引、改字段类型可能锁表,导致服务停顿或性能抖动。
- 引发新旧代码不兼容:例如先删除了旧字段,但线上仍有旧版本服务在读取该字段,会立刻导致错误。
- 可能损坏历史数据:伴随结构变更的数据修复逻辑若有误,会污染现有数据。
- 回滚困难:代码回滚容易,但数据回滚(尤其是已写入新数据后)往往复杂得多。
二十二、如何进行相对安全的数据库迁移?
- 充分评估影响:分析变更是否会锁表、影响性能、与旧代码冲突、数据量大小、是否需要分批执行。
- 采用兼容性演进策略:最安全的思路是“分步走”,而非“一步到位”。例如将旧字段
name迁移到新字段display_name:
- 第一步:新增
display_name字段。
- 第二步:代码改为双写(同时更新新旧字段)和兼容读(优先读新,无则读旧)。
- 第三步:运行数据迁移任务,将历史数据补全到新字段。
- 第四步:验证所有功能稳定后,将代码改为只读写新字段。
- 第五步:经过一段时间的观察,最终下线旧字段。
- 大表操作格外谨慎:表越大,变更的耗时和风险越高,需设计更细致的方案(如在线变更工具pt-online-schema-change)。
- 制定完备的回滚预案:上线前必须想清楚,如果迁移失败、代码与库不同步、数据补一半出错等情况,该如何快速回退。
二十三、事务、日志、迁移,在解决同一类问题
回顾全文,你会发现这些分散的主题,共同构成了数据库稳定运行的护城河,它们都在致力于:让数据库在复杂的现实环境中,始终保持正确、可靠和可控。
- 事务:保护一组操作的完整性。
- 锁与MVCC:保护并发下的数据正确性与访问效率。
- Redo/Undo/Binlog:保护系统在崩溃、回滚、复制时状态可恢复、可追溯。
- 迁移:保护数据库结构能够安全、有序地演进。
数据库远不止是“存数据的地方”,它是一套围绕数据正确性、系统可靠性、故障可恢复性、架构可演进性构建的精密工程系统。
二十四、总结
如果说建表和SQL是数据库的“外表”,那么事务、锁、MVCC、日志和迁移就是它的“内功”。它们保障的不是SQL能否执行,而是:
- 数据在并发下会不会错乱?
- 系统崩溃后能不能找回?
- 结构变更时会不会炸掉?
将其浓缩成一句话:事务保证操作原子,锁与MVCC协调并发,日志确保持久可恢复,迁移护航结构平滑演进。
这些机制环环相扣,共同构成了数据库作为核心基础设施的坚实底座。理解它们,能帮助开发者在云栈社区这样的技术交流平台上,更深入地进行架构设计和问题排查,构建出更健壮的应用系统。