前言
并发冲突是多用户系统的常见问题:两人同时编辑同一条数据,后提交的覆盖了先提交的。这篇给你3种并发控制策略的完整对比和PRD写法。
一、3种并发控制策略对比
| 策略 | 原理 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 乐观锁 | 提交时检查版本号 | 读多写少 | 性能高,无锁等待 | 冲突时需重试 |
| 悲观锁 | 操作前先加锁 | 写多读少 | 强一致性 | 性能低,可能死锁 |
| 最终一致性 | 允许短暂不一致 | 分布式系统 | 高可用 | 业务复杂度高 |
二、乐观锁(推荐)
原理
每条记录有一个版本号(version),更新时检查版本号是否一致。如果版本号不匹配,说明数据已被他人修改,返回冲突。
适用场景:读多写少,冲突频率低(如编辑商品信息、编辑用户资料等)
优点:性能高,无锁等待,适合高并发场景
缺点:冲突时需要重试,用户体验稍差
实现步骤
- 数据库设计:在表中添加version字段(整数类型,默认值为1)
- 查询数据:查询时返回version字段
- 提交更新:更新时带上version字段作为条件
- 检查结果:如果更新影响行数=0,说明版本号不匹配,返回冲突
- 用户提示:提示用户刷新页面,重新编辑
PRD写法
场景:编辑商品信息 并发控制策略:乐观锁(版本号机制) 【数据库设计】 表结构:product表添加version字段(整数类型,默认值为1) 示例:id, name, price, version 【实现方式】 1. 查询商品时,返回版本号(version) SELECT id, name, price, version FROM product WHERE id = ? 2. 提交更新时,带上版本号 UPDATE product SET name=?, price=?, version=version+1 WHERE id=? AND version=? 3. 检查更新结果 - 如果更新影响行数>0,说明更新成功 - 如果更新影响行数=0,说明版本号不匹配,返回冲突 【用户提示】 冲突提示:数据已被他人更新,请刷新后重试 恢复路径:刷新页面,重新编辑(不要自动重试) 【技术实现】 // 伪代码 function updateProduct(productId, newName, newPrice, version) { const affectedRows = db.execute( "UPDATE product SET name=?, price=?, version=version+1 WHERE id=? AND version=?", [newName, newPrice, productId, version] ); if (affectedRows === 0) { return { success: false, message: "数据已被他人更新,请刷新后重试" }; } return { success: true, message: "更新成功" }; }真实案例
场景:两人同时编辑商品价格
时间线: 10:00:00 - 用户A查询商品,price=100, version=1 10:00:01 - 用户B查询商品,price=100, version=1 10:00:05 - 用户A提交:price=120, version=1 → 成功,version变为2 10:00:06 - 用户B提交:price=110, version=1 → 失败(version已变为2) 10:00:07 - 系统提示用户B:数据已被他人更新,请刷新后重试 10:00:08 - 用户B刷新页面,看到price=120, version=2 10:00:10 - 用户B重新编辑:price=110, version=2 → 成功,version变为3 结果:用户A的修改(price=120)被保留,用户B的修改(price=110)在刷新后成功提交最佳实践
- 版本号字段:使用整数类型,每次更新自动+1
- 版本号初始化:新记录version默认为1
- 版本号检查:更新时必须检查version,不能跳过
- 冲突处理:不要自动重试,提示用户刷新页面重新编辑
- 前端提示:冲突时明确提示"数据已被他人更新",不要只说"更新失败"
常见错误
- 错误1:更新时不检查version,导致覆盖他人修改
❌ 错误:UPDATE product SET name=? WHERE id=? ✅ 正确:UPDATE product SET name=?, version=version+1 WHERE id=? AND version=? - 错误2:冲突时自动重试,导致用户修改丢失
❌ 错误:冲突时自动重试,可能导致用户修改丢失 ✅ 正确:冲突时提示用户刷新页面,重新编辑 - 错误3:版本号字段类型错误,导致精度问题
❌ 错误:version字段使用浮点数类型 ✅ 正确:version字段使用整数类型(INT或BIGINT)
三、悲观锁
原理
操作前先加锁(SELECT FOR UPDATE),其他人只能等待锁释放后才能操作。保证同一时间只有一个请求能修改数据。
适用场景:写多读少,冲突频率高(如秒杀扣减库存、抢票等)
优点:强一致性,不会丢失更新
缺点:性能低,可能死锁,不适合高并发场景
实现步骤
- 开启事务:在事务中执行操作
- 加锁查询:使用SELECT FOR UPDATE加锁
- 执行业务逻辑:在锁保护下执行业务逻辑
- 提交事务:提交事务时自动释放锁
- 异常处理:如果发生异常,回滚事务,释放锁
PRD写法
场景:秒杀扣减库存 并发控制策略:悲观锁(SELECT FOR UPDATE) 【实现方式】 1. 开启事务 BEGIN TRANSACTION; 2. 查询库存时加锁 SELECT * FROM stock WHERE id=? FOR UPDATE; (其他请求会等待,直到锁释放) 3. 检查库存 IF qty > 0 THEN 扣减库存:UPDATE stock SET qty=qty-1 WHERE id=? ELSE 返回"库存不足" END IF 4. 提交事务,释放锁 COMMIT; (如果发生异常,回滚事务:ROLLBACK;) 【用户提示】 库存不足:库存不足,请选择其他商品 恢复路径:无(库存已售罄) 【技术实现】 // 伪代码 function deductStock(productId) { try { db.beginTransaction(); // 加锁查询 const stock = db.query("SELECT * FROM stock WHERE id=? FOR UPDATE", productId); if (stock.qty <= 0) { db.rollback(); return { success: false, message: "库存不足" }; } // 扣减库存 db.execute("UPDATE stock SET qty=qty-1 WHERE id=?", productId); db.commit(); return { success: true, message: "扣减成功" }; } catch (error) { db.rollback(); return { success: false, message: "扣减失败,请重试" }; } }真实案例
场景:秒杀活动,1000个用户同时抢购最后10件商品
时间线(使用悲观锁): 10:00:00 - 用户A查询库存,加锁,qty=10 10:00:00 - 用户B查询库存,等待锁... 10:00:00 - 用户C查询库存,等待锁... 10:00:01 - 用户A扣减库存,qty=9,提交事务,释放锁 10:00:01 - 用户B获得锁,查询库存,qty=9 10:00:01 - 用户C继续等待锁... 10:00:02 - 用户B扣减库存,qty=8,提交事务,释放锁 10:00:02 - 用户C获得锁,查询库存,qty=8 ... 10:00:10 - 用户J获得锁,查询库存,qty=0 10:00:10 - 用户J返回"库存不足" 10:00:10 - 用户K获得锁,查询库存,qty=0 10:00:10 - 用户K返回"库存不足" 结果:只有10个用户成功购买,其他用户收到"库存不足"提示 (如果使用乐观锁,可能会有超卖问题)最佳实践
- 锁粒度:尽量缩小锁的范围,只锁必要的行
- 锁超时:设置锁超时时间(如30s),避免死锁
- 事务时间:尽量缩短事务时间,减少锁持有时间
- 死锁检测:数据库自动检测死锁,自动回滚其中一个事务
- 索引优化:WHERE条件使用索引,减少锁范围
常见错误
- 错误1:忘记提交事务,导致锁一直持有
❌ 错误:加锁后忘记提交事务,导致锁一直持有 ✅ 正确:加锁后立即执行业务逻辑,然后提交事务 - 错误2:锁范围过大,导致性能问题
❌ 错误:SELECT * FROM stock FOR UPDATE(锁整个表) ✅ 正确:SELECT * FROM stock WHERE id=? FOR UPDATE(只锁一行) - 错误3:没有设置锁超时,导致死锁
❌ 错误:没有设置锁超时,死锁时一直等待 ✅ 正确:设置锁超时时间(如30s),超时自动释放
死锁处理
死锁场景:两个事务互相等待对方释放锁
时间线(死锁场景): 10:00:00 - 事务A:锁定商品1,等待商品2 10:00:01 - 事务B:锁定商品2,等待商品1 10:00:02 - 死锁发生,数据库自动检测并回滚其中一个事务 【预防措施】 1. 按相同顺序加锁(如按id排序) 2. 尽量缩小锁范围 3. 设置锁超时时间 4. 使用索引,减少锁范围四、最终一致性
原理
允许短暂不一致,通过消息队列/事件驱动最终达到一致。适合分布式系统,保证高可用性。
适用场景:分布式系统,允许短暂不一致(如订单支付后更新库存、用户注册后发送欢迎邮件等)
优点:高可用,性能好,适合分布式系统
缺点:业务复杂度高,需要处理消息丢失、重复消费等问题
实现步骤
- 业务操作:执行业务操作(如订单支付)
- 发送消息:发送消息到消息队列(如RabbitMQ、Kafka)
- 消费消息:消费者服务消费消息,执行业务逻辑(如扣减库存)
- 重试机制:如果消费失败,重试3次
- 人工介入:如果重试3次仍失败,发送告警,人工介入
PRD写法
场景:订单支付后更新库存 并发控制策略:最终一致性(消息队列) 【实现方式】 1. 订单支付成功后,发送消息到队列 - 消息内容:{orderId, productId, quantity} - 消息队列:RabbitMQ / Kafka - 消息持久化:是(防止消息丢失) 2. 库存服务消费消息,扣减库存 - 消费者:库存服务 - 消费逻辑:扣减库存 - 幂等性:使用订单号作为幂等键,防止重复消费 3. 如果扣减失败,重试3次 - 重试间隔:1秒、3秒、5秒(指数退避) - 重试次数:3次 - 重试失败:发送告警,人工介入 4. 仍失败则人工介入 - 告警方式:邮件、短信、钉钉 - 处理方式:人工检查,手动处理 【用户提示】 支付成功:支付成功,库存更新中(预计1-2分钟) 恢复路径:等待系统处理,如有问题联系客服 【技术实现】 // 伪代码(订单服务) function payOrder(orderId) { // 1. 支付订单 const payment = processPayment(orderId); if (!payment.success) { return { success: false, message: "支付失败" }; } // 2. 发送消息到队列 const message = { orderId: orderId, productId: payment.productId, quantity: payment.quantity }; messageQueue.send("stock.deduct", message); return { success: true, message: "支付成功,库存更新中" }; } // 伪代码(库存服务) function consumeStockMessage(message) { try { // 1. 检查幂等键(防止重复消费) const existingDeduction = redis.get(`deduct:${message.orderId}`); if (existingDeduction) { return { success: true, isDuplicate: true }; } // 2. 扣减库存 const result = deductStock(message.productId, message.quantity); if (!result.success) { throw new Error("库存扣减失败"); } // 3. 保存幂等键 redis.set(`deduct:${message.orderId}`, { productId: message.productId }, 3600); return { success: true }; } catch (error) { // 重试3次 if (retryCount < 3) { retryCount++; setTimeout(() => consumeStockMessage(message), retryCount * 1000); } else { // 发送告警 sendAlert("库存扣减失败", message); } } }真实案例
场景:订单支付后更新库存、发送短信、更新积分
时间线(最终一致性): 10:00:00 - 用户支付订单,订单状态变为"已支付" 10:00:01 - 订单服务发送3条消息到队列: - 消息1:扣减库存 - 消息2:发送短信 - 消息3:更新积分 10:00:02 - 库存服务消费消息1,扣减库存(成功) 10:00:03 - 短信服务消费消息2,发送短信(成功) 10:00:04 - 积分服务消费消息3,更新积分(失败,重试) 10:00:05 - 积分服务重试消息3,更新积分(成功) 结果:订单支付成功,库存已扣减,短信已发送,积分已更新 (虽然积分更新延迟了3秒,但最终达到一致)最佳实践
- 消息持久化:消息必须持久化,防止消息丢失
- 幂等性:消费者必须支持幂等,防止重复消费
- 重试机制:消费失败时重试,使用指数退避策略
- 死信队列:重试3次仍失败,发送到死信队列,人工处理
- 监控告警:监控消息积压、消费延迟,及时告警
常见错误
- 错误1:消息没有持久化,服务重启后消息丢失
❌ 错误:消息不持久化,服务重启后消息丢失 ✅ 正确:消息必须持久化,防止消息丢失 - 错误2:消费者不支持幂等,重复消费导致数据错误
❌ 错误:消费者不支持幂等,重复消费导致库存扣减2次 ✅ 正确:消费者支持幂等,使用订单号作为幂等键 - 错误3:没有重试机制,消费失败后消息丢失
❌ 错误:消费失败后直接丢弃消息 ✅ 正确:消费失败后重试3次,仍失败则发送到死信队列
不适合的场景
最终一致性不适合以下场景:
- 金额操作:支付、退款等金额操作必须强一致性
- 库存扣减:秒杀、抢票等库存扣减必须强一致性
- 账户余额:账户余额变更必须强一致性
适合的场景:
- 订单支付后更新库存:允许短暂不一致,最终达到一致
- 用户注册后发送邮件:允许短暂延迟,最终发送成功
- 数据同步:主从数据库同步,允许短暂延迟
五、选择策略的决策树
根据业务场景选择合适的并发控制策略,以下是决策流程:
Q1:是否允许短暂不一致? ├─ 是 → 最终一致性(消息队列) │ └─ 适用场景:订单支付后更新库存、用户注册后发送邮件等 └─ 否 → Q2(必须强一致性) Q2:冲突频率高吗? ├─ 高(写多读少,如秒杀、抢票)→ 悲观锁(SELECT FOR UPDATE) │ └─ 优点:强一致性,不会丢失更新 │ └─ 缺点:性能低,可能死锁 └─ 低(读多写少,如编辑商品、编辑用户)→ 乐观锁(版本号) └─ 优点:性能高,无锁等待 └─ 缺点:冲突时需要重试策略对比表
| 策略 | 适用场景 | 性能 | 一致性 | 复杂度 |
|---|---|---|---|---|
| 乐观锁 | 读多写少(编辑商品、编辑用户) | 高 | 强一致性 | 低 |
| 悲观锁 | 写多读少(秒杀、抢票) | 低 | 强一致性 | 中 |
| 最终一致性 | 分布式系统(订单支付后更新库存) | 高 | 最终一致性 | 高 |
实际项目案例
案例1:电商系统
- 编辑商品信息:乐观锁(读多写少,冲突频率低)
- 秒杀扣减库存:悲观锁(写多读少,冲突频率高)
- 订单支付后更新库存:最终一致性(允许短暂不一致)
案例2:内容管理系统
- 编辑文章:乐观锁(读多写少,冲突频率低)
- 发布文章:悲观锁(写多读少,冲突频率高)
- 文章发布后发送通知:最终一致性(允许短暂延迟)
案例3:金融系统
- 账户余额变更:悲观锁(必须强一致性,不能丢失更新)
- 交易记录查询:不需要锁(只读操作)
- 交易后发送短信:最终一致性(允许短暂延迟)
六、常见错误与陷阱
错误1:乐观锁更新时不检查version
问题:更新时不检查version,导致覆盖他人修改。
❌ 错误示例: UPDATE product SET name=? WHERE id=? 问题:不检查version,可能覆盖他人修改 ✅ 正确示例: UPDATE product SET name=?, version=version+1 WHERE id=? AND version=? 检查version,如果version不匹配,更新失败错误2:悲观锁忘记提交事务
问题:加锁后忘记提交事务,导致锁一直持有,其他请求一直等待。
❌ 错误示例: BEGIN TRANSACTION; SELECT * FROM stock WHERE id=? FOR UPDATE; UPDATE stock SET qty=qty-1 WHERE id=?; -- 忘记提交事务,锁一直持有 ✅ 正确示例: BEGIN TRANSACTION; SELECT * FROM stock WHERE id=? FOR UPDATE; UPDATE stock SET qty=qty-1 WHERE id=?; COMMIT; -- 提交事务,释放锁错误3:最终一致性用于强一致性场景
问题:金额操作、库存扣减等强一致性场景使用最终一致性,导致数据错误。
❌ 错误示例: 支付扣款使用最终一致性(消息队列) 问题:支付成功后,库存可能还没扣减,导致超卖 ✅ 正确示例: 支付扣款使用悲观锁(强一致性) 库存扣减必须在支付时同步完成,不能异步处理错误4:乐观锁冲突时自动重试
问题:乐观锁冲突时自动重试,可能导致用户修改丢失。
❌ 错误示例: 乐观锁冲突时自动重试,使用旧数据重新提交 问题:用户的新修改可能丢失 ✅ 正确示例: 乐观锁冲突时提示用户刷新页面,重新编辑 不要自动重试,让用户看到最新数据后重新编辑错误5:悲观锁范围过大
问题:悲观锁范围过大,锁整个表或大量行,导致性能问题。
❌ 错误示例: SELECT * FROM stock FOR UPDATE 问题:锁整个表,其他请求全部等待,性能极差 ✅ 正确示例: SELECT * FROM stock WHERE id=? FOR UPDATE 只锁一行,其他请求可以正常访问其他行错误6:最终一致性没有幂等性
问题:消息队列消费者不支持幂等,重复消费导致数据错误。
❌ 错误示例: 消费者不支持幂等,重复消费导致库存扣减2次 问题:消息重复消费时,库存扣减多次 ✅ 正确示例: 消费者支持幂等,使用订单号作为幂等键 重复消费时,检查幂等键,已处理则直接返回七、FAQ
Q1:乐观锁冲突了怎么办?
答:提示用户刷新页面重新编辑。不要自动重试,因为用户的修改可能已经过时,自动重试可能导致用户修改丢失。
处理流程:
- 检测到version不匹配,返回冲突错误
- 前端提示用户"数据已被他人更新,请刷新后重试"
- 用户刷新页面,获取最新数据和version
- 用户重新编辑,使用最新的version提交
Q2:悲观锁会死锁吗?
答:会。两个事务互相等待对方释放锁时会发生死锁。
预防措施:
- 按相同顺序加锁(如按id排序)
- 尽量缩小锁范围
- 设置锁超时时间(如30s),超时自动释放
- 使用索引,减少锁范围
处理方式:数据库自动检测死锁,自动回滚其中一个事务。
Q3:最终一致性适合所有场景吗?
答:不适合。以下场景必须使用强一致性:
- 金额操作:支付、退款等金额操作必须强一致性
- 库存扣减:秒杀、抢票等库存扣减必须强一致性
- 账户余额:账户余额变更必须强一致性
适合的场景:
- 订单支付后更新库存:允许短暂不一致,最终达到一致
- 用户注册后发送邮件:允许短暂延迟,最终发送成功
- 数据同步:主从数据库同步,允许短暂延迟
Q4:乐观锁和悲观锁哪个性能更好?
答:乐观锁性能更好,因为不需要加锁,无锁等待。但冲突时需要重试,用户体验稍差。
性能对比:
- 乐观锁:读多写少场景,性能高,无锁等待
- 悲观锁:写多读少场景,性能低,有锁等待
选择建议:根据冲突频率选择,冲突频率低用乐观锁,冲突频率高用悲观锁。
Q5:如何监控并发控制效果?
答:监控以下指标:
- 乐观锁冲突率:version不匹配的次数 / 总更新次数
- 悲观锁等待时间:锁等待的平均时间
- 死锁次数:死锁发生的次数
- 最终一致性延迟:消息消费的平均延迟
告警阈值:
- 乐观锁冲突率 > 10%:考虑使用悲观锁
- 悲观锁等待时间 > 1秒:考虑优化锁范围
- 死锁次数 > 0:立即排查死锁原因
- 最终一致性延迟 > 5分钟:检查消息队列积压
Q6:分布式系统如何保证强一致性?
答:分布式系统保证强一致性有以下方案:
- 两阶段提交(2PC):保证所有节点同时提交或回滚,但性能低
- 三阶段提交(3PC):改进2PC,减少阻塞时间,但复杂度高
- 分布式锁:使用Redis或Zookeeper实现分布式锁,保证同一时间只有一个请求执行
- 最终一致性:允许短暂不一致,通过消息队列最终达到一致
选择建议:根据业务需求选择,强一致性场景用分布式锁,允许短暂不一致用最终一致性。
Q7:如何测试并发控制?
答:使用压力测试工具(如JMeter、LoadRunner)模拟并发请求:
- 乐观锁测试:模拟多个用户同时编辑同一条数据,检查是否覆盖他人修改
- 悲观锁测试:模拟多个用户同时扣减库存,检查是否超卖
- 最终一致性测试:模拟消息重复消费,检查是否幂等
测试指标:
- 并发用户数:100、500、1000
- 请求成功率:> 99%
- 数据一致性:100%(不能丢失更新)
- 响应时间:< 1秒
工具入口
生成并发控制思维导图