1. 并发更新问题的典型场景
上周排查一个电商订单系统的问题时,遇到了典型的丢失更新案例:两个客服同时处理同一个客诉订单,第一个客服将订单状态从"待处理"改为"处理中",几乎同一时刻第二个客服也执行了相同的操作。最终数据库里只保留了一个更新结果,导致后续流程出现混乱。这种多个事务同时修改同一数据时发生的更新覆盖,就是我们今天要讨论的丢失更新(Lost Update)问题。
丢失更新在MySQL的MVCC(多版本并发控制)机制下尤为隐蔽。与脏读、不可重复读这些显性问题不同,它往往在业务逻辑层面暴露,但根源在于数据库并发控制机制。理解这个问题的本质,需要先明确MySQL的四种事务隔离级别:
- 读未提交(READ UNCOMMITTED):允许读取未提交数据
- 读已提交(READ COMMITTED):只能读取已提交数据
- 可重复读(REPEATABLE READ):MySQL默认级别
- 串行化(SERIALIZABLE):完全串行执行
在REPEATABLE READ级别下,MySQL通过快照读(Snapshot Read)和当前读(Current Read)两种读取方式,埋下了丢失更新的隐患。当两个事务同时使用SELECT...FOR UPDATE进行当前读时,后提交的事务会直接覆盖前一个事务的更新,而不会像开发者预期的那样产生冲突报错。
2. 问题复现与原理分析
2.1 最小化复现场景
我们用一个简单的银行账户表模拟问题:
sql复制CREATE TABLE account (
id INT PRIMARY KEY,
balance DECIMAL(10,2) NOT NULL
);
INSERT INTO account VALUES (1, 1000.00);
两个并发事务按如下顺序执行:
| 时间 | 事务A | 事务B |
|---|---|---|
| T1 | BEGIN | |
| T2 | SELECT balance FROM account WHERE id=1; -- 返回1000 | BEGIN |
| T3 | SELECT balance FROM account WHERE id=1; -- 返回1000 | |
| T4 | UPDATE account SET balance=balance+100 WHERE id=1; | |
| T5 | COMMIT | |
| T6 | UPDATE account SET balance=balance+200 WHERE id=1; | |
| T7 | COMMIT |
最终余额会是1300(1000+100+200)吗?实际结果是1200,事务A的+100更新被完全覆盖。这是因为MySQL的UPDATE语句本质上是"读取-修改-写入"三个步骤的原子操作,在默认隔离级别下不会检测这种写冲突。
2.2 InnoDB的隐藏陷阱
InnoDB引擎通过两种读机制处理并发:
- 快照读:普通SELECT语句,读取事务开始时的数据快照
- 当前读:SELECT...FOR UPDATE、UPDATE、DELETE等操作,读取最新已提交数据
丢失更新常发生在混合使用这两种读的场景。例如:
sql复制-- 事务A
BEGIN;
SELECT balance INTO @bal FROM account WHERE id=1; -- 快照读
-- 基于@bal计算新值...
UPDATE account SET balance=@bal+100 WHERE id=1; -- 当前读
COMMIT;
-- 事务B在事务A的SELECT和UPDATE之间执行了UPDATE
这种情况下,事务B的更新对事务A不可见(因为A的快照读发生在B提交前),但A的UPDATE却基于过期数据计算,导致B的更新被覆盖。
3. 解决方案对比与实践
3.1 悲观锁方案
最直接的解决方案是使用SELECT...FOR UPDATE进行显式锁定:
sql复制BEGIN;
SELECT balance FROM account WHERE id=1 FOR UPDATE; -- 获取排他锁
-- 执行业务逻辑计算...
UPDATE account SET balance=新值 WHERE id=1;
COMMIT;
关键点:
- FOR UPDATE会阻塞其他事务对相同记录的锁定请求
- 锁持续到事务结束(COMMIT/ROLLBACK)
- 适用于冲突频率高的场景,但会降低并发性
注意:FOR UPDATE必须配合事务使用,单独执行会自动提交,锁会立即释放
3.2 乐观锁方案
适合低冲突场景的实现方式:
sql复制-- 添加version字段
ALTER TABLE account ADD COLUMN version INT DEFAULT 0;
-- 事务逻辑
BEGIN;
SELECT balance, version INTO @bal, @ver FROM account WHERE id=1;
-- 计算新余额...
UPDATE account
SET balance=@bal+100, version=version+1
WHERE id=1 AND version=@ver; -- 关键条件
COMMIT;
执行后检查affected_rows:
- 1:更新成功
- 0:版本号已变更,需要重试或报错
3.3 其他防御策略
-
原子操作:对于简单计算,直接用原子操作
sql复制UPDATE account SET balance=balance+100 WHERE id=1; -
串行化隔离:SET TRANSACTION ISOLATION LEVEL SERIALIZABLE
- 彻底解决但性能影响大
- 适合财务等关键系统
-
应用层队列:将并发请求串行化处理
4. 生产环境中的实战经验
4.1 库存扣减的经典案例
电商秒杀系统中,我推荐这种组合方案:
sql复制BEGIN;
-- 先检查库存是否充足
SELECT quantity FROM inventory WHERE item_id=123 AND quantity>=1 FOR UPDATE;
-- 业务逻辑判断...
UPDATE inventory SET quantity=quantity-1 WHERE item_id=123;
INSERT INTO order_log(...);
COMMIT;
关键技巧:
- 锁定记录后立即进行业务判断,减少锁持有时间
- 将库存检查和扣减放在同一个事务中
- 配合Redis缓存减轻数据库压力
4.2 银行转账的防重设计
处理金融交易时,我们采用这样的模式:
sql复制BEGIN;
-- 使用业务唯一ID防重
SELECT COUNT(*) INTO @exist FROM transfer_log WHERE request_id='tx123';
IF @exist = 0 THEN
-- 双账户锁定
SELECT * FROM account WHERE id IN (1,2) ORDER BY id FOR UPDATE;
-- 余额检查
SELECT balance INTO @from_bal FROM account WHERE id=1;
IF @from_bal >= 100 THEN
UPDATE account SET balance=balance-100 WHERE id=1;
UPDATE account SET balance=balance+100 WHERE id=2;
INSERT INTO transfer_log(request_id, from_id, to_id, amount)
VALUES ('tx123', 1, 2, 100);
END IF;
END IF;
COMMIT;
4.3 性能优化实践
在高并发系统中,我们总结出这些经验:
- 锁粒度控制:尽量只锁定必要记录,避免锁表
- 超时设置:innodb_lock_wait_timeout(默认50秒)需要调整
- 监控锁等待:show engine innodb status查看锁争用
- 索引优化:确保FOR UPDATE条件走索引,避免全表扫描锁
5. 深度问题排查指南
当出现更新丢失时,按以下步骤诊断:
-
确认隔离级别:
sql复制SELECT @@transaction_isolation; -
检查自动提交状态:
sql复制SELECT @@autocommit; -
分析事务时序:
bash复制# 开启general log SET GLOBAL general_log = 'ON'; -
检查锁等待:
sql复制SELECT * FROM performance_schema.events_waits_current; -
使用SHOW ENGINE INNODB STATUS查看最近死锁
常见误区和解决方案:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 乐观锁重试次数过多 | 冲突确实频繁 | 改用悲观锁或队列 |
| FOR UPDATE阻塞严重 | 事务执行时间过长 | 拆分大事务或优化查询 |
| 版本号冲突异常 | 未处理更新失败情况 | 添加重试机制 |
| 余额出现负数 | 检查与更新分离 | 使用原子操作或锁定 |
最后分享一个真实案例:某次促销活动期间,我们发现有少量用户获得了双倍优惠券。排查发现是并发请求时,检查"是否已发放"和"执行发放"两个操作分离导致的。最终通过给用户ID加分布式锁解决,关键代码如下:
java复制// Redis分布式锁实现
String lockKey = "coupon_lock:" + userId;
try {
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 执行发放逻辑
} else {
throw new BusyException("操作太频繁");
}
} finally {
redisTemplate.delete(lockKey);
}