ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

MySQL并发数据一致性实战:事务隔离、锁机制与乐观锁悲观锁选型

MySQL并发数据一致性实战:事务隔离、锁机制与乐观锁悲观锁选型 1. 从一个真实场景说起为什么并发会让数据对不上但凡做过后端开发的人大概率都遇到过这样的场景系统上线初期一切正常用户量一上来财务对账就开始出问题——账户余额莫名其妙多了一笔或者少了一笔库存扣成了负数优惠券被同一个人领了两次。排查日志的时候发现每条SQL单独执行都没毛病但组合在一起在高并发下就乱了套。这就是典型的并发场景下的数据一致性问题。MySQL作为最主流的关系型数据库本身提供了完整的事务机制和锁机制来应对这类问题但很多人只是知道有事务这回事真正到了业务里却不知道怎么用、用哪种隔离级别、锁该加在哪里。我见过不少项目代码里写满了Transactional注解结果该出问题还是出问题因为事务的粒度和锁的范围根本没搞对。这篇内容我打算用一个账户转账库存扣减的模拟实例把MySQL在并发场景下保证数据一致性的核心机制从头到尾拆一遍。涉及的知识点包括事务的ACID特性、四种隔离级别的实际表现、行锁与间隙锁的行为差异、乐观锁与悲观锁的选型对比以及在实际业务中怎么组合使用这些手段。适合有一定SQL基础、正在做交易类或库存类业务的后端开发者参考也适合想系统梳理MySQL并发知识的朋友。我不会只讲概念每个机制都会配上可复现的SQL示例和实测结果你可以直接在自己的测试库上跑一遍验证。文章里提到的参数配置和代码写法都是我在实际项目里验证过的方案踩过的坑也会一并说清楚。2. 先把底层逻辑理清楚事务与隔离级别到底在解决什么2.1 ACID里真正难保证的是哪几个事务的四个特性——原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability——教科书上背得滚瓜烂熟但实际开发中最容易出问题的其实是隔离性。原子性靠undo log回滚就能保证持久性靠redo log刷盘也能保证一致性是最终目的而隔离性才是并发场景下所有麻烦的根源。为什么这么说因为多个事务同时操作同一批数据时如果没有隔离机制就会出现四类问题脏读读到别的事务还没提交的数据、不可重复读同一个事务内两次读同一行结果不一样、幻读同一个事务内两次范围查询结果集行数不一样、丢失更新两个事务同时改一行后提交的覆盖了先提交的。这四类问题里丢失更新是业务层面最致命的因为它直接导致数据算错而且往往悄无声息。注意很多人把不可重复读和幻读搞混。简单区分——不可重复读针对的是同一行数据的值变了幻读针对的是结果集的行数变了。前者用行锁能解决后者需要间隙锁或表锁。2.2 四种隔离级别的实际表现差异MySQL的InnoDB引擎支持四种隔离级别默认是可重复读REPEATABLE READ。这个默认值很关键因为很多其他数据库默认是读已提交READ COMMITTED行为差异很大。隔离级别脏读不可重复读幻读实现机制读未提交READ UNCOMMITTED可能可能可能几乎不加锁读已提交READ COMMITTED不可能可能可能MVCC快照读可重复读REPEATABLE READ不可能不可能InnoDB基本避免MVCC间隙锁串行化SERIALIZABLE不可能不可能不可能全部加锁这里有个容易踩的坑在读已提交级别下每次SELECT都会生成一个新的ReadView所以同一事务内两次查询可能看到不同的数据。而在可重复读级别下ReadView是在第一次SELECT时生成的后续查询复用这个快照所以能看到一致的数据。这个差异在长事务里特别明显如果你的事务执行时间很长可重复读级别下读到的可能是过期数据。2.3 MVCC读操作不加锁的底气从哪来InnoDB实现隔离级别的核心武器是MVCC多版本并发控制。它的思路很朴素每次修改数据时不直接覆盖而是生成一个新版本旧版本保留在undo log里。读操作根据事务的ReadView判断应该读哪个版本从而实现读不加锁、读写不冲突。每行数据实际上有两个隐藏字段DB_TRX_ID最后修改该行的事务ID和DB_ROLL_PTR指向undo log中旧版本的指针。当你执行SELECT时InnoDB会沿着版本链找到对当前事务可见的那个版本。判断可见性的规则大致是如果行的DB_TRX_ID小于当前事务ID且不在活跃事务列表中说明这个版本是已提交的可见否则沿着DB_ROLL_PTR往前找。这套机制让读操作完全不用加锁极大提升了并发读的性能。但要注意MVCC只对快照读普通SELECT生效如果你用的是SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE那就是当前读会加锁不走MVCC。3. 实例拆解账户转账场景下的并发问题复现3.1 建表和初始化数据先建一张最简单的账户表模拟两个用户之间的转账CREATE TABLE account ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户ID, balance DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT 余额, version INT NOT NULL DEFAULT 0 COMMENT 版本号, PRIMARY KEY (id), UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账户表; INSERT INTO account (user_id, balance, version) VALUES (1001, 1000.00, 0); INSERT INTO account (user_id, balance, version) VALUES (1002, 500.00, 0);初始状态用户1001有1000元用户1002有500元。现在要做一个转账操作从1001转200元给1002正确结果应该是1001剩800元1002有700元。3.2 不加任何控制的并发转账会怎样假设有两个线程同时执行转账代码逻辑是先查余额再判断够不够再更新。用伪代码表示-- 线程A和线程B同时执行 SELECT balance FROM account WHERE user_id 1001; -- 都读到1000 -- 判断1000 200都通过 UPDATE account SET balance balance - 200 WHERE user_id 1001; UPDATE account SET balance balance 200 WHERE user_id 1002;如果两个线程几乎同时执行可能出现两种情况。第一种是丢失更新两个线程都读到1000都执行减200最终1001的余额变成800而不是600相当于只扣了一次。第二种更隐蔽如果UPDATE语句写的是SET balance 800在应用层算好再写回那后执行的会覆盖先执行的同样只扣一次。实操心得很多团队出问题就出在应用层计算再写回这种写法上。只要你的UPDATE语句里出现了具体的数值而不是balance - 200这样的表达式就一定要警惕并发覆盖问题。3.3 用事务包起来就万事大吉了吗有人会说加个事务不就行了把两条UPDATE放在一个事务里要么都成功要么都失败。事务确实能保证原子性但它保证不了隔离性带来的并发覆盖问题。因为默认的可重复读级别下两个事务各自读到的快照可能是一样的UPDATE时虽然会加行锁但如果事务提交顺序不当仍然可能出现逻辑错误。更关键的是事务的隔离级别和锁的范围决定了并发行为。如果只是简单地把操作包在START TRANSACTION ... COMMIT里而不考虑锁的粒度和获取顺序在高并发下依然会出问题甚至可能引发死锁。所以事务只是基础真正要解决问题还得靠锁策略和版本控制。4. 三种主流方案实测对比悲观锁、乐观锁、原子操作4.1 悲观锁方案SELECT FOR UPDATE悲观锁的思路是先锁住再操作在查询余额的时候就加上排他锁START TRANSACTION; SELECT balance FROM account WHERE user_id 1001 FOR UPDATE; -- 此时其他事务对user_id1001的FOR UPDATE查询会阻塞 UPDATE account SET balance balance - 200 WHERE user_id 1001; UPDATE account SET balance balance 200 WHERE user_id 1002; COMMIT;FOR UPDATE会对满足条件的行加排他锁X锁其他事务想对这行加锁就得等。这样就保证了同一时刻只有一个事务能操作这行数据彻底避免了并发覆盖。实测下来这种方案在并发量不高、事务执行时间短的场景下非常稳。但有两个明显的代价一是锁等待会降低吞吐量二是如果事务里还有其他耗时操作比如调用外部接口锁持有时间会很长容易拖垮整个系统。注意FOR UPDATE在没有索引的列上使用会锁全表这是新手最容易踩的坑。一定要确保WHERE条件走了索引否则并发一上来就是灾难。4.2 乐观锁方案版本号控制乐观锁的思路是先操作提交时检查有没有被别人改过。在表里加一个version字段每次更新时带上版本号-- 第一步查询当前余额和版本号 SELECT balance, version FROM account WHERE user_id 1001; -- 假设读到 balance1000, version0 -- 第二步更新时校验版本号 UPDATE account SET balance balance - 200, version version 1 WHERE user_id 1001 AND version 0; -- 第三步检查影响行数如果为0说明被改过重试或报错如果两个线程同时读到version0只有一个能更新成功因为更新后version变成1另一个的UPDATE影响行数为0应用层捕获后可以重试。乐观锁的优势是不加锁、吞吐量高特别适合读多写少的场景。缺点是冲突多的时候重试成本高如果并发写非常频繁大量请求都在重试反而比悲观锁更慢。另外重试逻辑需要应用层自己实现增加了代码复杂度。4.3 原子操作方案直接利用SQL的原子性还有一种更轻量的方案把判断和更新合并到一条SQL里利用数据库自身的原子性UPDATE account SET balance balance - 200 WHERE user_id 1001 AND balance 200;这条语句在InnoDB里是原子的balance 200的判断和balance - 200的更新在同一个语句内完成不会被打断。如果影响行数为0说明余额不足转账失败。这种方案性能最好、代码最简单但适用场景有限——它只能处理扣减这类单向操作如果业务逻辑复杂比如需要先查再算再更新就不好用一条SQL搞定。而且它无法处理需要读取中间值的场景。4.4 三种方案的选型对照表方案加锁方式适用场景吞吐量实现复杂度主要风险悲观锁行级排他锁写多读少、事务短中低锁等待、死锁乐观锁无锁版本校验读多写少、冲突少高中重试风暴原子操作语句级原子简单扣减/累加最高最低场景受限实际项目里我通常会组合使用核心扣减用原子操作复杂业务逻辑用悲观锁查询密集的配置类数据用乐观锁。没有银弹关键看业务特征。5. 深入锁机制行锁、间隙锁与死锁排查5.1 行锁到底锁的是什么InnoDB的行锁是加在索引上的不是加在数据行上的。这一点极其重要因为它决定了锁的范围。如果你的WHERE条件命中了唯一索引那锁的就是那一行如果命中的是普通索引可能会锁多行如果没走索引那就退化成表锁。举个例子account表的user_id上有唯一索引执行SELECT * FROM account WHERE user_id 1001 FOR UPDATE锁的就是user_id1001这一行对应的索引记录。但如果user_id上没有索引这条语句会锁住整张表的所有行并发直接归零。5.2 间隙锁幻读的克星也是死锁的温床在可重复读级别下InnoDB为了防止幻读引入了间隙锁Gap Lock。它不仅锁住存在的记录还锁住记录之间的间隙。比如表里有user_id为1001和1005两行执行范围查询WHERE user_id BETWEEN 1001 AND 1005 FOR UPDATE会锁住1001到1005之间的所有间隙其他事务无法在这个范围内插入新记录。间隙锁解决了幻读问题但也带来了新的麻烦——它显著增加了死锁的概率。因为间隙锁之间不冲突但间隙锁和插入意向锁会冲突两个事务如果以不同顺序获取间隙锁就很容易互相等待。实操心得如果你的业务不需要严格防止幻读可以考虑把隔离级别降到读已提交这样就没有间隙锁了死锁概率大幅降低。很多互联网公司就是这么做的用业务逻辑来兜底幻读问题。5.3 死锁的定位与处理死锁发生时MySQL会自动检测并回滚其中一个事务报错信息是Deadlock found when trying to get lock。排查死锁最直接的方式是查看SHOW ENGINE INNODB STATUS的输出里面会显示最近一次死锁的详细信息包括两个事务分别持有什么锁、等待什么锁。处理死锁的核心原则是统一加锁顺序。如果所有事务都按相同的顺序访问资源比如先锁账户A再锁账户B就不会出现循环等待。另外尽量缩短事务长度、减少锁的持有时间也能有效降低死锁概率。-- 查看当前锁等待情况 SELECT * FROM performance_schema.data_lock_waits; -- 查看当前事务 SELECT * FROM information_schema.innodb_trx;6. 实战中的参数配置与避坑清单6.1 几个关键参数的调整建议MySQL有几个和并发、事务相关的参数默认值不一定适合高并发场景参数默认值建议值说明innodb_lock_wait_timeout50秒5-10秒锁等待超时太长会拖垮连接池transaction_isolationREPEATABLE-READ按业务定读已提交可减少间隙锁innodb_autoinc_lock_mode12高并发插入时用交错模式max_connections151按需调连接数不够会导致请求排队innodb_lock_wait_timeout这个参数特别值得说。默认50秒太长了一旦出现锁等待请求会一直挂着连接池很快被占满整个服务雪崩。我一般会调到5到10秒超时后快速失败让应用层决定是重试还是报错。6.2 事务粒度控制的三个原则第一事务里不要做RPC调用。调用外部接口的耗时不可控可能几百毫秒甚至几秒锁一直不释放并发直接崩。正确做法是先做本地事务提交后再发消息或调接口。第二事务里不要做大批量操作。比如一次更新几万行锁持有时间会很长。可以拆成小批次每批几百行分批提交。第三事务里不要有用户交互。有些老系统会在事务里等用户输入这是大忌锁会一直不释放。6.3 常见问题速查表问题现象可能原因排查方向解决方案余额扣成负数丢失更新检查UPDATE是否用了表达式改用原子操作或加锁大量锁等待超时事务太长或锁范围大看innodb_trx和锁等待表缩短事务、检查索引偶发死锁加锁顺序不一致看死锁日志统一加锁顺序幻读导致数据错乱隔离级别不够确认隔离级别升级到可重复读或用间隙锁乐观锁重试过多并发冲突高统计重试率改用悲观锁或原子操作6.4 我踩过的几个坑第一个坑是在可重复读级别下用SELECT做判断。有次写了个先查库存够不够再扣减的逻辑测试环境没问题上线后大促直接超卖。原因是两个事务都读到了相同的快照都判断库存够然后都扣减。后来改成UPDATE ... WHERE stock num的原子写法才解决。第二个坑是FOR UPDATE没走索引。有张表在WHERE条件里用了函数导致索引失效FOR UPDATE直接锁全表并发一上来全部阻塞。后来把函数去掉改成范围查询才恢复。第三个坑是长事务导致的连接池耗尽。有个接口在事务里调了一个第三方服务那个服务偶尔会超时几十秒结果锁一直不释放连接池被占满整个服务不可用。后来把第三方调用移到事务外面问题消失。这些坑说到底都是对锁的范围和事务的边界理解不到位。MySQL的并发控制机制本身很完善问题往往出在使用方式上。把事务写短、把锁的范围控制到最小、优先用原子操作这三条原则能避开绝大多数并发问题。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表