目录
订单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人去抢购。