MySQL事务隔离级别:从原理到实践

117 Views
深入浅出解析MySQL的四种事务隔离级别,剖析并发场景下的常见问题(脏读、不可重复读、幻读),并提供选择隔离级别的实用建议与最佳实践。

MySQL事务隔离级别:从原理到实践

想象一下,在一个繁忙的在线商城,成千上万的用户同时下单、查库存、付款……MySQL数据库是如何在这种高并发的"枪林弹雨"中保持数据不出错、不混乱的呢?这背后的一大功臣,就是我们今天要聊的"事务隔离级别"。

本文将介绍MySQL的四种事务隔离级别,并提供选择隔离级别的实用建议与最佳实践。

事务的ACID特性:数据库的"四项基本原则"

在深入隔离级别之前,我们得先认识一下"事务"(Transaction)。你可以把它看作是数据库操作的一个基本单元,要么里面的所有操作都成功执行,要么就干脆都别做,回到最初的状态。为了保证事务的可靠性,它必须遵守四个特性,也就是我们常说的ACID:

  • 原子性(Atomicity):这个好理解,一个事务就像一个"原子"操作,不可再分。里面的所有SQL语句,要么全都执行成功,要么如果有一个失败了,整个事务都会回滚,数据库状态恢复到事务开始前的样子。不存在只做了一半的情况。
  • 一致性(Consistency):事务执行前后,数据库都必须处于一个"正确"的状态。比如,银行转账,A账户减钱,B账户加钱,总金额不能变。一致性是事务追求的最终目标,原子性、隔离性、持久性都是为了保证它。
  • 隔离性(Isolation):这是我们今天的重头戏。当很多事务同时并发执行时,隔离性确保一个事务的执行过程不会被其他事务"干扰"或"偷窥"到中间状态。理想情况下,每个事务都感觉自己是唯一在操作数据库的那个仔。
  • 持久性(Durability):一旦事务成功提交,它对数据库所做的修改就是永久性的,哪怕之后数据库崩了、重启了,这些数据也得能恢复回来。

今天,我们就把焦点放在"隔离性"上,看看MySQL是怎么玩转它的。

并发事务:热闹背后的"坑"

如果数据库同一时间只处理一个事务,那世界就清静了,自然也没那么多幺蛾子。但现实是,为了提高效率,数据库系统总是要同时处理N多个事务。这一热闹,就可能踩到下面这些"坑":

1. 脏读(Dirty Read):你读到了不该读的"脏数据"

想象一下,事务A正在修改一条数据,但还没提交(比如还在犹豫要不要改)。这时,事务B跑过来,把事务A改到一半(还未提交)的数据给读走了。如果事务A后来反悔了,执行了回滚,那事务B读到的数据就是名副其实的"脏数据",因为它在数据库里压根就没真正存在过。

-- 场景:银行账户A有1000元
-- 事务A (小明想转账100元给小红,但还在操作中)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = '小明'; -- 小明账户变为900元,但事务未提交
 
-- 事务B (小红在查小明账户余额)
BEGIN;
SELECT balance FROM accounts WHERE user_id = '小明'; -- 糟糕!读到了900元(脏数据)
-- 小红可能基于这个900元做了其他判断...
COMMIT;
 
-- 事务A (小明突然取消了转账)
ROLLBACK; -- 小明账户恢复为1000元。但小红已经按900元行动了!

脏读的后果很严重,基本没人敢在生产环境容忍它。

2. 不可重复读(Non-repeatable Read):咦,刚读的数据怎么变了?

这个是指,在一个事务(比如事务A)里,你前后两次读取同一行数据,结果竟然不一样!为啥呢?因为在你两次读取的间隙,有另一个事务B恰好修改了这行数据并且提交了。

-- 场景:产品X库存为10件
-- 事务A (客服查询库存)
BEGIN;
SELECT stock FROM products WHERE id = 'X'; -- 第一次读,库存是10件
 
-- 事务B (顾客成功购买了2件产品X,并完成支付)
BEGIN;
UPDATE products SET stock = stock - 2 WHERE id = 'X';
COMMIT; -- 顾客购买完成,库存变为8件
 
-- 事务A (客服因为其他原因,再次查询同一产品库存)
SELECT stock FROM products WHERE id = 'X'; -- 第二次读,库存变成了8件!
-- 客服懵了:我刚才看的不是10件吗?怎么自己变了?
COMMIT;

不可重复读主要针对的是**修改(UPDATE)**操作。你读的数据,被别人改了。

3. 幻读(Phantom Read):妈呀,多出来(或少了)几行!

幻读和不可重复读有点像,但又不一样。不可重复读是说你读的同一行数据内容变了,而幻读是说,你用同样的条件查询,第一次查出来一批数据,过了一会儿再用完全相同的条件查,结果发现多了一些"幽灵般"的新数据行,或者少了一些之前存在的行。

-- 场景:查询月薪大于10000的员工
-- 事务A (HR统计高薪员工)
BEGIN;
SELECT COUNT(*) FROM employees WHERE salary > 10000; -- 第一次查,结果是5人
 
-- 事务B (老板刚给一位新入职的大牛定薪15000,并录入系统)
BEGIN;
INSERT INTO employees (name, salary) VALUES ('大牛', 15000);
COMMIT; -- 新员工数据插入成功
 
-- 事务A (HR因为某些原因,重新执行了刚才的统计)
SELECT COUNT(*) FROM employees WHERE salary > 10000; -- 第二次查,结果变成了6人!
-- HR凌乱了:难道我眼花了?怎么多了一个人?这就是幻读。
COMMIT;

幻读主要针对的是**插入(INSERT)或删除(DELETE)**操作。你查询的"范围"内,数据条数变了。

4. 丢失更新(Lost Update):我的修改被覆盖了!

这是最不应该发生的一种情况。简单说,就是两个事务同时读取同一行数据,各自修改,然后都提交。结果,其中一个事务的修改把另一个事务的修改给覆盖掉了,导致后者的更新"丢失"了。

-- 场景:商品点赞数,初始为100
-- 事务A (用户张三点赞)
BEGIN;
SELECT likes FROM items WHERE id = 1; -- 读取到点赞数100
-- 张三在客户端计算新点赞数为 100 + 1 = 101
-- (网络延迟或其他原因,事务A还没提交)
 
-- 事务B (用户李四同时点赞)
BEGIN;
SELECT likes FROM items WHERE id = 1; -- 李四也读取到点赞数100
-- 李四在客户端计算新点赞数为 100 + 1 = 101
UPDATE items SET likes = 101 WHERE id = 1; -- 李四先提交了
COMMIT;
 
-- 事务A (张三的请求终于到了服务器)
UPDATE items SET likes = 101 WHERE id = 1; -- 张三也提交了
COMMIT;
-- 结果:点赞数是101,但实际上应该是102。李四的更新"丢失"了(或者说,张三的更新基于旧值覆盖了李四的)。

严格来说,丢失更新并不是一种由隔离级别直接导致的并发问题,它更多是应用层面逻辑处理不当(如经典的"先读后写"没加锁)造成的。但不同的隔离级别确实能影响它发生的概率和处理方式。

MySQL的四种"隔离服":应对并发挑战

为了让开发者能够根据业务场景在一致性和性能之间做取舍,SQL标准定义了四种事务隔离级别。MySQL的InnoDB存储引擎完整支持了这四种级别:

1. 读未提交(READ UNCOMMITTED)

这是隔离级别最低的一档,基本上就是"裸奔"。它允许一个事务读取到其他事务还没有提交的修改。 后果:上面提到的脏读、不可重复读、幻读统统都可能发生。

-- 设置当前会话的隔离级别为 读未提交
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
 
-- 查看当前的隔离级别
SELECT @@transaction_isolation; -- 或者 SELECT @@tx_isolation; (旧版)

特点

  • 隔离性最差,并发最好(因为锁最少)。
  • 数据一致性几乎没有保障。
  • 实际应用中,你几乎永远不会用到这个级别。 除非你真的完全不在乎读到的是不是正确数据。

2. 读已提交(READ COMMITTED)

比"裸奔"好一点,穿了件"小背心"。这个级别保证一个事务只能读取到其他事务已经提交的修改。 这样就解决了脏读问题。但是,不可重复读和幻读还是可能发生。

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

特点

  • 这是很多主流数据库(比如Oracle、SQL Server、PostgreSQL)的默认隔离级别。
  • 在一个事务中,每次执行SELECT查询,都会读取到当时最新的、已经提交了的数据。
  • 对于那些对数据一致性要求不是特别高,但又希望避免脏读的场景(比如一些报表系统),是个不错的选择。

3. 可重复读(REPEATABLE READ)

这件"隔离服"更强一些,除了解决脏读,还能解决不可重复读的问题。它保证在同一个事务中,多次读取同一批数据(通常是同一行或同一个范围的初始读取结果)时,看到的结果总是一样的,不受其他事务提交的影响。 但是,理论上,幻读问题仍然可能发生。

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

特点

  • 这是MySQL InnoDB存储引擎的默认隔离级别。
  • 它是如何做到可重复读的呢?奥秘在于MVCC(多版本并发控制)。事务开始时,InnoDB会为这个事务创建一个数据"快照"(Read View)。后续这个事务里的所有普通SELECT操作(快照读),读到的都是这个快照里的数据,仿佛时间定格在了事务开始那一刻。
  • 特别要强调的是,虽然SQL标准说可重复读级别下可能有幻读,但MySQL的InnoDB引擎通过MVCC和一种叫做"间隙锁"(Next-Key Locks)的机制,在很大程度上避免了幻读的发生。这是MySQL InnoDB的一个"超纲"表现。

4. 串行化(SERIALIZABLE)

这是最高级别的"隔离服",简直是"防化服"级别的。它通过强制事务串行执行(一个接一个排队),来彻底杜绝脏读、不可重复读和幻读所有这些并发问题。

SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

特点

  • 隔离性最强,数据绝对一致。
  • 怎么做到的呢?它会对所有读取的行都加上锁(通常是共享锁),如果其他事务想修改这些行,就得等着。写操作更是会加排它锁。
  • 代价就是并发性能极差。在高并发场景下,用这个级别很容易导致大量的超时和锁等待,系统吞吐量会急剧下降。
  • 一般只在对数据一致性有极端要求,且并发量不大的特殊场景下才会考虑。

四种隔离级别大PK

一张表总结一下:

隔离级别脏读 (Dirty Read)不可重复读 (Non-repeatable Read)幻读 (Phantom Read)InnoDB下能否避免幻读?性能开销
读未提交可能可能可能最小
读已提交避免可能可能较小
可重复读 (默认)避免避免理论可能大部分情况避免中等
串行化避免避免避免最大

记住那个星号:MySQL InnoDB在REPEATABLE READ级别下,由于MVCC和间隙锁的存在,比标准定义更能抵抗幻读。

原理探究

了解了表面现象,我们稍微往深挖一挖,看看InnoDB是如何支撑这些隔离级别的,特别是MVCC和锁。

MVCC(Multi-Version Concurrency Control):多版本并发控制

MVCC是InnoDB实现READ COMMITTEDREPEATABLE READ这两个隔离级别的核心技术。你可以把它想象成,数据库里的每一行数据,其实都可能同时存在好几个"版本"。

  • 每行数据除了真实的列值外,还会隐藏地存储一些额外信息,比如创建该版本的事务ID(DB_TRX_ID)和指向前一个版本的回滚指针(DB_ROLL_PTR)。
  • 当一个事务修改数据时,InnoDB不会直接在原地覆盖旧数据,而是会创建一个新版本的数据行,并把旧版本通过回滚指针串起来,形成一个版本链。
  • 当一个事务要读取数据时(特指"快照读",下面会讲),InnoDB会根据当前事务的ID和可见性规则(比如,我只能看到在我事务开始前就已经提交了的其他事务所做的修改,或者我自己做的修改),从版本链中选择一个合适的版本返回给事务。

这样一来,读操作基本不需要等待写操作(因为它可以去读旧版本),写操作之间如果操作的不是同一行,冲突也会减少,大大提高了并发性能。

快照读(Consistent Read) vs 当前读(Current Read)

在InnoDB中,特别是REPEATABLE READ级别下,读操作还分两种:

  1. 快照读 (Consistent Read / Snapshot Read)

    • 就是我们最常用的普通SELECT语句(不带FOR UPDATELOCK IN SHARE MODE)。
    • 它读取的是事务开始时创建的那个数据快照(Read View)。
    • 不加锁,所以不会阻塞其他事务的读写。非常快!
    • 示例:SELECT * FROM users WHERE id = 1;
  2. 当前读 (Current Read / Locking Read)

    • 它读取的是数据库中最新的、已提交的版本,并且会对读取到的记录加锁,以保证在当前事务完成前,这些记录不会被其他事务修改。
    • 以下操作属于当前读:
      • SELECT ... FOR UPDATE (加排他锁 X锁)
      • SELECT ... LOCK IN SHARE MODE (加共享锁 S锁,MySQL 8.0后 FOR SHARE是推荐写法)
      • INSERT, UPDATE, DELETE 这些修改数据的语句,它们在执行前会先进行一次当前读,确保操作的是最新数据,并加上适当的锁。
    • 示例:SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; (读取并锁定id=1的账户余额)
    • 示例:UPDATE accounts SET balance = balance - 100 WHERE id = 1; (隐式进行当前读并加锁)

理解这两种读的区别,对于分析锁冲突和并发行为至关重要。

间隙锁(Gap Locks)和 Next-Key Locks:防幻影的利器

前面提到,InnoDB在REPEATABLE READ级别下能很大程度避免幻读,靠的就是间隙锁和Next-Key锁。

  • 记录锁(Record Lock):这个好理解,就是锁住某一行记录。
  • 间隙锁(Gap Lock):它锁的不是记录本身,而是记录与记录之间的"间隙"。比如表里有id为1, 5, 10的记录,那么(1, 5)这个开区间,(5, 10)这个开区间,以及(10, +∞)这样的区间都可能被加上间隙锁。一旦某个间隙被锁住,其他事务就不能在这个间隙里插入新的记录了。
  • Next-Key Lock:它是个"加强版"的锁,是记录锁和这个记录前面的那个间隙锁的组合。比如,一个Next-Key Lock锁住id=5的记录,同时也锁住了(1, 5]这个左开右闭的区间(或者根据索引顺序是(前一个键值, 5])。

当你在REPEATABLE READ下执行一个范围查询并试图加锁(比如SELECT ... WHERE age > 20 FOR UPDATE)时,InnoDB不仅会给满足条件的现有记录加上记录锁(或Next-Key锁),还会给这些记录之间的间隙,以及查询范围边界处的间隙,都加上间隙锁或Next-Key锁。这样一来,其他事务想在这些被锁定的范围或间隙中插入新数据(可能导致幻读),就会被阻塞,从而防止了幻读。

虽然间隙锁能防幻读,但它也可能锁住比实际需要更大的范围,从而降低并发度,甚至增加死锁的风险。这也是为什么有时人们会考虑降级到READ COMMITTED(它没有间隙锁)的原因之一,当然,前提是能接受不可重复读和需要自己处理幻读。

如何选定隔离级别?

回到现实,我们该怎么选隔离级别呢?这得看你的业务场景、对数据一致性的要求以及对并发性能的容忍度。

  1. 金融、支付等对数据一致性要求极高的场景

    • 推荐 REPEATABLE READ (InnoDB默认) 或 SERIALIZABLE
    • 银行转账、订单扣库存、支付清算这类业务,数据的准确性是第一位的。REPEATABLE READ在InnoDB下已经很强了,如果还担心那极少数理论上的幻读(或有特殊业务逻辑需要绝对串行),可以考虑SERIALIZABLE,但一定要充分测试其性能影响。
  2. 读多写少、对实时一致性要求没那么极致的报表、分析类场景

    • 可以考虑 READ COMMITTED
    • 比如生成每日销售报表、用户行为分析等。这类查询通常希望能看到最新的已提交数据,即使两次查询结果略有不同(不可重复读)通常也能接受。READ COMMITTED的锁粒度更小,并发性更好。
  3. 并发量大,但能容忍一定数据不一致的场景(比如非核心业务)

    • 可以考虑 READ COMMITTED
    • 比如文章的点赞数、评论区的展示等。偶尔的不可重复读或幻读可能对用户体验影响不大,但并发性能更重要。
  4. 一个应用中包含多种业务需求

    • 可以在会话(连接)级别动态设置不同的隔离级别
    • 比如,核心交易流程用REPEATABLE READ,而一些辅助性的查询统计功能,可以在那个特定的数据库连接上临时设置为READ COMMITTED

永远不要用 READ UNCOMMITTED 在生产环境

怎么配置隔离级别?

-- 查看当前全局(服务器级别)默认隔离级别
SELECT @@global.transaction_isolation;
 
-- 修改全局默认隔离级别 (改完通常需要重启MySQL服务,或者在新连接中生效)
SET GLOBAL transaction_isolation = 'READ-COMMITTED'; -- 注意这里用连字符
 
-- 查看当前会话(连接)的隔离级别
SELECT @@session.transaction_isolation; -- 或者 SELECT @@transaction_isolation;
 
-- 修改当前会话的隔离级别 (只对当前连接有效)
SET SESSION transaction_isolation = 'REPEATABLE-READ';
 
-- 只想对下一个事务设置特定隔离级别?(MySQL 5.7.20+ / 8.0+)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- START TRANSACTION; ... COMMIT/ROLLBACK;
-- (这个设置只对紧接着的那个事务生效,事务结束后会话隔离级别恢复原状)

还可以在MySQL的配置文件(比如my.cnfmy.ini)中设置服务器的默认隔离级别:

[mysqld]
# transaction-isolation = REPEATABLE-READ (这是默认值,通常不用显式写)
transaction-isolation = READ-COMMITTED # 如果想改成读已提交

解决并发的其他方案

选对隔离级别很重要,但它并不能解决所有并发问题(比如"丢失更新"就需要应用层面配合)。我们还可以结合其他技术:

  1. 乐观锁(Optimistic Locking):不真正在数据库层面加锁,而是在更新数据时检查一下数据版本有没有被别人改过。

    • 通常做法是给表加一个版本号字段(比如version)或者时间戳字段。
    • 读取数据时,把版本号也读出来。
    • 更新时,UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = old_version
    • 如果WHERE条件中的version = old_version不成立(即影响行数为0),说明在你准备更新的这段时间里,数据已经被其他事务改过了(版本号变了)。这时应用可以选择重试、报错或其他策略。
    • 适合读多写少的场景,能减少锁竞争。
  2. 悲观锁(Pessimistic Locking):认为并发冲突很有可能发生,所以干脆在操作数据前就先把它锁起来。

    • 在MySQL中,主要通过SELECT ... FOR UPDATE(排他锁)或SELECT ... FOR SHARE(共享锁)来实现。
    • 事务开始,先用FOR UPDATE锁定要修改的行,然后再进行业务逻辑计算和UPDATE
    • 适合写多或者冲突概率高的场景,能保证数据一致性,但会牺牲一些并发性能。
  3. 应用层并发控制

    • 比如使用分布式锁(如基于Redis、ZooKeeper实现),确保某个关键操作在整个集群中同一时间只有一个执行。
    • 或者把并发请求放入消息队列,由后端服务串行化处理。
    • 这些通常用于更复杂的分布式系统或需要跨多个资源协调的场景。

性能账本:不同隔离级别的影响与监控

天下没有免费的午餐,隔离级别越高,数据一致性越好,但通常对性能的影响也越重。

隔离级别与性能权衡

隔离级别加锁的"狠"度主要潜在风险
READ UNCOMMITTED锁最少(几乎不怎么加锁)数据一塌糊涂
READ COMMITTED只对修改的行加行锁(通常时间较短)不可重复读、幻读
REPEATABLE READ对读写行加行锁,还可能有间隙锁间隙锁可能导致更多锁等待
SERIALIZABLE锁最狠(读也加锁,范围也锁)死锁、超时、吞吐量暴跌

如何监控MySQL里的锁和事务?

当系统慢了,或者出现大量锁等待时,你得知道去哪儿找线索:

-- 查看当前正在运行的事务(能看到事务ID,状态,执行的SQL等)
SELECT * FROM information_schema.innodb_trx;
 
-- 查看当前持有的锁 (MySQL 8.0+ 推荐,更详细)
SELECT * FROM performance_schema.data_locks;
 
-- 查看当前锁等待的情况 (MySQL 8.0+ 推荐)
SELECT * FROM performance_schema.data_lock_waits;
 
-- 如果用的是MySQL 5.7或更早版本,上面两个performance_schema表可能没有或不全
-- 可以用下面这些老的表,但信息不如新的详细
-- SHOW OPEN TABLES WHERE In_use > 0; (看哪些表被锁了)
-- SELECT * FROM information_schema.innodb_locks; (已持有的锁)
-- SELECT * FROM information_schema.innodb_lock_waits; (锁等待)
 
-- 查看最近一次死锁的详细信息 (内容比较多,需要仔细分析)
SHOW ENGINE INNODB STATUS; -- 找到 LATEST DETECTED DEADLOCK 部分

锁冲突常见处理思路

  1. 合理设置锁等待超时 innodb_lock_wait_timeout

    • 这个参数定义了一个事务等待行锁的最长时间(默认50秒)。超时后会报错。
    • 设太短可能导致正常操作也超时,设太长又可能让一个有问题的事务卡住大量其他请求。需要根据业务调整。
    • SET GLOBAL innodb_lock_wait_timeout = 30; (全局设置)
    • SET SESSION innodb_lock_wait_timeout = 10; (会话设置)
  2. 死锁检测 innodb_deadlock_detect

    • InnoDB默认开启了死锁检测机制 (ON)。当检测到死锁时,它会自动选择一个"牺牲品"事务进行回滚,让另一个事务能继续。
    • 在高并发且死锁频繁的场景,死锁检测本身也可能消耗一些CPU。有些人会考虑关闭它,然后依赖innodb_lock_wait_timeout来处理死锁(即让其中一个事务超时失败)。但这通常不推荐,除非你非常清楚其影响并有其他死锁处理机制。
  3. SQL优化和索引

    • 很多锁冲突其实是慢查询导致的。查询走不上索引,全表扫描,自然会锁住更多不必要的行,增加冲突概率。
    • 确保你的查询都用上了合适的索引,特别是WHERE条件和JOIN条件的列。
  4. 业务逻辑和事务拆分

    • 尽量让事务小而快。一个事务持有锁的时间越短,冲突就越少。
    • 审视业务流程,看是否能把一个大事务拆分成几个更小的、独立的事务。

实战案例剖析

来看两个简化版的例子。

案例1:电商秒杀,如何防止超卖?

场景:搞秒杀活动,100件商品,手快有手慢无。高并发下,最怕的就是库存扣成了负数(超卖)。

常见方案:使用REPEATABLE READ隔离级别,并配合SELECT ... FOR UPDATE

-- 假设数据库连接已设置为 REPEATABLE READ 隔离级别
 
-- 用户A发起秒杀请求,要买1件商品 (product_id = 123)
START TRANSACTION;
 
-- 1. 查询并锁定库存记录 (当前读,加排他锁)
-- 其他事务如果也想FOR UPDATE同一行,或者想UPDATE/DELETE这一行,会被阻塞
SELECT stock_quantity FROM products WHERE product_id = 123 FOR UPDATE;
 
-- 假设查出来 stock_quantity = 5 (还剩5件)
 
-- 2. 应用层面检查库存是否足够
IF stock_quantity >= 1 THEN
    -- 3. 库存足够,扣减库存
    UPDATE products SET stock_quantity = stock_quantity - 1 WHERE product_id = 123;
    
    -- 4. 创建订单
    INSERT INTO orders (product_id, user_id, quantity) VALUES (123, 'user_A_id', 1);
    
    COMMIT; -- 提交事务,释放锁
    -- 返回秒杀成功
ELSE
    ROLLBACK; -- 库存不足,回滚事务,释放锁
    -- 返回秒杀失败,库存不足
END IF;

在这个流程中,FOR UPDATE是关键。它确保了从查库存到扣库存这个过程中,这条商品库存记录是被当前事务独占的,其他想修改库存的事务必须排队。

另一种思路(乐观锁): 如果不想用FOR UPDATE那么强的锁,也可以用乐观锁。

-- 假设products表有version字段
START TRANSACTION;
SELECT stock_quantity, version FROM products WHERE product_id = 123;
-- 假设查到 stock_quantity = 5, version = 10
 
IF stock_quantity >= 1 THEN
    -- 尝试扣减库存,并检查版本号
    UPDATE products 
    SET stock_quantity = stock_quantity - 1, version = version + 1 
    WHERE product_id = 123 AND version = 10; -- 只有版本号匹配才更新
    
    -- 检查UPDATE影响的行数
    IF affected_rows() = 1 THEN -- 更新成功
        INSERT INTO orders ...;
        COMMIT;
        -- 秒杀成功
    ELSE -- 更新失败,说明在你查库存到更新库存之间,库存被别人改了
        ROLLBACK;
        -- 秒杀失败(比如提示"手慢了,请重试")
    END IF;
ELSE
    ROLLBACK;
    -- 库存不足
END IF;

乐观锁冲突时需要应用层面有重试或友好提示的逻辑。

案例2:银行转账,一分都不能少

场景:A账户给B账户转账100元。这事儿必须得滴水不漏,钱不能凭空多也不能凭空少。

解决方案:通常也建议REPEATABLE READ,如果对一致性有极致追求且并发压力可控,SERIALIZABLE也可以考虑。这里用REPEATABLE READ并辅以行锁。

-- 假设隔离级别为 REPEATABLE READ
-- A账户ID: from_acc_id, B账户ID: to_acc_id, 转账金额: amount
 
START TRANSACTION;
 
-- 为了防止死锁,建议按固定顺序锁定资源,比如账户ID从小到大
-- (这里假设 from_acc_id < to_acc_id,如果不是,代码中需要调整锁定顺序)
 
-- 1. 锁定转出账户并检查余额 (当前读)
SELECT balance FROM accounts WHERE account_id = from_acc_id FOR UPDATE;
-- 假设查到 A的余额 a_balance
 
IF a_balance >= amount THEN
    -- 2. 锁定转入账户 (当前读)
    -- 即使只是收款,也可能需要查一下账户状态或加行锁,防止账户在转账过程中被销户等操作
    SELECT balance FROM accounts WHERE account_id = to_acc_id FOR UPDATE;
    -- 假设查到 B的余额 b_balance
 
    -- 3. 扣减转出账户余额
    UPDATE accounts SET balance = balance - amount WHERE account_id = from_acc_id;
    
    -- 4. 增加转入账户余额
    UPDATE accounts SET balance = balance + amount WHERE account_id = to_acc_id;
    
    -- 5. 记录转账流水
    INSERT INTO transaction_log (from_account, to_account, amount, timestamp)
    VALUES (from_acc_id, to_acc_id, amount, NOW());
    
    COMMIT; -- 一切顺利,提交
ELSE
    ROLLBACK; -- 余额不足,回滚
    -- 返回错误:余额不足
END IF;
 
-- 如果在任何一步FOR UPDATE时发生锁等待超时,事务也会失败回滚。

在这个例子中,对两个账户都使用了FOR UPDATE来确保在整个转账过程中,这两个账户的余额不会被其他并发事务干扰。同时注意按固定顺序加锁能有效减少死锁概率。

写在最后:隔离级别是一把双刃剑

MySQL的事务隔离级别,就像一把需要在数据一致性与并发性能之间小心挥舞的双刃剑。

  • READ UNCOMMITTED:快是快,但基本是在"裸奔",数据可靠性差,生产禁用。
  • READ COMMITTED:在很多场景下是个不错的折中,避免了脏读,并发性也尚可。适合读多写少、对不可重复读和幻读不那么敏感的业务。
  • REPEATABLE READ:MySQL InnoDB的默认选择,凭借MVCC和间隙锁,提供了很强的一致性保障(包括大部分幻读场景),是大多数应用场景的可靠基石。
  • SERIALIZABLE:一致性的"金钟罩",但性能代价高昂,只在特定场景(如对数据准确性有洁癖且并发低的内部系统)酌情使用。

没有最好的隔离级别,只有最适合你的业务的隔离级别。

理解它们背后的原理(MVCC、锁机制),学会使用监控工具(information_schemaperformance_schemaSHOW ENGINE INNODB STATUS),并结合乐观锁、悲观锁、应用层控制等操作,才能在复杂的并发世界中游刃有余。

117 Views