6.一人一单超卖问题和分布式锁

目录

一人一单超卖

单机的一人一单问题

测试结果

问题原因

解决方案

集群下的一人一单问题 

分布式锁

Redis分布式锁的实现

1.基于 SETNX 的锁初步实现

2.锁的防误删实现

3.解锁的原子化实现

我们实现的Redis分布式锁就完善了吗?

使用redsync

redsync的内部逻辑


项目地址:https://github.com/liwook/PublicReview

一人一单超卖

很多时候有些商家要拿出一部分好用且贵重的产品来做促销引流,而将该物品进行低价售出或者出售一些折扣力度很大的优惠卷,此时为了防止有人恶意低买高卖以及保证引流的效果,我们要保证一个用户只能买一次,也就一人一单

一人一单超卖问题可以分成两种,单机的和集群下的。

单机的一人一单问题

我们的流程就有了改变。我们需要根据卷id和用户id来查询订单,若不存在则可以购买

代码:

func createVoucherOrder(c *gin.Context, req seckillResquest) {
	//添加 判断是否是第一单
	VoucherOrder := query.TbVoucherOrder
	val, err := VoucherOrder.Where(VoucherOrder.VoucherID.Eq(uint64(req.VoucherId)), VoucherOrder.UserID.Eq(uint64(req.UserId))).Find()
	if err != nil {
		slog.Error("seckill voucher bad", "err", err)
		code.WriteResponse(c, code.ErrDatabase, nil)
		return
	}
	if len(val) > 0 {
		code.WriteResponse(c, code.ErrDatabase, "当前用户已购买过该优惠卷")
		return
	}

	order := model.TbVoucherOrder{
		ID:        NextId("order"),
		VoucherID: uint64(req.VoucherId),
		UserID:    uint64(req.UserId),
	}

	//处理两张表(订单表,秒杀卷表),使用事务
	.............................
}

测试结果

库存100,模拟开启200个用户发起请求,都是为同一个用户去抢购。

只需要简单修改之前的测试代码

// 为了测试一人一单,所以 http请求的userid都为1, 表示都是为同一个用户抢购
func main() {
	for i := 0; i < *num; i++ {
		go func(i int) {
			//自己根据自己的需求修改VoucherId
			data := seckillBody{VoucherId: 11, UserId: 1}
		    ..........................
		}(i)
	}
}


 

通过测试,发现并没有达到我们想象中的目标,发现一个用户居然能够购买8次。这说明还是存在超卖问题!

问题原因

出现这个问题的原因和前面库存为负数数的情况是一样的。

  • 线程1查询用户A是否有订单,用户A还没有订单,那就准备下单;
  • 此时线程2也查询用户A是否有订单,由于线程1还没有完成下单操作,线程2同样发现用户A未下单,也准备下单。
  • 这样明明同一个用户只能下一单,结果下了两单,也就出现了超卖问题。

解决方案

也是加锁:

  1. 悲观锁
  2. 乐观锁

 乐观锁需要判断数据是否修改,而现在的情况是判断当前是否存在。我们现在是想要新增数据,所以是无法判断数据是否有过修改,所以无法像解决库存超卖一样使用CAS机制。

但是可以使用版本号法,但是版本号法需要新增一个字段,那为了不用修改数据库表,使用悲观锁解决超卖问题。

使用悲观锁

 那现在我们就需要在查询订单前加锁,插入订单后释放锁。那加锁会有两种情况。用个图来说明下。

左边的是所有线程都用同一个锁,这样是不符合我们的想法。只有同一个用户的才用同一把锁锁住,这样性能会好不少。 

那我们使用sync.Map,用于存储用户 ID 和对应的sync.Mutex锁,可以保护并发访问互斥锁。

在internal/shopservice中添加mutex.go文件。

type UserLock struct {
	locks sync.Map
}

func NewUserLock() *UserLock {
	return &UserLock{}
}

var UserLockMap = NewUserLock()

func (ul *UserLock) Lock(userID int) {
	var lock *sync.Mutex
	value, ok := ul.locks.Load(userID)
	if ok {
		lock = value.(*sync.Mutex)
	} else {
		lock = &sync.Mutex{} //没有的话,就需要创建一个新锁
		ul.locks.Store(userID, lock)
	}
	lock.Lock()
}

func (ul *UserLock) Unlock(userID int) {
	value, ok := ul.locks.Load(userID)
	if ok {
		lock := value.(*sync.Mutex)
		lock.Unlock()
	}
}

在SeckillVoucher函数中使用,锁住seckillVoucher函数。

要注意的是:要在事务提交后才能释放锁,因为事务提交后,订单才会创建成功

// 秒杀
// post /voucher/seckill
func SeckillVoucher(c *gin.Context) {
    ............................

	//1.查询该优惠卷
	seckill := query.TbSeckillVoucher
	voucher, err := seckill.Where(seckill.VoucherID.Eq(uint64(req.VoucherId))).Find()
	if err != nil {
		code.WriteResponse(c, code.ErrDatabase, nil)
		return
	}
	if len(voucher) == 0 {
		code.WriteResponse(c, code.ErrDatabase, "秒杀卷不存在")
		return
	}
	//2.判断秒杀卷是否合法,开始结束时间,库存
	now := time.Now()
	if voucher[0].BeginTime.After(now) || voucher[0].EndTime.Before(now) {
		code.WriteResponse(c, code.ErrValidation, "不在秒杀时间范围内")
		return
	}
	if voucher[0].Stock < 1 {
		code.WriteResponse(c, code.ErrValidation, "秒杀卷已被抢空")
		return
	}

    //使用锁
	UserLockMap.Lock(req.UserId)
	defer UserLockMap.Unlock(req.UserId)

	createVoucherOrder(c, req)
}

注意:使用 sync.Map还是会有问题,因为其中的元素在长久不被使用的时候是不会自动释放的,这样所占用的内存会原来越多,不算好。或者可以试试每次释放锁时候都删除该key。

测试:

开启100个协程模拟100个人为同一个用户抢购。结果显示只有一人抢购成功。查看数据库也是如此。

集群下的一人一单问题 

 当我们的业务越做越大,那并发量会越来越高,单台机器就承受不住,我们就需要多个机器部署该程序,这样就形成了集群。那在集群模式中出现了一人一单超卖问题了。

来简单部署个集群模式的。

在nginx配置文件中修改或添加下面的参数。 

upstream backend {
        server 127.0.0.1:20000;
        server 127.0.0.1:20001;
    }
    server {
        listen 80;
        location / {
            proxy_pass http://backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    }

 go build -o server main.go编译成可执行文件server。新开窗口,都到执行目录,分别执行./server -p=20000,./server -p=20001。和启动nginx。

1个进程中只有一个协程能成功购买,这是符合单机情况的。但是在集群情况下就出现问题,有两台机器,那就是一共开启了2个server进程,就有2个协程成功购买。那是因为集群情况下同一个用户的锁是不同的。不同进程之间的锁是没有关联的。只有在同一个进程中的锁,同一个用户id的才是一样的。 

那这个该怎么解决呢?可以使用分布式锁

分布式锁

满足分布式系统或集群模式下多进程可见并且互斥的锁。

核心思想就是让大家都使用同一把锁锁住线程。

满足条件:

  • 可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
  • 互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
  • 高可用:程序不易崩溃,时时刻刻都保证较高的可用性
  • 高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
  • 安全性:安全也是程序中必不可少的一环。

常见的分布式锁有三种:

  • 基于关系数据库:可以利用数据库的事务特性和唯一索引来实现分布式锁。通过向数据库插入一条具有唯一约束的记录作为锁,其他进程在获取锁时会受到数据库的并发控制机制限制。
  • 基于缓存(如Redis):使用分布式缓存服务(如Redis)提供的原子操作来实现分布式锁。通过将锁信息存储在缓存中,其他进程可以通过检查缓存中的锁状态来判断是否可以获取锁。
  • 基于ZooKeeper:ZooKeeper是一个分布式协调服务,可以用于实现分布式锁。通过创建临时有序节点,每个请求都会尝试创建一个唯一的节点,并检查自己是否是最小节点,如果是,则表示获取到了锁。

这里我们使用Redis来实现分布式锁。 

Redis分布式锁的实现

我写了两篇文章来讲解如何实现Redis分布式锁,详情可以查看。

Go语言实现Redis分布式锁

Go语言实现Redis分布式锁2

 Redis分布式锁主要是利用SETNX 命令。

该命令用于在Redis中设置某个不存在的键的值。如果该键不存在,则设置成功,如果该键存在,则设置失败,不作任何动作。基于此可以实现一种简单的抢占机制。

那接着容易想到一个问题,要是该线程突然挂掉了,那这个锁就一直不能释放,其他线程就不能获取到该锁,这就形成了死锁。所以,我们需要利用锁超时时间,到期释放锁机制来解决这个问题。

1.基于 SETNX 的锁初步实现

 创建pkg/redislock目录,并在该目录创建redislock.go文件。创建lock结构体,添加加锁解锁

结构体RedisLock有成员key,过期时间expire,连接的redis客户端redisCli。

var (
	defaultExpireTime = 5 * time.Second
)
 
type RedisLock struct {
	key      string
	expire   time.Duration
	redisCli *redis.Client
}
 
// expire: 锁的过期时间,为0则使用默认过期时间
func NewRedisLock(cli *redis.Client, key string, expire time.Duration) *RedisLock {
	if expire == 0 {
		expire = defaultExpireTime
	}

	return &RedisLock{
		key:      key,
		expire:   expire,
		redisCli: cli,
	}
}

加锁

func (lock *RedisLock) Lock() (bool, error) {
	return lock.redisCli.SetNX(context.Background(), lock.key, "111111", lock.expire).Result()
}

上面的加锁是一种简单的方法,非阻塞的,一有结果就直接返回,也不再二次尝试的。lock.redisCli.SetNX(lock.key, "111111", lock.expire) 这行代码本质上执行了如下Redis操作命令:

set key 111111 ex 5 nx

解锁

// lock.redisCli.Del(lock.key)对Redis中的lock.key进行删除.当删除后,竞争者才有机会对该键进行 SETNX。
func (lock *RedisLock) Unlock() error {
	res, err := lock.redisCli.Del(context.Background(), lock.key).Result()
	if err != nil {
		return err
	}
	if res != 1 {
		return fmt.Errorf("can not unlock because del result not is one")
	}
	return nil
}

2.锁的防误删实现

上面的写法会存在个问题,想象一个场景:这个键过期了,但是其持有者线程A仍未完成任务。但这时该键就已经没有,线程B就去获取锁,获取成功了。这时候线程A完成了任务,就去删除键。而这时键是被线程B持有的,而线程A却可以去删除,这就会出了问题。

所以,这里要解决的是只有自己才能删除自己创建的锁。为了解决这种问题,持有者可以给锁添加一个唯一标识,使之只能删除自己的锁。因此需要完善一下加解锁操作:

在结构体RedisLock中添加字段Id,这是唯一标识符,用uuid表示。在创建锁时候,需要创建出uuid,并赋值给字段Id。

type RedisLock struct {
	key      string
	expire   time.Duration
	Id       string //锁的标识,新添加的,也即是键的value
	redisCli *redis.Client
}
 
// expire: 锁的过期时间,为0则使用默认过期时间
func NewRedisLock(cli *redis.Client, key string, expire time.Duration) *RedisLock {
	if expire == 0 {
		expire = defaultExpireTime
	}
	id := strings.Join(strings.Split(uuid.New().String(), "-"), "")
	return &RedisLock{
		key:      key,
		expire:   expire,
		Id:       id,
		redisCli: cli,
	}
}

// 加锁, 设置了键的value
func (lock *RedisLock) Lock() (bool, error) {
	return lock.redisCli.SetNX(context.Background(), lock.key, lock.Id, lock.expire).Result()
}
 
// 解锁,锁的误删除实现
func (lock *RedisLock) Unlock() error {
	//获取锁并进行判断该锁是否是自己的
	val, err := lock.redisCli.Get(context.Background(), lock.key).Result()
	if err != nil {
		fmt.Println("lock not exit")
		return err
	}
	if val == "" || val != lock.Id {
		return fmt.Errorf("lock not belong to myself")
	}

	//进行删除锁
	res, err := lock.redisCli.Del(context.Background(), lock.key).Result()
	if err != nil {
		return err
	}
	if res != 1 {
		return fmt.Errorf("can not unlock because del result not is one")
	}
	return nil
}

3.解锁的原子化实现

上面的解锁操作中,仍然是存在一个问题的:在确认当前锁是自己的锁后,和删除锁之前,这个时间段,中途可能会进行阻塞,这个过程中,锁恰巧过期释放,且被其他竞争者抢占。那就有可能会删除了其他竞争者的锁。这是不妥的。

我们要把这两个操作变成原子操作,将整个解锁过程原子化,使得在解锁期间,其他竞争者的任何操作不能被Redis执行。

Redis中可以使用Lua脚本把一系列操作变成原子操作。

func (lock *RedisLock) Unlock() error {
	script := redis.NewScript(LauCheckAndDelete)
	res, err := script.Run(context.Background(), lock.redisCli, []string{lock.key}, lock.Id).Int64()
	if err != nil {
		return err
	}
	if res != 1 {
		return fmt.Errorf("can not unlock because del result not is one")
	}
	return nil
}
 
 
//lua.go
const (
	LauCheckAndDelete = `
		if(redis.call('get',KEYS[1])==ARGV[1]) then
		return redis.call('del',KEYS[1])
		else
		return 0
		end
	`
)

我们使用解锁的原子化版本来测试。

这里进行抢锁的时候,我们考虑下是否需要重新尝试获取锁。而这里我们就是要一人只能下单一次,所以不再需要重新尝试获取锁。

// 秒杀
// post /voucher/seckill
func SeckillVoucher(c *gin.Context) {
    ............
	//1.查询该优惠卷
	seckill := query.TbSeckillVoucher
	voucher, err := seckill.Where(seckill.VoucherID.Eq(uint64(req.VoucherId))).Find()
	if len(voucher) == 0 {
		code.WriteResponse(c, code.ErrDatabase, "秒杀卷不存在")
		return
	}
	//2.判断秒杀卷是否合法,开始结束时间,库存
    .................

	// UserLockMap.Lock(req.UserId)
	// defer UserLockMap.Unlock(req.UserId)

	//使用redids分布式锁
	lock := redislock.NewRedisLock(db.RedisClient, "order:"+strconv.Itoa(req.UserId), 0)
	success, err := lock.Lock()
	if !success || err != nil { //获取锁失败
		code.WriteResponse(c, code.ErrDatabase, "之前的下单逻辑还在处理/不允许重复下单")
		return
	}
	defer lock.Unlock()

	createVoucherOrder(c, req)
}

测试效果:

现在这个效果是符合我们的设想的了。每个进程对同一个userid使用的是同一个锁了。

我们实现的Redis分布式锁就完善了吗?

其实是不完善的。我们来思考:

这里仍然存在一个问题:当锁的持有者任务未完成,但是锁的有效期已过,虽然持有者此时仍可以完成任务,并且也不会误删其他持有者的锁,但是此时可能会存在多个执行者同时执行临界区代码,使得数据的一致性难以保证,造成意外的后果,分布式锁就失去了意义。

比如:抢购时候,业务时间太长,键过期了(即是键被delete了),但订单还没有创建完成;这时也是同一个用户来抢购,那这时可以获取锁,并判断了该用户还没有创建过订单,那就可以成功创建订单。这样同一个用户就创建了两个订单,这是不符合我们的要求的。

因此,需要一个锁的自动续期机制,很多分布式锁框架中就有这么一个看门狗,专门为将要到期的锁进行续期。

在文章Go语言实现Redis分布式锁2 实现了看门狗机制。

但是这还远远不够。

  • 分布式锁不可重入:不可重入是指同一线程不能重复获取同一把锁。比如,方法A中调用方法B,方法A需要获取分布式锁,方法B同样需要获取分布式锁,线程1进入方法A获取了一次锁,进入方法B又获取一次锁,由于锁不可重入,所以就会导致死锁
  • 不可重试:获取锁只尝试一次就返回false,没有重试机制,这会导致数据丢失,比如线程1获取锁,然后要将数据写入数据库,但是当前的锁被线程2占用了,线程1直接就结束了而不去重试,这就导致数据发生了丢失
  • 超时释放:超时释放机机制虽然一定程度避免了死锁发生的概率,但是如果业务执行耗时过长,期间锁就释放了,这样存在安全隐患。锁的有效期过短,容易出现业务没执行完就被释放。设置一个较短的有效期,但是加上一个 自动续期:在锁被获取后,可以使用心跳机制并自动续期锁的持有时间。
  • 主从一致性问题:如果Redis提供了主从集群,主从同步存在延迟,线程1获取了锁。

就是说我们自己开发的Redis分布式锁是还有很多细节没有处理的,可能会遇到不少问题的。我们如果想要更进一步优化分布式锁,当然是可以的,但是没必要,除非是迫不得已,我们完全可以直接使用已经造好的轮子,比如:redsync,这也是redis官网上推荐的。

使用redsync

redsync就是一个使用Redis解决分布式问题的方案的集合,当然它不仅仅是解决分布式相关问题,还包含其它的一些问题。

在db/redis.go文件中添加如下内容,进行初始化redsync。

注意:当前最新版本是github.com/go-redsync/redsync/v4@v4.13.0。该版本需要 go >= 1.22。而笔者使用的是go1.21,所以使用低版本,go get github.com/go-redsync/redsync/v4@v4.12.1。

import (
    ...............
	"github.com/go-redsync/redsync/v4"
	"github.com/go-redsync/redsync/v4/redis/goredis/v9"
)

var Rs *redsync.Redsync

func NewRedisClient(config *config.RedisSetting) (*redis.Client, error) {
	client := redis.NewClient(&redis.Options{
		Addr:     config.Host,     //自己的redis实例的ip和port
		Password: config.Password, //密码,有设置的话,就需要填写
		PoolSize: config.PoolSize, //最大的可连接数量
	})
    

	// 创建redsync的客户端连接池
	pool := goredis.NewPool(client)
	// 创建redsync实例
	Rs = redsync.New(pool)

	return client, err
}

 之后在函数seckillVoucher中使用redsync进行加锁

// 秒杀
// post /voucher/seckill
func SeckillVoucher(c *gin.Context) {
    ............
	//2.判断秒杀卷是否合法,开始结束时间,库存
	now := time.Now()
	if voucher[0].BeginTime.After(now) || voucher[0].EndTime.Before(now) {
		code.WriteResponse(c, code.ErrValidation, "不在秒杀时间范围内")
		return
	}
	if voucher[0].Stock < 1 {
		code.WriteResponse(c, code.ErrValidation, "秒杀卷已被抢空")
		return
	}


	//使用redids分布式锁
	// lock := redislock.NewRedisLock(db.RedisClient, "order:"+strconv.Itoa(req.UserId), 0)
	// success, err := lock.Lock()
	// if !success || err != nil { //获取锁失败
	// 	code.WriteResponse(c, code.ErrDatabase, "之前的下单逻辑还在处理/不允许重复下单")
	// 	return
	// }
	// defer lock.Unlock()

	//使用redsync进行加锁
	mutex := db.Rs.NewMutex("order:"+strconv.Itoa(req.UserId), redsync.WithTries(1))
	if err = mutex.Lock(); err != nil {
		code.WriteResponse(c, code.ErrDatabase, "之前的下单逻辑还在处理/不允许重复下单")
		return
	}
	defer mutex.Unlock()

	createVoucherOrder(c, req)
}

redsync的内部逻辑

创建锁时候,超时时间默认是8s,尝试次数是32次。若想要设置可以使用

	global.Rs.NewMutex("order", redsync.WithExpiry(10*time.Second), redsync.WithTries(10))
//redsync源代码
// NewMutex returns a new distributed mutex with given name.
func (r *Redsync) NewMutex(name string, options ...Option) *Mutex {
	m := &Mutex{
		name:   name,
		expiry: 8 * time.Second,
		tries:  32,
		delayFunc: func(tries int) time.Duration {
			return time.Duration(rand.Intn(maxRetryDelayMilliSec-minRetryDelayMilliSec)+minRetryDelayMilliSec) * time.Millisecond
		},
		genValueFunc:  genValue,
		driftFactor:   0.01,
		timeoutFactor: 0.05,
		quorum:        len(r.pools)/2 + 1,
		pools:         r.pools,
	}
	for _, o := range options {
		o.Apply(m)
	}
	if m.shuffle {
		randomPools(m.pools)
	}
	return m
}

redsync分布式锁原理:

  • 如何解决可重试问题:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制。
  • 如何解决超时续约问题:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间。
  • 如何解决主从一致性问题:利用redsync的multiLock,多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功。其缺陷:运维成本高、实现复杂。

测试结果:

结果是符合我们要求的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值