实战剖析 Java 秒杀系统的实现

本场 Chat 将为您介绍,如何从 0 到 1 搭建一个分布式架构的秒杀系统,如何利用 Redis 的特性发挥它在秒杀系统中的大作用,如何利用消息队列实现请求的异步处理。带您思考实现秒杀系统过程中需要注意的点,以及需要掌握的技巧。

架构介绍

一个基于 Spring Cloud + Spring Boot 搭建的服务框架。

核心支撑组件

  1. 服务网关 Zuul
  2. 服务注册发现 Eureka + Ribbon
  3. 认证授权中心 Spring Security OAuth2、JWTToken
  4. 服务框架 Spring MVC/Boot
  5. 服务容错 Hystrix
  6. 分布式锁 Redis
  7. 服务调用 Feign
  8. 消息队列 Kafka
  9. 文件服务 私有云盘 / HDFS
  10. 富文本组件 UEditor
  11. 定时任务 XXL-JOB
  12. 配置中心 Apollo

我们都知道,要正常去实现一个 Web 端的秒杀系统,前端的处理和后端的处理一样重要;前端一般会做 CDN,后端一般会做分布式部署、限流、性能优化等一系列的操作,并完成一些网络的优化,比如 IDC 多线路(电信、联通、移动)的接入、带宽的升级、利用 DNS 域名解析的负载均衡实现多 IP 接入等。

而如果您的系统前端是基于目前很火爆的小程序,那么前端部分的优化尽可能都是在代码中完成,CDN 这一步就可以免了。

这里粗略地画了两张图,可能很多细节并不是特别到位,但整体的布局基本如此。

enter image description here

enter image description here

关于秒杀的场景特点分析

秒杀系统的场景特点

  • 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增;
  • 秒杀一般是访问请求量远远大于库存数量,只有少部分用户能够秒杀成功;
  • 秒杀业务流程比较简单,一般就是下订单操作。

秒杀架构设计理念

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

秒杀设计思路

这里主讲后端的处理部分。

  1. 秒杀相关活动页面的相关接口,所有查询能加缓存的,全部添加 Redis 缓存。
  2. 活动相关真实库存、锁定库存、限购、下单处理状态等全放 Redis。
  3. 当有请求进来时,首先通过 Redis 原子自增的方式记录当前请求数。当请求超过一定量,比如说库存的 10 倍之后,后面进入的请求则直接返回活动太火爆的响应;而能进入抢购的请求,则首先进入活动 ID 为粒度的分布式锁。
  • 第一步进行用户购买的重复性校验,满足条件进入下一步,否则返回已下单的提示。
  • 第二步,判断当前可锁定的库存是否大于购买的数量,满足条件进入下一步,否则返回已售罄的提示。
  • 第三步,锁定当前请求的购买库存,从锁定库存中减除,并将下单的请求放入消息队列。
  • 第四步,在 Redis 中标记一个 Polling 的 Key(用于轮询的请求接口判断用户是否下订单成功),在消息队列的消费端消费完成创建订单之后需要删除该 Key,并且维护一个活动 ID + 用户 ID 的 Key,防止重复购买。
  • 第五步,消息队列消费,创建订单,创建订单成功则扣减 Redis 中的真实库存,并且删除 Polling 的 Key。如果下单过程出现异常,返还锁定库存,提示用户下单失败。
  • 第六步,提供一个轮询接口,给前端在完成抢购动作后,检查最终下订单操作是否成功,主要判断依据是 Redis 中的 Polling 的 Key 的状态。
  1. 整个流程会将所有到后端的请求拦截的在 Redis 的缓存层面,除了最终能下订单的库存限制订单会与数据库存在交互外,基本上无其他的交互,将数据库 I/O 压力降到了最低。

如上,讲的实现方案是可以简述为:

控制请求放行数,通过分布式锁控制控制库存超卖。

那么,接下来,要介绍另外一种方案,我先讲述整个处理流程。

  1. 秒杀相关活动页面的相关接口,所有查询能加缓存的,全部添加 Redis 缓存。
  2. 活动相关真实库存、锁定库存、限购、下单处理状态等全放 Redis。
  3. 当有请求进来时,从缓存中活动开始时间判断活动是否开始,未开始直接返回提示;下一步同样定义一个以活动 ID 为粒度的 Redis 计数器,放行活动库存的 N 倍(自定义)请求进入真正的业务处理,其余的直接返回提示已售罄。
  4. 而被放行的请求,则放入到消息队列中,返回一个请求成功的响应,等待前端继续发起轮询的请求。
  5. 接下来是消息队列中的处理,不断从队列中取出消息进行消费,并通过分布式锁来控制库存的超卖等情况。当库存被消耗完后,则提示商品已售罄,活动结束;若能够锁定库存的请求,则返回一个下单资格码,提示用户已经取得秒杀的资格,可以选择下单秒杀;当用户请求下单时,则带着下单资格码重新请求,并完成下单操作(这里需要检验下单资格码是否与用户对应)。

这里省略了部分细节的讲解。

接下来给出流程示意图:

enter image description here

enter image description here

关于限流

Spring Cloud Zuul 的层面有很好的限流策略,可以防止同一用户的恶意请求行为。

zuul:
    ratelimit:
        key-prefix: your-prefix  #对应用来标识请求的Key的前缀
        enabled: true
        repository: REDIS  #对应存储类型(用来存储统计信息)
        behind-proxy: true  #代理之后
        default-policy: #可选 - 针对所有的路由配置的策略,除非特别配置了policies
             limit: 10 #可选 - 每个刷新时间窗口对应的请求数量限制
             quota: 1000 #可选-  每个刷新时间窗口对应的请求时间限制(秒)
              refresh-interval: 60 # 刷新时间窗口的时间,默认值 (秒)
               type: #可选 限流方式
                    - user
                    - origin
                    - url
          policies:
                myServiceId: #特定的路由
                      limit: 10 #可选- 每个刷新时间窗口对应的请求数量限制
                      quota: 1000 #可选-  每个刷新时间窗口对应的请求时间限制(秒)
                      refresh-interval: 60 # 刷新时间窗口的时间,默认值 (秒)
                      type: #可选 限流方式
                          - user
                          - origin
                          - url

关于负载与分流

当一个活动的访问量级特别大的时候,可能从域名分发进来 Nginx 就算是做了高可用,但实际上最终还是单机在线。仍旧敌不过超大流量的压力时,我们可以考虑域名的多 IP 映射。也就是说同一个域名下面映射多个外网的 IP,再映射到 DMZ 的多组高可用的 Nginx 服务上,Nginx 再配置可用的应用服务集群来减缓压力。

这里也顺带介绍下,Redis 也可以采用 Redis Cluster 的分布式实现方案,同时 Spring Cloud Hystrix 也能有服务容错的效果。

而关于 Nginx、Spring Boot 的 Tomcat、Zuul 等的一系列参数优化操作,对于性能的访问提升也至关重要。

补充说明一点,即使前端是基于小程序实现的,但是活动相关的图片资源都是放在自己的云盘服务器上,所以在活动前把活动相关的图片资源上传到 CDN 也是至关重要的,否则哪怕你 IDC 有再大流量带宽,也会分分钟被吃完。

项目实战

整理了一个小 Demo,把主要的业务逻辑抽出来了。为了方便处理,暂时弄成了 Spring Boot 的小应用,上文中提到的很多组件并没有全部集成进来,只保留了核心的业务处理逻辑。

Git 地址:

https://github.com/coderliguoqing/distributed-seckill.git

项目集成了 Swagger 的接口管理,同时增加了一个简单的 Demo 页面的请求案例。

项目启动后访问 http://localhost:8080/swagger-ui.html

enter image description here

Web 版的秒杀地址:http://localhost:8080/views/index.html

enter image description here

敲黑板,划重点

enter image description here

如上的 Controller 方法中,几个关键点:

1. 活动的开始判断应该放在缓存中来完成

//判断秒杀活动是否开始
if( !seckillService.checkStartSeckill(stallActivityId) ) {
    return new BaseResponse(false, 6205, "秒杀活动尚未开始,请稍等!");
}

2. 限流的其中一种实现方式,防止过多的请求进入到队列中处理

//这里拒绝多余的请求,比如库存100,那么超过500或者1000的请求都可以拒绝掉,利用Redis的原子自增操作
long count = redisRepository.incr("BM_MARKET_SECKILL_COUNT_" + stallActivityId);
if( count > 500 ) {
    return new BaseResponse(false, 6405, "活动太火爆,已经售罄啦!");
}

这里要说明一下,限流的方式有很多种,首先从 Nginx 层面就可以实现,或者利用 Spring MVC 的 Interceptor 添加限流器来限流。具体可以参考项目中的 LimitInteceptor.java 中的限流器的实现。

而上个代码中,使用的是 Redis 的原子自增方式来做计数器,当达到一定量之后就直接返回。这样做的好处是能做到全局的统计。而至于 Redis 的原子自增,如果还不太清楚的老铁,那你可要加油啦。

//做用户重复购买校验
if( redisRepository.exists("BM_MARKET_SECKILL_LIMIT_" + stallActivityId + "_" + openId) ) {
    return new BaseResponse(false, 6105, "您正在参与该活动,不能重复购买!");
}
//放入kafka消息队列
kafkaSender.sendChannelMess("demo_seckill_queue", jsonStr.toString());
return new BaseResponse();

代码如上,做了两步操作,一是用户的重复性校验,防止重复下单出现;二是将最终校验通过的请求放入到 Kafka 的消息队列中,让 Consumer 端去消费消息,达到异步处理的目的。

3. 接下来就是 Kafka 消费端的处理过程,KafkaConsumer.java 类的讲解。

@KafkaListener(topics = {"demo_seckill_queue"})
public void receiveMessage2(String message) {
    JSONObject json = JSONObject.parseObject(message);

很简单的一段代码,在消费端去添加注解,指定我们在放入消息队列时的 Topic 进行消费,得到的消息转换为 JSON 对象。

RedisConnection redisConnection = jedisConnectionFactory.getConnection();
DistributedExclusiveRedisLock lock = new DistributedExclusiveRedisLock(redisTemplate, (Jedis)redisConnection.getNativeConnection()); //构造锁的时候需要带入RedisTemplate实例
lock.setLockKey("marketOrder"+stallActivityId); //控制锁的颗粒度(摊位活动ID)
lock.setExpires(1L); //每次操作预计的超时时间,单位秒
try{
    lock.lock();
    JSONObject result = new JSONObject();
    SeckillInfoResponse response = new SeckillInfoResponse();
    String redisStock = redisRepository.get("BM_MARKET_SECKILL_STOCKNUM_" + stallActivityId);
    int surplusStock = Integer.parseInt(redisStock == null ? "0" : redisStock); //剩余库存
    //如果剩余库存大于购买数量,则获得下单资格,并生成唯一下单资格码
    if( surplusStock >= purchaseNum ) {
        response.setIsSuccess(true);
        response.setResponseCode(0);
        response.setResponseMsg("您已获得下单资格,请尽快下单");
        response.setRefreshTime(0);
        String code = SerialNo.getUNID();
        response.setOrderQualificationCode(code);
        //将下单资格码维护到Redis中,用于下单时候的检验;有效时间10分钟;                   redisRepository.setExpire("BM_MARKET_SECKILL_QUALIFICATION_CODE_" + stallActivityId + "_" + openId, code, 10*60);
        //维护一个Key,防止获得下单资格用户重新抢购,当支付过期之后应该维护删除该标志
        redisRepository.setExpire("BM_MARKET_SECKILL_LIMIT_" + stallActivityId + "_" + openId, "true", 3600*24*7);
        //扣减锁定库存
        redisRepository.decrBy("BM_MARKET_SECKILL_STOCKNUM_" + stallActivityId, purchaseNum);
    }else {
        response.setIsSuccess(false);
        response.setResponseCode(6102);
        response.setResponseMsg("秒杀失败,商品已经售罄");
        response.setRefreshTime(0);
    }
    result.put("response", response);
    //将信息维护到Redis中          redisRepository.setExpire("BM_MARKET_SECKILL_QUEUE_"+stallActivityId+"_"+openId, result.toJSONString(), 3600*24*7);
}finally{
    lock.unlock();
    redisConnection.close();
}

如上这段代码是重点,也是最终获得下单资格者在做库存处理时的重点。而在此处引入 Redis 分布式锁的目的,也是为了防止分布式场景下,多客户端同时消费造成的并发情况,导致在锁定库存时可能出错导致出卖的情况。

并且处理过程中的数据在此处都不直接与数据库打交道,而是直接放入 Redis 缓存。如果用户最终获得下单资格,并且锁定了库存,那么则生成下单资格码,存在以用户 ID 为标志的 Redis 中,并扣减库存。

4. 轮询请求判断是否获得了下单资格码

    /**
     * 轮询请求  判断是否获得下单资格
     * @param jsonObject
     * @return
     */
    @ApiOperation(value="轮询接口--先队列模式",nickname="Guoqing")
    @RequestMapping(value="/seckillPollingQueue", method=RequestMethod.POST)
    public SeckillInfoResponse seckillPollingQueue(@RequestBody JSONObject jsonObject) {
        int stallActivityId = jsonObject.containsKey("stallActivityId") ? jsonObject.getInteger("stallActivityId") : -1;        //活动Id
        AssertUtil.isTrue(stallActivityId != -1, "非法參數");
        String openId = jsonObject.containsKey("openId") ? jsonObject.getString("openId") : null;
        AssertUtil.isTrue(!StringUtil.isEmpty(openId), 1101, "非法參數");

        SeckillInfoResponse response = new SeckillInfoResponse();
        //是否存在下单资格码的Key
        if( redisRepository.exists("BM_MARKET_SECKILL_QUEUE_"+stallActivityId+"_"+openId) ){
            String result = redisRepository.get("BM_MARKET_SECKILL_QUEUE_"+stallActivityId+"_"+openId);
            response = JSONObject.parseObject(JSONObject.parseObject(result).getJSONObject("response").toJSONString(), SeckillInfoResponse.class);
        } else {
            response.setIsSuccess(true);
            response.setResponseCode(0);
            response.setResponseMsg("活动太火爆,排队中...");
            response.setRefreshTime(0);
        }
        return response;
    }

首先,说说为什么用轮询而不是长连接,可能这里也会存在疑问或者争论。我的观点是,此处的轮询请求并不会太多。由于轮询基本是直接访问 Redis 缓存,效率很快,并且前端设置轮询的频率 1s/次就足够了,那么对系统的性能并不会有太大的影响。而如果采用长连接,则反而对系统的性能消耗更大,因为系统要一直维护许多的长连接,只到请求完成。

这个接口相对简单,只有一个目的,就是根据自己上一个秒杀的成功请求,来询问自己是不是最终获得了下单资格码。即通过活动 ID 和用户 ID 去 Redis 中获取 Value,得到最终结果。

5. 根据下单资格码创建订单

    /**
     * 根据获取到的下单资格码创建订单
     * @param jsonObject
     * @return
     */
    @ApiOperation(value="先队列模式--下单接口",nickname="Guoqing")
    @RequestMapping(value="/createOrder", method=RequestMethod.POST)
    public BaseResponse createOrder(@RequestBody JSONObject jsonObject) {
        int stallActivityId = jsonObject.containsKey("stallActivityId") ? jsonObject.getInteger("stallActivityId") : -1;        //活动Id
        AssertUtil.isTrue(stallActivityId != -1, "非法參數");
        String openId = jsonObject.containsKey("openId") ? jsonObject.getString("openId") : null;
        AssertUtil.isTrue(!StringUtil.isEmpty(openId), 1101, "非法參數");
        String orderQualificationCode = jsonObject.containsKey("orderQualificationCode") ? jsonObject.getString("orderQualificationCode") : null;
        AssertUtil.isTrue(!StringUtil.isEmpty(orderQualificationCode), 1101, "非法參數");

        //校验下单资格码
        String redisQualificationCode = redisRepository.get("BM_MARKET_SECKILL_QUALIFICATION_CODE_" + stallActivityId + "_" + openId);
        if(StringUtils.isEmpty(redisQualificationCode) || !orderQualificationCode.equals(redisQualificationCode) ) {
            return new BaseResponse(false, 6305, "您的资格码已经过期!");
        }else {
            //走后续的下单流程,并校验真实库存;该接口的流量已经是与真实库存几乎相匹配的流量值,按理不应该存在超高并发
            return new BaseResponse();
        }
    }

该请求的前提是,用户在上一个轮询接口之后,拿到了下单资格码,那么就请求该接口,并且传入下单资格码,然后同样在 Redis 中检验已经维护好的下单资格码是否最终匹配。如果匹配的话,那么恭喜你,你秒杀成功了!

至于下订单,我就不再过多讲解了,与正常的下订单别无二异啦,只是要注意异常情况下的数据回滚和维护,以及减库存等关键操作的并发问题。

所以,分析下来,在这次实战中,Redis 和消息队列,尤其是 Redis 扮演了特别重要的角色。而这也正是由于 Redis 作为一个内存数据库高性能的表现。

对于前端的处理过程,我在项目中写了一个简单的 Web 页面,在 src/main/webapp 目录下,有个 index.html 文件,里面有具体的前端请求过程。只要逻辑清楚,思路相当简单,就是理清楚请求的逻辑顺序就 OK。

大致是 goSeckillByQueue -> seckillPollingQueue -> createOrder 而在 seckillPollingQueue 这个步骤上会存在轮询的请求。

性能测试

我们都知道,仅仅只是完成一个请求,那自然是很简单,而如果一旦大并发请求进来时,你能处理才是真正的成功;所以,这里笔者也提供了 Apache Jmeter 的性能测试报告,仅仅只是在单机下秒杀请求接口的测试就能达到如下的结果。

设置线程组参数:

enter image description here

设置请求参数:

enter image description here

运行结果:

enter image description here

由于篇幅有限,如果大家想了解更多的内容,笔者会继续发布 Chat 与大家分享。当然,如果您喜欢本人的分享,那么也欢迎大家帮忙转发分享给更多的朋友。

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值