从超卖到数据一致(解决并发问题):分布式系统中锁的演进与实践

#新星杯·14天创作挑战营·第13期#

在分布式系统中,并发操作共享资源(如库存、订单)是常见场景,而 “超卖”“重复提交” 等问题往往源于缺乏跨节点的同步机制。本文从实际业务场景出发,详解如何通过普通锁、数据库悲观锁、乐观锁解决分布式并发问题,分析每种方案的原理、实现与适用场景,为你的业务选择提供参考。

一、分布式并发的 “老大难”:从超卖问题说起

在单机系统中,我们可以用synchronizedReentrantLock保证临界区代码的互斥执行。但在分布式集群(多台服务器、多个进程)中,这些单机锁会失效 —— 比如两个不同节点的进程同时扣减同一件商品的库存,就可能导致 “超卖”:

场景:商品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)字段实现:

  1. 查询资源时,同时获取当前版本号(如SELECT stocks, version FROM inventory WHERE goods = 1);
  2. 更新资源时,检查版本号是否与查询时一致(如UPDATE inventory SET stocks = stocks - 1, version = version + 1 WHERE goods = 1 AND version = 5);
  3. 若版本号一致,更新成功(版本号 + 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 机制实现,可靠性强,适合对一致性要求极高的场景。

这些方案将在后续文章中详细讲解,它们共同构成了分布式系统中解决并发问题的完整工具箱。

总结:锁的本质是 “权衡”

从普通锁到悲观锁、乐观锁,分布式锁的演进本质是 “在一致性与性能之间找平衡”。没有 “万能锁”,只有 “最合适的锁”—— 理解业务场景中的并发冲突频率、数据一致性要求,才能选择最优的锁方案,既保证数据正确,又不牺牲性能。


如果这篇文章对大家有帮助可以点赞关注,你的支持就是我的动力😊!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值