5.全局唯一的订单id和超卖问题

5.全局唯一的订单id和超卖问题

目录

订单id

分布式ID的实现

分布式ID生成器代码

优惠卷秒杀

实现添加优惠卷接口

1. 添加普通券

2 .添加秒杀券

实现优惠卷秒杀接口

解决超卖问题

超卖问题

为什么会产生超卖问题?

解决方法:加锁

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

订单id

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

id的规律性太明显,容易出现信息的泄露,数据可能容易被猜测到,被不怀好意的人伪造请求

受单表数据量的限制,MySQL中表能够存储的数据有限,会出现分库分表的情况,id不能够一直自增

那么该如何解决呢?我们需要使用分布式ID (也叫 全局唯一ID)。其需要满足 唯一性、安全性、递增性、高性能、高可用。

分布式ID的实现

其实现方式:

UUIDRedis自增数据库自增snowflake算法(雪花算法)

这里我们配合Redis自增和自定义的方式实现。

Redis可以保证id全局唯一;为了增加ID的安全性,我们不直接使用Redis自增的数值,而是拼接一些其它信息。

自定义格式:时间戳+序列号

符号位:1bit,永远为0(表示正数)时间戳:31bit,以秒为单位,可以使用69年序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID(redis自增)

分布式ID生成器代码

在handler目录下添加order目录,在order下添加order.go文件。

//order.go

const (

coutBits = 32

incr = "incr:"

)

// 生成分布式id, 这个是订单id。不是优惠卷id

func NextId(keyPrefix string) int64 {

now := time.Now().Unix()

//生成序列号

//Go语言的时间格式是通过一个特定的参考时间来定义的,这个参考时间是Mon Jan 2 15:04:05 MST 2006

date := time.Now().Format("2006:01:01") //要用2006才能确保时间格式化正确

count, err := db.RedisDb.Incr(context.Background(), incr+keyPrefix+":"+date).Result()

if err != nil {

slog.Error("Incr bad", "err", err)

return -1

}

//拼接并返回

return now<

}

优惠卷秒杀

实现添加优惠卷接口

每个店铺都可以发放优惠券,分为平价券和特价券。平价券可以任意抢购,特价券需要秒杀抢购。

表tb_voucher:优惠券基本信息,优惠金额,使用规则等。表tb_seckill_voucher:优惠券的库存,开始抢购时间,结束抢购时间,只有特价优惠券才需要填写这些信息。

查看下创建这两张表的sql语句(有部分删减)。

CREATE TABLE `tb_voucher` (

`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',

`shop_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '商铺id',

`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '代金券标题',

`sub_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '副标题',

`rules` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '使用规则',

`pay_value` bigint(10) UNSIGNED NOT NULL COMMENT '支付金额,单位是分。例如200代表2元',

`actual_value` bigint(10) NOT NULL COMMENT '抵扣金额,单位是分。例如200代表2元',

`type` tinyint(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0,普通券;1,秒杀券',

`status` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '1,上架; 2,下架; 3,过期',

PRIMARY KEY (`id`) USING BTREE

) AUTO_INCREMENT = 10;

CREATE TABLE `tb_seckill_voucher` (

`voucher_id` bigint(20) UNSIGNED NOT NULL COMMENT '关联的优惠券的id',

`stock` int(8) NOT NULL COMMENT '库存',

`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

`begin_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',

`end_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '失效时间',

PRIMARY KEY (`voucher_id`) USING BTREE

) COMMENT = '秒杀优惠券表,与优惠券是一对一关系' ROW_FORMAT = Compact;

先创建秒杀券对应的voucher结构体。用于接受http请求发送过来的body,方便解析。

// handler/order/types.go

type voucher struct {

ShopId int `json:"shopId"` //关联的商店id

Title string `json:"title"`

SubTitle string `json:"subTitle"`

Rules string `json:"rules"`

PayValue int `json:"payValue"` //优惠的价格

ActualValue int `json:"actualValue"`

Type int `json:"type"` //优惠卷类型

Stock int `json:"stock"` //库存

BeginTime string `json:"beginTime"`

EndTime string `json:"endTime"`

}

添加创建优惠卷的函数

// handler/order/voucher.go

// post /api/v1/vouchers

func AddVoucher(c *gin.Context) {

var value Voucher

err := c.BindJSON(&value)

if err != nil {

slog.Error("AddVoucher, bind json bad", "err", err)

response.Error(c, response.ErrBind)

return

}

var id uint64

switch value.Type {

case 0: //普通优惠卷

id, err = addOrdinaryVoucher(value)

case 1:

id, err = addSeckillVoucher(value)

default:

response.Error(c, response.ErrValidation, "type must be 0 or 1")

return

}

response.HandleBusinessResult(c, err, gin.H{"voucherId": id})

}

1. 添加普通券

func addOrdinaryVoucher(voucherReq voucher) (uint64, error) {

v := model.TbVoucher{

ShopID: uint64(voucherReq.ShopId),

Title: voucherReq.Title,

SubTitle: voucherReq.SubTitle,

Rules: voucherReq.Rules,

PayValue: uint64(voucherReq.PayValue),

ActualValue: int64(voucherReq.ActualValue),

Type: uint32(voucherReq.Type),

}

//往数据库添加

err := query.TbVoucher.Create(&v)

if err != nil {

return 0, response.WrapBusinessError(response.ErrDatabase, err, "")

}

return v.ID, nil

}

2 .添加秒杀券

注意:需要往tb_seckill_voucher和表tb_voucher中添加数据,所以需要使用事务来保证这两个表操作都成功或都失败。

func addSeckillVoucher(voucherReq voucher) (uint64, error) {

start, err := time.Parse(timeLayout, voucherReq.BeginTime)

if err != nil {

return 0, response.WrapBusinessError(response.ErrValidation, err, "BeginTime "+timeFormatError)

}

end, err := time.Parse(timeLayout, voucherReq.EndTime)

if err != nil {

return 0, response.WrapBusinessError(response.ErrValidation, err, "EndTime "+timeFormatError)

}

// 验证时间逻辑

if !end.After(start) {

return 0, response.WrapBusinessError(response.ErrValidation, nil, "EndTime must be after BeginTime")

}

v := model.TbVoucher{

ShopID: uint64(voucherReq.ShopId),

Title: voucherReq.Title,

SubTitle: voucherReq.SubTitle,

Rules: voucherReq.Rules,

PayValue: uint64(voucherReq.PayValue),

ActualValue: int64(voucherReq.ActualValue),

Type: uint32(voucherReq.Type),

}

q := query.Use(db.DBEngine)

//使用事务

err = q.Transaction(func(tx *query.Query) error {

//1.先添加到优惠卷表 tb_voucher

err := tx.TbVoucher.Create(&v)

if err != nil {

return response.WrapBusinessError(response.ErrDatabase, err, "")

}

//2.再添加信息到秒杀卷表 tb_seckill_voucher

seckill := model.TbSeckillVoucher{

VoucherID: v.ID,

Stock: int32(voucherReq.Stock),

BeginTime: start,

EndTime: end,

}

err = tx.TbSeckillVoucher.Create(&seckill)

if err != nil {

return response.WrapBusinessError(response.ErrDatabase, err, "")

}

return nil

})

return v.ID, err

}

实现优惠卷秒杀接口

抢购成功的话,就需要生成订单。而这个订单记录了这个优惠卷是哪个用户的,是关于哪张优惠卷等等信息。我们来查看表tb_voucher_order。

之前实现的全局唯一id就是使用在这张表上的。

CREATE TABLE `tb_voucher_order` (

`id` bigint(20) NOT NULL COMMENT '主键',

`user_id` bigint(20) UNSIGNED NOT NULL COMMENT '下单的用户id',

`voucher_id` bigint(20) UNSIGNED NOT NULL COMMENT '购买的代金券id',

`pay_type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '支付方式 1:余额支付;2:支付宝;3:微信',

`status` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款',

`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',

`pay_time` timestamp NULL DEFAULT NULL COMMENT '支付时间',

`use_time` timestamp NULL DEFAULT NULL COMMENT '核销时间',

`refund_time` timestamp NULL DEFAULT NULL COMMENT '退款时间',

PRIMARY KEY (`id`) USING BTREE

) ;

注意:字段`pay_time`类型是timestamp,是不允许插入'00000-00-00 00:00:00',在MySQL 5.7及以上版本中,严格模式(strict mode)下不允许使用'0000-00-00 00:00:00'这样的"零值"日期。表中`pay_time`的默认值是NULL,go语言中timestamp对应的类型是time.time,Go的time.Time是一个结构体,当它处于零值状态时通常表示为"0001-01-01 00:00:00 UTC"。 那么可以指定只插入需要的字段,让其他字段使用创建表时候的默认值。

业务流程:

注意:需要处理两张表(订单表,秒杀卷表),要使用事务。

//type.go

type seckillResquest struct {

VoucherId int `json:"voucherId"`

UserId int `json:"userId"`

}

//order.go

// post /api/v1/seckill/vouchers

func SeckillVoucher(c *gin.Context) {

var req seckillResquest

err := c.BindJSON(&req)

if err != nil {

slog.Error("bind json bad", "err", err)

response.Error(c, response.ErrBind)

return

}

//1.查询该优惠卷

seckill := query.TbSeckillVoucher

voucher, err := seckill.Where(seckill.VoucherID.Eq(uint64(req.VoucherId))).First()

if err != nil {

if errors.Is(err, gorm.ErrRecordNotFound) {

response.Error(c, response.ErrNotFound, "秒杀卷不存在")

return

}

response.Error(c, response.ErrDatabase)

return

}

//2.判断秒杀卷是否合法,开始结束时间,库存

now := time.Now()

if voucher.BeginTime.After(now) || voucher.EndTime.Before(now) {

response.Error(c, response.ErrValidation, "不在秒杀时间范围内")

return

}

if voucher.Stock < 1 {

response.Error(c, response.ErrValidation, "秒杀卷已被抢空")

return

}

orderId, err := createVoucherOrder(req)

if err != nil {

response.HandleBusinessError(c, err)

return

}

response.Success(c, gin.H{"orderId": orderId})

}

func createVoucherOrder(req seckillResquest) (int64, error) {

order := model.TbVoucherOrder{

ID: NextId("order"),

VoucherID: uint64(req.VoucherId),

UserID: uint64(req.UserId),

}

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

q := query.Use(db.DBEngine)

err := q.Transaction(func(tx *query.Query) error {

//3.合法,库存数量减1

info, err := tx.TbSeckillVoucher.Where(tx.TbSeckillVoucher.VoucherID.Eq(uint64(req.VoucherId))).UpdateSimple(tx.TbSeckillVoucher.Stock.Add(-1))

if err != nil {

return response.WrapBusinessError(response.ErrDatabase, err, "")

}

if info.RowsAffected == 0 {

slog.Warn("库存扣减失败", "voucherID", req.VoucherId, "reason", "库存不足或券不存在")

return response.WrapBusinessError(response.ErrValidation, nil, "秒杀卷已被抢空")

}

//4.成功,创建对应的订单,并保存到数据中

// err = tx.TbVoucherOrder.Create(&order)

//出现问题Error 1292 (22007): Incorrect datetime value: '0000-00-00' for column 'pay_time' at row 1

//表 `tb_voucher_order` 的字段`pay_time`,`use_time`,`refund_time`类型是timestamp,不允许插入'00000-00-00 00:00:00',数据库不接受这种无效的日期时间值。

//可以指定更新需要的字段,不更新其他字段

// err = tx.TbVoucherOrder.Select(tx.TbVoucherOrder.ID, tx.TbVoucherOrder.UserID, tx.TbVoucherOrder.VoucherID).Create(&order)

//也可以这样写

err = tx.TbVoucherOrder.Omit(tx.TbVoucherOrder.PayTime, tx.TbVoucherOrder.UseTime, tx.TbVoucherOrder.RefundTime).Create(&order)

if err != nil {

return response.WrapBusinessError(response.ErrDatabase, err, "")

}

return nil

})

return order.ID, err

}

解决超卖问题

超卖问题

在测试后发现逻辑跑通了,看上去已经成功的解决了秒杀优惠券功能。但是前面我们只是正常的测试,那如果换到高并发的场景下能否成功解决?

我写了个go程序来测试。使用

go run testseckill.go -n=300

300是开启的协程数量,即是模拟300人同时抢购。

var success int32 = 0 //抢购的成功数

var send int32 = 0 //成功发起抢购的次数(即是成功发送http请求的次数)

const seckillUrl = "http://localhost:8080/api/v1/seckill/vouchers"

type seckillBody struct {

VoucherId int `json:"voucherId"`

UserId int `json:"userId"`

}

func main() {

num := pflag.IntP("num", "n", 400, "number of requests")

pflag.Parse()

fmt.Println("num:", *num)

wg := sync.WaitGroup{}

wg.Add(*num)

for i := 0; i < *num; i++ {

//Go 1.21 版本通过语言规范调整,明确了循环变量在迭代体中的绑定行为,彻底解决了“闭包/Goroutine 捕获循环变量最终值”的历史问题。

//Go 1.21 版本需要手动启动的,set GOEXPERIMENT=loopvar # 临时生效(当前命令行窗口)。1.22版本才是默认生效

//自己根据自己的需求修改VoucherId

go sendSeckillRequest(10, i, &wg)

}

wg.Wait()

fmt.Println("success:", success, " send:", send)

}

func sendSeckillRequest(voucherId, userId int, wg *sync.WaitGroup) {

defer wg.Done()

data := seckillBody{VoucherId: voucherId, UserId: userId}

jsonData, err := json.Marshal(data)

if err != nil {

fmt.Printf("Error marshaling JSON: %v\n", err)

return

}

client := &http.Client{Timeout: 10 * time.Second}

req, err := http.NewRequest("POST", seckillUrl, bytes.NewBuffer(jsonData))

if err != nil {

fmt.Printf("Error creating request: %v\n", err)

return

}

req.Header.Set("Content-Type", "application/json")

resp, err := client.Do(req)

if err != nil {

fmt.Printf("Error sending request: %v\n", err)

return

}

defer resp.Body.Close()

atomic.AddInt32(&send, 1)

_, err = io.ReadAll(resp.Body)

if err != nil {

fmt.Printf("Error reading response body: %v\n", err)

return

}

if resp.StatusCode == 200 {

atomic.AddInt32(&success, 1)

}

}

测试:

现在往数据库表tb_seckill_voucher中添加了库存为100,优惠卷id是11的秒杀表;模拟开启300人去抢购。查看数据库库存是变成负数了。

为什么会产生超卖问题?

线程1查询库存,发现库存充足,可以创建订单,当同时线程2和线程3也发现库存充足。然后线程1执行完扣减操作后,库存变成了0,但线程2和线程3也同样完成了库存减扣操作,最终就导致库存变成了负数。这个就是超卖问题的流程。

解决方法:加锁

那该如何解决这个问题呢?那就是加锁。总的来说就有两种锁:悲观锁和乐观锁。

简单的,使用MySql的锁。我们在查库存的时候,锁住需要查看的该行,都不给其他用户查看。等到自己扣减完库存,创建了订单后才开放给别人。即是把查看库存的操作也加入事务,并且是使用select ... FOR UPDATE。这种是排它锁,不允许他人查看,也不允许修改。

或者使用go语言的 sync.Mutex。这两种都是悲观锁,这样就是串行运行,性能较差。

那么,我们尝试使用乐观锁。那么这里的重点是 如何判断之前查到的数据是否有被修改过。

实现方式一:版本号法

首先我们要为 tb_seckill_voucher 表新增一个版本号字段 version 。线程1查询完库存,在进行库存扣减操作的同时将版本号+1;线程2在查询库存时,同时查询出当前的版本号,发现库存充足,也准备执行库存扣减操作,但是需要判断当前的版本号是否是之前查询时的版本号,结果发现版本号发生了改变,这就说明数据库中的数据已经发生了修改,需要进行重试或者直接失败。

实现方式二:CAS法

CAS法类似与版本号法,但是不需要另外在添加一个 version 字段,而是直接使用库存替代版本号。

线程1查询完库存后进行库存扣减操作,线程2在查询库存时,发现库存充足,也准备执行库存扣减操作,但是需要判断当前的库存是否是之前查询时的库存,结果发现库存数量发生了改变,这就说明数据库中的数据已经发生了修改,需要进行重试或直接失败。

拓展:

CAS(比较并交换)

​​定义​​:多线程并发编程中的原子操作,通过乐观锁机制解决数据竞争问题,无需加锁即可保证线程安全。

​​核心要素​​:

​​三参数​​:内存地址V(操作对象)、预期值A(旧值)、新值B(更新值)。​​三步骤​​:

​​比较​​:若V当前值 == A,则执行下一步;否则失败。​​交换​​:将V的值更新为B。​​原子性​​:整个过程不可中断,失败则自动重试(无锁自旋)。

​​优势​​:

避免传统锁的线程阻塞开销,适用于细粒度并发控制。

​​局限性​​:

​​ABA问题​​:无法感知“修改→回退”的中间状态(如A→B→A),需引入版本号/标记位解决。​​自旋开销​​:失败时持续重试占用CPU资源,高并发下可能性能下降。​​并发限制​​:多线程竞争同一地址时,仅一线程成功,其余需重试。

综上所述,使用CAS法要更加好,能够避免额外的内存开销,而对于我们的需求,需要修改代码的地方也比较少的。

//在Where中添加了 tx.TbSeckillVoucher.Stock.Eq(voucher.Stock)

func createVoucherOrder(req seckillResquest, voucher *model.TbSeckillVoucher) error {

...................................

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

db := query.Use(global.DBEngine)

return db.Transaction(func(tx *query.Query) error {

//3.合法,进行。库存数量减1

//使用乐观锁,每次都需要判断之前查询到的库存是否和现在的一致

info, err := tx.TbSeckillVoucher.Where(tx.TbSeckillVoucher.VoucherID.Eq(uint64(req.VoucherId)),

tx.TbSeckillVoucher.Stock.Eq(voucher.Stock)).UpdateSimple(tx.TbSeckillVoucher.Stock.Add(-1))

....................

})

}

查看效果

库存为100,优惠卷id是11的秒杀表;模拟300人去抢购。

抢购成功的数量不是100。这次没有超卖的,但是却没有把库存完全卖光,成功率太低了,这也不符合我们要求。

这就是乐观锁的弊端,我们只要发现数据修改就直接终止操作了。我们只需要修改一下判断条件,即只要库存大于0就可以进行修改,而不是发现库存量已被修改就终止操作。

//把之前的tx.TbSeckillVoucher.Stock.Eq(voucher.Stock) 修改成 tx.TbSeckillVoucher.Stock.Gt(0)

func seckillVoucher(voucherId int, userId int) error {

...................

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

//解决超卖问题, 人数多于库存时候,让仅能卖出库存数量

db := query.Use(global.DBEngine)

return db.Transaction(func(tx *query.Query) error {

//3.合法,进行。库存数量减1

//使用乐观锁,cas

//使用update,要是没有该条数据,不会返回gorm.ErrRecordNotFound或者有错误的。

info, err := tx.TbSeckillVoucher.Where(tx.TbSeckillVoucher.VoucherID.Eq(uint64(req.VoucherId)), tx.TbSeckillVoucher.Stock.Gt(0)).UpdateSimple(tx.TbSeckillVoucher.Stock.Add(-1))

........................

})

}

查看效果:

库存为100,优惠卷id是11的秒杀表;模拟开启300人去抢购。

你可能也喜欢

城市足球联赛全国火热,球迷不看世界杯了?国际足联真慌了?
加入新的 App
365bet皇冠体

加入新的 App

📅 09-02 👀 624
翰墨丹青是什么意思
365bet繁体中文

翰墨丹青是什么意思

📅 10-03 👀 2010
京东上门取件要多久
365体育网址备用

京东上门取件要多久

📅 06-29 👀 4073
帝王蟹产自于哪里?帝王蟹是哪里产的
365体育网址备用

帝王蟹产自于哪里?帝王蟹是哪里产的

📅 08-22 👀 876
[设计周报] 墙上的世界杯 从涂鸦看巴西
365bet繁体中文

[设计周报] 墙上的世界杯 从涂鸦看巴西

📅 08-20 👀 8596
揭秘电影压缩:如何让巨作轻装上阵,轻松享受高清观影体验
绿茶种类名称大全,绿茶都有哪些种类
365体育网址备用

绿茶种类名称大全,绿茶都有哪些种类

📅 08-17 👀 8327
《星空 Starfield》預定 5/15 釋出上市以來最大規模更新 強化地表地圖呈現等功能