资讯动态

MySQL并发更新安全吗?InnoDB行锁与丢失更新深度解析

发布时间:2026/10/13 11:25:44 来源:尧图企业网站定制
1. 从一个经典面试题说起这条SQL到底安不安全“update A set numnum? where id?”——这句话几乎每个后端开发都写过但真要问它在并发场景下有没有线程安全问题能答利索的人并不多。我第一次被问到这个问题是在一次内部技术评审上当时负责订单库存模块的同事拍着胸脯说“数据库有锁肯定安全”结果压测时库存超卖了十几单场面一度非常尴尬。先把结论摆在前面这条SQL在MySQL的InnoDB引擎下配合默认的REPEATABLE READ隔离级别单条语句本身是原子的不会出现“两个事务都读到num10然后都写11”这种丢失更新的情况。但这个“安全”是有前提的一旦你的代码结构、隔离级别、执行计划或者数据库引擎发生变化结论可能完全反转。这篇文章我想把这个问题彻底拆开讲清楚。适合谁看写过增删改查但没深究过锁机制的后端开发、正在准备技术面试的同学、以及线上遇到过库存超卖或计数不准的运维和DBA。我会从原理讲到实操从单机讲到分布式把“为什么安全”和“什么情况下不安全”都掰开揉碎。看完你至少能做到两件事一是能准确回答面试官二是在真实项目里知道哪里会踩坑。2. 先搞清楚“线程安全”在数据库语境下指什么2.1 应用层的线程安全和数据库层的并发控制不是一回事很多人一听到“线程安全”就想到Java里的synchronized、ReentrantLock或者Go里的mutex。但数据库的并发问题跟应用层的线程安全是两个层面的东西。你的应用可能是多线程的每个线程拿着自己的数据库连接去执行SQL也可能是多进程的甚至是多台机器上的多个服务实例。这些请求最终都会汇聚到MySQL服务端由MySQL自己来协调并发。所以讨论这条SQL安不安全本质是在问多个并发事务同时执行这条更新语句时MySQL能不能保证num的最终值等于所有增量之和不会丢更新。这属于数据库的并发控制范畴靠的是锁机制和事务隔离级别而不是你应用层加没加锁。2.2 丢失更新、脏读、幻读这条SQL最该担心哪个并发场景下的经典问题有四个脏读、不可重复读、幻读、丢失更新。对于“numnum?”这种自增型更新最致命的是丢失更新Lost Update。什么叫丢失更新两个事务同时读到num10事务A写回11事务B也写回11最终结果是11而不是12事务A的更新被“覆盖”掉了。这里要区分两种丢失更新。第一种是回滚丢失事务A更新了但回滚了事务B基于A更新后的值继续操作A一回滚B的数据就错了。第二种是覆盖丢失就是我上面说的两个事务都基于同一个旧值计算后提交的覆盖先提交的。InnoDB的行锁机制主要解决的是第二种第一种需要应用层配合。注意很多人把“丢失更新”和“不可重复读”混为一谈。不可重复读是同一个事务内两次读同一行结果不一样丢失更新是两个事务的写操作互相覆盖。前者靠MVCC解决后者靠锁解决机制完全不同。2.3 为什么大家会怀疑它不安全怀疑的根源在于“读-改-写”这个动作看起来分了三步。如果MySQL先把num读出来在内存里加1再写回去那中间确实有窗口期。但InnoDB的UPDATE语句执行时并不是简单地在应用层做“读-改-写”而是在存储引擎内部通过当前读Current Read 行锁来完成的。它会在加锁的前提下读取最新值然后直接在该值上做增量整个过程在锁的保护下是原子的。理解这一点是理解整篇文章的钥匙。接下来我从锁的粒度、隔离级别、执行计划三个维度展开。3. InnoDB的行锁机制这条SQL到底锁了什么3.1 聚簇索引上的记录锁假设表A的id是主键那么“where id?”会走聚簇索引InnoDB会对id对应的那条聚簇索引记录加排他锁X锁。这个锁是加在索引记录上的不是加在表上也不是加在行数据上——这点很关键因为InnoDB的锁本质是索引锁。当两个事务同时执行“update A set numnum1 where id100”时第一个事务拿到id100这条记录的X锁第二个事务的加锁请求会被阻塞直到第一个事务提交或回滚。等第二个事务拿到锁时它读取的是第一个事务提交后的最新值所以num会正确地累加两次。这就是为什么单条UPDATE语句在InnoDB下是安全的。3.2 如果where条件没走索引会怎样这是最容易翻车的地方。如果id不是主键或者where条件用了一个没有索引的字段比如“update A set numnum1 where status1”那么InnoDB在RR隔离级别下会扫描全表并对每一条扫描到的记录加锁。更麻烦的是它还会加间隙锁Gap Lock把记录之间的间隙也锁住导致大量并发更新互相阻塞甚至出现死锁。我实测过一个案例一张十万行的表status字段没索引两个并发事务执行上述更新第二个事务等待了将近3秒才拿到锁QPS直接掉到个位数。所以第一条实操建议就是UPDATE语句的WHERE条件必须走索引最好是唯一索引或主键。3.3 唯一索引等值查询的锁退化如果where条件用的是唯一索引的等值查询比如“where order_noXXX”InnoDB会把间隙锁退化成记录锁只锁住匹配的那一条记录不会锁间隙。这个优化叫“锁退化”是InnoDB在RR级别下对唯一索引等值查询的特殊处理。理解这一点对优化并发性能很重要。WHERE条件类型加锁范围并发影响主键等值单条记录X锁最小推荐唯一索引等值单条记录X锁锁退化小推荐普通索引等值记录锁间隙锁中等可能阻塞插入无索引全表扫描所有记录锁间隙锁极大几乎串行3.4 两阶段提交与锁的释放时机InnoDB的锁是在事务提交或回滚时才释放的不是语句执行完就释放。这意味着如果你在一个事务里先执行了这条UPDATE然后去做了一些耗时的业务逻辑比如调用外部接口锁会一直持有到事务结束。高并发下这会成为性能瓶颈甚至引发锁等待超时。实操心得事务里只放数据库操作把RPC调用、文件IO、复杂计算都挪到事务外面。我见过一个项目在事务里调了三次外部接口平均耗时800ms导致库存更新锁等待严重后来把接口调用挪出去TPS直接翻了四倍。4. 隔离级别对这条SQL的影响RR和RC的差异4.1 REPEATABLE READ下的行为MySQL InnoDB默认是REPEATABLE READ。在这个级别下普通的SELECT用的是快照读MVCC但UPDATE、DELETE、SELECT ... FOR UPDATE用的是当前读会加锁并读取最新已提交的数据。所以“update A set numnum? where id?”在RR下是安全的不会丢更新。但RR下的间隙锁会带来额外的并发开销。如果你的业务是“先查后改”的模式比如先SELECT查出num在应用层加1再UPDATE写回那即使在RR下也可能出问题——因为SELECT是快照读读到的是事务开始时的旧值。这种写法必须改成“update A set numnum? where id?”把计算交给数据库。4.2 READ COMMITTED下的行为很多互联网公司会把隔离级别调成READ COMMITTED主要是为了减少间隙锁、提升并发。在RC下这条UPDATE语句依然是安全的因为当前读加X锁的机制没变。区别在于RC下没有间隙锁只有记录锁所以并发插入不会被阻塞锁冲突更少。但RC下有个坑如果你的更新依赖了之前SELECT的结果而SELECT在RC下每次都是最新快照可能出现“读到了别的事务刚提交的值”的情况。不过对于“numnum?”这种自增写法因为计算在数据库内部完成RC和RR都是安全的。4.3 两种级别下的实测对比我在一台4核8G的测试机上做过对比表结构是id主键、num整型10万行数据用sysbench模拟200并发持续更新同一条记录。RR级别下平均延迟12msRC级别下平均延迟9ms差异主要来自间隙锁的开销。但如果更新的是不同记录两者差异就不明显了。隔离级别单条更新延迟并发插入阻塞丢失更新风险REPEATABLE READ较高有间隙锁阻塞无READ COMMITTED较低无无4.4 什么时候隔离级别会“失效”有一种情况需要特别警惕如果应用层用了“先SELECT再UPDATE”的两步写法隔离级别再高也救不了你。比如-- 事务A SELECT num FROM A WHERE id100; -- 读到10 -- 应用层计算 10111 UPDATE A SET num11 WHERE id100; -- 事务B同时执行同样的逻辑 SELECT num FROM A WHERE id100; -- 也读到10 UPDATE A SET num11 WHERE id100; -- 覆盖了A的更新这种写法在RR下SELECT是快照读不加锁两个事务都读到10最终num11丢了一次更新。解决办法有两个一是改成“numnum1”的单语句写法二是把SELECT改成“SELECT ... FOR UPDATE”加锁。前者性能更好后者适合需要复杂计算的场景。5. 实操验证亲手复现并发更新场景5.1 环境准备与表结构设计光讲理论不够我带你实际跑一遍。准备一个MySQL 8.0实例建一张简单的表CREATE TABLE counter ( id BIGINT PRIMARY KEY, num INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0 ) ENGINEInnoDB; INSERT INTO counter (id, num) VALUES (1, 0);然后开两个会话可以用两个终端连同一个MySQL分别执行事务。为了观察锁的情况我们还会用到“SELECT * FROM performance_schema.data_locks”来查看当前锁信息。5.2 用两个会话模拟并发更新会话A执行BEGIN; UPDATE counter SET num num 1 WHERE id 1; -- 先不提交会话B执行同样的语句BEGIN; UPDATE counter SET num num 1 WHERE id 1; -- 此时会阻塞你会发现会话B卡住了光标一直闪。这时候在第三个会话里查锁SELECT * FROM performance_schema.data_locks WHERE OBJECT_NAMEcounter;能看到会话A持有id1这条记录的X锁会话B在等待。等会话A执行COMMIT后会话B立刻拿到锁并完成更新最终num2。这就验证了单条UPDATE语句的原子性。5.3 观察锁等待和超时如果会话A一直不提交会话B会等到“innodb_lock_wait_timeout”设置的秒数默认50秒后报错“Lock wait timeout exceeded”。这个参数在生产环境通常要调小比如设成5秒避免请求长时间挂起拖垮连接池。注意锁等待超时后会话B的事务并不会自动回滚只是那条语句失败了。你需要显式ROLLBACK否则事务还开着可能持有其他锁。5.4 用压测工具验证最终一致性单次验证不够我用Python写了个简单的压测脚本开50个线程每个线程循环100次执行“update counter set numnum1 where id1”最后检查num是否等于5000。import threading import pymysql def worker(): conn pymysql.connect(host127.0.0.1, userroot, passwordxxx, databasetest) cur conn.cursor() for _ in range(100): cur.execute(UPDATE counter SET num num 1 WHERE id 1) conn.commit() conn.close() threads [threading.Thread(targetworker) for _ in range(50)] for t in threads: t.start() for t in threads: t.join() # 检查结果 conn pymysql.connect(...) cur conn.cursor() cur.execute(SELECT num FROM counter WHERE id 1) print(cur.fetchone()) # 输出 (5000,)实测下来无论跑多少次结果都是精确的5000没有丢更新。这从实践层面证明了这条SQL在InnoDB下的安全性。5.5 对比“先查后改”的错误写法把脚本改成先SELECT再UPDATEcur.execute(SELECT num FROM counter WHERE id 1) num cur.fetchone()[0] cur.execute(UPDATE counter SET num %s WHERE id 1, (num 1,))同样的50线程100次最终结果往往小于5000通常是4800多。这就是丢失更新的真实表现。两种写法的差异就是这篇文章的核心价值所在。6. 那些让这条SQL变得不安全的场景6.1 MyISAM引擎表锁也救不了丢失更新如果表用的是MyISAM引擎情况完全不同。MyISAM只支持表锁不支持事务。虽然表锁能保证同一时刻只有一个写操作但“先查后改”的写法依然会丢更新因为SELECT和UPDATE之间没有事务保护其他会话可以插入进来。而且MyISAM的表锁粒度太大并发性能极差现在基本没人用了。如果还在用建议尽快迁到InnoDB。6.2 分库分表后的跨库更新单库单表下这条SQL是安全的但一旦分库分表num这个字段如果被拆分到不同库或者更新需要跨多个分片数据库层面的行锁就管不到了。比如订单表按用户ID分片但库存字段在另一个库更新库存和更新订单在两个事务里就需要分布式事务或最终一致性方案来保证。这时候“线程安全”问题升级成了分布式一致性问题。6.3 读写分离下的主从延迟如果用了读写分离写走主库、读走从库那么“update A set numnum? where id?”在主库执行是安全的但从库同步有延迟。如果业务逻辑是“更新后立刻查”可能读到旧值。这不是更新语句本身的问题而是架构带来的可见性问题。解决办法是更新后的关键读走主库或者用半同步复制降低延迟。6.4 应用层缓存与数据库不一致还有一种常见情况num的值被缓存在Redis里更新时先更新数据库再删缓存或者先删缓存再更新数据库。如果并发请求在缓存失效的窗口期同时打到数据库虽然数据库更新是安全的但缓存里可能被写入旧值。这属于缓存一致性问题需要配合延迟双删、版本号等策略解决。场景是否安全原因应对InnoDB单条UPDATE安全行锁当前读无需处理先查后改不安全快照读无锁改单语句或FOR UPDATEMyISAM不安全无事务迁InnoDB分库分表需额外处理锁不跨库分布式事务读写分离读可能旧主从延迟关键读走主库7. 高频问题排查与避坑清单7.1 死锁是怎么产生的两个事务互相等待对方持有的锁就会死锁。比如事务A更新id1再更新id2事务B更新id2再更新id1就可能死锁。InnoDB有死锁检测机制会主动回滚其中一个事务并报错“Deadlock found”。避免死锁的核心原则是按固定顺序访问资源比如所有事务都按id升序更新。7.2 锁等待超时的排查思路线上遇到“Lock wait timeout exceeded”第一步是查“SHOW ENGINE INNODB STATUS”看LATEST DETECTED DEADLOCK和TRANSACTIONS部分找到持锁的事务和等待的事务。第二步是查“information_schema.innodb_trx”看哪个事务运行时间长。第三步是查“performance_schema.data_locks”看具体锁在哪条记录上。通常罪魁祸首是长事务或者没走索引的更新。7.3 常见问题速查表问题现象可能原因排查方法解决num值比预期小先查后改丢更新检查代码写法改单语句更新阻塞严重WHERE没走索引EXPLAIN看执行计划加索引死锁报错加锁顺序不一致看死锁日志统一顺序锁等待超时长事务持锁查innodb_trx拆小事务主从数据不一致从库延迟看Seconds_Behind_Master关键读走主库7.4 几个容易被忽略的细节第一autocommit1时单条UPDATE自动提交锁立即释放并发性能最好。如果手动BEGIN了却忘了COMMIT锁会一直持有。第二UPDATE影响行数为0不代表没加锁即使没有匹配的记录InnoDB也可能加间隙锁。第三批量UPDATE要控制数量一次更新太多行会持有大量锁建议分批。第四监控锁等待指标比如“Innodb_row_lock_waits”和“Innodb_row_lock_time_avg”提前发现隐患。实操心得我习惯在代码review时专门检查所有UPDATE语句的WHERE条件是否走索引以及事务边界是否合理。这两个点能挡住80%的并发问题。另外压测时一定要用真实并发量去跑更新场景单线程测试永远发现不了锁冲突。8. 从单机到分布式这条SQL的边界在哪里8.1 单机InnoDB的可靠性边界在单机单实例InnoDB下只要满足三个条件——引擎是InnoDB、WHERE走索引、用单语句自增写法——这条SQL就是线程安全的。这个结论经过了理论分析和压测验证可以放心。但要注意这里的“安全”指的是不丢更新不保证性能。高并发下同一条记录的更新会串行化TPS受限于单行锁的争用。8.2 主从架构下的可见性边界主从复制是异步的主库提交后从库可能还没应用。如果业务在更新后立刻从从库读可能读到旧值。这不是更新语句的错而是复制延迟导致的。对于num这种计数场景通常更新后不需要立刻读或者读的时候容忍短暂不一致。如果必须强一致可以用“SELECT ... FOR UPDATE”走主库或者用半同步复制。8.3 分布式场景下的替代方案当单机扛不住时num的更新可能需要换思路。常见方案有几种一是用Redis的INCR做原子自增定期同步回数据库二是用消息队列串行化更新请求三是用数据库的乐观锁版本号比如“update A set numnum1, versionversion1 where id? and version?”。每种方案都有取舍Redis性能高但可能丢数据消息队列解耦但增加复杂度乐观锁适合冲突少的场景。8.4 一个真实的容量估算案例假设单条记录每秒能承受的更新大约是1000到2000次受限于行锁争用和磁盘IO如果业务峰值是每秒5000次更新同一条记录单机InnoDB就扛不住了。这时候要么把计数拆成多个槽位比如num_1到num_10更新时随机选一个读的时候求和要么上Redis。这个估算需要根据实际硬件压测得出不能拍脑袋。9. 写在最后几个我踩过的坑第一个坑是以为加了事务就万事大吉。早期写代码时我在事务里先SELECT再UPDATE觉得有事务保护肯定没问题结果压测时库存超卖。后来才明白事务的隔离性靠的是MVCC和锁快照读不加锁该丢还是丢。第二个坑是忽略了索引对锁的影响。有次线上一个更新接口突然变慢排查发现是某个字段的索引被误删了导致UPDATE全表扫描加锁把整个表锁住了。从那以后我养成了上线前用EXPLAIN检查所有更新语句的习惯。第三个坑是事务里做了太多事。有个项目在事务里发短信、写日志、调第三方接口事务平均耗时2秒锁持有时间太长高峰期大量锁等待。后来把这些非数据库操作全部挪到事务外问题迎刃而解。这条SQL看似简单背后牵扯的是InnoDB的锁机制、事务隔离、索引优化、架构设计一整套知识。把这一条吃透很多并发问题都能触类旁通。如果你正在做库存、计数、余额这类高频更新场景建议先把单机InnoDB的方案压测到极限再考虑分布式方案不要一上来就上复杂的架构。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑