秒杀思路

因为做过电子商城类型的项目,所以经常问这个问题

首先这是一个具体的使用场景,角色:

1、用户(N)

2、商城系统-订单模块(1)

这边体现的首先是一个多对一关系

具体的功能点:

1、用户下单

  1.1、锁住库存成功

  1.2、已无库存

2、订单超时或取消,库存恢复

3、订单支付成功(这边认为在秒杀场景中,支付成功,可以权且当做一个结束点,不再考虑退款的问题)

可以看出库存是一个关键词

那么上面是一个比较理想和正常的业务场景,而实际生产环境中呢?

在互联网时代,面对的经常都是几亿的网民流量,也就是说我们可能要面临几万个人抢100个库存的场景

也就是说我们需要为秒杀考虑并发场景

并发一般需要考虑的有三个比较大的概念性问题

1、安全性问题:指错误的结果不会发生,这边对应的是超卖

2、活跃性问题:指正确的事情会发生,这边指用户可以在抢到库存后成功下单并支付

3、性能问题:指正确的事情可以尽快发生,这边指抢单不会像以前抢火车票一样,抢了一两个小时才抢到

一、在传统单机部署系统中

这边主要针对如何做库存管理做描述

1、利用AtomicInteger,原子变量做库存管理

如下:

package com.example.demo;

import org.junit.jupiter.api.Test;

import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class Test1 {

    static class StockManager {
        final int stockTotal;
        final AtomicInteger stockAtomic;

        public StockManager(int stock) {
            stockTotal = stock;
            this.stockAtomic = new AtomicInteger(stock);;
        }

        public boolean lockStock(int stock) {
            int currentStock = stockAtomic.get();
            int newStock = currentStock - stock;
            if (newStock < 0) { // 如果库存不足,直接返回失败
                return false;
            } else {
                return stockAtomic.compareAndSet(currentStock, newStock);
            }
        }

        public boolean unLock(int stock) {
            int currentStock = stockAtomic.get();
            int newStock = currentStock + stock;
            if (newStock > stockTotal) {
                // 其实这个不严谨,如果存在售卖期间,自行调整库存的情况,就会有问题,但这个问题域会更复杂点,在这里先不考虑
                // 这里更应该抛出异常
                return false;
            } else {
                return stockAtomic.compareAndSet(currentStock, newStock);
            }
        }

    }




    @Test
    public void test() {
        final StockManager stockManager = new StockManager(100);
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(100, 150, 1, TimeUnit.MINUTES, new LinkedBlockingDeque());
        for (int i = 0; i < 10000; i++) {
            int finalI = i;
            threadPoolExecutor.submit(() -> {
                if (stockManager.lockStock(1)) {
                    System.out.println("客户" + finalI + "成功抢占库存");
                    if (finalI % 2 == 0) {
                        stockManager.unLock(1);
                        System.out.println("客户" + finalI + "放弃库存");
                    }
                }
            });
        }
        // 不再接受新的任务,执行完之后关闭
        threadPoolExecutor.shutdown(); 
        while (!threadPoolExecutor.isShutdown()) {

        }
    }

}

执行完之后会发现抢占成功的次数-放弃库存的次数等于100.

这边实际用到的是硬件级别的锁

首先我们要确定一点,原子变量在JVM、Java代码的层级是没有锁机制的,它应用的是一种CAS的机制,完整的单词是Compare And Set(也有做Compare And Swap,但我看Java中暴露的方法是前者)

意思大概就是先比较再设置,那么了解并发的人就会意识到,先比较后设置,和先检查再执行,都是属于两个操作,是没有原子性的。

那么为什么这个变量又叫做原子性变量呢。

如果我们到JVM的源码中去看的话(因为在JDK中,只能看到最终调用的是一个本地方法接口),应该能看到最终这个方法调用的是C++中的一个方法,那么重点是这个方法应用的是CPU原语操作。

简单来说的话,就是CPU保证这个操作是原子性的,现代CPU基本上都支持CAS操作的原子性

ps:由此衍生的另外一个问题是ABA问题,这边ABA只是随便取的,重点要描述的是:一个变量,比如从5 到 10 再到5,看起来值是没变的,但实际中中途是变过的。

也就是说理论上CAS存在过程不一致,只能保证最终一致。

另外我们可以看出CAS实际上是一种乐观锁机制,那么与我们常规关系型数据库中使用的版本式的乐观锁不同,这边的版本号取自变量原值,所以存在上面的CAS问题。也就是说一般要解决ABA问题,版本号需要相对独立(可以使用带版本号的AtomicStampedRefence)

使用带版本号的乐观锁的改进版本

package com.example.demo;

import org.junit.jupiter.api.Test;

import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;

public class Test1 {

    static class StockManager {
        final Integer stockTotal;
        final AtomicStampedReference<Integer> stockAtomic;

        public StockManager(Integer stock) {
            stockTotal = stock;
            this.stockAtomic = new AtomicStampedReference(stock, 1);;
        }

        public boolean lockStock(Integer stock) {
            Integer stamp = stockAtomic.getStamp();
            Integer currentStock = stockAtomic.getReference();
            Integer newStock = currentStock - stock;
            if (newStock < 0) { // 如果库存不足,直接返回失败
                return false;
            } else {
                return stockAtomic.compareAndSet(currentStock, newStock, stamp, stamp + 1);
            }
        }

        public boolean unLock(Integer stock) {
            Integer stamp = stockAtomic.getStamp();
            Integer currentStock = stockAtomic.getReference();
            Integer newStock = currentStock + stock;
            if (newStock > stockTotal) {
                // 其实这个不严谨,如果存在售卖期间,自行调整库存的情况,就会有问题,但这个问题域会更复杂点,在这里先不考虑
                // 这里更应该抛出异常
                return false;
            } else {
                return stockAtomic.compareAndSet(currentStock, newStock, stamp, stamp + 1);
            }
        }

    }




    @Test
    public void test() {
        final StockManager stockManager = new StockManager(100);
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(100, 150, 1, TimeUnit.MINUTES, new LinkedBlockingDeque());
        for (Integer i = 0; i < 10000; i++) {
            Integer finalI = i;
            threadPoolExecutor.submit(() -> {
                if (stockManager.lockStock(1)) {
                    if (finalI % 2 == 0) {
                        stockManager.unLock(1);
                        System.out.println("客户" + finalI + "放弃库存");
                    } else {
                        System.out.println("客户" + finalI + "成功抢占库存");
                    }
                }
            });
        }
        // 不再接受新的任务,执行完之后关闭
        threadPoolExecutor.shutdown();
        while (true) {
        }
    }

}

** 需要注意的事抢占成功不一定是100。。因为可能在版本冲突过程中,线程已经全部执行完成。。。。这个就是和自旋锁不断重试的机制不同的地方(后者会不断取出当前值做比较)

与我们一般使用的乐观锁还有的不同是,这边的CAS实际上是一种自旋锁机制,即不断重试,由此衍生的问题也就是说,竞争情况很多的时候会导致CPU性能瓶颈(只能说适用场景不同,并不能说自旋锁不适用于高并发场景)

**************************************切割下,现代网站系统通常都是分布式的,所以理论上这些都没办法直接被应用***************************************

通常我们可能会采用一种单机服务机制,比如说像授权服务,我们会有统一的一个服务,虽然可能会有多机部署,但从使用上,会保证一个登陆记录的唯一性

所以分布式中,对于秒杀的一般思路,很多面试官提及或者想要问的是队列。我自己大概也有几种思路(还没参考过网上的,所以也不知道这几个思路是不是有可取的)

第一种:

1、类似于我去大丰收吃饭,高峰期,取个号等就是了,门口就不会被堵住。

那么如何实现取号呢?

   - 在redis中预热加载库存信息,并初始化比如说100张的入场券

 - 提交订单的时候先到叫号机服务中获取一个编号,这个叫号机采用的是无边界队列模式(另外可以参考有边界的,通常是100个长度,最多取100个号,其他人直接拒绝),这个号码被绑定到登录信息中

  ps:这边其实可以在这个叫号机上面做扩展,相当于流量控制算法

 - 服务器后台自动轮询这个队列,一但入场券池中有券,就绑定到具体的号码(其实相当于关联到某个用户);超过一定时间没有被响应(消费)就清除绑定关系,被取消排号的有效性,将入场券重新返回池中

  ps:redis中使用lua脚本控制事务性,保证入场券的ACID特性

 - 用户发现没有抢成功,但还显示有库存的时候,重新点击提交订单,此时叫号机发现已经交过号了,便不再排号,而是继续等待(等待服务器轮询入场券池,有空余的时候按照顺序绑定到叫号机的队列的具体元素中)

  ps:如果此时用户第一次抢失败,但是第二次点击的时候,入场券池的后台服务已经给此用户分配了券,则可以提交成功成功

 - 用户一旦取消订单,则入场券会重新回到池中

上面的这个做法大致体现了公平性(如果100个并发用户在第二轮抢占时,将会优先给之前排号在前的)

第二种:我觉得比较简单粗暴,就是生产和消费者的关系

  1、用户提交订单的时候,把对应的信息放到消息队列中(这里是生产)

  2、商城的后台系统直接从队列中轮询,一一消费,这里我可以直接操作关系型数据库了,因为队列的执行保证有序和一定时间内的处理数量

  3、如果商城后台成功消费了前台用户的订单信息,则会在消息队列中另外推送一条订单创建成功的信息

  4、前台用户通过同步(前端等待抢单成功中)或者异步(收到微信通知抢单成功)进入订单支付页面,相当于消费掉这条订单创建成功的消息

综合考虑:

个人认为一般在现代的分布式系统中会有如下几个考虑

1、利用网关的特性,限制指定时间内流量的数量,这个和服务没有任何直接关系,用的特性也是队列的机制;可以考虑线程池任务队列满的时候的执行策略(比如说限制1分钟内10000个访问量,那么多过的部分怎么处理,是直接拒绝,还是等待,还是分发一个类似于叫号的方式)

2、使用生产+消费的双工流程,保证操作的有序性,且拆解出不同的操作步骤;比如下单和库存锁定,我把它切割开,各自集中处理各自的逻辑(在这边的思路中可能下单的逻辑没有深入,所以看上去只是序列化下下单信息而已)

原文地址:https://www.cnblogs.com/gabin/p/13569536.html