乐趣区

关于go:gozero微服务实战系列八如何处理每秒上万次的下单请求

在前几篇的文章中,咱们花了很大的篇幅介绍如何利用缓存优化零碎的读性能,究其原因在于咱们的产品大多是一个读多写少的场景,尤其是在产品的初期,可能少数的用户只是过去查看商品,真正下单的用户非常少。但随着业务的倒退,咱们就会遇到一些高并发写申请的场景,秒杀抢购就是最典型的高并发写场景。在秒杀抢购开始后用户就会疯狂的刷新页面让本人尽早的看到商品,所以秒杀场景同时也是高并发读场景。那么应答高并发读写场景咱们怎么进行优化呢?

解决热点数据

秒杀的数据通常都是热点数据,解决热点数据个别有几种思路:一是优化,二是限度,三是隔离。

优化

优化热点数据最无效的方法就是缓存热点数据,咱们能够把热点数据缓存到内存缓存中。

限度

限度更多的是一种爱护机制,当秒杀开始后用户就会一直地刷新页面获取数据,这时候咱们能够限度单用户的申请次数,比方一秒钟只能申请一次,超过限度间接返回谬误,返回的谬误尽量对用户敌对,比方 “ 店小二正在忙 ” 等敌对提醒。

隔离

秒杀零碎设计的第一个准则就是将这种热点数据隔离进去,不要让 1% 的申请影响到另外的 99%,隔离进去后也更不便对这 1% 的申请做针对性的优化。具体到实现上,咱们须要做服务隔离,即秒杀性能独立为一个服务,告诉要做数据隔离,秒杀所调用的大部分是热点数据,咱们须要应用独自的 Redis 集群和独自的 Mysql,目标也是不想让 1% 的数据有机会影响 99% 的数据。

流量削峰

针对秒杀场景,它的特点是在秒杀开始那一刹那霎时涌入大量的申请,这就会导致一个特地高的流量峰值。但最终可能抢到商品的人数是固定的,也就是不论是 100 人还是 10000000 人发动申请的后果都是一样的,并发度越高,有效的申请也就越多。然而从业务角度来说,秒杀流动是心愿有更多的人来参加的,也就是秒杀开始的时候心愿有更多的人来刷新页面,然而真正开始下单时,申请并不是越多越好。因而咱们能够设计一些规定,让并发申请更多的延缓,甚至能够过滤掉一些有效的申请。

削峰实质上是要更多的延缓用户申请的收回,以便缩小和过滤掉一些有效的申请,它听从申请数要尽量少的准则。咱们最容易想到的解决方案是用音讯队列来缓冲刹时的流量,把同步的间接调用转换成异步的间接推送,两头通过一个队列在一端承接刹时的流量洪峰,在另一端平滑的将音讯推送进来,如下图所示:

采纳音讯队列异步解决后,那么秒杀的后果是不太好同步返回的,所以咱们的思路是当用户发动秒杀申请后,同步返回响应用户 “ 秒杀后果正在计算中 …” 的提示信息,当计算完之后咱们如何返回后果给用户呢?其实也是有多种计划的。

  • 一是在页面中采纳轮询的形式定时被动去服务端查问后果,例如每秒申请一次服务端看看有没有处理结果,这种形式的毛病是服务端的申请数会减少不少。
  • 二是被动 push 的形式,这种就要求服务端和客户端放弃长连贯了,服务端解决完申请后被动 push 给客户端,这种形式的毛病是服务端的连接数会比拟多。

还有一个问题就是如果异步的申请失败了该怎么办?我感觉对于秒杀场景来说,失败了就间接抛弃就好了,最坏的后果就是这个用户没有抢到而已。如果想要尽量的保障偏心的话,那么失败了当前也能够做重试。

如何保障音讯只被生产一次

kafka 是可能保障 ”At Least Once” 的机制的,即音讯不会失落,但有可能会导致反复生产,音讯一旦被反复生产那么就会造成业务逻辑解决的谬误,那么咱们如何防止音讯的反复生产呢?

咱们只有保障即便生产到了反复的音讯,从生产的最终后果来看和只生产一次的后果等同就好了,也就是保障在音讯的生产和生产的过程是幂等的。什么是幂等呢?如果咱们生产一条音讯的时候,要给现有的库存数量减 1,那么如果生产两条雷同的音讯就给库存的数量减 2,这就不是幂等的。而如果生产一条音讯后处理逻辑是将库存的数量设置为 0,或者是如果以后库存的数量为 10 时则减 1,这样在生产多条音讯时所失去的后果就是雷同的,这就是幂等的。说白了就是一件事无论你做多少次和做一次产生的后果都是一样的,那么这就是幂等性。

咱们能够在音讯被生产后,把惟一 id 存储在数据库中,这里的惟一 id 能够应用用户 id 和商品 id 的组合,在解决下一条音讯之前先从数据库中查问这个 id 看是否被生产过,如果生产过就放弃。伪代码如下:

isConsume := getByID(id)
if isConsume {return} 
process(message)
save(id)

还有一种形式是通过数据库中的惟一索引来保障幂等性,不过这个要看具体的业务,在这里不再赘述。

代码实现

整个秒杀流程图如下:

应用 kafka 作为音讯队列,所以要先在本地装置 kafka,我应用的是 mac 能够用 homebrew 间接装置,kafka 依赖 zookeeper 也会主动装置

brew install kafka

装置完后通过 brew services start 启动 zookeeper 和 kafka,kafka 默认侦听在 9092 端口

brew services start zookeeper

brew services start kafka

seckill-rpc 的 SeckillOrder 办法实现秒杀逻辑,咱们先限度用户的申请次数,比方限度用户每秒只能申请一次,这里应用 go-zero 提供的 PeriodLimit 性能实现,如果超出限度间接返回

code, _ := l.limiter.Take(strconv.FormatInt(in.UserId, 10))
if code == limit.OverQuota {return nil, status.Errorf(codes.OutOfRange, "Number of requests exceeded the limit")
}

接着查看以后抢购商品的库存,如果库存有余就间接返回,如果库存足够的话则认为能够进入下单流程,发消息到 kafka,这里 kafka 应用 go-zero 提供的 kq 库,非常简单易用,为秒杀新建一个 Topic,配置初始化和逻辑如下:

Kafka:
  Addrs:
    - 127.0.0.1:9092
  SeckillTopic: seckill-topic
KafkaPusher: kq.NewPusher(c.Kafka.Addrs, c.Kafka.SeckillTopic)
p, err := l.svcCtx.ProductRPC.Product(l.ctx, &product.ProductItemRequest{ProductId: in.ProductId})
if err != nil {return nil, err}
if p.Stock <= 0 {return nil, status.Errorf(codes.OutOfRange, "Insufficient stock")
}
kd, err := json.Marshal(&KafkaData{Uid: in.UserId, Pid: in.ProductId})
if err != nil {return nil, err}
if err := l.svcCtx.KafkaPusher.Push(string(kd)); err != nil {return nil, err}

seckill-rmq 生产 seckill-rpc 生产的数据进行下单操作,咱们新建 seckill-rmq 服务,构造如下:

tree ./rmq

./rmq
├── etc
│   └── seckill.yaml
├── internal
│   ├── config
│   │   └── config.go
│   └── service
│       └── service.go
└── seckill.go

4 directories, 4 files

仍然是应用 kq 初始化启动服务,这里咱们须要注册一个 ConsumeHand 办法,该办法用以生产 kafka 数据

srv := service.NewService(c)
queue := kq.MustNewQueue(c.Kafka, kq.WithHandle(srv.Consume))
defer queue.Stop()

fmt.Println("seckill started!!!")
queue.Start()

在 Consume 办法中,生产到数据后先反序列化,而后调用 product-rpc 查看以后商品的库存,如果库存足够的话咱们认为能够下单,调用 order-rpc 进行创立订单操作,最初再更新库存

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}
  p, err := s.ProductRPC.Product(context.Background(), &product.ProductItemRequest{ProductId: data.Pid})
  if err != nil {return err}
  if p.Stock <= 0 {return nil}
  _, err = s.OrderRPC.CreateOrder(context.Background(), &order.CreateOrderRequest{Uid: data.Uid, Pid: data.Pid})
  if err != nil {logx.Errorf("CreateOrder uid: %d pid: %d error: %v", data.Uid, data.Pid, err)
    return err
  }
  _, err = s.ProductRPC.UpdateProductStock(context.Background(), &product.UpdateProductStockRequest{ProductId: data.Pid, Num: 1})
  if err != nil {logx.Errorf("UpdateProductStock uid: %d pid: %d error: %v", data.Uid, data.Pid, err)
    return err
  }
  // TODO notify user of successful order placement
  return nil
}

在创立订单过程中波及到两张表 orders 和 orderitem,所以咱们要应用本地事务进行插入,代码如下:

func (m *customOrdersModel) CreateOrder(ctx context.Context, oid string, uid, pid int64) error {_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {err := conn.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {_, err := session.ExecCtx(ctx, "INSERT INTO orders(id, userid) VALUES(?,?)", oid, uid)
      if err != nil {return err}
      _, err = session.ExecCtx(ctx, "INSERT INTO orderitem(orderid, userid, proid) VALUES(?,?,?)", "", uid, pid)
      return err
    })
    return nil, err
  })
  return err
}

订单号生成逻辑如下,这里应用工夫加上自增数进行订单生成

var num int64

func genOrderID(t time.Time) string {s := t.Format("20060102150405")
  m := t.UnixNano()/1e6 - t.UnixNano()/1e9*1e3
  ms := sup(m, 3)
  p := os.Getpid() % 1000
  ps := sup(int64(p), 3)
  i := atomic.AddInt64(&num, 1)
  r := i % 10000
  rs := sup(r, 4)
  n := fmt.Sprintf("%s%s%s%s", s, ms, ps, rs)
  return n
}

func sup(i int64, n int) string {m := fmt.Sprintf("%d", i)
  for len(m) < n {m = fmt.Sprintf("0%s", m)
  }
  return m
}

最初别离启动 product-rpc、order-rpc、seckill-rpc 和 seckill-rmq 服务还有 zookeeper、kafka、mysql 和 redis,启动后咱们调用 seckill-rpc 进行秒杀下单

grpcurl -plaintext -d '{"user_id": 111,"product_id": 10}' 127.0.0.1:9889 seckill.Seckill.SeckillOrder

在 seckill-rmq 中打印了生产记录,输入如下

{"@timestamp":"2022-06-26T10:11:42.997+08:00","caller":"service/service.go:35","content":"Consume value: {\"uid\":111,\"pid\":10}\n","level":"info"}

这个时候查看 orders 表中曾经创立了订单,同时商品库存减一

结束语

实质上秒杀是一个高并发读和高并发写的场景,下面咱们介绍了秒杀的注意事项以及优化点,咱们这个秒杀场景相对来说比较简单,但其实也没有一个通用的秒杀的框架,咱们须要依据理论的业务场景进行优化,不同量级的申请优化的伎俩也不尽相同。这里咱们只展现了服务端的相干优化,但对于秒杀场景来说整个申请链路都是须要优化的,比方对于静态数据咱们能够应用 CDN 做减速,为了避免流量洪峰咱们能够在前端设置答题性能等等。

心愿本篇文章对你有所帮忙,谢谢。

每周一、周四更新

代码仓库: https://github.com/zhoushuguang/lebron

我的项目地址

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

欢送应用 go-zerostar 反对咱们!

微信交换群

关注『微服务实际 』公众号并点击 交换群 获取社区群二维码。

退出移动版