未记录事实核验 · 状态:现行内容

记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。

warning 适用边界与维护说明

本文适合学习 InnoDB 的 ACID、事务语法、隔离级别及基础并发控制,不是支付、库存或金融账务的完整正确性方案;场景清单仅作评估起点,持久性须结合实际配置理解。含 WHILE、外部 API 调用和省略号的片段属于流程伪代码,performance_schema.data_locks、data_lock_waits 的诊断示例应按 MySQL 8.0 环境使用。START TRANSACTION不等于已经持有目标行锁;普通一致性COUNT查询的快照与版本保留成本应与锁定操作及锁等待区分。本次仅作全文结构审阅,未执行 SQL 或进行外部事实与链接核验。

事务与锁机制基础 - ACID、隔离级别与并发控制入门

abstract 本节概览

为什么需要事务?

想象一个银行转账场景:Alice 向 Bob 转账 500 元,需要两步操作:

  1. Alice 账户减少 500 元
  2. Bob 账户增加 500 元

如果两步之间系统崩溃了,或者两个操作只成功了一个,会发生什么?钱会凭空消失或凭空产生——这在现实业务中是不可接受的。事务就是解决这类问题的机制:它保证多个操作要么全部成功,要么全部撤销。


一、ACID 四大特性

%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart LR
    classDef default fill:#FDFBF5,stroke:#6E6A60,color:#2D2A26,stroke-width:1.5px
    classDef startend fill:#2D2A26,stroke:#2D2A26,color:#FDFBF5,font-weight:bold
    classDef decision fill:#F6EAD8,stroke:#C08A1A,color:#2D2A26,font-weight:bold
    classDef hub fill:#3B5A80,stroke:#3B5A80,color:#FDFBF5,font-weight:bold
    classDef io fill:#E5EAF0,stroke:#3B5A80,color:#2D2A26
    classDef accent fill:#A6432C,stroke:#A6432C,color:#FDFBF5,font-weight:bold
    classDef storage fill:#F3EEDF,stroke:#9A9488,color:#2D2A26
    TX[事务 Transaction]
    TX --> A["A - 原子性 Atomicity\n操作不可分割"]
    TX --> C["C - 一致性 Consistency\n业务规则始终满足"]
    TX --> I["I - 隔离性 Isolation\n事务互不干扰"]
    TX --> D["D - 持久性 Durability\n提交后永久保存"]
    class TX,A,C,I,D default

原子性(Atomicity)

事务中的所有操作是一个不可分割的整体:要么全部成功提交,要么全部失败回滚。

生活比喻:可将 ATM 取款的理想业务目标理解为扣款与吐钱保持一致;但物理吐钞不属于数据库事务,实际系统仍需处理设备故障、对账与补偿,数据库原子性本身不能保证两者绝不部分失败。

实现机制:InnoDB 通过 undo log(回滚日志) 实现原子性。执行每个写操作时,都会记录"如何撤销这个操作"的反向日志,一旦回滚,按反向日志恢复数据。

一致性(Consistency)

事务执行前后,数据库必须处于一致状态,满足所有业务规则和约束。

生活比喻:转账前后,Alice 和 Bob 的账户总金额之和不变。一致性是事务的最终目标,原子性、隔离性、持久性都是为了保障一致性而存在的手段。

实现机制:约束(NOT NULL、UNIQUE、外键)、应用层业务规则、以及其他三个特性共同保障。

隔离性(Isolation)

多个并发事务之间互相隔离,一个事务的中间状态对其他事务不可见。

生活比喻:两个人同时在 ATM 转账时互不影响,每人看到的账户余额都是正确的,不会互相看到对方操作的中间状态。

实现机制:InnoDB 通过锁机制和 MVCC(多版本并发控制) 实现隔离性。

持久性(Durability)

事务一旦提交,即使系统崩溃,其结果也永久保存在数据库中。

生活比喻:银行转账收到"转账成功"通知后,即使银行系统随即断电,你的钱也已经到账,重启后账单依然正确。

实现机制:InnoDB 通过 redo log(重做日志) 实现持久性。InnoDB 使用 redo log 支持崩溃恢复,并遵循先写日志再写数据页的 WAL 机制;提交时是否将 redo log 刷入持久存储取决于 innodb_flush_log_at_trx_commit 等配置,不能脱离配置承诺故障后已提交数据一定不丢失。


二、事务操作语法

-- 基础事务语法
START TRANSACTION;  -- 或 BEGIN;
    -- 你的 SQL 操作
    UPDATE accounts SET balance = balance - 500 WHERE id = 1;
    UPDATE accounts SET balance = balance + 500 WHERE id = 2;
COMMIT;             -- 提交:使所有更改永久生效

-- 回滚:撤销自 START TRANSACTION 以来的所有更改
START TRANSACTION;
    UPDATE accounts SET balance = balance - 500 WHERE id = 1;
    -- 发现 id=2 不存在,需要撤销
ROLLBACK;

-- 保存点(Savepoint):部分回滚
START TRANSACTION;
    INSERT INTO orders VALUES (1001, 'Alice', 200.00);
    SAVEPOINT sp1;
    INSERT INTO order_items VALUES (1, 1001, 'Book', 150.00);
    SAVEPOINT sp2;
    INSERT INTO order_items VALUES (2, 1001, 'Pen', 50.00);
    -- 只回滚到 sp2(撤销最后一个 INSERT)
    ROLLBACK TO SAVEPOINT sp2;
COMMIT;  -- 提交前两个 INSERT

-- 自动提交模式(默认开启)
SHOW VARIABLES LIKE 'autocommit';  -- 默认 ON
-- 关闭自动提交(每条 DML 需手动 COMMIT)
SET autocommit = 0;

warning autocommit 的陷阱

MySQL 默认 autocommit=ON,每条语句自动作为一个事务提交。 如果你执行了 SET autocommit=0,记得在每次操作后显式 COMMIT 或 ROLLBACK, 否则连接关闭时会自动回滚,导致数据"消失"。


三、四种隔离级别

不同的隔离级别在"数据一致性"和"并发性能"之间做出不同的权衡取舍。

三类并发问题

问题 说明 举例
脏读 读到其他未提交事务的修改 看到了别人还没付款的订单
不可重复读 同一事务中,前后两次读取同一行,结果不同 查余额两次,中间别人转账了
幻读 同一事务中,前后两次查询,结果集的行数不同 统计订单数,中间新增了订单

四种隔离级别及其解决的问题

%%{init: {"theme":"base","themeVariables":{"fontFamily":"PingFang SC, -apple-system, Source Han Serif SC, serif","fontSize":"16px","background":"#FDFBF5","primaryColor":"#FDFBF5","primaryTextColor":"#2D2A26","primaryBorderColor":"#6E6A60","secondaryColor":"#E8EFE8","secondaryBorderColor":"#8FA890","secondaryTextColor":"#2D2A26","tertiaryColor":"#FDFBF5","tertiaryBorderColor":"#9A9488","tertiaryTextColor":"#6E6A60","lineColor":"#6E6A60","textColor":"#2D2A26","mainBkg":"#FDFBF5","nodeBorder":"#2D5A3D","clusterBkg":"#F3EEDF","clusterBorder":"#9A9488","edgeLabelBackground":"#FDFBF5"},"flowchart":{"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":60,"rankSpacing":70,"useMaxWidth":false},"themeCSS":".node rect, .node polygon, .node circle { stroke-width: 1.5px; } .edgePath .path { stroke-width: 1.5px; } .cluster-label .nodeLabel, .cluster-label foreignObject { color: #2D2A26 !important; } .edgeLabel, .edgeLabel foreignObject { color: #2D2A26 !important; background-color: #FDFBF5; } .edgeLabel rect { fill: #FDFBF5; }"}}%%
flowchart LR
    classDef default fill:#FDFBF5,stroke:#6E6A60,color:#2D2A26,stroke-width:1.5px
    classDef startend fill:#2D2A26,stroke:#2D2A26,color:#FDFBF5,font-weight:bold
    classDef decision fill:#F6EAD8,stroke:#C08A1A,color:#2D2A26,font-weight:bold
    classDef hub fill:#3B5A80,stroke:#3B5A80,color:#FDFBF5,font-weight:bold
    classDef io fill:#E5EAF0,stroke:#3B5A80,color:#2D2A26
    classDef accent fill:#A6432C,stroke:#A6432C,color:#FDFBF5,font-weight:bold
    classDef storage fill:#F3EEDF,stroke:#9A9488,color:#2D2A26
    RU["读未提交\nREAD UNCOMMITTED\n最低隔离"] --> RC["读已提交\nREAD COMMITTED\n解决脏读"]
    RC --> RR["可重复读\nREPEATABLE READ\n解决不可重复读\n(MySQL 默认)"]
    RR --> SZ["串行化\nSERIALIZABLE\n解决所有问题\n最低并发"]
    class RU,RC,RR,SZ default
隔离级别 脏读 不可重复读 幻读 并发性
READ UNCOMMITTED 可能 可能 可能 最高
READ COMMITTED 解决 可能 可能 较高
REPEATABLE READ 解决 解决 部分解决* 中等
SERIALIZABLE 解决 解决 解决 最低

*InnoDB 在 REPEATABLE READ 级别下,通过 MVCC + 间隙锁(Gap Lock)在很大程度上防止了幻读,但在某些特定场景下仍可能出现。详见 事务与锁深度解析。

隔离级别操作命令

-- 查看当前隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
-- 或(MySQL 8.0+)
SELECT @@transaction_isolation;

-- 修改会话级别隔离级别(仅影响当前连接)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 修改全局隔离级别(影响后续新建连接;已有连接如需修改,应显式设置会话隔离级别)
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 设置下一个事务的隔离级别(仅影响下一个事务)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
-- 此事务以 SERIALIZABLE 运行
COMMIT;

四种级别的场景选择

-- READ UNCOMMITTED:几乎不用,仅适合对数据精度要求极低的场景
-- 例如:粗略统计某些状态数量,允许有微小误差

-- READ COMMITTED:许多互联网公司的选择(Oracle 默认也是此级别)
-- 例如:电商平台 - 允许看到刚提交的价格变动,避免读历史旧价
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- REPEATABLE READ:MySQL 默认,大多数场景的最佳平衡点
-- 例如:财务报表 - 一次查询过程中看到的数据保持一致

-- SERIALIZABLE:极少使用,用于强一致性要求的金融核心场景
-- 代价:读操作也会加锁,并发性大幅下降

四、乐观锁与悲观锁

乐观锁和悲观锁不是 MySQL 的底层实现,而是两种并发控制的思维模式。

悲观锁(Pessimistic Locking)

思路:对数据持悲观态度,假设并发冲突经常发生,所以在操作数据前先加锁。

-- 悲观锁实现:SELECT ... FOR UPDATE
-- 场景:抢购商品库存,防止超卖

START TRANSACTION;

-- 加锁读取库存(其他事务无法同时修改此行)
SELECT stock FROM products WHERE id = 100 FOR UPDATE;
-- 此时其他事务的 SELECT ... FOR UPDATE 会阻塞等待

-- 检查库存并扣减
UPDATE products SET stock = stock - 1 WHERE id = 100 AND stock > 0;

COMMIT;

适用场景:

  • 写多读少的场景
  • 并发冲突概率高时
  • 对数据一致性要求极高(金融核心交易)

缺点:性能开销大,容易死锁,在高并发下成为瓶颈。

乐观锁(Optimistic Locking)

思路:对数据持乐观态度,假设并发冲突很少发生,读取时不加锁,提交时验证数据是否被修改。

-- 乐观锁实现:version 版本号(或 updated_at 时间戳)

-- 建表时添加 version 字段
ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 0;

-- 读取时记录 version
SELECT id, stock, version FROM products WHERE id = 100;
-- 假设返回:id=100, stock=10, version=5

-- 更新时带上版本号作为条件
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5 AND stock > 0;  -- 版本号匹配且库存充足才扣减

-- 检查影响行数
-- affected_rows = 1:更新成功(无并发冲突)
-- affected_rows = 0:未更新;可能是版本冲突、记录不存在或库存不足,应区分原因后决定是否重试

适用场景:

  • 读多写少的场景
  • 并发冲突概率低时
  • 需要高吞吐量的查询密集型业务

缺点:冲突后需要重试(应用层逻辑更复杂),高冲突场景下重试次数多。

乐观锁 vs 悲观锁对比

维度 乐观锁 悲观锁
实现层 应用层(version 字段) 数据库层(FOR UPDATE)
加锁时机 提交时检查 读取时加锁
冲突处理 失败后重试 阻塞等待
适合场景 读多写少 写多读少
并发性能 高(无长时间锁持有) 低(持锁时间长)
死锁风险 仍可能发生,版本号更新也会获取数据库写锁 有

五、行锁与表锁

表锁(Table Lock)

整张表被锁住,粒度最粗,并发性最差。

-- 手动加表锁(一般不需要手动操作)
LOCK TABLE orders READ;   -- 读锁:其他事务可读,不可写
LOCK TABLE orders WRITE;  -- 写锁:其他事务不可读也不可写
UNLOCK TABLES;

-- 查看当前表锁情况
SHOW OPEN TABLES WHERE In_use > 0;

何时会发生表锁:

  • DDL 操作(ALTER TABLE,不使用 Online DDL 时)
  • MyISAM 引擎的 DML 操作
  • InnoDB 不会因扫描或锁定的行数较多而自动将行锁升级为表锁;无合适索引可能扩大记录锁范围

行锁(Row Lock)

只锁住特定的行,InnoDB 的默认锁粒度,允许高并发。

-- InnoDB 行锁类型:

-- 1. 共享锁(S Lock / 读锁):允许其他事务读,阻止其他事务写
SELECT * FROM orders WHERE id = 1 LOCK IN SHARE MODE;

-- 2. 排他锁(X Lock / 写锁):阻止其他事务对同一记录获取冲突锁或修改;普通一致性读通常仍可通过 MVCC 读取
SELECT * FROM orders WHERE id = 1 FOR UPDATE;

-- 3. UPDATE/DELETE/INSERT 自动获取排他锁
UPDATE orders SET status = 'paid' WHERE id = 1;

note 行锁的前提是走索引

InnoDB 的行锁是通过索引记录实现的。如果 WHERE 条件缺少合适索引,执行计划可能需要扫描并锁定大量记录,显著降低并发性,但这不等于升级为表锁。

例如:UPDATE orders SET status='paid' WHERE remark='test' 如果 remark 列没有可用索引,此语句可能扫描大量记录;实际锁范围还取决于执行计划与隔离级别。


六、死锁的产生与处理

死锁产生的条件

死锁发生在两个(或多个)事务互相持有对方需要的锁时。

事务 A 持有锁1,等待锁2
事务 B 持有锁2,等待锁1
→ 永久等待,形成死锁
-- 死锁示例
-- Session 1
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 锁住 id=1
-- 等待...
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 等待 id=2 的锁

-- Session 2(同时执行)
START TRANSACTION;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;   -- 锁住 id=2
UPDATE accounts SET balance = balance + 50 WHERE id = 1;   -- 等待 id=1 的锁
-- 死锁!MySQL 检测到后会选择代价小的事务回滚

死锁检测与处理

-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS\G
-- 在输出中查找 "LATEST DETECTED DEADLOCK" 部分

-- 查看当前等待的锁
SELECT * FROM performance_schema.data_lock_waits\G

-- 查看当前持有的锁
SELECT * FROM performance_schema.data_locks
WHERE LOCK_STATUS = 'GRANTED'\G

MySQL InnoDB 有自动死锁检测机制(innodb_deadlock_detect=ON,默认开启):

  • 检测到死锁时,自动选择回滚代价最小的事务(通常是最近修改行数少的)
  • 被回滚的事务会收到错误:ERROR 1213: Deadlock found when trying to get lock

死锁预防的最佳实践

-- 最佳实践1:按固定顺序访问资源
-- 无论哪个事务,都先锁 id 较小的行,再锁 id 较大的行
-- 事务 A 和 事务 B 都按 id 升序更新
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 先锁1
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 再锁2
COMMIT;

-- 最佳实践2:缩小事务范围,减少锁持有时间
-- 不好的写法:事务内包含大量耗时逻辑
START TRANSACTION;
-- 调用外部接口(示例耗时500ms;此前未示出锁定操作,不能据此认定已持有目标行锁;事务仍被延长)
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- ... 大量业务逻辑 ...
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

-- 好的写法:先完成业务逻辑,再开事务执行数据库操作
-- 先在业务层计算好需要的结果
-- 再开一个短事务快速执行 SQL
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock > 0;
COMMIT;

-- 最佳实践3:对可能发生死锁的场景,捕获异常并重试
-- (应用层代码处理 1213 错误,重试若干次)

七、实际业务中的事务使用建议

事务设计原则

tip 黄金法则:事务要短、要小

  1. 短:事务持续时间要短,避免长事务占用锁
  2. 小:一个事务只做一件事,不要把不相关的操作塞进同一个事务
  3. 本地:尽量避免跨数据库的分布式事务
  4. 明确:不要依赖自动提交,显式控制事务边界

常见反模式

-- 长事务示例:普通一致性COUNT查询可能增加快照与版本保留成本,不会仅因处于事务中就持有阻塞写入的记录锁;UPDATE阶段另需考虑锁持有与等待
START TRANSACTION;
SELECT COUNT(*) FROM orders;  -- 全表扫描,耗时数秒
UPDATE inventory SET stock = 10 WHERE id = 1;
COMMIT;
-- 改进:将统计查询移到事务外,或使用近似值

-- 反模式2:在循环中逐行插入
START TRANSACTION;
WHILE i < 10000 DO
    INSERT INTO logs VALUES (i, NOW(), 'info');
END WHILE;
COMMIT;
-- 改进:使用批量插入
INSERT INTO logs VALUES (1, NOW(), 'info'), (2, NOW(), 'info'), ...;

-- 反模式3:事务中调用外部服务
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 调用短信 API(可能超时!持锁等待!)
CALL_SMS_API();
UPDATE orders SET sms_sent = 1 WHERE id = 1;
COMMIT;
-- 改进:先提交数据库事务,再异步发送短信

隔离级别在实际业务中的选择

电商订单、支付:可从 REPEATABLE READ(MySQL 默认)评估起,但正确性仍取决于业务约束、事务边界、锁定方式和冲突处理
报表统计查询:READ COMMITTED(避免长时间持有一致性快照)
库存扣减(高并发):REPEATABLE READ + 乐观锁/行锁
金融核心账务:REPEATABLE READ + 悲观锁

学习路径

本篇介绍了事务与锁的基础概念,进一步学习请参考: