MySQL悲观锁和乐观锁在并发控制中怎么选
摘要:# 悲观锁还是乐观锁?MySQL并发控制,别让“锁”事拖垮你的系统 前两天帮一个做电商的朋友看他们的大促预案,发现他们库存扣减那块儿,清一色用的`SELECT ... FOR UPDATE`(也就是悲观锁)。我问他们为啥这么选,技术负责人一脸理所当然:“…
悲观锁还是乐观锁?MySQL并发控制,别让“锁”事拖垮你的系统
前两天帮一个做电商的朋友看他们的大促预案,发现他们库存扣减那块儿,清一色用的SELECT ... FOR UPDATE(也就是悲观锁)。我问他们为啥这么选,技术负责人一脸理所当然:“并发控制嘛,肯定要锁住啊,不然数据不就乱了?”
这场景你应该不陌生吧?很多团队在面临高并发数据竞争时,第一反应就是“上锁”。但说实话,很多所谓的“稳妥方案”,上线后真遇到流量洪峰,可能比不锁还容易出问题——不是超卖,就是直接把数据库连接池打满,整个服务雪崩。
我自己看过不少生产事故,问题往往不是没上防护,而是锁用错了地方,或者选错了类型。今天咱们就抛开那些教科书定义,聊聊MySQL里悲观锁和乐观锁到底该怎么选。这可不是二选一的考试题,而是一个直接关系到你系统能不能扛住618、双十一的实战问题。
一、先搞明白它俩到底在干啥(说人话版)
别被名字唬住。悲观锁和乐观锁,核心区别就一点:它们对待“冲突”的态度截然不同。
悲观锁像个疑心很重的保安。它的逻辑是:“我觉得肯定会有人跟我抢(数据冲突概率很高),所以我要先占住,谁也别想动。” 在MySQL里,你通常用SELECT ... FOR UPDATE或者SELECT ... LOCK IN SHARE MODE来实现。一旦你给某行数据上了这把锁,在事务提交前,其他想改这行数据的事务都得乖乖排队等着。
这听起来很安全,对吧?但代价是性能和并发度。想象一下早高峰只有一个闸机的地铁站。
乐观锁则像个心态开放的协调员。它觉得:“大家应该不会总撞到一起吧(冲突概率低)?那咱们先各自改,改完提交的时候再看看有没有冲突。” 它不真的去“锁”数据,而是给数据加个“版本号”(比如一个version字段,或者用时间戳,甚至数据本身的某些哈希值)。你读数据的时候把版本号记下来,更新的时候,必须带上这个版本号,并且检查“我更新的时候,这数据的版本号还是不是我刚才读到的那个”。如果不是,说明有人在我修改期间动了数据,那这次更新就失败,业务上通常选择重试。
说白了,悲观锁是 “先防后改”,乐观锁是 “先改后验”。
二、什么时候该用悲观锁?(别犹豫,就这些场景)
别一听乐观锁时髦就无脑上。悲观锁在下面这些场景里,依然是“定海神针”。
1. 冲突是常态,而不是意外。 如果你的业务逻辑决定了一行数据被频繁争用是日常状态,那用乐观锁会是一场灾难。比如一个热门商品的唯一库存、一个全局配置项的开关、一个需要严格递增的序列号生成器。在这些场景下,乐观锁会导致大量的更新失败和重试,重试本身也是消耗,还可能引发“惊群效应”。这时候,用悲观锁虽然会让请求排队,但顺序是可控的,结果是确定的,反而更简单可靠。
我见过一个票务系统,对最前排的几张“黄金座”用了乐观锁,结果开售瞬间,99%的请求都在不停重试抢版本号,数据库CPU直接飙满,还不如老实排队。
2. 重试成本极高,或根本无法重试。 有些业务操作是“一锤子买卖”,失败了不能简单地再来一次。比如,从你的账户余额里扣一笔钱,如果采用乐观锁更新失败,难道能对用户说“抱歉扣款冲突了,请您再付一次”吗?显然不行。这类涉及核心资产、状态必须绝对准确的场景,用悲观锁一把锁住,保证原子性,避免后续所有复杂的补偿和回滚逻辑,往往是更经济的选择。
3. 你要操作的数据,本身就需要被锁定来保证一组操作的绝对串行。 这有点绕。举个例子,你需要先读A,再根据A的值决定怎么改B,最后还要更新A。这一连串操作必须作为一个不可分割的整体。如果中间A被其他人改了,你的整个逻辑就乱套了。这时候,在事务一开始就用悲观锁锁住A,是最清晰、最不容易出错的写法。虽然这可能影响一点并发,但换来了逻辑的简洁和正确性。
一句话总结:当你“伤不起”或者“肯定要打架”的时候,选悲观锁。
三、什么时候该拥抱乐观锁?(这才是性能提升的关键)
乐观锁被大规模应用,尤其是在互联网高并发场景下,不是没有道理的。它的好处太明显了:避免锁等待,提升系统的整体吞吐量。数据库连接是宝贵的,不让线程阻塞在锁上,它们就能去服务其他请求。
下面这些情况,你认真考虑一下乐观锁:
1. 读多写少,这是乐观锁的天堂。 大部分系统都是读操作远多于写操作。比如新闻详情页、商品信息页。即便有写操作,冲突的概率也很低。在这种场景下,你为那1%可能的冲突,让99%的读操作都去走一遍加锁解锁的流程(即使是共享锁),简直是巨大的浪费。用乐观锁,读操作完全无锁,畅行无阻,体验极佳。
2. 写冲突确实不频繁。 哪怕是一个写操作较多的业务,如果数据维度设计得好,冲突也能被稀释。比如,不是把100个商品的库存都放在一行记录里,而是拆分成100行,每个商品ID独立一行。这样用户A买商品1,用户B买商品2,根本不会冲突。在这种情况下,乐观锁的更新失败率会很低,重试机制完全能 cover 住。
3. 应用层能很好地处理“更新失败”。
这是使用乐观锁的前提。你的业务代码不能假设更新一定成功,必须准备好面对UPDATE ... WHERE version = xxx返回影响行数为0的情况。然后,你需要决定:是立刻重试(适合短时冲突)?是返回给用户一个友好提示(如“信息已过期,请刷新”)?还是进入一个更复杂的冲突解决流程?
如果你的团队有这种设计和编码意识,乐观锁会是一个强大的武器。
4. 追求极致的系统吞吐量。 在一些秒杀、抢购的极致场景下,有一种“乐观锁+库存预扣”的混合玩法。先在应用层用Redis或内存计数器做一层“乐观”的库存校验和预减,最后到数据库用乐观锁做最终扣减。这相当于把大部分冲突拦截在数据库之外,数据库层只是做最终的一致性保障,承受的压力小了很多。
四、别走极端:混合使用与实战心法
看到这里,你可能觉得:“懂了,我的系统大部分是读,所以全上乐观锁!” 打住,这才是最危险的想法。
一个成熟的系统,往往是悲观锁和乐观锁的混合体。 关键就在于 “按场景细分”。
- 用户账户余额、核心订单状态 -> 优先考虑悲观锁。钱的事,稳字当头。
- 商品库存(非极度热门) -> 可以大胆用乐观锁。配合库存分桶(一个SKU拆成多个库存记录),效果更好。
- 文章点赞数、阅读数 -> 甚至可以用更“乐观”的办法,比如Redis累加再定时同步回DB,或者直接用
UPDATE table SET count = count + 1(这其实是一种特殊的乐观锁)。 - 配置信息、全局开关 -> 悲观锁,或者利用MySQL的写锁(
FOR UPDATE)保证读到的绝对是最新值。
几个实战中容易踩的坑:
- 长事务 + 悲观锁 = 灾难。如果你用悲观锁锁住一行数据,然后这个事务里还在调外部API、处理复杂业务逻辑,十几秒不提交,其他所有相关请求全堵死。用悲观锁,事务一定要短平快。
- 乐观锁的“版本号”选不对。用
version字段是最清晰的。别用update_time,因为时间精度可能有问题,不同机器也可能有时钟差。 - 无脑重试。乐观锁更新失败,如果只是简单循环重试,可能会在热点数据上造成恶性循环。要加指数退避,要设重试上限,甚至可以考虑把失败请求丢到队列里异步慢慢处理。
- 忘了“读已提交”隔离级别的影响。在默认的“可重复读”级别下,普通的
SELECT看不到别的事务已提交的修改。这可能会让你误判冲突概率。根据业务需要,有时可能需要调整隔离级别。
写在最后
选择悲观锁还是乐观锁,本质上是在 “数据绝对安全” 和 “系统并发性能” 之间做权衡。没有银弹。
我的建议是,下次设计数据访问层时,别凭感觉。先压测:模拟真实并发场景,看看冲突率到底有多高。再评估:这个业务能不能接受更新失败和重试。最后做决定。
技术选型就像选工具,锤子再好,也不能用来拧螺丝。把悲观锁用在它该用的地方,那是稳健;把乐观锁用在适合它的场景,那叫智慧。
如果你的源站还在所有并发场景下无脑FOR UPDATE,看完这篇文章,你心里应该已经有答案了吧?行了,思路就聊这么多,具体代码怎么写,怎么调优,那就是另一个故事了。

