这一讲是“三高”代码实战中的最后一讲,也是秒杀系统功能的最后一个环节:扣减库存。
前面我提到过,秒杀库存是最核心的数据。如果库存数据不一致,出现超售,可能会导致公司在秒杀活动中的严重亏本。因此,如何保证库存扣减正常不出现超售,是保障秒杀活动正常进行的关键。那么,这该怎么做到呢?
在高并发下,为了确保数据的一致性,通常采用事务来操作数据。但是,直接使用事务会影响系统的并发性能。为此,我们通常会通过队列采用异步的方式将请求排队和串行化,这样可以大大降低事务的并发操作,提升系统性能。
接下来我就给你详细介绍下,秒杀系统中是如何使用内存队列将请求串行化,以及如何使用 Redis 事务来操作库存。
内存队列实战
内存队列主要用于接收请求后,在服务内部进行初步排队。具体来说,在队列的生产端,通过扣减内存库存的方式对请求进行初步过滤,然后推送到队列中;在消费端,以固定速度消费队列中的请求,并过滤掉超时的请求,再扣减 Redis 库存。
定义
为了方便后续扩展多种类型的队列,我在 infrastructure/mq/mq.go 文件中抽象出了 Queue、Producer、Consumer 这三个接口类型,分别表示队列、生产者、消费者。其中 Producer 提供 Produce 方法,Consumer 提供 Consume 方法,而 Queue 主要是包含了 Producer、Consumer、Closer。代码如下:
type Queue interface {
Producer
Consumer
io.Closer
}
type Producer interface {
Produce(task pool.Task) error
}
type Consumer interface {
Consume() (pool.Task, error)
}
然后,我采用设计模式中的工厂方法设计模式,定义了 Factory 这个接口类用于表示工厂,它有 New、NewProducer、NewConsumer 这三个方法,分别用于创建队列、创建消费者、创建生产者。此外,我还定义了一个 FactoryFunc 类型,用于实现最简单的工厂。代码如下:
ype Factory interface {
New(name string) (Queue, error)
NewProducer(name string) (Producer, error)
NewConsumer(name string) (Consumer, error)
}
type FactoryFunc func(name string) (Queue, error)
func (f FactoryFunc) New(name string) (Queue, error) {
return f(name)
}
func (f FactoryFunc) NewProducer(name string) (Producer, error) {
return f.New(name)
}
func (f FactoryFunc) NewConsumer(name string) (Consumer, error) {
return f.New(name)
}
接下来,我实现了两个函数——Register 函数和 NewFactory 函数。前者用于将不同的工厂类注册到统一的 map 中,以便后面创建不同的工厂;后者用于根据工厂类型参数创建对应的工厂。具体代码如下:
var queueFactories = make(map[string]Factory)
func Register(tp string, f Factory) {
if _, ok := queueFactories[tp]; ok {
panic("duplicate queue factory " + tp)
}
queueFactories[tp] = f
}
func NewFactory(tp string) Factory {
return queueFactories[tp]
}
实现
在秒杀系统中,请求排队的队列有多个生产者,但只有一个消费者,且以固定速度消费,这种模式就是我前面提到的 Fan-In 模式。刚好前面我们实现了 RateLimiter,因此,这里我们就可以基于 Fan-In 模式的 RateLimiter 来实现内存队列。
在 infrastructure/mq/memory.go 文件中,我定义了一个 memoryQueue 结构体,它包含一个类型为 RateLimiter 的字段 q。然后我按照 Queue 的定义,为 memoryQueue 实现了 Produce、Consume、Close 这三个方法。接下来,我实现了一个 memoryQueueFactory 函数,用于从配置中读取队列的配置,并创建队列。最后,在 init 函数中调用 Register 函数注册 memoryQueueFactory。代码如下:
type memoryQueue struct {
q utils.RateLimiter
}
func init() {
Register("memory", FactoryFunc(memoryQueueFactory))
}
func memoryQueueFactory(name string) (Queue, error) {
rate := viper.GetInt64(fmt.Sprintf("queue.%s.rate", name))
size := viper.GetInt(fmt.Sprintf("queue.%s.size", name))
q, _ := utils.NewRateLimiter(size, rate, utils.FanIn)
mq := &memoryQueue{
q: q,
}
return mq, nil
}
func (mq *memoryQueue) Produce(task pool.Task) error {
if ok := mq.q.Push(task); !ok {
return errors.New("queue producer error")
}
return nil
}
func (mq *memoryQueue) Consume() (pool.Task, error) {
t, ok := mq.q.Pop()
if !ok {
return nil, errors.New("queue consumer error")
}
return t, nil
}
func (mq *memoryQueue) Close() error {
return mq.q.Close()
}
使用
实现完内存队列后,该如何使用呢?
首先,我们需要在 domain/shop/shop.go 文件中实现一个 Init 方法来初始化内存队列,并启用一个 Goroutine 作为消费者,一直从队列中消费任务并执行。代码如下:
var queue mq.Queue
func Init() {
queueFactory := mq.NewFactory("memory")
if queueFactory == nil {
panic("no memory queue factory")
}
queue, _ = queueFactory.New("shop")
go func() {
for {
task, err := queue.Consume()
if err != nil {
logrus.Error(err)
break
}
task.Do()
}
}()
}
注意,我们需要在 interfaces/api/api.go 的 Run 函数中调用这个 Init 函数,以便程序启动时初始化好队列。
然后,我们需要定义一个 Context 结构体,用于在创建任务的时候传递任务所需要的参数。它包含原始 HTTP 请求 Request、连接 Conn、用于写 HTTP 返回的 Writer,以及用于抢购商品的活动 ID、商品 ID、用户 ID。代码如下:
type Context struct {
Request *http.Request
Conn net.Conn
Writer *bufio.ReadWriter
GoodsID string
EventID string
UID string
}
接下来,我们需要实现一个处理函数,接收 Context 类型的参数,创建任务并推送到队列中。代码如下:
func Handle(ctx *Context) {
start := time.Now().Unix()
t := func() {
data := &utils.Response{
Code: OK,
Data: nil,
Msg: "ok",
}
status := http.StatusOK
now := time.Now().Unix()
if now-start > requestTimeout {
data.Msg = "request timeout"
data.Code = ErrTimeout
} else {
// 扣减 Redis 库存
st, _ := stock.NewRedisStock(ctx.EventID, ctx.GoodsID)
if s, err := st.Sub(ctx.UID); err != nil {
data.Msg = err.Error()
data.Code = ErrRedis
} else if s < 0 {
data.Msg = "no stock"
data.Code = ErrNoStock
}
}
// 此处实现操作购物车的逻辑
body, _ := json.Marshal(data)
resp := &http.Response{
Proto: ctx.Request.Proto,
ProtoMinor: ctx.Request.ProtoMinor,
ProtoMajor: ctx.Request.ProtoMajor,
Header: make(http.Header),
ContentLength: int64(len(body)),
Body: ioutil.NopCloser(bytes.NewReader(body)),
StatusCode: status,
Close: false,
}
resp.Header.Set("Content-Type", "application/json")
resp.Write(ctx.Writer)
ctx.Writer.Flush()
ctx.Conn.Close()
}
queue.Produce(pool.TaskFunc(t))
}
你会发现,这个函数在处理 HTTP 返回的时候还挺复杂的。这是为什么呢?
要知道,Go HTTP 框架(如 gin 框架)默认是同步处理请求的。只要 Handler 执行完毕,就会直接返回 HTTP 请求,因此我们不能直接用框架提供的 Context 来处理返回。
用了队列后,请求是异步处理的,要如何避免 gin 框架直接返回 HTTP 请求呢?这里就要用到 Go HTTP 请求中的Hijack 方法了。为此,我们需要修改 application/api/api.go 文件中的 AddCart 方法。在该方法中先提取商品 ID、活动 ID、用户 ID 等参数,然后调用框架的 Context 提供的 Hijack 方法,将后续的处理交给秒杀系统自己处理。具体代码如下:
func (s *Shop) AddCart(ctx *gin.Context) {
resp := &utils.Response{
Code: 0,
Data: nil,
Msg: "ok",
}
status := http.StatusOK
params := struct {
GoodsID string `json:"goods_id"`
EventID string `json:"event_id"`
}{}
var userInfo *user.Info
if v, ok := ctx.Get("userInfo"); ok {
userInfo, _ = v.(*user.Info)
}
err := ctx.BindJSON(¶ms)
if err != nil || params.EventID == "" || params.GoodsID == "" || userInfo == nil {
resp.Msg = "bad request"
status = http.StatusBadRequest
ctx.JSON(status, resp)
return
}
logrus.Info(params)
conn, w, err1 := ctx.Writer.Hijack()
if err1 != nil {
resp.Msg = "bad request"
status = http.StatusBadRequest
ctx.JSON(status, resp)
return
}
logrus.Info("shop add cart")
shopCtx := &shop.Context{
Request: ctx.Request,
Conn: conn,
Writer: w,
GoodsID: params.GoodsID,
EventID: params.EventID,
UID: userInfo.UID,
}
shop.Handle(shopCtx)
}
你可能会问:为什么不能在 AddCart 方法中创建一个 Channel 来同步处理结果呢?
这里涉及一个很重要的性能问题。要知道,框架中执行请求的时候都需要占用一个 Goroutine 。如果用 Channel 同步,会导致框架创建的 Goroutine 无法得到及时回收。特别是高并发的时候,会导致框架频繁地创建并累积大量 Goroutine ,占用大量内存和 CPU 资源。而秒杀抢购接口请求和返回的数据都很简单,因此我们就可以使用 Hijack 来将连接从框架中劫持到我们自己的处理逻辑中,由我们自己来控制请求返回。
最后,需要注意的是,在调用 Hijack 前,我们还需要提前扣减内存缓存中的库存,用于初步获取资格。如果内存缓存中库存被扣减到小于 0,需要直接返回。代码如下:
st, _ := stock.NewMemStock(params.EventID, params.GoodsID)
if s, _ := st.Sub(userInfo.UID); s < 0 {
resp.Code = shop.ErrNoStock
resp.Msg = "no stock"
ctx.JSON(http.StatusOK, resp)
return
}
Redis 事务实战
抢购接口中,除了扣减库存外,还需要通过抢购记录判断用户之前是否抢购过,而这两个逻辑是有依赖关系的。前面我们通过队列将请求串行化了,但秒杀系统有多个节点,它们同时操作 Redis 中两个拥有依赖关系的数据时,如果没处理好,可能会导致数据不一致的问题。
比如,X 商品剩余库存为 1 的时候,node1 上的 A 用户和 node2 上的 B 用户同时去抢 X。他们同时发现该商品还有库存,于是都执行扣减库存的操作,结果库存从 1 扣减为 -1,而不是 0。虽然我们可以通过扣减后的库存值来判断最终是谁抢到了,然后让没有抢到的节点归还 1 个库存,但这会带来额外的 Redis 请求,也会带来其他未知风险。
那么,这个问题该怎么解决呢?这就需要用到 Redis 事务了。
要知道,Redis 是可以执行 Lua 脚本的。Redis 在执行一个 Lua 脚本的时候,其他 Lua 脚本和命令都必须等它完毕后才能执行。因此,我们可以用 Lua 脚本这个事务特性,来解决校验资格并扣减库存时数据不一致的问题。
具体来说,我们可以修改 domain/stock/stock.go 中的 Sub 方法,增加一个 uid 参数,并在方法中改用 Redis 的 Eval 方法来执行一个 Lua 脚本。该脚本先判断用户在这场活动中是否购买过该商品,以及活动中该商品是否还有库存。如果用户购买过该商品,或者该商品没有库存了,则返回 -1;如果用户没有购买,且该商品还有库存,则扣减库存并设置用户购买记录,成功后返回扣减后的库存,失败则返回 -1。代码如下:
func (rs *redisStock) Sub(uid string) (int64, error) {
cli := redis.GetClient()
script := `
if redis.call('get',KEYS[1]) >= '1' or redis.call('get', KEYS[2]) <= '0' then
return '-1'
else
local stock=redis.call('decr', KEYS[2])
if stock >= '0' and redis.call('set', KEYS[1], '1', '86400') > '0' then
return stock
end
return '-1'
end`
if res, err := cli.Eval(script, []string{fmt.Sprintf("%s#%s", rs.key, uid), rs.key}).Result(); err != nil {
return -1, err
} else if resStr, ok := res.(string); !ok {
return -1, errors.New("redis error")
} else if resInt, err := strconv.ParseInt(resStr, 10, 64); err != nil {
return -1, err
} else {
return resInt, nil
}
}
小结
这一讲我为你介绍了秒杀系统中如何实现和使用内存队列来将请求串行化,以及如何使用 Redis 事务来确保库存数据一致性。其中我特别强调了 Go HTTP 框架中的 Hijack 的用法,以及用它解决了什么问题。希望你可以把它们掌握并熟练运用到工作中。