电商抢购秒杀场景,高并发下单功能两种解决方案
从事多年电商,遇到过不少需要做秒杀、抢购的场景,有过蛮多成功案例,也出现过个别严重事故。
先说严重的情况。有一次我们做活动,是一种新品首发活动,对外宣传是抢购活动,但因为备的货存量比较大,因此个人觉得可能根本不需要抢,用户只需要像平常一样付款即可,因此没有做任何的限流、削峰处理。导致在那次活动开始的时候,服务器瞬间出现负载,app直接假死,库存计算完全错误,也影响到了其他依赖这个下单服务的其他功能使用。
那次教训非常惨重,当时的后端下单处理逻辑是这样的。
1.用户提交下单按钮
2.后端得到商品信息及价格后进行对比(查库)
3.校验用户收货地址 得到各商品邮费
4.扣减库存
5.其他逻辑: 根据商品价格计算进行积分赠送
6.请求第三方支付平台 得到支付id
7.创建订单
以上就是最早的下单流程,在经过上述一系列的业务过程中,后端需要频繁的对数据库进行查、改操作,不过在抢购之前,它其实运行一直很稳定。
由于那次并没有任何订制页面开发,而是直接使用日常的下单页面进行促销活动,因此才偷了懒。一般的新品首发等商品抢购活动都会有专门的抢购页面,而后端也会同步做一套专门的抢购下单逻辑。
以下是我使用过的两种做法,效果很好,第一种最为常见。
利用 redis 的分布式锁+计数器处理抢购逻辑
以golang为例,在初始化时,先设置好抢购商品库存。
// 预设名额
// 请注意此字段并非对应真正库存
// 在并发场景下它会为负数 但不会造成真正的库存超卖
// 仅用于计数器
var count = 100
// 商品key
var skuKey = "snap:sku:id:1:key"
// lockKey
var lockKey = "snap:sku:id:1:lock:key:uid:%d"
func Init() {
// 设置 100 库存
if err := rdsCli.SetNX(skuKey, count, 20*time.Minute).Err(); redis.IsNotNill(err) {
panic(err)
}
}
抢购功能golang代码。
func Rush() error {
// 参数检查等逻辑
// ...
// 削峰 同一用户2秒内不允许重复点击
key := fmt.Sprintf(lockKey, uid)
if !rdsCli.SetNx(lockKey, true, time.Second*2).Var() {
return errors.New("重复点击")
}
// 业务处理 如价格比对、查询用户信息、收货地址等
// ...
// 操作计数器
amount, err := rdsCli.Decr().Result()
if redis.IsNotNill(err) {
panic(err)
}
// 此处计数器已经小于0了
if amount < 0 {
return errors.New("卖完了~")
}
// 正式扣减库存及其他逻辑处理
// ...
}
以上是其中一种电商抢购处理逻辑,这种比较常用,也很可靠。为了更好地做削峰,前端也应该做削峰处理,例如用户点击提交后就锁定按钮,这种情况下可防止用户重复点击,已经能起到很大削峰作用了。
如果对库存回库有要求,还可以设置一个库存冻结时间,做超时处理,或者将库存与订单过期逻辑绑定在一起,订单过期了就回库。如用户抢到了商品,但长时间不给钱,这时候就操作订单过期。
第一种抢购做法最主要是下单线程不可控制,例如,放出100个名额,同时就有100个人下单,此时后台虽然做了并发处理,但主要还是针对库存超卖方面,在理论上还是会同时处理100个订单,对于小服务器而言仍有压力。于是,应该考虑使用队列,后台通过可控制数量的协程去逐个处理这些队列数据。经过文章开头所说的那次教训之后,我对后台下单做了全面优化,就是使用了这种方法改造了下单模块。
利用消息队列,异步处理抢购下单逻辑
利用消息队列处理下单可以将“同时处理订单的数量“主动权交给后端,原理是这样的
用户提交订单时,后端记录被购买的商品信息后,立刻生成一个订单号返回给前端
后端将生成的订单号与商品绑定在一起,放入队列
前端得到订单号之后,每n秒轮循请求后台以获取到支付平台的 chargeId
后端默认启动n个线程监听队列,不停从队列中取得订单进行消费
后端消费队列完成后,将订单对应的 chargeId 写入到中间件(如redis)中
前端通过第三步的逻辑得到了 redis 中存放的 chargeId
前端根据 chargeId 支付完成
队列的中间件选择有很多,我这里利用 redis 的列表类型,通过 lpush, rpop 做先进先出的效果。以下golang代码只列出核心处理逻辑,生产环境请自行改造。
// queueKey 队列名称
var queueKey = "order:queue:list"
// orderKey 订单key
var orderKey = "order:num:key:"
type orderData struct{
Err error
Uid int64
OrderNum string
Submit interface{}
}
// fetchOrder 生成订单号
func fetchOrder() {
// 生成订单号过程
// ...
orderNum := "20230227xxx"
data, err := json.Marshal(&orderData{
Uid: 1,
OrderId: orderNum,
Submit: "xxxxxxx",
})
if err := rdsCli.LPush(queueKey, data).Err(); redis.IsNotNil(err) {
return err
}
// 其他业务处理
// ...
// 返回订单号给客户端
return orderNum
}
type Charge struct{
Id string
}
// queueHandler 消费队列数据
// 处理订单业务
func queueHandler() {
// 启动10个协程抢队列的订单数据
for i := 0; i < 10; i ++ {
go func() {
for {
var ret []string
if ret = rdsCli.BRPop(time.Second*30, queueKey).Val(); len(ret) <= 1 {
continue
}
var (
err error
arg = new(orderData)
)
if err = json.Unmarshal([]byte(ret[1]), arg); err != nil {
log.Errorf("can not unmarsh order from queue, ret: %s, reason: %s", ret[1], err.Error())
_ = returnQueueOrderInfo(&OrderProduct{Id: arg.OrderId, Err: errors.New("出错了")})
continue
}
// 相关业务处理
// 金额对比
// 扣减库存
// 赠送积分
// 请求第三方 得到 chargeId
// ...
chargeID := ""
buf, _ :=json.Marshal(&Charge{
Id: chargeID,
})
// 处理完成后将订单chargeId写入redis
// 用户需要在1分钟内支付
if err := rdsCli.Set(orderKey+arg.OrderNum, buf, time.Minute*1).Err(); redis.IsNotNil(err) {
writeErr(&orderData{Err: err, OrderNum: orderNum})
continue
}
}
}
}
}
// getOrderAPI 用于提供前端查询订单信息
// 即订单提交后返回订单号给前端
// 前端通过轮循请求这个接口获取到关键的 chargeId
func getOrderAPI(arg *orderData) {
if result := rdsCli.Get(orderKey + arg.OrderNum).Val(); result != "" {
var ret = new(Charge)
_ = json.Unmarshal([]byte(result), ret)
return ret
}
}
// writeErr 返回错误信息
func writeErr(e *orderData) error {
buf, err := json.Marshal(e)
if err != nil {
return err
}
if err := rdsCli.Set(orderKey+e.Id, buf, time.Minute*1).Err(); redis.IsNotNil(err) {
return err
}
return nil
}
以上就是golang利用消息队列处理下单的逻辑。在一般小规模并发下,这种做法非常稳定,现在已经用在生产环境大约有一年时间了,没有出现过问题。
声明: 因编程语言版本更新较快,当前文章所涉及的语法或某些特性相关的信息并不一定完全适用于您当前所使用的版本,请仔细甄别。文章内容仅作为学习和参考,若有错误,欢迎指正。