Appearance
1数据库事务的定义与特性
1.1 数据库事务的概念
数据库事务(Transaction)是数据库中一组操作序列,这些操作被视为 一个完整的逻辑单元,要么全部成功,要么全部失败回滚。
数据库事务(DatabaseTransaction)是指对数据库的一系列操作组成的逻辑工作单元。 事务是作为单个逻辑工作单元执行的一系列操作。一个逻辑工作单元必须有四个属性,称为原子性、一致性、隔离性和持久性 (ACID) 属性,只有这样才能成为一个事务。
💡 事务 = 一组不可分割的操作,保证数据一致性
在现代数据库中(MySQL / PostgreSQL / Oracle / SQL Server),事务是保障 金融转账、库存扣减、订单流程、支付系统等高一致性业务的基础。
1.2 为什么必须使用事务?
事务就像银行保险柜的操作流程——你要么完整地完成"打开柜门→取出物品→关闭柜门"这三步,要么一步都不做。绝不会出现"柜门开了一半就卡住"或者"取了东西忘记关门"的情况。数据库事务也是如此,要么所有操作都成功,要么全部撤销,保证数据的完整性和一致性。
🛒 场景1:电商购物车结算(最常见的事务应用)
业务流程:用户购买一件商品,涉及以下操作
**❌ 不使用事务会发生什么?**
扣减库存成功(库存 -1)
⚠️ 服务器突然断电
创建订单失败(未执行)
扣款失败(未执行)
💥 结果:库存减少了,但用户没付钱也没订单!商家直接亏损!
**✅ 使用事务的正确流程**
开启事务
START TRANSACTION扣减库存(库存 -1)
⚠️ 服务器突然断电
🔄 数据库重启后自动回滚
✔️ 结果:库存恢复原值,就像什么都没发生过,数据完全一致!
💰 场景2:银行转账(最经典的事务案例)
业务流程:张三给李四转账 500 元
**❌ 不使用事务的灾难**
张三账户 -500 元(成功)
⚠️ 网络抖动/程序崩溃
李四账户 +500 元(失败)
💥 结果:张三的钱凭空消失了!银行面临巨额赔偿和信誉危机!
**✅ 使用事务的安全保障**
开启事务
张三账户 -500 元
李四账户 +500 元
✔️ 两步都成功才
COMMIT❌ 任何一步失败就
ROLLBACK
✔️ 结果:要么两个账户都变化,要么都不变,钱不会凭空消失!
❤️ 场景3:社交平台点赞/关注(高并发场景)
业务流程:用户给一条帖子点赞,同时有 1000 人在点赞
**❌ 不使用事务的混乱**
同时 1000 人点赞:
线程A读取点赞数:100
线程B读取点赞数:100
线程C读取点赞数:100
线程A写入:101
线程B写入:101(覆盖)
线程C写入:101(覆盖)
💥 结果:1000 人点赞,但点赞数只增加了 1!数据完全错误!
**✅ 使用事务 + 锁的正确做法**
事务保证原子性:
开启事务并加锁
线程A:读取 100 → 写入 101 → 提交
线程B:等待A完成 → 读取 101 → 写入 102 → 提交
线程C:等待B完成 → 读取 102 → 写入 103 → 提交
...
✔️ 结果:1000 人点赞,点赞数准确增加 1000,数据完全正确!
⚠️ 总结:没有事务,系统会面临的核心风险
数据不一致:写入部分成功部分失败,违反业务逻辑(如转账只扣款不到账)
脏数据问题:并发读写时读到未提交的"假数据",导致业务判断错误
并发冲突:两个用户同时修改同一条记录产生覆盖,丢失更新
业务崩溃:订单扣款成功但库存未减少,或反之,造成严重经济损失
💎 **核心结论:** 事务不是可选项,而是必选项!任何涉及多步操作、并发访问、数据一致性要求的业务场景,都必须使用事务来保证数据的正确性和完整性。没有事务,就没有可靠的数据库应用!
1.3 事务的 ACID 四大特性(通俗易懂版)
🎯原子性 (Atomicity)
一句话:要么全做,要么全不做
🎲 比喻:就像转账操作,张三给李四转100元,要么两步都成功(张三-100,李四+100),要么都失败。绝不会出现张三扣了钱,李四却没收到的情况。
技术实现:Undo Log(回滚日志)
✅一致性 (Consistency)
一句话:数据始终保持逻辑正确
💰 比喻:转账前后,两个账户的总金额不变。如果转账前张三500元 + 李四300元 = 800元,转账后无论成功失败,总金额必须还是800元。
实现方式:业务规则 + 数据库约束
🔒隔离性 (Isolation)
一句话:多个事务互不干扰
🏧 比喻:就像ATM机,你在取钱的同时,别人也在取钱,但你们看到的账户余额互不影响。你不会看到别人操作到一半的"中间状态"。
技术实现:锁机制 + MVCC
💾持久性 (Durability)
一句话:提交后永久生效
⚡ 比喻:转账成功后,即使银行系统立刻断电、数据库崩溃,重启后你的钱还在。已完成的交易不会因为系统故障而消失。
技术实现:Redo Log(重做日志)
**💡 记忆口诀:** 原子要么全做要么不做,一致前后逻辑正确,隔离互不干扰独立,持久提交永不丢失。
1.4 数据库内部如何实现 ACID?(深入解析)
Undo Log(回滚日志) — 保证原子性,可回滚到事务开始前的状态。
Redo Log(重做日志 / WAL) — 保证持久性,数据库崩溃后可恢复提交的数据。
锁机制(Locks) — 保证隔离性(如行锁、间隙锁、临键锁、表锁等)。
MVCC(多版本并发控制) — 实现高并发读写,减少锁冲突。
一致性约束 — 主键、唯一键、外键、CHECK、触发器等确保数据逻辑一致。
1.5 事务的生命周期(从开始到提交/回滚)
🚀
START TRANSACTION
开始事务
⚙️
执行 SQL 操作
可能产生 Undo/Redo 日志
🔒
加锁 / 生成 ReadView
按隔离级别进行并发控制
❓
事务结果?
成功或失败
✅ 成功
✔️
COMMIT
写入 Redo Log,持久化数据
❌ 失败
↩️
ROLLBACK
使用 Undo Log 恢复数据
关键点:
开始事务后执行的每条更新都会记录 Undo 和 Redo
提交时只是写日志,不一定立即落盘
回滚时用 Undo 日志恢复数据
1.6 关键总结(增强版)
事务是保证数据一致性的最基本单位
ACID 是事务设计的核心目标
Undo Log + Redo Log 是事务的技术基础
锁与 MVCC 是隔离性的核心手段
一致性并不是数据库保证的,而是数据库 + 业务约束共同保证的
不同数据库(MySQL / PG / Oracle)事务机制实现存在巨大差异
2事务隔离级别
📊 为什么需要隔离级别?
事务隔离级别定义了一个事务可能受其他并发事务影响的程度。SQL 标准定义了四种隔离级别, 从低到高分别为:读未提交、读已提交、可重复读、串行化。
2.1 读未提交(Read Uncommitted)
这是最低的隔离级别,允许一个事务读取另一个事务未提交的数据,也被称为“脏读”。这种级别可能导致脏读、不可重复读和幻读等问题。
优点:并发性能最高,因为几乎没有锁定机制。
缺点:数据一致性最差,可能出现各种并发问题。
适用场景:对数据一致性要求极低,追求极致性能的场景。
2.2 读已提交(Read Committed)
一个事务只能读取另一个事务已经提交的数据。可以避免脏读问题,但可能出现不可重复读和幻读。
优点:避免了脏读,是PostgreSQL、Oracle、SQL Server等数据库的默认隔离级别。
缺点:可能出现不可重复读和幻读。
适用场景:大多数应用程序的常见选择。
2.3 可重复读(Repeatable Read)
在一个事务内多次读取同一数据时,结果是一致的。可以避免脏读和不可重复读,但可能出现幻读(注:MySQL InnoDB通过Next-Key Lock机制可以避免幻读)。
优点:避免了脏读和不可重复读,是MySQL InnoDB的默认隔离级别。
缺点:标准SQL中可能出现幻读,性能相对较低。
适用场景:对数据一致性要求较高的场景。
2.4 串行化(Serializable)
最高的隔离级别,强制事务串行执行,避免了脏读、不可重复读和幻读的问题。通过强制事务排序,避免了并发执行。
优点:数据一致性最高,解决了所有并发问题。
缺点:性能最差,严重降低数据库系统的并发性。
适用场景:对数据一致性要求极高,且能接受性能损失的场景。
2.5 查看和更改事务隔离级别
在实际开发中,了解和设置数据库的事务隔离级别是非常重要的。不同的应用场景可能需要不同的隔离级别来平衡数据一致性和系统性能。本节将详细介绍如何查看和更改主流数据库系统的事务隔离级别。
2.5.1 如何查看当前事务隔离级别
不同数据库系统提供了不同的方法来查看当前的事务隔离级别:
MySQL
`-- 查看全局隔离级别
SELECT @@global.transaction_isolation;
-- 查看会话隔离级别 SELECT @@session.transaction_isolation;
-- 查看当前连接的隔离级别 SELECT @@transaction_isolation;`
PostgreSQL
`-- 查看当前事务隔离级别
SHOW transaction_isolation;
-- 或者使用 SELECT current_setting('transaction_isolation');`
SQL Server
`-- 查看当前会话的隔离级别
SELECT CASE transaction_isolation_level WHEN 1 THEN 'Read Uncommitted' WHEN 2 THEN 'Read Committed' WHEN 3 THEN 'Repeatable Read' WHEN 4 THEN 'Serializable' WHEN 5 THEN 'Snapshot' ELSE 'Unknown' END AS isolation_level FROM sys.dm_exec_sessions WHERE session_id = @@SPID;`
Oracle
`-- Oracle 默认使用读已提交隔离级别
-- 可以通过以下查询查看当前会话信息 SELECT s.sid, s.serial#, s.isolation_level FROM v$session s WHERE s.audsid = SYS_CONTEXT('USERENV','SESSIONID');`
2.5.2 如何更改事务隔离级别
更改事务隔离级别有两种方式:临时更改(仅对当前会话有效)和永久更改(对所有新会话有效)。不同数据库系统的语法有所不同:
MySQL
`-- 临时更改当前会话的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 临时更改全局隔离级别(影响所有新连接) SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 注意:全局更改不影响已存在的连接`
PostgreSQL
`-- 更改当前事务的隔离级别
BEGIN; SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- 执行事务操作 COMMIT;
-- 更改当前会话的隔离级别 SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;`
SQL Server
`-- 设置当前会话的隔离级别
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 隔离级别选项包括: -- READ UNCOMMITTED -- READ COMMITTED (默认) -- REPEATABLE READ -- SNAPSHOT -- SERIALIZABLE`
Oracle
`-- Oracle 中设置事务隔离级别
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Oracle 支持的隔离级别: -- READ COMMITTED (默认) -- SERIALIZABLE -- READ ONLY
-- 注意:Oracle 的设置只对当前事务有效`
2.5.3 注意事项和最佳实践
关键点
更改隔离级别通常只影响后续的事务,不会影响已经在执行的事务
全局更改隔离级别会影响所有新建立的连接,但不会影响已存在的连接
某些隔离级别(如 SERIALIZABLE)会显著降低数据库性能,应谨慎使用
在生产环境中更改隔离级别前,应充分测试其对应用性能的影响
不同的数据库系统对隔离级别的实现可能存在细微差别
在分布式系统中,需要考虑跨数据库的隔离级别一致性
2.5.4 常见问题解答
FAQ
Q: 更改隔离级别后为什么没有立即生效?
A: 大多数情况下,更改隔离级别只影响新开始的事务。如果需要立即生效,需要重新开始事务。Q: 为什么设置了 SERIALIZABLE 隔离级别后性能下降明显?
A: SERIALIZABLE 是最高级别的隔离,会强制事务串行执行,从而大大降低并发性能。只有在确实需要最高数据一致性时才使用。Q: 不同数据库系统的隔离级别名称是否完全一致?
A: 虽然遵循 SQL 标准,但各数据库系统在实现上可能有细微差别。例如,SQL Server 有 SNAPSHOT 隔离级别,而 MySQL 没有直接对应的级别。Q: 如何选择合适的隔离级别?
A: 应根据应用的具体需求来选择: 对数据一致性要求不高的读密集型应用可以选择 READ COMMITTED需要避免不可重复读的场景可以选择 REPEATABLE READ
对数据一致性要求极高的场景可以选择 SERIALIZABLE
需要高性能读取且能容忍轻微不一致的场景可以考虑 READ UNCOMMITTED
3事务操作指南
**实战指南:** 在实际开发中,正确使用事务是保证数据一致性的关键。以下是事务操作的基本步骤和最佳实践。
3.1 开启事务
在执行事务操作前,首先需要开启一个事务。不同数据库系统开启事务的语法略有不同:
`-- MySQL
START TRANSACTION;
-- PostgreSQL BEGIN;
-- SQL Server BEGIN TRANSACTION;`
3.2 执行事务操作
在事务中执行一系列的数据库操作,如插入、更新或删除数据:
`INSERT INTO users (name, email) VALUES ('张三', 'zhangsan@example.com');
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;`
3.3 提交事务
如果所有操作都成功执行,使用 COMMIT 命令提交事务,使更改永久生效:
`COMMIT;`
3.4 回滚事务
如果在事务执行过程中出现错误,使用 ROLLBACK 命令回滚事务,撤销所有未提交的更改:
`ROLLBACK;`
4并发事务问题
💡 为什么会出现并发问题?
当多个事务同时访问数据库时,如果没有适当的隔离机制,就可能发生数据读取异常。 这些问题主要分为三类:脏读、不可重复读、幻读。 不同的事务隔离级别可以解决不同的问题。
🔍 三种并发问题的核心区别
🚨脏读 (Dirty Read)
问题本质:读到了未提交的数据
触发操作:其他事务的 UPDATE/INSERT/DELETE
严重程度:⭐⭐⭐ (非常严重)
读到可能被回滚的数据
🔄不可重复读 (Non-repeatable Read)
问题本质:同一条数据前后读取不一致
触发操作:其他事务的 UPDATE/DELETE
严重程度:⭐⭐ (中等)
影响已存在的记录
👻幻读 (Phantom Read)
问题本质:范围查询记录数量变化
触发操作:其他事务的 INSERT/DELETE
严重程度:⭐ (较轻)
影响统计查询结果
📊 三种问题的对比一览
对比项
脏读
不可重复读
幻读
**问题本质**
读到未提交的数据
同一数据多次读取不一致
范围查询记录数量变化
**触发原因**
其他事务 UPDATE/DELETE/INSERT
其他事务 UPDATE/DELETE
其他事务 INSERT/DELETE
**影响范围**
单条记录
单条记录
记录集合
**最低隔离级别**
Read Committed
Repeatable Read
Serializable(或MySQL RR+Next-Key Lock)
**严重程度**
⭐⭐⭐ 非常严重
⭐⭐ 中等
⭐ 较轻
**常见场景**
读到回滚数据
金融转账、订单处理
统计报表、汇总查询
🧠 记忆口诀
• **脏读** :读了“脏”数据(未提交),可能被回滚
• **不可重复读** :同一条数据前后读取结果不同
• **幻读** :记录数量像“幻觉”一样变化了
4.1 脏读(Dirty Read)
脏读是指一个事务读取了另一个未提交事务的数据。例如,事务 A 修改了一条数据但尚未提交,事务 B 此时读取了这条数据。如果事务 A 随后回滚,事务 B 读取到的就是无效数据。
**🛡️ 解决方案:** 使用 **读已提交(Read Committed)** 及以上的隔离级别可完全避免脏读问题。
**可避免脏读的隔离级别:**
✅ Read Committed(读已提交)
✅ Repeatable Read(可重复读)
✅ Serializable(串行化)
**会出现脏读的隔离级别:**❌ Read Uncommitted(读未提交)
关键点
发生在读取未提交数据时
可能导致严重的数据不一致问题
在实际应用中应尽量避免
生产环境中几乎不会使用Read Uncommitted隔离级别
4.2 不可重复读(Non-repeatable Read)
不可重复读是指在一个事务内多次读取同一数据时,由于其他事务的修改或删除操作,导致每次读取的结果不一致。
**🛡️ 解决方案:** 使用 **可重复读(Repeatable Read)** 及以上的隔离级别可完全避免不可重复读问题。
**可避免不可重复读的隔离级别:**
✅ Repeatable Read(可重复读) ← MySQL默认
✅ Serializable(串行化)
**会出现不可重复读的隔离级别:**❌ Read Uncommitted(读未提交)
❌ Read Committed(读已提交) ← PostgreSQL/Oracle默认
关键点
发生在同一数据多次读取结果不一致时
主要由其他事务的 UPDATE/DELETE 操作引起
在金融系统等对数据一致性要求高的场景需特别注意
MySQL InnoDB默认的Repeatable Read级别可以避免此问题
4.3 幻读(Phantom Read)
幻读是指在一个事务内多次查询某个范围内的数据时,由于其他事务的插入或删除操作,导致每次查询返回的结果集不一致。
**🛡️ 解决方案:**
SQL标准:使用 Serializable(串行化) 隔离级别可完全避免幻读
MySQL InnoDB:在 Repeatable Read 隔离级别下,通过 Next-Key Lock(临键锁) 机制也可以避免幻读
**可避免幻读的隔离级别:**✅ Serializable(串行化) ← 所有数据库都支持
✅ Repeatable Read(可重复读) ← 仅MySQL InnoDB通过Next-Key Lock避免
**会出现幻读的隔离级别:**❌ Read Uncommitted(读未提交)
❌ Read Committed(读已提交)
❌ Repeatable Read(可重复读) ← PostgreSQL/Oracle会出现幻读
关键点
发生在相同条件查询记录数不一致时
主要由其他事务的 INSERT/DELETE 操作引起(改变记录数量)
在统计查询、报表生成等场景中需重点关注
MySQL InnoDB的Repeatable Read + Next-Key Lock可以防止幻读
其他数据库(PostgreSQL/Oracle)需要Serializable级别才能防止
5锁机制
在数据库并发控制中,锁机制是保证数据一致性和完整性的重要手段。主要有两种锁机制:乐观锁和悲观锁。
5.1 乐观锁
核心特征:基于数据版本控制机制,假设并发操作不会发生冲突,仅在提交时检查数据是否被修改。
实现方式:
版本号机制:为数据表增加一个 version 字段,每次更新时将 version 加 1。提交更新时检查 version 值是否发生变化,若变化则拒绝更新。
`-- 查询时获取版本号
SELECT id, name, version FROM users WHERE id = 1;
-- 更新时检查版本号 UPDATE users SET name = 'new_name', version = version + 1 WHERE id = 1 AND version = @original_version;`
时间戳机制:使用时间戳字段代替版本号,原理与版本号机制相同。
`-- 查询时获取时间戳
SELECT id, name, timestamp FROM users WHERE id = 1;
-- 更新时检查时间戳 UPDATE users SET name = 'new_name', timestamp = CURRENT_TIMESTAMP WHERE id = 1 AND timestamp = @original_timestamp;`
关键点
适用于读多写少的应用场景
需要处理版本冲突的情况
不适合长时间运行的事务
假设数据冲突概率低,只在提交时检查冲突
通过版本号或时间戳机制实现
能提供更好的并发性能
可能发生写冲突导致事务重试
5.2 悲观锁
核心特征:基于数据库锁定机制,假设并发操作会发生冲突,在操作开始时就锁定相关资源。
实现方式:
行级锁:通过 SELECT ... FOR UPDATE 语句实现,锁定特定行直到事务结束。
`-- 启动事务
START TRANSACTION;
-- 锁定特定行 SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 执行更新操作 UPDATE users SET name = 'new_name' WHERE id = 1;
-- 提交事务释放锁 COMMIT;`
表级锁:通过 LOCK TABLES 语句实现,锁定整张表。
`-- 锁定表进行写操作
LOCK TABLES users WRITE;
-- 执行操作 UPDATE users SET name = 'new_name' WHERE id = 1;
-- 释放锁 UNLOCK TABLES;`
关键点
可能导致死锁,需要合理设计事务顺序
会降低系统并发性能
需要及时释放锁,避免长时间持有
假设数据冲突概率高,提前锁定数据
通过数据库锁机制实现
适用于写多读少或数据竞争激烈的应用场景
能确保数据一致性
需要注意死锁问题
5.3 两种锁机制的对比
对比项
乐观锁
悲观锁
适用场景
读多写少,冲突较少
写多读少,冲突较多
并发性能
高
低
实现复杂度
较高
较低
死锁风险
低
高
优点
提高并发性能,减少锁开销
实现简单,数据一致性有保障
缺点
需要处理冲突,可能重试
降低并发性能,可能死锁
最佳实践建议
根据应用特点选择合适的锁机制:读多写少选乐观锁,写多读少选悲观锁
在使用乐观锁时,要合理设计重试机制,避免无限重试
在使用悲观锁时,要注意锁的粒度,尽量减少锁的范围
对于金融系统等对数据一致性要求极高的场景,优先考虑悲观锁
定期监控锁的使用情况,及时发现和解决性能瓶颈
推荐使用场景:
乐观锁:适用于电商商品库存查询、用户信息查看等读多写少的场景
悲观锁:适用于银行转账、订单处理等对数据一致性要求极高的场景
6MySQL 内部锁机制
MySQL InnoDB 内部使用多种锁来保证事务隔离性与数据安全,其中包含行锁、间隙锁、临键锁、意向锁、表锁、MDL 锁、自增锁等。 理解这些锁的触发条件与作用范围,有助于避免死锁与性能瓶颈。
6.1 行锁(Record Lock)
锁定索引上的单条记录,是最常见、最细粒度的锁。
触发条件
WHERE 条件使用索引并命中某条记录
主键/唯一索引精确匹配
`SELECT * FROM users WHERE id = 10 FOR UPDATE;`
特点
不会阻塞插入操作(除非存在间隙锁)
并发能力高
6.2 间隙锁(Gap Lock)
用于锁定“索引记录之间的空隙”,防止其他事务插入新数据,避免幻读。
触发条件
范围查询(<, >, BETWEEN)
隔离级别为 REPEATABLE READ
查询使用了索引
`SELECT * FROM users WHERE age > 30 FOR UPDATE;`
该查询会锁住“age 大于 30 的所有间隙”。
6.3 临键锁(Next-Key Lock)
临键锁 = 行锁 + 间隙锁,是 MySQL InnoDB 默认的锁定方式。 用于彻底避免幻读。
示例
`SELECT * FROM users WHERE age BETWEEN 20 AND 30 FOR UPDATE;`
锁定范围
age 在 20–30 之间的所有记录
这些记录之间的所有间隙
6.4 意向锁(Intention Lock)
表级锁,用于表示“当前事务将要在某些记录上加行级锁”。 意向锁自动添加,无需业务方干预。
IS(意向共享锁):将要加 S 锁
IX(意向排他锁):将要加 X 锁
示例
`UPDATE users SET age = 18 WHERE id = 1;`
执行该更新时,MySQL 会自动加 IX(表级)+ X(行级)锁。
6.5 表锁(Table Lock)
直接锁定整张表,通常用于管理操作。
`LOCK TABLES users WRITE;`
特点
阻塞所有读写
并发能力差,不建议在业务操作中使用
6.6 MDL 锁(Metadata Lock)
MySQL 自动加的元数据锁,用于保证查询期间表结构不会变更。
示例
`SELECT * FROM users;`
上述查询会加 MDL-S 锁,阻止如下 DDL 立即执行:
`ALTER TABLE users ADD COLUMN age INT;`
注意事项
- 长事务容易引发 MDL 锁等待,导致“全库堵塞”
6.7 自增锁(AUTO_INCREMENT Lock)
用于保护自增值的分配。在 MySQL 5.7+ 已优化为“轻量锁”,几乎不会造成阻塞。
`INSERT INTO orders (user_id) VALUES (1001);`
特点
保证自增值唯一性
不会阻塞普通查询
各类锁对比总结
锁类型
粒度
作用
行锁
单条记录
锁定记录本身
间隙锁
记录之间的空隙
防止插入导致幻读
临键锁
行锁 + 间隙锁
全面避免幻读(默认)
意向锁
表级
表明将加行级锁,提高锁检查效率
表锁
整表
阻塞所有读写(危险)
MDL 锁
整表
保护表结构不被更改
AUTO_INCREMENT 锁
自增计数器
保证自增值唯一
7死锁分析与案例
死锁(Deadlock)是指两个或多个事务在执行过程中,由于相互等待对方释放资源, 从而产生循环等待,导致事务永远无法继续执行的情况。 MySQL InnoDB 能自动检测死锁并回滚其中一个事务,以打破死锁。
7.1 死锁形成的四个必要条件
根据操作系统并发原理,死锁必须同时满足以下四个条件:
互斥条件:资源一次只能被一个事务占用。
占有且等待:事务持有资源的同时还在等待其他资源。
不可抢占:已获得的资源不可被抢夺,只能由持有者主动释放。
循环等待:多个事务形成“环状等待链”。
只要破坏其中任意一个条件,就能避免死锁。
7.2 常见死锁场景
(1)两个事务更新相同记录,但顺序不同
`-- 事务 A
BEGIN; UPDATE users SET age = 20 WHERE id = 1; UPDATE users SET age = 30 WHERE id = 2;
-- 事务 B BEGIN; UPDATE users SET age = 30 WHERE id = 2; UPDATE users SET age = 20 WHERE id = 1;`
两个事务更新相同表的两条记录,但顺序不同 → 容易形成循环等待。
(2)间隙锁导致插入阻塞
`-- 事务 A
SELECT * FROM users WHERE age > 30 FOR UPDATE;
-- 事务 B INSERT INTO users(age) VALUES(35);`
如果事务 A 再去等待 B 持有的行锁,则会形成死锁。
(3)唯一索引冲突 + 更新导致死锁
唯一键检查可能触发内部间隙锁 → 与更新锁冲突 → 死锁。
(4)范围更新导致锁范围不一致
范围锁(间隙锁 / 临键锁)非常容易在业务不规范时触发死锁。
7.3 死锁日志分析(SHOW ENGINE INNODB STATUS)
当数据库发生死锁时,可使用以下命令查看最近一次死锁:
`SHOW ENGINE INNODB STATUS;`
典型死锁日志(中文解释版)
`------------------------
最新检测到的死锁(LATEST DETECTED DEADLOCK)
*** (1) 事务(Transaction 1): UPDATE users SET age = 20 WHERE id = 1;
*** (1) 等待的锁(WAITING FOR LOCK): 记录锁(Record Lock) 锁模式:X(排他锁) 索引:PRIMARY(主键索引) 相关表:test.users
*** (2) 事务(Transaction 2): UPDATE users SET age = 30 WHERE id = 2;
*** (2) 当前持有的锁(HOLDS THE LOCKS): 记录锁(Record Lock) 锁模式:X(排他锁) 索引:PRIMARY(主键索引)
*** 死锁检测到,回滚事务 (1) (WE ROLL BACK TRANSACTION (1))`
如何分析死锁日志?
找出两个事务分别持有哪些锁、在等待哪些锁
分析两者是否形成循环等待链
确认锁类型(行锁、间隙锁、临键锁)
定位触发死锁的 SQL 和索引
MySQL 会选择回滚“代价最小”的事务
7.4 避免死锁的最佳实践
(1)保持统一的加锁顺序
所有业务模块对同一批记录必须使用固定顺序加锁(如按 id 从小到大)。
(2)减少范围查询,降低间隙锁发生
尽量使用主键 / 唯一键精确更新
避免在事务中 UPDATE ... WHERE age > 30
(3)缩短事务时间
不要在事务中执行耗时操作(网络调用、sleep)
减少事务包含的 SQL 数量
(4)合理添加索引
避免全表扫描导致“锁全表”
减少因为走错索引导致锁范围过大
(5)分段操作批量数据
- 避免单个事务操作过多行(锁范围大,死锁概率增高)
(6)适当使用较低隔离级别(如 RC)
在 RC 隔离级别下不会出现间隙锁,死锁概率大幅下降(但需业务允许)。
关键总结
死锁是多个事务因相互占有资源而形成“循环等待”
InnoDB 会自动检测并回滚其中一个事务
死锁常见来源:加锁顺序不一致、范围更新、间隙锁、唯一键冲突
统一加锁顺序 + 精确索引查询 = 避免死锁的关键
8高并发事务 + 锁实战案例(Java + Spring)
在实际业务开发中,MySQL 的行锁、间隙锁、临键锁等机制常与 Java Spring 的事务管理结合使用。 正确使用事务 + 加锁模式,是保证高并发情况下数据一致性的关键。
8.1 高并发库存扣减(防止超卖)
这是电商系统最经典的高并发场景。
错误示例:不加锁,必然超卖
`@Transactional
public void reduceStock(Long productId) { Product p = productMapper.selectById(productId); if (p.getStock() > 0) { productMapper.reduceStock(productId); // UPDATE stock = stock - 1 } }`
多个线程几乎同时读取到 stock=1,会出现超卖。
正确写法:使用 MySQL 行锁(SELECT ... FOR UPDATE)
`@Transactional
public void reduceStock(Long productId) { // 加排他锁(InnoDB 行锁) Product p = productMapper.selectForUpdate(productId);
if (p.getStock()
MyBatis Mapper 示例:
`@Select("SELECT * FROM product WHERE id = #{id} FOR UPDATE")
Product selectForUpdate(Long id);`
FOR UPDATE 加排他锁,仅在事务内生效
多线程同时访问时,其他线程会阻塞等待锁
确保并发下 stock 不会被扣到负数
8.2 使用统一加锁顺序避免死锁(账户转账)
死锁通常由于“加锁顺序不一致”导致。
错误写法(极易死锁)
`@Transactional
public void transfer(Long fromId, Long toId, int amount) { // 查询顺序是不确定的 Account from = accountMapper.selectForUpdate(fromId); Account to = accountMapper.selectForUpdate(toId);
from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);
accountMapper.update(from);
accountMapper.update(to);
}`
当多个事务转账不同账户时,锁顺序不同 → 极易循环等待。
正确写法:按照 id 排序后再加锁
`@Transactional
public void transfer(Long a, Long b, int amount) {
Long minId = Math.min(a, b);
Long maxId = Math.max(a, b);
// 按固定顺序加锁,避免死锁
Account first = accountMapper.selectForUpdate(minId);
Account second = accountMapper.selectForUpdate(maxId);
if (a.equals(minId)) {
first.setBalance(first.getBalance() - amount);
second.setBalance(second.getBalance() + amount);
} else {
second.setBalance(second.getBalance() - amount);
first.setBalance(first.getBalance() + amount);
}
accountMapper.update(first);
accountMapper.update(second);
}`
通过统一加锁顺序,可以彻底避免死锁。
8.3 分布式高并发:Redis 分布式锁 + MySQL 行锁组合
在多实例部署的微服务架构中,仅依赖数据库行锁已经不够。
推荐做法:
第一层:Redis 分布式锁(控制多实例之间的并发)
第二层:MySQL 行锁(控制数据库内部并发)
示例(使用 Redisson)
`public void reduceStockDistributed(Long productId) {
RLock lock = redissonClient.getLock("lock:product:" + productId);
lock.lock(); // 分布式锁
try {
reduceStock(productId); // 内部使用 MySQL 行锁
} finally {
lock.unlock();
}
}`
双保险策略:
微服务层阻断多节点同时处理同一商品
数据库层阻断同一时间内并发更新同一行
8.4 Spring 事务传播机制对锁行为影响
传播机制
是否加入当前事务
锁行为
REQUIRED(默认)
✔ 加入
锁一直保持到事务提交
REQUIRES_NEW
✘ 新开事务
锁独立,不受外层影响
NOT_SUPPORTED
无事务
无法使用 MySQL 行锁(FOR UPDATE 无效)
总结:
只要使用 FOR UPDATE,方法必须在事务中执行
否则会出现“加锁成功但瞬间释放”,导致并发安全问题
关键总结
库存扣减必须用 SELECT … FOR UPDATE
转账必须按 ID 排序统一加锁,有效避免死锁
高并发下推荐“分布式锁 + MySQL 行锁”双保险
Spring 必须开启事务,否则数据库锁不会生效
9MVCC 多版本并发控制(MySQL vs PostgreSQL)
9.1 什么是 MVCC?
MVCC 全称 Multi-Version Concurrency Control(多版本并发控制), 是数据库用来在高并发读写场景下,同时兼顾性能和一致性的一种机制。
一句话描述:
“读不阻塞写,写也尽量不阻塞读” —— 通过保存数据的多个版本,让不同事务各自看到“合适版本”的数据快照。
MVCC 的核心目标:
减少读写之间的锁冲突(读操作尽量不加锁)
降低死锁概率,提升并发性能
在可接受的成本下,尽量满足事务隔离级别的要求
9.2 MVCC 的四个关键技术点
版本号 / 事务 ID(Transaction ID):标记每个版本是由哪个事务产生的。
多版本存储(Version Chain):同一行数据的不同历史版本形成一条"版本链"。
Undo Log / 旧版本存储:保存旧版本数据,用来构造历史快照。
可见性规则(Read View / Visibility):当前事务根据自己的"视图",决定能看到哪个版本。
📌 技术点1:版本号 / 事务ID
作用:
唯一标识每个事务,确保数据版本可追溯
为每次数据修改打上"时间戳"(逻辑时间)
判断数据版本的新旧关系
实现原理:
数据库系统维护一个全局递增的事务ID计数器
每个事务开始时分配唯一的事务ID(如:100, 101, 102...)
每次INSERT/UPDATE时,将当前事务ID写入数据行
通过比较事务ID大小,确定版本的先后顺序
`示例:
事务T1 (ID=100) 插入数据:row_version = 100 事务T2 (ID=101) 更新数据:row_version = 101 ← 更新的版本 事务T3 (ID=102) 读取时,看到 101
📌 技术点2:多版本存储(Version Chain)
作用:
保留同一条数据的多个历史版本
允许不同事务读取不同时间点的数据快照
实现"读不阻塞写、写不阻塞读"的并发控制
实现原理:
通过链表结构连接同一行的多个版本
最新版本在链表头部,旧版本依次向后
每个版本包含:数据内容、事务ID、指向上一版本的指针
`版本链示例(id=1的数据):
┌─────────────────┐ │ 当前版本 V3 │ ← 最新(事务30修改) │ value = 'C' │ │ trx_id = 30 │ │ prev_ptr ───────┼──┐ └─────────────────┘ │ ↓ ┌─────────────────┐ │ │ 历史版本 V2 │ ←┘ │ value = 'B' │ (事务20修改) │ trx_id = 20 │ │ prev_ptr ───────┼──┐ └─────────────────┘ │ ↓ ┌─────────────────┐ │ │ 最初版本 V1 │ ←┘ │ value = 'A' │ (事务10插入) │ trx_id = 10 │ │ prev_ptr = NULL │ └─────────────────┘`
关键点:不同事务可以同时读取V1、V2、V3,互不影响!
📌 技术点3:Undo Log / 旧版本存储
作用:
存储数据修改前的旧值(用于回滚和MVCC)
事务回滚时,根据Undo Log恢复数据
为读事务提供历史版本数据
实现原理(MySQL InnoDB):
UPDATE操作:将旧值写入Undo Log,更新当前记录
DELETE操作:标记删除,旧值保存到Undo Log
回滚指针:当前记录通过DB_ROLL_PTR指向Undo Log
清理机制:当没有事务需要时,purge线程清理Undo Log
`UPDATE users SET name='Bob' WHERE id=1;
执行流程:
- 将旧值 name='Alice' 写入 Undo Log
- 更新当前记录:
- name = 'Bob'
- DB_TRX_ID = 当前事务ID
- DB_ROLL_PTR = 指向 Undo Log 中的旧值
数据结构: 当前页面:[id=1, name='Bob', trx_id=30, roll_ptr→undo_20] ↓ Undo Log:[id=1, name='Alice', trx_id=20, roll_ptr→undo_10] ↓ Undo Log:[id=1, name='Tom', trx_id=10, roll_ptr=NULL]`
⚠️ 重要提示
长事务的危害:
长时间未提交的事务会阻止Undo Log清理
导致Undo Log无限增长,占用大量磁盘空间
影响数据库性能和查询效率
建议:避免长时间开启事务,及时提交或回滚
📌 技术点4:可见性规则(Read View)
作用:
决定当前事务能看到哪些数据版本
实现事务隔离级别(RC、RR)
确保读取到一致性快照
实现原理(MySQL InnoDB):
每次快照读时,创建一个Read View,包含:
m_ids:当前活跃(未提交)的事务ID列表
min_trx_id:活跃事务中最小的ID
max_trx_id:系统下一个将要分配的事务ID
creator_trx_id:创建Read View的事务ID
可见性判断规则:
`对于版本V(trx_id=X):
如果 X == creator_trx_id → 可见(自己修改的数据)
如果 X = max_trx_id → 不可见(在当前事务开始后才创建的事务)
如果 min_trx_id
**隔离级别差异:** • **READ COMMITTED:** 每次SELECT都创建新的Read View • **REPEATABLE READ:** 事务内第一次SELECT创建Read View,后续复用 `示例场景:
事务T1 (ID=10) 开始,创建 Read View: m_ids = [15, 20] ← 活跃事务 min_trx_id = 15 max_trx_id = 25
读取 id=1 的数据,版本链如下: V3: trx_id=20 → 在m_ids中,不可见 → 继续找 V2: trx_id=18 → 不在m_ids中,18
用一个简单的“版本链”图示理解:
id = 1 的这条记录的历史:
最新版本:V3 (trx_id = 30)
↑
│ 由事务 30 更新
│
版本 V2 (trx_id = 20)
↑
│ 由事务 20 更新
│
版本 V1 (trx_id = 10) ← 最初插入不同事务读取时,会根据自己的事务 ID / Read View,在 V1 / V2 / V3 中选择“对自己可见”的那个版本。
9.3 MySQL InnoDB 的 MVCC 实现
9.3.1 隐藏列:事务 ID + 回滚指针
InnoDB 每行记录内部会多出几个隐藏列(简化理解):
DB_TRX_ID:最近一次修改这条记录的事务 ID
DB_ROLL_PTR:指向 Undo Log 中“上一版本”的指针
这样,多次 UPDATE 后就形成一条版本链:
当前页中的记录:最新版本 (DB_TRX_ID = 30, DB_ROLL_PTR → undo_20)
undo_20:上一个版本 (DB_TRX_ID = 20, DB_ROLL_PTR → undo_10)
undo_10:更早版本 (DB_TRX_ID = 10, DB_ROLL_PTR → null)9.3.2 Undo Log:旧版本数据存哪里?
当事务对记录做 UPDATE/DELETE 时,InnoDB 不会直接覆盖旧数据, 而是将旧值写入 Undo Log,同时在当前记录上更新 DB_TRX_ID 和 DB_ROLL_PTR。
这样做的好处:
可以为其他“晚点来读”的事务构造历史快照
事务回滚时可以用 Undo Log 恢复数据
9.3.3 Read View:当前事务能看到哪个版本?
每个“快照读”(普通 SELECT)会基于一个 Read View 来判断可见性: 简化理解,Read View 会记录:
当前系统中活跃的事务 ID 集合
当前已分配的最大事务 ID
可见性规则(简化版):
如果版本的 trx_id < 最小活跃事务 ID → 一定可见
如果版本的 trx_id 在活跃事务集合中 → 不可见(别的事务还没提交)
如果版本的 trx_id > 当前最大已分配 ID → 不可能存在(将来事务)
9.3.4 快照读 vs 当前读
快照读(Snapshot Read):普通 SELECT,不加锁,走 MVCC
当前读(Current Read):SELECT ... FOR UPDATE / UPDATE / DELETE,会读取“最新版本”,并加锁
`-- 快照读(不加锁,走 MVCC)
SELECT * FROM users WHERE id = 1;
-- 当前读(加行锁,返回最新已提交或自己未提交的版本) SELECT * FROM users WHERE id = 1 FOR UPDATE;`
9.3.5 不同隔离级别下 Read View 的差异
隔离级别
Read View 生成时机
现象
READ COMMITTED
每次 SELECT 都生成新的 Read View
可能出现不可重复读
REPEATABLE READ(默认)
事务第一次快照读时生成,后续复用
同一事务内多次读到的结果一致
9.3.6 MySQL MVCC 的优点与缺点
MySQL InnoDB MVCC 优点
读写冲突少,大部分 SELECT 不加锁
RR 隔离级别下也能有较好的性能
Undo Log 可用于回滚,也用于 MVCC
旧版本主要存在 Undo Log 中,表本身膨胀压力相对较小
MySQL InnoDB MVCC 缺点
Undo Log 过多会带来存储与清理成本
长事务会阻止 Undo Log 被清理,导致历史版本堆积
实现较复杂,调优时需理解 Read View、Undo、清理机制
9.4 PostgreSQL 的 MVCC 实现
9.4.1 “多版本存储”而不是 Undo Log
PostgreSQL 采取的是“多版本直接存储在表中”的方式: 每次 UPDATE / DELETE 不会在 Undo Log 里放旧版本,而是在表里插入一条新版本记录, 旧版本记录仍然留在表里,只是标记为“对当前或未来某些事务不可见”。
每条记录会有两个核心字段:
xmin:创建该版本的事务 ID
xmax:删除 / 覆盖该版本的事务 ID(未删除时为空)
9.4.2 可见性规则(简化版)
若 xmin 已提交,且 xmax 未提交或为空 → 对当前事务可见
若 xmin 未提交 → 不可见(脏数据)
若 xmax 已提交,且提交时间早于当前快照 → 视为已删除
9.4.3 版本膨胀与 VACUUM
由于旧版本直接存在表中,随着 UPDATE/DELETE 增多,表和索引会被大量“无效版本”填满, 这就是所谓的:
表膨胀(Table Bloat)
索引膨胀(Index Bloat)
为了解决膨胀,PostgreSQL 需要:
VACUUM:标记并清理无效版本
AUTOVACUUM:自动触发 VACUUM 的后台进程
VACUUM FULL:重写整个表,压缩空间(会锁表)
9.4.4 PostgreSQL MVCC 的优点与缺点
PostgreSQL MVCC 优点
实现思路相对“直接”:版本就存在表里,通过 xmin/xmax 判断可见性
每个版本天然带有“时间线”,适合做审计、时间旅行(配合额外机制)
快照隔离与可见性规则可控性较强
PostgreSQL MVCC 缺点
如果 VACUUM 配置不合理,表和索引会严重膨胀
需要 DBA 对 AUTOVACUUM 做调优(频率、阈值等)
热点表频繁更新时,I/O 与空间开销较大
9.5 MySQL vs PostgreSQL 的 MVCC 差异
对比项
MySQL InnoDB
PostgreSQL
旧版本存放位置
Undo Log + 页中记录指针
直接存放在表中多条 tuple
版本链实现
DB_ROLL_PTR 指向 Undo 链
通过 xmin/xmax 和索引扫描选择可见版本
膨胀风险
主要是 Undo 累积 + 历史版本未清理
表 & 索引膨胀较明显,需要 VACUUM
清理机制
后台 purge 清理 Undo 和历史版本
AUTOVACUUM / VACUUM FULL
读写冲突
快照读基本不加锁,配合 RR 效果好
快照读也不加锁,但热点表更新多时压力大
调优关注点
Undo 表空间、长事务、purge 速度
autovacuum 配置、膨胀监控、定期清理
9.6 实战中的选择与建议
在 MySQL 上:
避免长事务(长时间不提交会阻止 Undo 清理)监控 Undo 表空间与历史版本堆积情况
合理选择隔离级别(多数场景 READ COMMITTED / REPEATABLE READ 即可)
在 PostgreSQL 上:
务必理解并调优 AUTOVACUUM 参数监控表膨胀(bloat),必要时做 VACUUM FULL 或重建索引
避免高频更新同一行的热点表设计(可做分片 / 日志表等拆分)
无论 MySQL 还是 PostgreSQL:
理解 MVCC 的“多版本 + 可见性规则”是设计高并发读写系统的基础读多写少场景非常适合利用 MVCC 的优势
写多读多的极端场景则需要配合缓存、CQRS、异步化等架构手段
9.7 MySQL 与 PostgreSQL MVCC 流程对比图
下面通过图形化展示 MySQL 与 PostgreSQL 在 MVCC 处理流程上的核心差异。
🐬 MySQL InnoDB MVCC 流程
📋 客户端查询
▼
👁️ 生成 Read View
▼
🔍 判断 DB_TRX_ID 是否可见
▼
❓ 可见?
✓ 是
✗ 否
✔️ 返回该版本
🔙 回溯 Undo Log
▼
📜 找到可见旧版本
**📌 版本链示意:**
记录页(最新) → undo_40 → undo_20 → undo_10 → null
DB_TRX_ID=50
**💡 特点:**
旧版本存 Undo Log
purge 后台清理
表膨胀轻
长事务导致 Undo 堆积
🐘 PostgreSQL MVCC 流程 📋 客户端查询 ▼ 📸 生成 Snapshot ▼ 🔎 扫描表中所有版本 tuple ▼ 🔍 根据 xmin/xmax 判断可见性 ▼ ❓ 可见? ✓ 是 ✗ 否 ✔️ 返回该版本 ➡️ 扫描下一个 tuple ▼ 🔄 直到找到可见版本 **📌 多版本示意:** tuple1: xmin=10, xmax=20 ← 旧版本 tuple2: xmin=20, xmax=30 ← 较新旧版本 tuple3: xmin=30, xmax=0 ← 最新版本 **💡 特点:**版本直接存在表中
表膨胀严重(bloat)
需 VACUUM / AUTOVACUUM 清理
xmin/xmax 判定可见性
9.7 小结:两者 MVCC 流程核心差异
对比项
MySQL InnoDB
PostgreSQL
旧版本存储
Undo Log 中(版本链回溯)
表中多个 tuple 物理存在
可见性判断
Read View + DB_TRX_ID
Snapshot + xmin/xmax
膨胀风险
Undo 堆积(长事务)
表膨胀(bloat)明显
清理机制
Purge 线程自动清理
VACUUM / AUTOVACUUM
✓总结
🎓 学习总结
数据库事务是保证数据一致性和完整性的核心机制。通过理解事务的 ACID 特性、隔离级别以及并发控制机制,我们可以更好地设计和优化数据库应用。在实际开发中,应根据具体业务需求选择合适的隔离级别和锁机制,平衡数据一致性和系统性能。
目录
↑