切换日光/暗黑模式
060. 悲观锁与乐观锁处理并发更新
学习目标
这一节用账户充值案例讲并发更新的两种解决方式。
学完后,你应该能理解:
for update如何形成悲观锁;- 悲观锁为什么必须放在事务里;
- 行锁为什么依赖索引;
- 锁表为什么危险;
- 乐观锁为什么需要版本号;
- 乐观锁失败后为什么要重试;
- 两种方案分别适合什么场景。
悲观锁
悲观锁假设并发冲突一定会发生。
所以读取数据时就先加锁。
在 MySQL 里,可以在查询语句后面加:
sql
for update这会让数据库给查询到的记录加排他锁。
其他请求再查同一条记录并尝试加锁时,会被阻塞等待。
排他锁如何解决覆盖问题
还是账户充值案例。
账户余额是 200。
多个请求同时给同一个账户充值。
第一个请求查到账户并加锁。
第二个请求查同一条记录时,会等第一个请求提交事务。
第一个请求提交后,第二个请求再读到的是更新后的余额。
这样后续请求不会基于旧余额计算,结果就不会互相覆盖。
必须开启事务
for update 必须在事务里才有意义。
如果没有事务,查询结束后锁会很快释放。
后续更新还没完成,锁已经没了,自然无法保护并发更新。
所以悲观锁流程通常是:
- 开启事务;
- 查询记录并
for update; - 处理业务逻辑;
- 更新记录;
- 提交事务;
- 锁自动释放。
行锁与表锁
理想情况下,悲观锁只锁住目标行。
例如只锁账户 001,不会影响账户 002。
但如果查询条件没有命中索引,数据库可能退化成锁表。
锁表会影响整张表里的其他记录。
这会导致大量请求被阻塞,甚至引发大面积超时。
索引非常关键
行锁是加在索引记录上的。
所以 where 条件要尽量命中索引。
按主键查询通常比较稳。
如果按没有索引的字段加 for update,就可能锁表。
使用悲观锁前,要用 explain 检查 SQL 是否命中索引。
悲观锁的适用场景
悲观锁适合:
- 并发量不高;
- 数据一致性要求极高;
- 查询条件明确命中索引;
- 可以接受请求排队等待。
它的优点是逻辑直接,正确性强。
缺点是并发能力差,且锁表风险高。
乐观锁
乐观锁假设大多数时候不会冲突。
它不在读取时加数据库锁。
而是在更新时判断:我读到的那条记录,在我处理业务逻辑期间有没有被别人改过。
如果没被改过,就更新成功。
如果被改过,就重新读取、重新计算、重新更新。
版本号字段
乐观锁通常需要一个版本号字段。
例如:
txt
version读取记录时,同时读到当前 version。
更新时带上旧 version 作为条件。
例如:
sql
update account
set amount = new_amount,
version = version + 1
where id = '001'
and version = old_version;如果更新行数是 0,说明 version 已经被别人改过。
这次更新失败,需要重试。
为什么要重试
乐观锁失败不是业务一定失败。
它只是说明你手里的数据过期了。
正确流程是:
- 重新查最新数据;
- 重新执行业务计算;
- 再次尝试更新;
- 超过最大次数后返回失败。
重试次数不能无限。
否则高并发时会让后端一直空转。
重试要离开事务
一次更新失败后,要结束当前事务,再等待一小段时间,然后重新开始下一轮。
不要在同一个事务里一直等待。
失败后抛出异常,事务会回滚。
下一轮重试重新开启事务,读到更新后的数据。
乐观锁的压力转移
悲观锁把等待压力放在数据库。
请求会在数据库锁上排队。
乐观锁把更多压力放到后端。
后端自己重试、等待、重新计算。
这对扩展更友好,因为后端服务更容易横向扩容。
数据库通常更难无限扩容。
重试次数的取舍
重试次数越多,业务成功概率越高。
但后端 CPU 和请求占用也越高。
重试次数越少,后端压力越小,但用户遇到失败的概率更高。
课程案例里使用有限次数重试。
真实项目要根据接口重要性、并发规模和用户体验决定。
悲观锁与乐观锁对比
悲观锁:
- 数据一致性强;
- 实现简单;
- 数据库压力大;
- 容易因为索引问题锁表;
- 适合低并发高一致性场景。
乐观锁:
- 并发能力更好;
- 需要版本号;
- 代码更复杂;
- 失败后要重试;
- 适合读多写少或冲突概率可控的场景。
这一节的重点
这一节解决字段累加时的并发覆盖问题。
你需要记住:
for update是悲观锁;- 悲观锁必须在事务里使用;
- 行锁依赖索引,索引失效可能锁表;
- 乐观锁依赖版本号;
- 乐观锁失败要重试,而不是覆盖;
- 选方案时要看一致性要求和并发压力。