go-zero微服務實戰系列(九、極致優化秒殺性能)

上一篇文章中引入了消息隊列對秒殺流量做削峯的處理,我們使用的是Kafka,看起來似乎工作的不錯,但其實還是有很多隱患存在,如果這些隱患不優化處理掉,那麼秒殺搶購活動開始後可能會出現消息堆積、消費延遲、數據不一致、甚至服務崩潰等問題,那麼後果可想而知。本篇文章我們就一起來把這些隱患解決掉。

批量數據聚合

SeckillOrder這個方法中,每來一次秒殺搶購請求都往往Kafka中發送一條消息。假如這個時候有一千萬的用戶同時來搶購,就算我們做了各種限流策略,一瞬間還是可能會有上百萬的消息會發到Kafka,會產生大量的網絡IO和磁盤IO成本,大家都知道Kafka是基於日誌的消息系統,寫消息雖然大多情況下都是順序IO,但當海量的消息同時寫入的時候還是可能會扛不住。

那怎麼解決這個問題呢?答案是做消息的聚合。之前發送一條消息就會產生一次網絡IO和一次磁盤IO,我們做消息聚合後,比如聚合100條消息後再發送給Kafka,這個時候100條消息纔會產生一次網絡IO和磁盤IO,對整個Kafka的吞吐和性能是一個非常大的提升。其實這就是一種小包聚合的思想,或者叫Batch或者批量的思想。這種思想也隨處可見,比如我們使用Mysql插入批量數據的時候,可以通過一條SQL語句執行而不是循環的一條一條插入,還有Redis的Pipeline操作等等。

那怎麼來聚合呢,聚合策略是啥呢?聚合策略有兩個維度分別是聚合消息條數和聚合時間,比如聚合消息達到100條我們就往Kafka發送一次,這個條數是可以配置的,那如果一直也達不到100條消息怎麼辦呢?通過聚合時間來兜底,這個聚合時間也是可以配置的,比如配置聚合時間爲1秒鐘,也就是無論目前聚合了多少條消息只要聚合時間達到1秒,那麼就往Kafka發送一次數據。聚合條數和聚合時間是或的關係,也就是隻要有一個條件滿足就觸發。

在這裏我們提供一個批量聚合數據的工具Batcher,定義如下

type Batcher struct {
  opts options

  Do       func(ctx context.Context, val map[string][]interface{})
  Sharding func(key string) int
  chans    []chan *msg
  wait     sync.WaitGroup
}

Do方法:滿足聚合條件後就會執行Do方法,其中val參數爲聚合後的數據

Sharding方法:通過Key進行sharding,相同的key消息寫入到同一個channel中,被同一個goroutine處理

在merge方法中有兩個觸發執行Do方法的條件,一是當聚合的數據條數大於等於設置的條數,二是當觸發設置的定時器

代碼實現比較簡單,如下爲具體實現:

type msg struct {
  key string
  val interface{}
}

type Batcher struct {
  opts options

  Do       func(ctx context.Context, val map[string][]interface{})
  Sharding func(key string) int
  chans    []chan *msg
  wait     sync.WaitGroup
}

func New(opts ...Option) *Batcher {
  b := &Batcher{}
  for _, opt := range opts {
    opt.apply(&b.opts)
  }
  b.opts.check()

  b.chans = make([]chan *msg, b.opts.worker)
  for i := 0; i < b.opts.worker; i++ {
    b.chans[i] = make(chan *msg, b.opts.buffer)
  }
  return b
}

func (b *Batcher) Start() {
  if b.Do == nil {
    log.Fatal("Batcher: Do func is nil")
  }
  if b.Sharding == nil {
    log.Fatal("Batcher: Sharding func is nil")
  }
  b.wait.Add(len(b.chans))
  for i, ch := range b.chans {
    go b.merge(i, ch)
  }
}

func (b *Batcher) Add(key string, val interface{}) error {
  ch, msg := b.add(key, val)
  select {
  case ch <- msg:
  default:
    return ErrFull
  }
  return nil
}

func (b *Batcher) add(key string, val interface{}) (chan *msg, *msg) {
  sharding := b.Sharding(key) % b.opts.worker
  ch := b.chans[sharding]
  msg := &msg{key: key, val: val}
  return ch, msg
}

func (b *Batcher) merge(idx int, ch <-chan *msg) {
  defer b.wait.Done()

  var (
    msg        *msg
    count      int
    closed     bool
    lastTicker = true
    interval   = b.opts.interval
    vals       = make(map[string][]interface{}, b.opts.size)
  )
  if idx > 0 {
    interval = time.Duration(int64(idx) * (int64(b.opts.interval) / int64(b.opts.worker)))
  }
  ticker := time.NewTicker(interval)
  for {
    select {
    case msg = <-ch:
      if msg == nil {
        closed = true
        break
      }
      count++
      vals[msg.key] = append(vals[msg.key], msg.val)
      if count >= b.opts.size {
        break
      }
      continue
    case <-ticker.C:
      if lastTicker {
        ticker.Stop()
        ticker = time.NewTicker(b.opts.interval)
        lastTicker = false
      }
    }
    if len(vals) > 0 {
      ctx := context.Background()
      b.Do(ctx, vals)
      vals = make(map[string][]interface{}, b.opts.size)
      count = 0
    }
    if closed {
      ticker.Stop()
      return
    }
  }
}

func (b *Batcher) Close() {
  for _, ch := range b.chans {
    ch <- nil
  }
  b.wait.Wait()
}

使用的時候需要先創建一個Batcher,然後定義Batcher的Sharding方法和Do方法,在Sharding方法中通過ProductID把不同商品的聚合投遞到不同的goroutine中處理,在Do方法中我們把聚合的數據一次性批量的發送到Kafka,定義如下:

b := batcher.New(
  batcher.WithSize(batcherSize),
  batcher.WithBuffer(batcherBuffer),
  batcher.WithWorker(batcherWorker),
  batcher.WithInterval(batcherInterval),
)
b.Sharding = func(key string) int {
  pid, _ := strconv.ParseInt(key, 10, 64)
  return int(pid) % batcherWorker
}
b.Do = func(ctx context.Context, val map[string][]interface{}) {
  var msgs []*KafkaData
  for _, vs := range val {
    for _, v := range vs {
      msgs = append(msgs, v.(*KafkaData))
    }
  }
  kd, err := json.Marshal(msgs)
  if err != nil {
    logx.Errorf("Batcher.Do json.Marshal msgs: %v error: %v", msgs, err)
  }
  if err = s.svcCtx.KafkaPusher.Push(string(kd)); err != nil {
    logx.Errorf("KafkaPusher.Push kd: %s error: %v", string(kd), err)
  }
}
s.batcher = b
s.batcher.Start()

SeckillOrder方法中不再是每來一次請求就往Kafka中投遞一次消息,而是先通過batcher提供的Add方法添加到Batcher中等待滿足聚合條件後再往Kafka中投遞。

err = l.batcher.Add(strconv.FormatInt(in.ProductId, 10), &KafkaData{Uid: in.UserId, Pid: in.ProductId})
if err!= nil {
    logx.Errorf("l.batcher.Add uid: %d pid: %d error: %v", in.UserId, in.ProductId, err)
}

降低消息的消費延遲

通過批量消息處理的思想,我們提供了Batcher工具,提升了性能,但這主要是針對生產端而言的。當我們消費到批量的數據後,還是需要串行的一條條的處理數據,那有沒有辦法能加速消費從而降低消費消息的延遲呢?有兩種方案分別是:

  • 增加消費者的數量
  • 在一個消費者中增加消息處理的並行度

因爲在Kafka中,一個Topci可以配置多個Partition,數據會被平均或者按照生產者指定的方式寫入到多個分區中,那麼在消費的時候,Kafka約定一個分區只能被一個消費者消費,爲什麼要這麼設計呢?我理解的是如果有多個Consumer同時消費一個分區的數據,那麼在操作這個消費進度的時候就需要加鎖,對性能影響比較大。所以說當消費者數量小於分區數量的時候,我們可以增加消費者的數量來增加消息處理能力,但當消費者數量大於分區的時候再繼續增加消費者數量就沒有意義了。

不能增加Consumer的時候,可以在同一個Consumer中提升處理消息的並行度,即通過多個goroutine來並行的消費數據,我們一起來看看如何通過多個goroutine來消費消息。

在Service中定義msgsChan,msgsChan爲Slice,Slice的長度表示有多少個goroutine並行的處理數據,初始化如下:

func NewService(c config.Config) *Service {
  s := &Service{
    c:          c,
    ProductRPC: product.NewProduct(zrpc.MustNewClient(c.ProductRPC)),
    OrderRPC:   order.NewOrder(zrpc.MustNewClient(c.OrderRPC)),
    msgsChan:   make([]chan *KafkaData, chanCount),
  }
  for i := 0; i < chanCount; i++ {
    ch := make(chan *KafkaData, bufferCount)
    s.msgsChan[i] = ch
    s.waiter.Add(1)
    go s.consume(ch)
  }

  return s
}

從Kafka中消費到數據後,把數據投遞到Channel中,注意投遞消息的時候按照商品的id做Sharding,這能保證在同一個Consumer中對同一個商品的處理是串行的,串行的數據處理不會導致併發帶來的數據競爭問題

func (s *Service) Consume(_ string, value string) error {
  logx.Infof("Consume value: %s\n", value)
  var data []*KafkaData
  if err := json.Unmarshal([]byte(value), &data); err != nil {
    return err
  }
  for _, d := range data {
    s.msgsChan[d.Pid%chanCount] <- d
  }
  return nil
}

我們定義了chanCount個goroutine同時處理數據,每個channel的長度定義爲bufferCount,並行處理數據的方法爲consume,如下:

func (s *Service) consume(ch chan *KafkaData) {
  defer s.waiter.Done()

  for {
    m, ok := <-ch
    if !ok {
      log.Fatal("seckill rmq exit")
    }
    fmt.Printf("consume msg: %+v\n", m)
    p, err := s.ProductRPC.Product(context.Background(), &product.ProductItemRequest{ProductId: m.Pid})
    if err != nil {
      logx.Errorf("s.ProductRPC.Product pid: %d error: %v", m.Pid, err)
      return
    }
    if p.Stock <= 0 {
      logx.Errorf("stock is zero pid: %d", m.Pid)
      return
    }
    _, err = s.OrderRPC.CreateOrder(context.Background(), &order.CreateOrderRequest{Uid: m.Uid, Pid: m.Pid})
    if err != nil {
      logx.Errorf("CreateOrder uid: %d pid: %d error: %v", m.Uid, m.Pid, err)
      return
    }
    _, err = s.ProductRPC.UpdateProductStock(context.Background(), &product.UpdateProductStockRequest{ProductId: m.Pid, Num: 1})
    if err != nil {
      logx.Errorf("UpdateProductStock uid: %d pid: %d error: %v", m.Uid, m.Pid, err)
    }
  }
}

怎麼保證不會超賣

當秒殺活動開始後,大量用戶點擊商品詳情頁上的秒殺按鈕,會產生大量的併發請求查詢庫存,一旦某個請求查詢到有庫存,緊接着系統就會進行庫存的扣減。然後,系統生成實際的訂單,並進行後續的處理。如果請求查不到庫存,就會返回,用戶通常會繼續點擊秒殺按鈕,繼續查詢庫存。簡單來說,這個階段的操作就是三個:檢查庫存,庫存扣減、和訂單處理。因爲每個秒殺請求都會查詢庫存,而請求只有查到庫存有餘量後,後續的庫存扣減和訂單處理纔會被執行,所以,這個階段中最大的併發壓力都在庫存檢查操作上。

爲了支撐大量高併發的庫存檢查請求,我們需要使用Redis單獨保存庫存量。那麼,庫存扣減和訂單處理是否都可以交給Mysql來處理呢?其實,訂單的處理是可以在數據庫中執行的,但庫存扣減操作不能交給Mysql直接處理。因爲到了實際的訂單處理環節,請求的壓力已經不大了,數據庫完全可以支撐這些訂單處理請求。那爲什麼庫存扣減不能直接在數據庫中執行呢?這是因爲,一旦請求查到有庫存,就意味着該請求獲得購買資格,緊接着就會進行下單操作,同時庫存量會減一,這個時候如果直接操作數據庫來扣減庫存可能就會導致超賣問題。

直接操作數據庫扣減庫存爲什麼會導致超賣呢?由於數據庫的處理速度較慢,不能及時更新庫存餘量,這就會導致大量的查詢庫存的請求讀取到舊的庫存值,並進行下單,此時就會出現下單數量大於實際的庫存量,導致超賣。所以,就需要直接在Redis中進行庫存扣減,具體的操作是,當庫存檢查完後,一旦庫存有餘量,我們就立即在Redis中扣減庫存,同時,爲了避免請求查詢到舊的庫存值,庫存檢查和庫存扣減這兩個操作需要保證原子性。

我們使用Redis的Hash來存儲庫存,total爲總庫存,seckill爲已秒殺的數量,爲了保證查詢庫存和減庫存的原子性,我們使用Lua腳本進行原子操作,讓秒殺量小於庫存的時候返回1,表示秒殺成功,否則返回0,表示秒殺失敗,代碼如下:

const (
  luaCheckAndUpdateScript = `
local counts = redis.call("HMGET", KEYS[1], "total", "seckill")
local total = tonumber(counts[1])
local seckill = tonumber(counts[2])
if seckill + 1 <= total then
  redis.call("HINCRBY", KEYS[1], "seckill", 1)
  return 1
end
return 0
`
)

func (l *CheckAndUpdateStockLogic) CheckAndUpdateStock(in *product.CheckAndUpdateStockRequest) (*product.CheckAndUpdateStockResponse, error) {
  val, err := l.svcCtx.BizRedis.EvalCtx(l.ctx, luaCheckAndUpdateScript, []string{stockKey(in.ProductId)})
  if err != nil {
    return nil, err
  }
  if val.(int64) == 0 {
    return nil, status.Errorf(codes.ResourceExhausted, fmt.Sprintf("insufficient stock: %d", in.ProductId))
  }
  return &product.CheckAndUpdateStockResponse{}, nil
}

func stockKey(pid int64) string {
  return fmt.Sprintf("stock:%d", pid)
}

對應的seckill-rmq代碼修改如下:

func (s *Service) consume(ch chan *KafkaData) {
  defer s.waiter.Done()

  for {
    m, ok := <-ch
    if !ok {
      log.Fatal("seckill rmq exit")
    }
    fmt.Printf("consume msg: %+v\n", m)
    _, err := s.ProductRPC.CheckAndUpdateStock(context.Background(), &product.CheckAndUpdateStockRequest{ProductId: m.Pid})
    if err != nil {
      logx.Errorf("s.ProductRPC.CheckAndUpdateStock pid: %d error: %v", m.Pid, err)
      return
    }
    _, err = s.OrderRPC.CreateOrder(context.Background(), &order.CreateOrderRequest{Uid: m.Uid, Pid: m.Pid})
    if err != nil {
      logx.Errorf("CreateOrder uid: %d pid: %d error: %v", m.Uid, m.Pid, err)
      return
    }
    _, err = s.ProductRPC.UpdateProductStock(context.Background(), &product.UpdateProductStockRequest{ProductId: m.Pid, Num: 1})
    if err != nil {
      logx.Errorf("UpdateProductStock uid: %d pid: %d error: %v", m.Uid, m.Pid, err)
    }
  }
}

到這裏,我們已經瞭解瞭如何使用原子性的Lua腳本來實現庫存的檢查和扣減。其實要想保證庫存檢查和扣減的原子性,還有另外一種方法,那就是使用分佈式鎖。

分佈式鎖的實現方式有很多種,可以基於Redis、Etcd等等,用Redis實現分佈式鎖的文章比較多,感興趣的可以自行搜索參考。這裏給大家簡單介紹下基於Etcd來實現分佈式鎖。爲了簡化分佈式鎖、分佈式選舉、分佈式事務的實現,etcd社區提供了一個名爲concurrency的包來幫助我們更簡單、正確的使用分佈式鎖。它的實現非常簡單,主要流程如下:

  • 首先通過concurrency.NewSession方法創建Session,本質上是創建了一個TTL爲10的Lease
  • 得到Session對象後,通過concurrency.NewMutex創建一個mutex對象,包括了Lease、key prefix等信息
  • 然後聽過mutex對象的Lock方法嘗試獲取鎖
  • 最後通過mutex對象的Unlock方法釋放鎖
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
   log.Fatal(err)
}
defer cli.Close()

session, err := concurrency.NewSession(cli, concurrency.WithTTL(10))
if err != nil {
   log.Fatal(err)
}
defer session.Close()

mux := concurrency.NewMutex(session, "lock")
if err := mux.Lock(context.Background()); err != nil {
   log.Fatal(err)
}


if err := mux.Unlock(context.Background()); err != nil {
   log.Fatal(err)
}

結束語

本篇文章主要是針對秒殺功能繼續做了一些優化。在Kafka消息的生產端做了批量消息聚合發送的優化,Batch思想在實際生產開發中使用非常多,希望大家能夠活靈活用,在消息的消費端通過增加並行度來提升吞吐能力,這也是提升性能常用的優化手段。最後介紹了可能導致超賣的原因,以及給出了相對應的解決方案。同時,介紹了基於Etcd的分佈式鎖,在分佈式服務中經常出現數據競爭的問題,一般可以通過分佈式鎖來解決,但分佈式鎖的引入勢必會導致性能的下降,所以,還需要結合實際情況考慮是否需要引入分佈式鎖。

希望本篇文章對你有所幫助,謝謝。

每週一、週四更新

代碼倉庫: https://github.com/zhoushuguang/lebron

項目地址

https://github.com/zeromicro/go-zero

歡迎使用 go-zerostar 支持我們!

微信交流羣

關注『微服務實踐』公衆號並點擊 交流羣 獲取社區羣二維碼。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章