秒杀项目

  • Post author:
  • Post category:其他




秒杀系统介绍

​ 秒杀无论是双十一购物还是 12306 抢票,秒杀场景已随处可见。简单来说,秒杀就是在同一时刻大量请求争抢购买同一商品并完成交易的过程。从架构视角来看,秒杀系统本质是一个高性能、高一致、高可用的三高系统。

通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动



秒杀系统特点

  • 高并发:秒杀的特点就是这样

    时间极短



    瞬间用户量大

  • 库存量少:一般秒杀活动商品量很少,这就导致了只有极少量用户能成功购买到。
  • 业务简单:流程比较简单,一般都是下订单、扣库存、支付订单
  • 恶意请求,数据库压力大
  • 秒杀的商品不需要添加到购物车
  • 秒杀系统独立部署

秒杀活动对稀缺或者特价的商品进行定时定量售卖,吸引成大量的消费者进行抢购,但又只有少部分消费者可以下单成功。因此,秒杀活动将在较短时间内产生比平时大数十倍,上百倍的页面访问流量和下单请求流量。

秒杀活动可以分为3个阶段:

  • 秒杀前:用户不断刷新商品详情页,页面请求达到瞬时峰值。
  • 秒杀开始:用户点击秒杀按钮,下单请求达到瞬时峰值。
  • 秒杀后:一部分成功下单的用户不断刷新订单或者产生退单操作,大部分用户继续刷新商品详情页等待退单机会。

消费者提交订单,一般做法是利用数据库的行级锁,只有抢到锁的请求可以进行库存查询和下单操作。但是在高并发的情况下,数据库无法承担如此大的请求,往往会使整个服务 blocked,在消费者看来就是服务器宕机。



秒杀需要解决的问题

  • 商品超卖问题
  • 高平发的处理
  • 库存和订单一致性的问题



秒杀系统设计理念

  • 限流:鉴于只有少部分用户能够秒杀成功,所以要限制大部分流量,只允许少部分流量进入服务后端(暂未处理)。
  • 削峰:对于秒杀系统瞬时的大量用户涌入,所以在抢购开始会有很高的瞬时峰值。实现削峰的常用方法有利用缓存或消息中间件等技术。
  • 异步处理:对于高并发系统,采用异步处理模式可以极大地提高系统并发量,异步处理就是削峰的一种实现方式。
  • 内存缓存:秒杀系统最大的瓶颈最终都可能会是数据库的读写,主要体现在的磁盘的 I/O,性能会很低,如果能把大部分的业务逻辑都搬到缓存来处理,效率会有极大的提升。
  • 可拓展:如果需要支持更多的用户或更大的并发,将系统设计为弹性可拓展的,如果流量来了,拓展机器就好。



秒杀场景

  • 电商抢购限量商品
  • 火车票抢座 12306



数据库建表

CREATE TABLE `t_goods` (
  `id` int(10) NOT NULL AUTO_INCREMENT,
  `gname` varchar(255) COLLATE utf8_bin DEFAULT NULL,
  `gdesc` varchar(255) COLLATE utf8_bin DEFAULT NULL,
  `gprice` decimal(10,0) DEFAULT NULL,
  `gstock` int(10) DEFAULT NULL,
  `gpic` text COLLATE utf8_bin,
  `gtype` int(11) DEFAULT NULL,
  `version` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;


CREATE TABLE `t_order` (
  `id` int(10) NOT NULL AUTO_INCREMENT,
  `gid` int(10) DEFAULT NULL,
  `gname` varchar(30) COLLATE utf8_bin DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5335 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;



业务流程

1601282030683



使用乐观锁解决超卖问题

public interface IGoodsMpper{

    @Select("select * from t_goods where id = #{gid}")
    public Goods getGoodsById(Integer id);

    @Update("update t_goods set gstock = gstock - 1,version=version+1 where id = #{id} and version = #{version}")
    public int updateGstock(@Param("id") Integer id, @Param("version") Integer version);
}



接口限流

​ 什么是接口限流?

​ 某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或宕机`



保护高并发系统的三把利器
  • 缓存:缓存的目的是提升系统访问速度和增大系统处理容量
  • 降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行
  • 限流:限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。


如何解决接口限流

​ 常用的限流算法有

令牌桶

和和

漏桶(漏斗算法)

,而Google开源项目Guava中的RateLimiter使用的就是令牌桶控制算法



漏斗算法

1601282825403

​ 把请求比作是水,水来了都先放进桶里,并以限定的速度出水,当水来得过猛而出水不够快时就会导致水直接溢出,即拒绝服务。

​ 漏斗有一个进水口 和 一个出水口,出水口以一定速率出水,并且有一个最大出水速率。

在漏斗中没有水的时候:

如果进水速率小于等于最大出水速率,那么,出水速率等于进水速率,此时,不会积水

如果进水速率大于最大出水速率,那么,漏斗以最大速率出水,此时,多余的水会积在漏斗中

在漏斗中有水的时候:

如果漏斗未满,且有进水的话,那么这些水会积在漏斗中

如果漏斗已满,且有进水的话,那么这些水会溢出到漏斗之外



令牌桶

1601283891781

​ 最初来源于计算机网络。在网络传输数据时,为了防止网络拥塞,需限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且拿令牌的过程并不是消耗很大的事情。



令牌桶的应用


添加依赖
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>28.2-jre</version>
</dependency>


令牌桶算法应用
  // 创建令牌桶实例, 每秒给桶中放10个令牌
    private RateLimiter rateLimiter = RateLimiter.create(10);

    @GetMapping("/testToken")
    @ResponseBody
    public String testToken() {
        // 获取令牌,该方法会被阻塞直到获取到请求,返回0表示获取令牌成功
        double acquire = rateLimiter.acquire();

        // 尝试获取令牌,设置等待时间为1s
        if (rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
            System.out.println("抢购成功");
            return "抢购成功。。。";
        } else {
            System.out.println("抢购失败");
            return "抢购失败。。。";
        }
    }


RateLimiter主要接口
/**
* 创建一个稳定输出令牌的RateLimiter,保证了平均每秒不超过permitsPerSecond个请求
* 当请求到来的速度超过了permitsPerSecond,保证每秒只处理permitsPerSecond个请求
* 当这个RateLimiter使用不足(即请求到来速度小于permitsPerSecond),会囤积最多permitsPerSecond个请求
*/
public static RateLimiter create(double permitsPerSecond);

/**
* 创建一个稳定输出令牌的RateLimiter,保证了平均每秒不超过permitsPerSecond个请求
* 还包含一个热身期(warmup period),热身期内,RateLimiter会平滑的将其释放令牌的速率加大,直到起达到最大速率
* 同样,如果RateLimiter在热身期没有足够的请求(unused),则起速率会逐渐降低到冷却状态
*
* 设计这个的意图是为了满足那种资源提供方需要热身时间,而不是每次访问都能提供稳定速率的服务的情况(比如带缓存服务,需要定期刷新缓存的)
* 参数warmupPeriod和unit决定了其从冷却状态到达最大速率的时间
*/
public static RateLimiter create(double permitsPerSecond, long warmupPeriod, TimeUnit unit);


// 表示获取一个或多个令牌.如果没有令牌则一直等待,返回等待的时间(单位为秒),没有被限流则直接返回0.0:
public double acquire();
public double acquire(int permits);


// 尝试获取令牌,分为待超时时间和不带超时时间两种:
//尝试获取一个令牌,立即返回
public boolean tryAcquire();
public boolean tryAcquire(int permits);
public boolean tryAcquire(long timeout, TimeUnit unit);
//尝试获取permits个令牌,带超时时间
public boolean tryAcquire(int permits, long timeout, TimeUnit unit);



使用令牌桶实现接口限流



添加拦截器
@Component
public class LimitInterceptor implements HandlerInterceptor {

    private RateLimiter rateLimiter = RateLimiter.create(60);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 获取到令牌往下执行
        if (rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) {
            return true;
        } else {
            return false;
        }
    }
}


配置拦截器
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private LimitInterceptor limitInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(limitInterceptor).addPathPatterns("/**");
    }
}



限时抢购

​ 目前为止我们完成了防止超卖商品和抢购接口的限流,已经能够防止大流量把我们的服务器直接搞炸,但是现在设计的系统还存在一些问题。

  • 在任何时间都可以进行秒杀,一般是在一定时间内可以进行秒杀
  • 秒杀开始之后单个用户请求的频率
  • 黑客通过脚本进行抢购

利用Redis超时机制实现秒杀,秒杀开始后把seckilil_gid作为key,gid做为value,并设置超时时间,秒杀请求过来后先判断该key是否已进过期,如果已经过期说明秒杀已经结束。



秒杀开始设置超时时间

1601298832525



抢购是判断是否已经结束
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public Goods checkGoodsStock(Integer id) {
        Goods goods = killMapper.getGoodsById(id);
        if (goods.getGstock() <= 0) {
            throw new RuntimeException("商品已经被抢购为空");
        }
        return goods;

    }

    public int updateStock(Goods goods) {
        int row = killMapper.updateGstock(goods.getId(), goods.getVersion());
        if (row <= 0) {
            throw new RuntimeException("修改库存失败。。");
        }
        return row;
    }

    public void createOrder(Goods goods) {
        Order order = new Order();
        order.setGid(goods.getId());
        order.setGname(goods.getGname());
        order.setCreateTime(new Date());
        orderMapper.insert(order);
    }


    @Override
    public boolean killGoods(Integer id) {

        // 检查秒杀是否开始
        checkSecillIsStart(id);

        // 1.检查库存
        Goods goods = checkGoodsStock(id);

        // 2.修改库存
        updateStock(goods);

        // 3.创建订单
        createOrder(goods);
        return true;
    }

    private void checkSecillIsStart(Integer id) {
        if(!stringRedisTemplate.hasKey("seckill_"+id)){
            throw new RuntimeException("秒杀已经结束");
        }
    }



限制单用户抢购频率

   private boolean chekUserAccessLimit(Integer gid, Integer uid) {
        // 设置redis中的key
        String key = "seckill_" + uid + "_" + gid;

        if (!stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 60, TimeUnit.SECONDS)) {
            // 获取用户访问商品的次数
            synchronized (this){
                String num = stringRedisTemplate.opsForValue().get(key);
//            System.out.println("用户访问次数:" + num);
                if (Integer.parseInt(num) >= 10) {
                    return  false;
                }
                // 访问次数自增
                stringRedisTemplate.opsForValue().increment(key,1);
            }
        }
        return true;
    }



削锋填谷

某应用的处理能力是每秒 10 个请求。在某一秒,突然到来了 30 个请求,而接下来两秒,都没有请求到达。在这种情况下,如果直接拒绝 20 个请求,应用在接下来的两秒就会空闲。所以,需要把请求突刺均摊到一段时间内,让系统负载保持在请求处理水位之内,同时尽可能地处理更多请求,从而起到“削峰填谷”的效果。

spring:
  rabbitmq:
    host: 192.168.193.88
    port: 5672
    username: guest
    password: guest
    virtual-host: /
    listener:
      type: simple
      simple:
   		prefetch: 10 # 消费者每次从队列获取的消息数量
        concurrency: 1 # 消费者数量
        max-concurrency: 1 # 启动消费者最大数量



秒杀系统其它环节解决方案

前端:页面资源静态化,按钮控制,使用答题校验码可以防止秒杀器的干扰,让更多用户有机会抢到

nginx:校验恶意请求,转发请求,负载均衡;动静分离,不走tomcat获取静态资源;gzip压缩,减少静态文件传输的体积,节省带宽,提高渲染速度

业务层:集群,多台机器处理,提高并发能力

redis:集群保证高可用,持久化数据;分布式锁(悲观锁);缓存热点数据(库存)

mq:削峰限流,MQ堆积订单,保护订单处理层的负载,Consumer根据自己的消费能力来取Task,实际上下游的压力就可控了。重点做好路由层和MQ的安全

数据库:读写分离,拆分事务提高并发度

img




秒杀系统设计小结

  • 秒杀系统就是一个“三高”系统,即

    高并发、高性能



    高可用

    的分布式系统
  • 秒杀设计原则:

    前台请求尽量少,后台数据尽量少,调用链路尽量短,尽量不要有单点
  • 秒杀高并发方法:

    访问拦截、分流、动静分离
  • 秒杀数据方法:

    减库存策略、热点、异步、限流降级
  • 访问拦截主要思路:通过CDN和缓存技术,尽量把访问拦截在离用户更近的层,尽可能地过滤掉无效请求。
  • 分流主要思路:通过分布式集群技术,多台机器处理,提高并发能力



什么是CDN?

CDN——Content Delivery Network,内容分发网络, 就是采用更多的缓存服务器(CDN边缘节点),布放在用户访问相对集中的地区或网络中。当用户访问网站时,利用全局负载技术,将用户的访问指向距离最近的缓存服务器上,由缓存服务器响应用户请求。(有点像电商的本地仓吧?)




利用浏览器缓存和 CDN 抗压静态页面流量

秒杀前,用户不断刷新商品详情页,造成大量的页面请求。所以,我们需要把秒杀商品详情页与普通的商品详情页分开。对于秒杀商品详情页尽量将能静态化的元素静态化处理,除了秒杀按钮需要服务端进行动态判断,其他的静态数据可以缓存在浏览器和 CDN 上。这样,秒杀前刷新页面导致的流量进入服务端的流量只有很小的一部分。


利用读写分离 Redis 缓存拦截流量

CDN 是第一级流量拦截,第二级流量拦截我们使用支持读写分离的 Redis。在这一阶段我们主要读取数据,读写分离 Redis 能支持高达60万以上 qps,完全可以支持需求。

首先通过数据控制模块,提前将秒杀商品缓存到读写分离 Redis,并设置秒杀开始标记如下:

"goodsId_count": 100 //总数
"goodsId_start": 0   //开始标记
"goodsId_access": 0  //接受下单数
  1. 秒杀开始前,服务集群读取 goodsId_Start 为 0,直接返回未开始。
  2. 数据控制模块将 goodsId_start 改为1,标志秒杀开始。
  3. 服务集群缓存开始标记位并开始接受请求,并记录到 redis 中 goodsId_access,商品剩余数量为(goodsId_count – goodsId_access)。
  4. 当接受下单数达到 goodsId_count 后,继续拦截所有请求,商品剩余数量为 0。

可以看出,最后成功参与下单的请求只有少部分可以被接受。在高并发的情况下,允许稍微多的流量进入。因此可以控制接受下单数的比例。


利用主从版 Redis 缓存加速库存扣量

成功参与下单后,进入下层服务,开始进行订单信息校验,库存扣量。为了避免直接访问数据库,我们使用主从版 Redis 来进行库存扣量,主从版 Redis 提供10万级别的 QPS。使用 Redis 来优化库存查询,提前拦截秒杀失败的请求,将大大提高系统的整体吞吐量。

通过数据控制模块提前将库存存入 Redis,将每个秒杀商品在 Redis 中用一个 hash 结构表示。

"goodsId" : {
    "Total": 100
    "Booked": 100
}

扣量时,服务器通过请求 Redis 获取下单资格,通过以下 lua 脚本实现,由于 Redis 是单线程模型,lua 可以保证多个命令的原子性。

local n = tonumber(ARGV[1])
if not n or n == 0 then
    return 0
end
local vals = redis.call("HMGET", KEYS[1], "Total", "Booked");
local total = tonumber(vals[1])
local blocked = tonumber(vals[2])
if not total or not blocked then
    return 0
end
if blocked + n <= total then
    redis.call("HINCRBY", KEYS[1], "Booked", n)
    return n;
end
return 0

先使用

SCRIPT LOAD

将 lua 脚本提前缓存在 Redis,然后调用

EVALSHA

调用脚本,比直接调用

EVAL

节省网络带宽:

redis 127.0.0.1:6379>SCRIPT LOAD "lua code"
"438dd755f3fe0d32771753eb57f075b18fed7716"
redis 127.0.0.1:6379>EVAL 438dd755f3fe0d32771753eb57f075b18fed7716 1 goodsId 1

秒杀服务通过判断 Redis 是否返回抢购个数 n,即可知道此次请求是否扣量成功。


使用主从版 Redis 实现简单的消息队列异步下单入库

扣量完成后,需要进行订单入库。如果商品数量较少的时候,直接操作数据库即可。如果秒杀的商品是1万,甚至10万级别,那数据库锁冲突将带来很大的性能瓶颈。因此,利用消息队列组件,当秒杀服务将订单信息写入消息队列后,即可认为下单完成,避免直接操作数据库。

  1. 消息队列组件依然可以使用 Redis 实现,在 R2 中用 list 数据结构表示。
orderList {
     [0] = {订单内容}
     [1] = {订单内容}
     [2] = {订单内容}
     ...
 }

将订单内容写入 Redis:

LPUSH orderList {订单内容}

异步下单模块从 Redis 中顺序获取订单信息,并将订单写入数据库。

BRPOP orderList 0

通过使用 Redis 作为消息队列,异步处理订单入库,有效的提高了用户的下单完成速度。


数据控制模块管理秒杀数据同步

扣量完成后,需要进行订单入库。如果商品数量较少的时候,直接操作数据库即可。如果秒杀的商品是1万,甚至10万级别,那数据库锁冲突将带来很大的性能瓶颈。因此,利用消息队列组件,当秒杀服务将订单信息写入消息队列后,即可认为下单完成,避免直接操作数据库。

  1. 消息队列组件依然可以使用 Redis 实现,在 R2 中用 list 数据结构表示。
orderList {
     [0] = {订单内容}
     [1] = {订单内容}
     [2] = {订单内容}
     ...
 }

将订单内容写入 Redis:

LPUSH orderList {订单内容}

异步下单模块从 Redis 中顺序获取订单信息,并将订单写入数据库。

BRPOP orderList 0

通过使用 Redis 作为消息队列,异步处理订单入库,有效的提高了用户的下单完成速度。


数据控制模块管理秒杀数据同步

最开始,利用读写分离 Redis 进行流量限制,只让部分流量进入下单。对于下单检验失败和退单等情况,需要让更多的流量进来。因此,数据控制模块需要定时将数据库中的数据进行一定的计算,同步到主从版 Redis,同时再同步到读写分离的 Redis,让更多的流量进来



版权声明:本文为weixin_43556773原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。