MySQL事务篇-事务概念、并发事务问题、隔离级别
事务
事务是一组不可分割的操作集合,这些操作要么同时成功提交,要么同时失败回滚。
acid事物的四大特性
原子性
最小工作单元,要么同时成功,要么同时失败。
例如A转账300给B,A账户-300与B账户+300必须满足操作原子性,避免出现A已转账但B未收到的一致性问题。
一致性
事务操作的起点和终点必须是从一个一致性状态到另一个一致性状态,也就是数据库的数据变化必须符合预定义期望变化。(不会出现一个数据库修改成功、一个失败的情况)
例如在转账案例中事务开始时的账户总额等于事务结束时的账户金额。(并不是一定相等,数据变化符合业务预定义期望即可)
隔离性
并发的事务是相互隔离的。
例如多个并发转账事务,每个转账操作的数据是相互独立的,不会出现数据混乱的情况。
持久性
一旦事务提交,其结果就是永久的,不会因系统崩溃丢失。
事务提交后会将数据持久化到硬盘,例如在装张案例中,变更后账户数据持久化在硬盘,数据库崩溃依然被保留。
并发事务问题
脏读
事务A读取到事务B未提交的修改。
不可重复读
同一事务内多次读取同一数据时因为其他事物在此期间提交了数据修改导致结果不同。
幻读
同一事务内对一张表的查询结果集不同,因为其他事务在此期间插入删除了数据。
select * 结果集行数不同。
select count()/sum() 等聚合函数,查询内容可能不同。
例如,事务A查询name=张三不存在,事务B插入张三,事务A按照张三不存在的业务逻辑插入张三但无法插入。
隔离级别
读未提交(RU)
允许事务读取其他事物未提交的修改(脏读)。
并发性能最高。
读已提交(RC)
不允许事务读取其他事物未提交的修改(脏读)。
无法避免不可重复读现象。
可重复读(RR)
不会出现脏读和不可重复读问题。
无法避免幻读问题。
MySQL默认隔离级别。
串行化(S)
完全避免所有并发问题。
并发性能最低。
如何选择隔离级别
隔离级别越高,并发性能越低。
读未提交(RU):仅适用对数据准确性要求极低,并发性能要求极高的场景,如监控数据,日志采集,瞬时数据不影响整体的场景,但实际实际生产环境下中还是极少使用,规避脏读风险。
读已提交(RC):适用大部分普通业务场景,也是大部分数据库的默认隔离级别。例如用户信息页,用户A修改提交后,用户B刷新就能看到用户A提交的修改内容,但不会看到用户A未提交的内容。
RC下不可重复读问题:
🛒 场景一:库存扣减(并发抢购)
业务逻辑: 用户下单时,需要检查并扣减商品库存(例如商品A,初始库存10件)。
事务A (用户1下单):
BEGIN;
(RC隔离级别)SELECT stock FROM products WHERE id = 'A';
// 返回 10 (库存充足)(基于查询结果10,决定继续下单逻辑... 生成订单、计算价格等,耗时几毫秒/秒)
事务B (用户2下单): (几乎与事务A同时发生)
BEGIN;
(RC隔离级别)SELECT stock FROM products WHERE id = 'A';
// 也返回 10 (库存充足)UPDATE products SET stock = stock - 1 WHERE id = 'A';
// 扣减1件,库存变为9COMMIT;
// 用户2下单成功,库存更新为9并生效
事务A 继续执行:
(执行完其他逻辑后)
UPDATE products SET stock = stock - 1 WHERE id = 'A';
// 此时基于 *当前已提交数据* (stock=9) 扣减,库存变为8COMMIT;
// 用户1下单成功
问题:
两个用户都成功下单购买了商品A。
最终库存变为 8,这符合物理扣减。
不可重复读在哪里?
事务A 在步骤2读取
stock=10
。在它执行后续逻辑时,事务B 修改并提交了库存(变为9)。
当事务A 执行更新操作(步骤2.2)时,它没有基于自己最初读到的10去减1,而是基于最新已提交值9去减1。虽然最终库存正确(8),但事务A在逻辑判断(库存是否充足)后,执行更新操作时依赖的数据(库存值)已经发生了变化(10 -> 9)。这就是一次“不可重复读”(在同一个事务A内,如果它再次执行
SELECT stock...
,结果会是9,而不是最初的10)。
潜在风险:
超卖风险: 如果初始库存只有1件,多个事务都读到1(认为充足),然后都去扣减1(事务B扣成0并提交,事务A再基于0扣减就会变成-1)。这就是经典的并发超卖问题!虽然RC下避免了脏读(不会读到事务B未提交的扣减),但因为不可重复读,两个事务都基于“过时”的充足判断进行了扣减,导致库存为负。解决超卖通常需要额外的并发控制(如乐观锁、悲观锁、Redis分布式锁等),而不仅仅是依赖隔离级别。
🕒 场景二:预约系统(时间段占用检查)
业务逻辑: 用户预约某个资源(如会议室A在10:00-11:00时段)。
事务A (用户1预约):
BEGIN;
(RC隔离级别)SELECT COUNT(*) FROM bookings WHERE room = 'A' AND start_time < '11:00' AND end_time > '10:00';
// 返回 0 (表示10:00-11:00空闲)(用户1填写预约信息,点击确认... 耗时几秒)
事务B (用户2预约): (几乎与事务A同时发生,且操作更快)
BEGIN;
(RC隔离级别)SELECT ...
// 同样返回0 (空闲)INSERT INTO bookings (room, start_time, end_time, user) VALUES ('A', '10:00', '11:00', 'user2');
// 插入预约记录COMMIT;
// 用户2预约成功
事务A 继续执行:
(用户1点击确认)
INSERT INTO bookings (room, start_time, end_time, user) VALUES ('A', '10:00', '11:00', 'user1');
// 尝试插入(可能成功也可能失败,取决于唯一性约束)
COMMIT;
问题:
事务A和事务B都检查了同一时间段,都认为它是空闲的(SELECT返回0)。
事务B更快地插入记录并提交。
事务A随后也尝试插入记录。
不可重复读在哪里?
事务A在步骤2执行SELECT查询,得知会议室A在10:00-11:00空闲。
在它执行插入操作之前,事务B已经插入并提交了占用该时间段的记录。
当事务A执行插入操作时,它所依赖的“空闲”状态(SELECT的结果)已经不再成立(因为事务B的插入已提交)。事务A在逻辑判断(是否空闲)后,执行插入操作时依赖的数据状态(时间段是否被占用)已经发生了变化。如果表上有
(room, start_time, end_time)
的唯一约束,事务A的插入会失败(主键/唯一键冲突)。如果没有唯一约束,则会产生双重预订!
潜在风险:
双重预订: 最严重的后果!同一个时间段被预约给了两个用户,导致冲突和用户投诉。解决双重预订通常需要更严格的并发控制,如对目标时间段加行锁(SELECT FOR UPDATE)或使用乐观锁(版本号)。
可重复读(RR):适用同一事务内涉及一个以上对同一数据的查询,业务要求不能使两次查询结果不一致。
幻读问题典型案例
假设存在一张
goods
表,存储商品库存信息,初始数据如下:id name stock 1 手机 10 2 电脑 5 现在有两个并发事务:事务 A 负责查询并修改库存小于 10 的商品,事务 B 负责插入一条新的库存小于 10 的商品记录。
步骤 1:事务 A 启动并首次查询
事务 A 开始,执行查询 “库存小于 10 的商品”:
-- 事务 ABEGIN;-- 第一次查询:查询库存 < 10 的商品SELECT * FROM goods WHERE stock < 10;
此时结果为:
id name stock 2 电脑 5 步骤 2:事务 B 插入新数据并提交
事务 B 启动,插入一条新商品记录(库存 8,符合
stock < 10
),并提交事务:-- 事务 BBEGIN;-- 插入一条新商品,库存 8(符合 stock < 10)INSERT INTO goods (name, stock) VALUES ('平板', 8);COMMIT;
此时表中数据变为:
id name stock 1 手机 10 2 电脑 5 3 平板 8 步骤 3:事务 A 再次查询并尝试修改
事务 A 再次执行相同的查询:
-- 事务 A-- 第二次查询:再次查询库存 < 10 的商品SELECT * FROM goods WHERE stock < 10;
在 RR 隔离级别下,由于 MVCC 的可重复读特性,事务 A 第二次查询的结果仍为:
id name stock 2 电脑 5 但此时如果事务 A 尝试修改 “所有库存 < 10 的商品”(例如批量增加库存):
-- 事务 A-- 尝试修改所有库存 < 10 的商品UPDATE goods SET stock = stock + 2 WHERE stock < 10;COMMIT;
执行后,事务 A 查看最终数据时会发现:新插入的 “平板”(id=3)的库存也被修改为 10(8+2)。 这就是幻读:事务 A 两次查询都没看到 “平板”,但修改操作却影响了它,仿佛数据 “凭空出现” 并被修改。
在RR级别下,不可重复读场景能被解决,但依然会出现更新操作前判断失效的情况,update是当前读会直接读取最新数据修改,依然会出现同时判断成功的超卖问题。
场景一:库存扣减
RR下的行为:
1.事务A开始并创建快照,执行
SELECT stock...
读取的始终是快照中的库存值(如10)2.事务B开始并执行扣减库存,此时数据库中stock值为9
3.事务A开始执行扣减库存操作
UPDATE stock = stock - 1
但会读取到被修改后的最新数据修改。结果:事务AB库存判断成功虽然解决了不可重复读问题但还是会导致超卖。
解决方法:乐观锁、悲观锁、分布式锁、库存判断加
For UPDATE
<select id="selectStockForUpdate" resultType="com.example.Goods">SELECT id, stock FROM goods WHERE id = #{id} FOR UPDATE <!-- 关键:对查询到的行加排他锁 --></select>
串行化(S):事务串行化执行,适用RR下会出现幻读且业务不允许的场景及事务必须严格按照提交顺序执行的场景。
1. 💰 金融核心系统 - 银行转账
-- 事务A: 检查A余额≥100 → A-100 → B+100-- 事务B: 检查B余额≥50 → B-50 → C+50
风险:事务A先开启但是在未提交的情况下,事务B开启并检测B的余额,业务逻辑上B用户账户余额一定满足>=50,但是在RC,RR情况下事务A未提交所以事务B可能产生误判。
串行化解决方案:
严格顺序执行:
事务A完全执行后,再执行事务B
或事务B完全执行后,再执行事务A