解决库存超卖问题

文章目录

        • 前言
        • 超卖复现
        • 使用go自带的锁解决超卖
        • 常用的基于乐观锁的实现
        • 使用redis分布式锁解决超卖问题

前言

在并发的情况下扣减库存会出现库存超卖的现象。
这里使用golang进行讲解,数据库操作使用gorm + mysql。
先运行下面的代码将测试表和数据创建好。

package main

import (
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
	"log"
	"os"
	"time"
)

type Inventory struct {
	gorm.Model
	ID  uint `gorm:"primarykey"`
	Num *int `gorm:"type:bigint not null;default:20"`
}

func (i Inventory) TableName() string {
	return "inventory"
}

func getConnect() *gorm.DB {
	//换成自己的数据库连接
	dsn := "root:Xrx@1994113@tcp(127.0.0.1:3306)/proxy?charset=utf8mb4&parseTime=True&loc=Local"
	newLogger := logger.New(
		//os.Stdout 标准输出,控制台打印
		// 以\r\n来作为打印间隔
		// log.LstdFlags 前面这串: 2022/08/13 15:22:34
		log.New(os.Stdout, "\r\n", log.LstdFlags),
		logger.Config{
			SlowThreshold:             time.Second, // 慢 SQL 阈值
			LogLevel:                  logger.Info, // 日志级别
			IgnoreRecordNotFoundError: true,        // 是否忽略ErrRecordNotFound(记录未找到)错误
			Colorful:                  true,        //是否开启彩色打印
		},
	)

	// 全局模式
	open, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		//禁止创建外键
		DisableForeignKeyConstraintWhenMigrating: true,
		Logger:                                   newLogger,
	})
	if err != nil {
		panic(err)
	}
	return open
}

func CreateTable(connect *gorm.DB, model interface{}) {

	//迁移时设置引擎和默认字符集
	err := connect.Set("gorm:table_options", "ENGINE=InnoDB DEFAULT CHARSET UTF8").AutoMigrate(model)
	if err != nil {
		panic(err)
	}
}

func main() {
	connect := getConnect()
	CreateTable(connect, &Inventory{})
	num := 20
	connect.Create(&[]Inventory{
		{ID: 1, Num: &num},
		{ID: 2, Num: &num},
		{ID: 3, Num: &num},
		{ID: 4, Num: &num},
	})
}

超卖复现

由于下面例子执行速度过快,20个修改前的查询数据结果差不多,导致20个协程减库存成功。但是数据值只减少了1。导致多卖出19件。

package main

import (
	"fmt"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
	"log"
	"math/rand"
	"os"
	"sync"
	"time"
)

type Inventory struct {
	gorm.Model
	ID  uint `gorm:"primarykey"`
	Num *int `gorm:"type:bigint not null;default:20"`
}

func (i Inventory) TableName() string {
	return "inventory"
}

func getConnect() *gorm.DB {
	//换成自己的数据库连接
	dsn := "root:Xrx@1994113@tcp(127.0.0.1:3306)/proxy?charset=utf8mb4&parseTime=True&loc=Local"
	newLogger := logger.New(
		//os.Stdout 标准输出,控制台打印
		// 以\r\n来作为打印间隔
		// log.LstdFlags 前面这串: 2022/08/13 15:22:34
		log.New(os.Stdout, "\r\n", log.LstdFlags),
		logger.Config{
			SlowThreshold:             time.Second, // 慢 SQL 阈值
			LogLevel:                  logger.Info, // 日志级别
			IgnoreRecordNotFoundError: true,        // 是否忽略ErrRecordNotFound(记录未找到)错误
			Colorful:                  true,        //是否开启彩色打印
		},
	)

	// 全局模式
	open, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		//禁止创建外键
		DisableForeignKeyConstraintWhenMigrating: true,
		Logger:                                   newLogger,
	})
	if err != nil {
		panic(err)
	}
	return open
}

func main() {
	connect := getConnect()
	var wg sync.WaitGroup
	wg.Add(23)
	for i := 0; i < 23; i++ {
		go func() {
			id := 2
			inv := Inventory{}
			connect.Where(Inventory{ID: uint(id)}).Find(&inv)
			//判断是否查询,异常,自己去完善,略......
			if *inv.Num < 1 {
				fmt.Println("库存不足无法扣除")
				return
			}
           
           //随机休眠,模拟同时查询到数据,不同时更改的情况
			time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
			result := connect.Model(&Inventory{}).Where("id = ?", id).Update("num", *inv.Num-1)
			if result.Error != nil || result.RowsAffected == 0{
				fmt.Println("库存扣减失败")
			} else {
				fmt.Println("库存扣减成功")
			}

			defer wg.Done()
		}()
	}
	wg.Wait()
}
使用go自带的锁解决超卖

自带的锁可以解决单体应用下的超卖问题,如果是分布式的就无能为力了。
下面例子会有三个扣除失败

package main

import (
	"fmt"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
	"log"
	"os"
	"sync"
	"time"
)

type Inventory struct {
	gorm.Model
	ID  uint `gorm:"primarykey"`
	Num *int `gorm:"type:bigint not null;default:20"`
}

func (i Inventory) TableName() string {
	return "inventory"
}

func getConnect() *gorm.DB {
	//换成自己的数据库连接
	dsn := "root:Xrx@1994113@tcp(127.0.0.1:3306)/proxy?charset=utf8mb4&parseTime=True&loc=Local"
	newLogger := logger.New(
		//os.Stdout 标准输出,控制台打印
		// 以\r\n来作为打印间隔
		// log.LstdFlags 前面这串: 2022/08/13 15:22:34
		log.New(os.Stdout, "\r\n", log.LstdFlags),
		logger.Config{
			SlowThreshold:             time.Second, // 慢 SQL 阈值
			LogLevel:                  logger.Info, // 日志级别
			IgnoreRecordNotFoundError: true,        // 是否忽略ErrRecordNotFound(记录未找到)错误
			Colorful:                  true,        //是否开启彩色打印
		},
	)

	// 全局模式
	open, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		//禁止创建外键
		DisableForeignKeyConstraintWhenMigrating: true,
		Logger:                                   newLogger,
	})
	if err != nil {
		panic(err)
	}
	return open
}

func main() {
	connect := getConnect()
	var wg sync.WaitGroup
	var l sync.Mutex
	wg.Add(23)
	for i := 0; i < 23; i++ {
		go func() {
			id := 3
			inv := Inventory{}
			l.Lock()
			defer l.Unlock()
			defer wg.Done()
			connect.Where(Inventory{ID: uint(id)}).Find(&inv)
			//判断是否查询,异常,自己去完善,略......
			if *inv.Num < 1 {
				fmt.Println("库存不足无法扣除")
				return
			}

			result := connect.Model(&Inventory{}).Where("id = ?", id).Update("num", *inv.Num-1)
			if result.Error != nil || result.RowsAffected == 0 {
				fmt.Println("库存扣减失败")
			} else {
				fmt.Println("库存扣减成功")
			}
		}()
	}
	wg.Wait()
}

常用的基于乐观锁的实现

依靠mysql set + where 语句进行库存扣减,保证库存不会扣除超过某个值(这里以a作为目标,扣减数量为b,扣除商品id为c),那么sql语句为: UPDATE inventory SET num= (num - b) WHERE (id = c and num >= a + b)。下面例子总共23个库存,23个协程去扣除,会有3个扣除失败。库存最后为0

package main

import (
	"fmt"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
	"log"
	"os"
	"sync"
	"time"
)

type Inventory struct {
	gorm.Model
	ID  uint `gorm:"primarykey"`
	Num *int `gorm:"type:bigint not null;default:20"`
}

func (i Inventory) TableName() string {
	return "inventory"
}

func getConnect() *gorm.DB {
	//换成自己的数据库连接
	dsn := "root:Xrx@1994113@tcp(127.0.0.1:3306)/proxy?charset=utf8mb4&parseTime=True&loc=Local"
	newLogger := logger.New(
		//os.Stdout 标准输出,控制台打印
		// 以\r\n来作为打印间隔
		// log.LstdFlags 前面这串: 2022/08/13 15:22:34
		log.New(os.Stdout, "\r\n", log.LstdFlags),
		logger.Config{
			SlowThreshold:             time.Second, // 慢 SQL 阈值
			LogLevel:                  logger.Info, // 日志级别
			IgnoreRecordNotFoundError: true,        // 是否忽略ErrRecordNotFound(记录未找到)错误
			Colorful:                  true,        //是否开启彩色打印
		},
	)

	// 全局模式
	open, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		//禁止创建外键
		DisableForeignKeyConstraintWhenMigrating: true,
		Logger:                                   newLogger,
	})
	if err != nil {
		panic(err)
	}
	return open
}

func main() {
	connect := getConnect()
	var wg sync.WaitGroup
	wg.Add(23)
	for i := 0; i < 23; i++ {
		go func() {
			result := connect.Model(&Inventory{}).Where("id = ? and num >= 1", 1).Update("num", connect.Raw("num - 1"))
			if result.Error != nil || result.RowsAffected == 0 {
				fmt.Println("库存扣减失败")
			} else {
				fmt.Println("库存扣减成功")
			}
			defer wg.Done()
		}()
	}
	wg.Wait()
}

使用redis分布式锁解决超卖问题

查看我的文章《go 使用reids分布式锁》

你可能感兴趣的:(Golang,golang)