在分布式系统中,并发操作共享资源(如库存、订单)是常见场景,而 “超卖”“重复提交” 等问题往往源于缺乏跨节点的同步机制。本文从实际业务场景出发,详解如何通过普通锁、数据库悲观锁、乐观锁解决分布式并发问题,分析每种方案的原理、实现与适用场景,为你的业务选择提供参考。
一、分布式并发的 “老大难”:从超卖问题说起
在单机系统中,我们可以用synchronized
或ReentrantLock
保证临界区代码的互斥执行。但在分布式集群(多台服务器、多个进程)中,这些单机锁会失效 —— 比如两个不同节点的进程同时扣减同一件商品的库存,就可能导致 “超卖”:
场景:商品A库存为1,两个并发请求同时购买
1. 请求1查询库存:1件,足够
2. 请求2查询库存:1件,足够
3. 请求1扣减库存:库存变为0
4. 请求2扣减库存:库存变为-1(超卖)
问题的核心是:分布式系统中,多个节点无法通过单机锁感知彼此的操作。因此,我们需要一种跨节点的 “分布式锁” 机制,确保同一时间只有一个节点能操作共享资源。
二、普通锁的局限性:为什么全局锁不是答案?
面对并发问题,很多人第一反应是 “加锁”。但在分布式场景中,简单的 “全局锁” 可能适得其反。
1. 普通锁的 “看似可行” 与实际问题
以下是一段 “尝试用全局锁解决库存扣减” 的代码:
// 错误示例:全局锁导致性能瓶颈
var m *redsync.Mutex // 全局锁
func (*InventoryServer) TrySell(ctx context.Context, req *proto.SellInfo) (*emptypb.Empty, error) {
tx := global.DB.Begin()
m.Lock() // 所有请求都抢同一把锁
for _, goodInfo := range req.GoodsInfo {
// 查询库存、扣减库存...
}
tx.Commit()
m.Unlock()
return &emptypb.Empty{}, nil
}
问题分析:
- 全局锁会导致 “不同资源的请求互相阻塞”:比如请求 1 购买商品 A,请求 2 购买商品 B,本可以并行执行,却因全局锁被迫串行,性能骤降(并发量越高,瓶颈越明显);
- 分布式环境中,全局锁的 “持有者崩溃” 可能导致锁无法释放(需额外的超时机制)。
结论:普通锁(尤其是全局锁)仅适用于单机单进程场景,分布式系统中需要更精细的锁机制。
三、悲观锁:用数据库行锁实现 “强一致性”
悲观锁的核心思想是 “假设冲突一定会发生”,因此在操作资源前先加锁,阻止其他节点的并发操作。数据库的SELECT ... FOR UPDATE
是实现分布式悲观锁的常用方式。
图示:
1. 悲观锁的实现原理
MySQL 的FOR UPDATE
语句会对查询到的记录加行级排他锁,其他事务若要修改这些记录,必须等待锁释放(事务提交或回滚)。这种机制天然适合分布式场景:
- 所有节点都通过同一数据库操作共享资源,数据库的事务特性保证锁的互斥性;
- 行锁仅锁定目标记录(如商品 A 的库存记录),不影响其他记录(如商品 B),比全局锁更高效。
2. 用 GORM 实现悲观锁
结合库存扣减场景,用 GORM 实现悲观锁的代码如下:
// 悲观锁实现:基于MySQL的FOR UPDATE
func (*InventoryServer) TrySell(ctx context.Context, req *proto.SellInfo) (*emptypb.Empty, error) {
tx := global.DB.Begin() // 开启事务(必须在事务中使用FOR UPDATE)
for _, goodInfo := range req.GoodsInfo {
var inv model.Inventory
// 关键:用FOR UPDATE加行锁(GORM中通过Clauses实现)
// 锁定商品ID为goodInfo.GoodsId的库存记录
result := tx.Clauses(clause.Locking{Strength: "UPDATE"})
.Where("goods = ?", goodInfo.GoodsId)
.First(&inv)
// 检查库存记录是否存在
if result.RowsAffected == 0 {
tx.Rollback()
return nil, status.Errorf(codes.InvalidArgument, "商品%d无库存记录", goodInfo.GoodsId)
}
// 检查库存是否充足
if inv.Stocks < goodInfo.Num {
tx.Rollback()
return nil, status.Errorf(codes.ResourceExhausted, "商品%d库存不足", goodInfo.GoodsId)
}
// 扣减库存(此时记录已加锁,其他事务无法修改)
inv.Stocks -= goodInfo.Num
tx.Save(&inv)
}
tx.Commit() // 提交事务,释放行锁
return &emptypb.Empty{}, nil
}
3. 悲观锁的关键注意事项
- 必须在事务中使用:
FOR UPDATE
的锁仅在事务内有效,事务提交 / 回滚后自动释放;若在非事务中使用,MySQL 会立即释放锁,失去互斥效果。 - 依赖索引避免锁升级:
WHERE
条件必须命中索引(如goods
字段有索引),否则 MySQL 会将行锁升级为表锁,导致所有商品的库存操作都被阻塞(严重影响性能)。 - 控制事务范围:事务不宜过长(如避免在事务中调用外部接口),否则锁持有时间过长,导致其他请求阻塞超时。
4. 悲观锁的适用场景与优缺点
- 适用场景:并发冲突频繁(如秒杀场景)、对数据一致性要求极高(如金融交易)。
- 优点:实现简单,无需手动处理冲突,数据库自动保证互斥性。
- 缺点:加锁会导致并发性能下降(串行化执行),可能出现死锁(如两个事务互相等待对方的锁)。
四、乐观锁:用版本号实现 “无锁并发”
乐观锁的核心思想是 “假设冲突很少发生”,因此操作资源时不加锁,仅在提交时检查是否有并发修改,若有则重试。版本号机制是实现乐观锁的主流方式。
图示:
1. 乐观锁的实现原理
乐观锁通过给资源添加 “版本号”(version
)字段实现:
- 查询资源时,同时获取当前版本号(如
SELECT stocks, version FROM inventory WHERE goods = 1
); - 更新资源时,检查版本号是否与查询时一致(如
UPDATE inventory SET stocks = stocks - 1, version = version + 1 WHERE goods = 1 AND version = 5
); - 若版本号一致,更新成功(版本号 + 1);若不一致,说明有并发修改,更新失败,需重试。
2. 用 GORM 实现乐观锁
结合库存扣减场景,用 GORM 实现乐观锁的代码如下:
// 乐观锁实现:基于版本号机制
func (*InventoryServer) TrySell(ctx context.Context, req *proto.SellInfo) (*emptypb.Empty, error) {
tx := global.DB.Begin()
for _, goodInfo := range req.GoodsInfo {
var inv model.Inventory
// 1. 查询库存和当前版本号
if result := global.DB.Where("goods = ?", goodInfo.GoodsId).First(&inv); result.RowsAffected == 0 {
tx.Rollback()
return nil, status.Errorf(codes.InvalidArgument, "商品%d无库存记录", goodInfo.GoodsId)
}
// 2. 循环重试:直到更新成功或确认库存不足
for {
// 检查库存是否充足(内存中判断,非数据库)
if inv.Stocks < goodInfo.Num {
tx.Rollback()
return nil, status.Errorf(codes.ResourceExhausted, "商品%d库存不足", goodInfo.GoodsId)
}
// 3. 尝试更新:仅当版本号匹配时才生效
// 更新库存并将版本号+1
rowsAffected := tx.Model(&model.Inventory{})
.Where("goods = ? AND version = ?", goodInfo.GoodsId, inv.Version)
.Updates(map[string]interface{}{
"stocks": inv.Stocks - goodInfo.Num,
"version": inv.Version + 1,
}).RowsAffected
if rowsAffected > 0 {
// 更新成功,退出重试循环
break
}
// 更新失败(版本号不匹配),重新查询最新数据和版本号
global.DB.Where("goods = ?", goodInfo.GoodsId).First(&inv)
zap.S().Info("商品%d库存并发更新,重试...", goodInfo.GoodsId)
}
}
tx.Commit()
return &emptypb.Empty{}, nil
}
3. 乐观锁的关键注意事项
- 重试逻辑必不可少:当并发冲突时(
rowsAffected = 0
),必须重新查询最新数据并再次尝试,否则会丢失更新;重试次数需限制(避免无限循环)。 - 版本号字段的设计:版本号必须是自增的(如
INT
类型),且更新时必须 + 1,确保每次修改后版本号唯一。 - 不依赖数据库锁:乐观锁本质是 “逻辑锁”,不占用数据库锁资源,因此并发性能优于悲观锁。
4. 乐观锁的适用场景与优缺点
- 适用场景:并发冲突较少(如普通商品销售)、对性能要求高(不希望加锁阻塞)。
- 优点:无锁竞争,并发性能好;适合分布式系统(无需依赖数据库锁机制)。
- 缺点:实现较复杂(需处理重试逻辑);在冲突频繁的场景下,重试会导致性能下降(多次无效更新)。
五、三种锁方案的对比与选择
锁类型 | 核心思想 | 实现方式 | 并发性能 | 适用场景 | 代码复杂度 |
---|---|---|---|---|---|
普通锁 | 全局互斥 | 进程内锁(如 Mutex) | 低 | 单机单进程,资源冲突极少 | 低 |
悲观锁 | 假设冲突,先锁后操作 | MySQL FOR UPDATE | 中 | 并发冲突频繁,强一致性要求 | 中 |
乐观锁 | 假设无冲突,提交时检查 | 版本号机制 | 高 | 并发冲突少,性能要求高 | 高 |
选择建议:
- 若系统是单机部署,且并发量低,普通锁即可满足需求;
- 若分布式部署,且冲突频繁(如秒杀),优先用悲观锁(简单可靠);
- 若分布式部署,且冲突较少(如日常销售),优先用乐观锁(性能更优)。
六、分布式锁的更多可能
除了上述基于数据库的方案,分布式锁还有更灵活的实现方式,比如:
- 基于 Redis 的分布式锁:利用 Redis 的
SET NX
原子命令实现,性能极高,适合高并发场景; - 基于 ZooKeeper/etcd 的分布式锁:利用节点的顺序性和 Watcher 机制实现,可靠性强,适合对一致性要求极高的场景。
这些方案将在后续文章中详细讲解,它们共同构成了分布式系统中解决并发问题的完整工具箱。
总结:锁的本质是 “权衡”
从普通锁到悲观锁、乐观锁,分布式锁的演进本质是 “在一致性与性能之间找平衡”。没有 “万能锁”,只有 “最合适的锁”—— 理解业务场景中的并发冲突频率、数据一致性要求,才能选择最优的锁方案,既保证数据正确,又不牺牲性能。
如果这篇文章对大家有帮助可以点赞关注,你的支持就是我的动力😊!