–需求:抢购商品
涉及问题:并发问题,库存变更,活动时间
涉及技术栈:redis,go
1.并发方案
-使用redis的原子性的特质实现并发问题。也是较为常用的解决方案
-因为redis是单线程运行,不管同时有多少并发,都只会执行一个语句,所以可以用来解 决并发问题
实际操作:
1,让商品的唯一标识ID作为下表,再加上项目前缀确保唯一性。value为库存
2,每次减库存前先判断库存量是否足够然后再执行减库存操作
3,使用decyby减少数量
4,如果decyby后数量小于0,返回错误并将库存加回去
5,同步到MySQL中
问题:
因为减库存时执行了两个操作(查看+修改)这两个其实已经破坏了原子性操作,所以会有一点并发的问题
例如:有三个库存,两个用户A,B。A要买2个,B也要买2个,
操作顺序
操作1:A判断数量是否足够,yes
操作2:B判断数量是否足够,yes
操作3:A减少数量 yes
操作4:B减少数量 no
操作5:B归还库存数量(因为小于1)yes
这种情况说明假如再一个用户还没有实际减库存之前,就有另外一个用户读到库存了,就会产生这种类似于幻读
的问题。那为什么仍然要用这种方案呢,不把第一个判断库存数量删除?
因为假如不判断的话他仍然不是原子性操作,因为(修改+归还库存-库存不足条件下)这也会有并发问题
例如:有三个库存,两个用户A,B。A要买4个,B也要买2个,
操作序列
操作1:A减少数量 no
操作2:B减少数量 no
操作3:A归还库存数量(因为小于0)yes
操作4:B归还库存数量(因为小于0)yes
可以看到即便剩余4个库存,B要买2个,也会购买失败,并且在实际测试中发现这种比例近乎1/4,这是业务上
不能接受的。于是选择了第一种在减少库存前,先判断一下数量。
但是第一种例子的问题其实和第二种类似,但是他的比例就已经降低了许多,多次尝试都没有出现问题,
对于业务上来说可以接受,所以最终选择了这个方案。虽然这个概率比实际偏低(测试手段问题)
其他方案
其他方案-1:
如果想要完全解决并发问题,可以使用redis中的list数据结构,通过list的len来代表库存。这样的话在库存为
0时,redis会自己报错。不需要我们再操作,这样就可以保证原子性,但是这样会有一个弊端,redis并不支持
pop指定数量的节点,
所以这种方案只有确定了每个用户限购1个的业务要求下才能使用
其他方案-2
在实际使用的方案上确保操作的原子性,可以增加redis的事务。并将三步操作简化为2步即把判断库存是否足够
删掉。因内已经保证了还库存这一操作的原子性,所以已经不存在库存足够,但是却买不到的问题
但是这种方案有局限性。因为redis的事务并不是真正意义上的事务,他不能完全保证原子性。且会影响抢购时
的性能,(这个方案是我提出的)最后被pass掉了
实现代码:
package main
```go
import (
"context"
"fmt"
"math/rand"
"strconv"
"sync"
"time"
"github.com/go-redis/redis"
)
var ctx = context.Background()
var wg sync.WaitGroup
func main() {
startTime := time.Now().UnixNano()
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
})
err := rdb.Set("stock", 10, 0).Err()
if err != nil {
fmt.Println("设置库存失败!")
}
//设置库存
oldStock := rdb.Get("stock")
fmt.Println("当前库存数量为: ", oldStock.Val())
wg.Add(50)
for i := 0; i < 50; i++ {
go func() {
rand.Seed(time.Now().UnixMicro())
buyNumber := rand.Intn(3) + 1 //购买数量1-4随机
//提前判断一下库存,减少符号条件但购买失败的概率
TmpStock := rdb.Get("stock")
Tmp, _ := strconv.Atoi(TmpStock.Val())
if Tmp < buyNumber {
fmt.Println(strconv.Itoa(buyNumber) + "件购买失败!")
wg.Done()
return
}
res := rdb.DecrBy("stock", int64(buyNumber))
if res.Val() < 0 {
fmt.Println(strconv.Itoa(buyNumber) + "件购买失败!")
rdb.IncrBy("stock", int64(buyNumber))
} else {
fmt.Println(strconv.Itoa(buyNumber) + "件购买成功!")
}
wg.Done()
}()
}
wg.Wait()
//设置库存
newStock := rdb.Get("stock")
fmt.Println("当前库存数量为: ", newStock)
fmt.Println("运行时间:(ns)", time.Now().UnixNano()-startTime)
}
# 2, 库存变更
其实第一部分是redis部分的库存变更,已经把重点讲完了,针对MySQL部分的库存变更,用一般的方法也是+一个事务,同时
设置一个字段version用来加乐观锁。同时要注意在事务中,讲涉及到+行锁的更新操作后移,减少行锁时间。
# 3,活动时间
活动时间的不同会导致活动的状态不同,待审核,待发布,待开始,进行中,已结束,取消。
可以将待开始,进行中, 已结束整合成一个状态。在展示的列表接口或者状态判断时使用time.now进行判断,虽然代码会
更复杂一点,但是时间判断更加准确。
但是这种方法有一种情况不能使用,在开始,结束等时间点,需要修改表中字段时,此方法没有办法修改,只做展示用。还是
需要跑定时脚本