引言
在购物平台中,扣库存是一个非常关键的操作,涉及到多个并发的操作,比如多个用户同时购买同一商品,或者同时购买多个商品等。在这种情况下,如果不加锁,可能会导致数据不一致、库存错误等问题。因此,当出现以下几种问题时,为了确保数据的一致性和准确性,需要加锁来处理扣库存操作。
- 并发访问问题: 在购物平台中,可能有多个用户同时尝试购买同一个商品,或者一个用户同时购买多个商品。如果没有加锁,这些并发的购买操作可能会同时减少库存,导致库存出现错误。
- 数据一致性: 扣库存操作是一个涉及多个步骤的复杂操作,可能包括检查库存是否足够、减少库存、生成订单等。如果这些步骤没有在一个原子性的操作中完成,可能会导致数据不一致的情况,例如库存被重复减少或订单生成错误。
- 避免超卖: 如果多个用户同时购买同一个商品,并且没有加锁来控制库存的扣减,可能会导致超卖现象,即实际库存不足的情况下仍然允许购买。
- 竞态条件: 在并发环境中,竞态条件可能会导致操作顺序的不确定性,从而导致错误的结果。通过加锁,可以防止不同的操作交叉执行,保证操作的有序性。
因此,加锁是为了确保购物平台在多个并发操作中保持数据的一致性、准确性和完整性。下面介绍以下如何在项目中使用悲观锁,乐观锁,redis分布式锁解决并发问题。
悲观锁
悲观锁是一种悲观的并发控制策略,它认为并发冲突很可能发生。在使用悲观锁时,事务在读取数据时会立即加锁,阻止其他事务对数据进行修改,直到该事务完成。悲观锁通常使用排他锁或共享锁来实现。隔离级别中的可串行化就是使用悲观锁的策略。例如你写的程序需要对商品库存进行自减操作,在分布式系统中,多个用户下单购买商品时,并发访问数据库扣库存,那么在一个扣库存的事务中就可以使用悲观锁,并且悲观锁一般需要和事务配合使用,若不使用事务,锁会在加锁那条查询语句执行完毕后自动释放,而查询之后扣库存的操作就没有持有锁。下面用gorm实现一下悲观锁
db.Transaction(func(tx *gorm.DB) error {
err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength:"UPDATE"}).
Model(&model.Stock{}).
Where("goods_id = ?", goodsId).
First(&data).Error
if err != nil {
return err
}
// 2. 校验;现有库存数>0 且 大于等于num
if data.Num-num < 0 {
return errno.ErrUnderstock
}
// 3. 扣减
data.Num -= num
// 保存
err = tx.WithContext(ctx).
Save(&data).Error // save更新所有字段! 97 -> 99 要保证更新的数据是准确的。
if err != nil {
zap.L().Error(
"reduceStock save failed",
zap.Int64("goods_id", goodsId),
)
return err
}
return nil
})
乐观锁
乐观锁是一种乐观的并发控制策略,它认为并发冲突不太常见。在使用乐观锁时,事务在读取数据时不会立即加锁,而是在更新数据时检查是否有其他事务对数据进行了修改。如果发现冲突,事务会回滚或者重新尝试。乐观锁通常通过使用版本号或时间戳等方式来实现。MVCC可以和乐观锁结合使用:在MVCC中,多版本记录的机制提供了一致性的数据快照,而乐观锁可以用来处理在写入时可能发生的并发冲突,当检测到冲突时,事务可以回滚或者重新尝试。 例如你写的程序需要对商品库存进行自减操作,在分布式系统中,多个用户下单购买商品时,并发访问数据库扣库存,那么在一个扣库存的事务中就可以使用乐观锁,在这里我们会让数据表中有个version字段,表示乐观锁版本号,查询数据时获取版本号,然后在更新数据时需要将查询时获得的版本号与当前数据库的版本号进行对比,如果一样就说明没有其他事务修改这条数据,就可以进行更新操作,下面用gorm实现一下乐观锁
func ReduceStock(ctx context.Context, goodsId, num int64) (*model.Stock, error) {
var (
date model.Stock
retry = 0
isSuccess false
)
for !isSuccess && retry < 20 {
err := db.WithContext(ctx).
Model(&model.Stock{}).
Where("goods_id = ?", goodsId).
First(&data).Error
if err != nil {
return nil, err
}
// 2. 校验;现有库存数>0 且 大于等于num
if data.Num-num < 0 {
return errno.ErrUnderstock
}
// 3. 扣减
data.Num -= num
n := db.WithContext(ctx).
Model(&model.Stock{}).
Where("goods_id = ? and version = ?",date.GoodsId,date.Version).
Updates(map[string] interface{}{
"goods_id": date.GoodsId,
"num": date.Num,
"version": date.Version + 1,
}).RowsAffected //updates操作需要判断数据库返回的受影响的行数来判断,因为给nil数据update也会成功,不会返回错误
if n < 1{
fmt.Printf("update err:%v\n", err)
retry++ //更新失败就重试
continue
}
// 成功更新数据
isSuccess = true
break
}
if !isSuccess {
return nil, errno.ErrReducestockFailer
}
return &data, nil
}
分布式锁
Redis可以用于实现分布式锁,其基本原理是利用Redis的单线程特性以及原子性操作来实现在分布式系统中的互斥访问。
实现分布式锁的常见方式之一是使用Redlock算法,该算法基于Redis的分布式特性,但需要在多个Redis实例之间达成共识,因此并不是绝对可靠。以下是基于这种思想的简要分布式锁实现方式:
获取锁: 当一个进程想要获得锁时,它尝试在Redis中设置一个特定的key-value对。这个key在整个分布式系统中是唯一的,充当锁的标识。
如果这个key之前不存在,那么它被设置成功,该进程获得了锁。
如果这个key之前已经存在(即已经有其他进程持有了锁),那么获取锁的请求可能会失败。这里有几种处理方式:
- 阻塞等待:进程可以选择在这个key上进行阻塞,直到锁被释放。这可能导致进程被长时间阻塞。
- 轮询重试:进程可以定期尝试获取锁,避免长时间的阻塞。但是这种方式可能会增加Redis的负载。
释放锁:当进程完成了它需要加锁的操作后,它会通过删除对应的key来释放锁,让其他进程有机会获得锁。
func ReduceStock(ctx context.Context, goodsId, num int64) (*model.Stock, error) {
// 1. 查询现有库存
var data model.Stock
// 创建key
mutexname := fmt.Sprintf("xx-stock-%d", goodsId)
// 创建锁
mutex := redis.Rs.NewMutex(mutexname)
// 获取锁
if err := mutex.Lock(); err != nil {
return nil, errno.ErrReducestockFailed
}
defer mutex.Unlock() // 释放锁
// 获取锁成功
// 开启事务
db.Transaction(func(tx *gorm.DB) error {
err := tx.WithContext(ctx).
Model(&model.Stock{}).
Where("goods_id = ?", goodsId).
First(&data).Error
if err != nil {
return err
}
// 2. 校验;现有库存数>0 且 大于等于num
if data.Num-num < 0 {
return errno.ErrUnderstock
}
// 3. 扣减
data.Num -= num
// 保存
err = tx.WithContext(ctx).
Save(&data).Error
if err != nil {
zap.L().Error(
"reduceStock save failed",
zap.Int64("goods_id", goodsId),
)
return err
}
return nil
})
return &data, nil
}