1. 并发更新问题的本质与业务场景
上周排查一个电商库存超卖问题时,发现这是典型的丢失更新案例。当多个事务同时读取-修改-写入同一条记录时,后提交的事务会覆盖前一个事务的修改。比如两个客服同时处理同一订单的退款申请,系统记录的处理次数本该是2次,最终却只显示1次——这就是丢失更新的典型表现。
在MySQL的默认事务隔离级别REPEATABLE READ下,这种问题尤为隐蔽。开发环境可能一切正常,但生产环境一旦并发量上来,就会随机出现数据异常。我见过最严重的案例是金融系统账户余额更新丢失,直接导致资金对账不平。
2. 问题复现与原理分析
2.1 最小化复现场景
用这个简单的库存表做演示:
sql复制CREATE TABLE `inventory` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_code` varchar(32) NOT NULL,
`stock` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_product_code` (`product_code`)
) ENGINE=InnoDB;
INSERT INTO `inventory` VALUES (1, 'IPHONE_15', 100);
开启两个MySQL会话模拟并发:
sql复制-- 会话1
BEGIN;
SELECT stock FROM inventory WHERE product_code = 'IPHONE_15'; -- 得到100
-- 会话2
BEGIN;
SELECT stock FROM inventory WHERE product_code = 'IPHONE_15'; -- 也得到100
-- 会话1
UPDATE inventory SET stock = 99 WHERE product_code = 'IPHONE_15';
COMMIT; -- 库存变为99
-- 会话2
UPDATE inventory SET stock = 99 WHERE product_code = 'IPHONE_15';
COMMIT; -- 库存仍然是99!
2.2 InnoDB的MVCC机制解析
问题的根源在于MVCC(多版本并发控制)的工作方式:
- 两个事务在READ COMMITTED隔离级别下看到相同的快照数据
- UPDATE操作实际执行的是当前读(current read),会读取已提交的最新数据
- 但WHERE条件判断仍基于快照读的数据版本
这就导致第二个事务的UPDATE语句执行时:
sql复制-- 实际执行的逻辑
UPDATE inventory SET stock = 99
WHERE product_code = 'IPHONE_15'
AND (快照读到的stock = 100) -- 这个隐藏条件导致更新失效
3. 六种解决方案对比与实践
3.1 悲观锁方案(SELECT FOR UPDATE)
sql复制BEGIN;
SELECT stock FROM inventory WHERE product_code = 'IPHONE_15' FOR UPDATE;
-- 业务逻辑处理
UPDATE inventory SET stock = 99 WHERE product_code = 'IPHONE_15';
COMMIT;
注意:必须确保所有相关操作都使用相同的索引加锁,否则可能锁升级为表锁
3.2 乐观锁方案(版本号控制)
sql复制ALTER TABLE inventory ADD COLUMN version int NOT NULL DEFAULT 0;
-- 业务代码示例
BEGIN;
SELECT stock, version FROM inventory WHERE product_code = 'IPHONE_15';
-- 处理业务逻辑...
UPDATE inventory SET stock = 99, version = version + 1
WHERE product_code = 'IPHONE_15' AND version = {之前查到的version};
COMMIT;
3.3 原子操作方案
sql复制UPDATE inventory SET stock = stock - 1 WHERE product_code = 'IPHONE_15';
3.4 串行化隔离级别
sql复制SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
-- 业务操作
COMMIT;
3.5 应用层队列
通过Redis或Kafka实现请求序列化:
code复制1. 将更新请求放入队列
2. 单线程消费者顺序处理
3. 保证同一商品的更新操作串行执行
3.6 触发器审计
sql复制CREATE TABLE inventory_audit (
id BIGINT AUTO_INCREMENT,
product_code VARCHAR(32),
old_stock INT,
new_stock INT,
PRIMARY KEY(id)
);
DELIMITER //
CREATE TRIGGER before_inventory_update
BEFORE UPDATE ON inventory
FOR EACH ROW
BEGIN
INSERT INTO inventory_audit(product_code, old_stock, new_stock)
VALUES(NEW.product_code, OLD.stock, NEW.stock);
END//
DELIMITER ;
4. 不同场景下的方案选型指南
| 场景特征 | 推荐方案 | 性能影响 | 实现复杂度 |
|---|---|---|---|
| 超高并发秒杀 | 原子操作+应用层队列 | ★★★★☆ | ★★☆☆☆ |
| 财务系统金额变更 | 悲观锁+事务 | ★★☆☆☆ | ★★★☆☆ |
| 商品后台管理系统 | 乐观锁 | ★★★☆☆ | ★★★☆☆ |
| 数据变更审计要求 | 触发器+乐观锁 | ★★☆☆☆ | ★★★★☆ |
| 分布式系统库存扣减 | Redis原子操作+MQ补偿 | ★★★☆☆ | ★★★★☆ |
5. 生产环境中的典型陷阱
5.1 嵌套事务的锁失效
java复制// Spring声明式事务的坑
@Transactional
public void updateStock() {
// 这里查询没有加锁
Inventory inventory = inventoryMapper.selectByProductCode("IPHONE_15");
// 调用另一个事务方法
anotherService.process(inventory);
// 此时其他事务可能已修改数据
inventoryMapper.updateStock(inventory);
}
解决方案:使用编程式事务或统一在入口处加锁
5.2 批量更新的性能问题
sql复制-- 这种写法会导致全表扫描加锁
UPDATE inventory SET stock = stock - 1
WHERE product_code IN ('IPHONE_15', 'IPAD_PRO');
正确做法:
sql复制-- 方案1:分解为多个单行更新
-- 方案2:使用索引条件强制走索引
UPDATE inventory SET stock = stock - 1
WHERE product_code IN ('IPHONE_15', 'IPAD_PRO')
AND product_code = ANY(VALUES);
5.3 锁等待超时配置
ini复制# my.cnf关键参数
innodb_lock_wait_timeout=50 # 默认50秒太长
innodb_rollback_on_timeout=ON
建议设置为3-5秒,配合应用层重试机制。
6. 监控与排查技巧
6.1 锁等待检测
sql复制-- 查看当前锁等待
SELECT * FROM performance_schema.events_waits_current
WHERE EVENT_NAME LIKE '%lock%';
-- 查看被阻塞的事务
SELECT * FROM sys.innodb_lock_waits;
6.2 慢事务分析
sql复制-- 开启事务监控
SET GLOBAL innodb_status_output=ON;
SET GLOBAL innodb_status_output_locks=ON;
-- 查看引擎状态
SHOW ENGINE INNODB STATUS\G
6.3 性能模式配置
sql复制-- 开启事务监控
UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES'
WHERE NAME LIKE '%transaction%';
-- 查看事务统计
SELECT * FROM performance_schema.events_transactions_summary_global_by_event_name;
7. 真实案例:电商库存系统的优化之路
某跨境电商平台遇到这样的问题:
- 每天2000万次库存操作
- 高峰期更新冲突率高达15%
- 使用SELECT FOR UPDATE导致连接池耗尽
最终解决方案:
- 热点商品拆分为库存分片(product_code_hash%16)
- 每个分片使用Redis原子计数器做预扣减
- MySQL层使用批量乐观锁更新
- 异步对账补偿机制
优化后效果:
- 更新冲突率降至0.3%
- 数据库QPS降低60%
- 超卖投诉减少99%