分布式场景下的秒杀架构与秒杀实现

随着项目的上线与稳定运行,有关小程序秒杀系统的工作也算是告一段落了,最近也是抽空整理整理相关资料,留下了这篇文档;

分析,在做秒杀系统的设计之初,一直在思考如何去设计这个秒杀系统,使之在现有的技术基础和认知范围内,能够做到最好;同时也能充分的利用公司现有的中间件来完成系统的实现。

我们都知道,正常去实现一个WEB端的秒杀系统,前端的处理和后端的处理一样重要;前端一般会做CDN,后端一般会做分布式部署,限流,性能优化等等一系列的操作,并完成一些网络的优化,比如IDC多线路(电信、联通、移动)的接入,带宽的升级等等。而由于目前系统前端是基于微信小程序,所以关于前端部分的优化就尽可能都是在代码中完成,CDN这一步就可以免了;

1、架构介绍

后端项目是基于SpringCloud+SpringBoot搭建的微服务框架架构

前端在微信小程序商城上

### 核心支撑组件

- 服务网关 Zuul

- 服务注册发现 Eureka+Ribbon

- 认证授权中心 Spring Security OAuth2、JWTToken

- 服务框架 Spring MVC/Boot

- 服务容错 Hystrix

- 分布式锁 Redis

- 服务调用 Feign

- 消息队列 Kafka

- 文件服务 私有云盘

- 富文本组件 UEditor

- 定时任务 xxl-job

- 配置中心 apollo

2、关于秒杀的场景特点分析

#### 秒杀系统的场景特点

- 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增;

- 秒杀一般是访问请求量远远大于库存数量,只有少部分用户能够秒杀成功;

- 秒杀业务流程比较简单,一般就是下订单操作;


#### 秒杀架构设计理念

-限流:鉴于只有少部分用户能够秒杀成功,所以要限制大部分流量,只允许少部分流量进入服务后端(暂未处理);

-削峰:对于秒杀系统瞬时的大量用户涌入,所以在抢购开始会有很高的瞬时峰值。实现削峰的常用方法有利用缓存或者消息中间件等技术;

-异步处理:对于高并发系统,采用异步处理模式可以极大地提高系统并发量,异步处理就是削峰的一种实现方式;

-内存缓存:秒杀系统最大的瓶颈最终都可能会是数据库的读写,主要体现在的磁盘的I/O,性能会很低,如果能把大部分的业务逻辑都搬到缓存来处理,效率会有极大的提升;

-可拓展:如果需要支持更多的用户或者更大的并发,将系统设计为弹性可拓展的,如果流量来了,拓展机器就好;


#### 秒杀设计思路

- 由于前端是属于小程序端,所以不存在前端部分的访问压力,所以前端的访问压力就无从谈起;

- 1、秒杀相关的活动页面相关的接口,所有查询能加缓存的,全部添加redis的缓存;

- 2、活动相关真实库存、锁定库存、限购、下单处理状态等全放redis;

- 3、当有请求进来时,进入活动ID为粒度的分布式锁,第一步进行用户购买的重复性校验,满足条件进入下一步,否则返回已下单的提示;

- 4、第二步,判断当前可锁定的库存是否大于购买的数量,满足条件进入下一步,否则返回已售罄的提示;

- 5、第三步,锁定当前请求的购买库存,从锁定库存中减除,并将下单的请求放入kafka消息队列;

- 6、第四步,在redis中标记一个polling的key(用于轮询的请求接口判断用户是否下订单成功),在kafka消费端消费完成创建订单之后需要删除该key,并且维护一个活动id+用户id的key,防止重复购买;

- 7、第五步,消息队列消费,创建订单,创建订单成功则扣减redis中的真实库存,并且删除polling的key。如果下单过程出现异常,则删除限购的key,返还锁定库存,提示用户下单失败;

- 8、第六步,提供一个轮询接口,给前端在完成抢购动作后,检查最终下订单操作是否成功,主要判断依据是redis中的polling的key的状态;

- 9、整个流程会将所有到后端的请求拦截的在redis的缓存层面,除了最终能下订单的库存限制订单会与数据库存在交互外,基本上无其他的交互,将数据库I/O压力降到了最低;


#### 关于限流

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

1 zuul:

2     ratelimit:

3         key-prefix: your-prefix  #对应用来标识请求的key的前缀

4         enabled: true

5         repository: REDIS  #对应存储类型(用来存储统计信息)

6         behind-proxy: true  #代理之后

7         default-policy: #可选 - 针对所有的路由配置的策略,除非特别配置了policies

8              limit: 10 #可选 - 每个刷新时间窗口对应的请求数量限制

9              quota: 1000 #可选-  每个刷新时间窗口对应的请求时间限制(秒)

10               refresh-interval: 60 # 刷新时间窗口的时间,默认值 (秒)

11                type: #可选 限流方式

12                     - user

13                     - origin

14                     - url

15           policies:

16                 myServiceId: #特定的路由

17                       limit: 10 #可选- 每个刷新时间窗口对应的请求数量限制

18                       quota: 1000 #可选-  每个刷新时间窗口对应的请求时间限制(秒)

19                       refresh-interval: 60 # 刷新时间窗口的时间,默认值 (秒)

20                       type: #可选 限流方式

21                           - user

22                           - origin

23                           - url


#### 关于负载与分流

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

这里也顺带介绍redis可以采用redis cluster的分布式实现方案,同时springcloud hystrix 也能有服务容错的效果;

而关于nxinx、springboot的tomcat、zuul等一系列参数优化操作对于性能的访问提升也是至关重要;

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

 2、主要代码实现

1    /**

2      * 06.04-去秒杀,创建秒杀订单

3      * <p>Title: testSeckill</p> 

4      * <p>Description: 秒杀下单</p> 

5      * @param jsonObject

6      * @return

7      */

8    @RequestMapping(value="/goSeckill", method=RequestMethod.POST)

9    public SeckillInfoResponse goSeckill(@RequestBody JSONObject jsonObject) {

            欢迎工作一到五年的Java工程师朋友们加入Java架构交流:681065582 提供免费的Java架构学习资料

10        int stallActivityId = jsonObject.containsKey("stallActivityId") ? jsonObject.getInteger("stallActivityId") : -1;        //活动Id

11        AssertUtil.isTrue(stallActivityId != -1, "非法參數");

12        int purchaseNum = jsonObject.containsKey("purchaseNum") ? jsonObject.getInteger("purchaseNum") : 1;        //购买数量

13        AssertUtil.isTrue(purchaseNum != -1, "非法參數");

14        String openId = jsonObject.containsKey("openId") ? jsonObject.getString("openId") : null;

15        AssertUtil.isTrue(!StringUtil.isEmpty(openId), 1101, "非法參數");

16        String formId = jsonObject.containsKey("formId") ? jsonObject.getString("formId") : null;

17        AssertUtil.isTrue(!StringUtil.isEmpty(formId), 1101, "非法參數");

18        long addressId = jsonObject.containsKey("addressId") ? jsonObject.getLong("addressId") : -1;

19        AssertUtil.isTrue(addressId != -1, "非法參數");

20        //通过分享入口进来的参数

21        String shareCode =  jsonObject.getString("shareCode");

22        String shareSource =  jsonObject.getString("shareSource");

23        String userCode =  jsonObject.getString("userId");

24       

25        return seckillService.startSeckill(stallActivityId, purchaseNum, openId, formId, addressId, shareCode, shareSource, userCode);

26    }

1    /**

2      * 06.05-轮询请求当前用户是否秒杀下单成功

3      * <p>Title: seckillPolling</p> 

4      * <p>Description: </p> 

5      * @param jsonObject

6      * @return

7      */

8    @RequestMapping(value="/seckillPolling", method=RequestMethod.POST)

9    public SeckillInfoResponse seckillPolling(@RequestBody JSONObject jsonObject) {

10        int stallActivityId = jsonObject.containsKey("stallActivityId") ? jsonObject.getInteger("stallActivityId") : -1;        //活动Id

11        AssertUtil.isTrue(stallActivityId != -1, "非法參數");

12        String openId = jsonObject.containsKey("openId") ? jsonObject.getString("openId") : null;

13        AssertUtil.isTrue(!StringUtil.isEmpty(openId), 1101, "非法參數");

14       

15        SeckillInfoResponse response = new SeckillInfoResponse();

16        if( redisRepository.exists("BM_MARKET_LOCK_POLLING_" + stallActivityId + "_" + openId) ) {

17            //如果缓存中存在锁定秒杀和用户ID的key,则证明该订单尚未处理完成,需要继续等待

18            response.setIsSuccess(true);

19            response.setResponseCode(6103);

20            response.setResponseMsg("排队中,请稍后");

21            response.setRefreshTime(1000);

22        } else {

23            //如果缓存中该key已经不存在,则表明该订单已经下单成功,可以进入支付操作,并取出orderId返回

                欢迎工作一到五年的Java工程师朋友们加入Java架构交流:681065582 提供免费的Java架构学习资料

24            String redisOrderInfo = redisRepository.get("BM_MARKET_SECKILL_ORDERID_" + stallActivityId + "_" + openId);

25            if( redisOrderInfo == null ) {

26                response.setIsSuccess(false);

27                response.setResponseCode(6106);

28                response.setResponseMsg("秒杀失败,下单出现异常,请重试!");

29                response.setOrderId(0);

30                response.setOrderCode(null);

31                response.setRefreshTime(0);

32            }else {

33                String[] orderInfo = redisOrderInfo.split("_");

34                long orderId = Integer.parseInt(orderInfo[0]);

35                String orderCode = orderInfo[1];

36                response.setIsSuccess(true);

37                response.setResponseCode(6104);

38                response.setResponseMsg("秒杀成功");

39                response.setOrderId(orderId);

40                response.setOrderCode(orderCode);

41                response.setRefreshTime(0);

42            }

43        }

44        return response;

45    }

1    @Override

2    @Transactional

3    public SeckillInfoResponse startSeckill(int stallActivityId, int purchaseNum, String openId, String formId, long addressId,

4              String shareCode, String shareSource, String userCode) {

5        SeckillInfoResponse response = new SeckillInfoResponse();

6        //判断秒杀活动是否开始

7        if( !checkStartSeckill(stallActivityId) ) {

8            response.setIsSuccess(false);

9            response.setResponseCode(6205);

10            response.setResponseMsg("秒杀活动尚未开始,请稍等!");

11            response.setRefreshTime(0);

12            return response;

13        }

14        DistributedExclusiveRedisLock lock = new DistributedExclusiveRedisLock(redisTemplate); //构造锁的时候需要带入RedisTemplate实例

15        lock.setLockKey("BM_MARKET_SECKILL_" + stallActivityId);        //控制锁的颗粒度

16        lock.setExpires(2L);    //每次操作预计的超时时间,单位秒

17        try {

18            lock.lock();    //获取锁

19            //做用户重复购买校验

20            if( redisRepository.exists("BM_MARKET_SECKILL_LIMIT_" + stallActivityId + "_" + openId) ) {

21                response.setIsSuccess(false);

22                response.setResponseCode(6105);

23                response.setResponseMsg("您正在参与该活动,不能重复购买");

24                response.setRefreshTime(0);

25            } else {

26                String redisStock = redisRepository.get("BM_MARKET_SECKILL_STOCKNUM_" + stallActivityId);

27                int surplusStock = Integer.parseInt(redisStock == null ? "0" : redisStock);    //剩余库存

28                //如果剩余库存大于购买数量,则进入消费队列

29                if( surplusStock >= purchaseNum ) {

30                    try {

31                        //锁定库存,并将请求放入消费队列

32                        surplusStock = surplusStock - purchaseNum;

33                        redisRepository.set("BM_MARKET_SECKILL_STOCKNUM_" + stallActivityId, Integer.toString(surplusStock));

34                        JSONObject jsonStr = new JSONObject();

35                        jsonStr.put("stallActivityId", stallActivityId);

36                        jsonStr.put("purchaseNum", purchaseNum);

37                        jsonStr.put("openId", openId);

38                        jsonStr.put("addressId", addressId);

39                        jsonStr.put("formId", formId);

40                        jsonStr.put("shareCode", shareCode);

41                        jsonStr.put("shareSource", shareSource);

42                        jsonStr.put("userCode", userCode);

43                        //放入kafka消息队列

44                        messageQueueService.sendMessage("bm_market_seckill", jsonStr.toString(), true);

45                        //此处还应该标记一个seckillId和openId的唯一标志来给轮询接口判断请求是否已经处理完成,需要在下单完成之后去维护删除该标志,并且创建一个新的标志,并存放orderId

46                        redisRepository.set("BM_MARKET_LOCK_POLLING_" + stallActivityId + "_" + openId, "true");

47                        //维护一个key,防止用户在该活动重复购买,当支付过期之后应该维护删除该标志

48                        redisRepository.setExpire("BM_MARKET_SECKILL_LIMIT_" + stallActivityId + "_" + openId, "true", 3600*24*7);

49                       

50                        response.setIsSuccess(true);

51                        response.setResponseCode(6101);

52                        response.setResponseMsg("排队中,请稍后");

53                        response.setRefreshTime(1000);

54                    } catch (Exception e) {

55                        e.printStackTrace();

56                        response.setIsSuccess(false);

57                        response.setResponseCode(6102);

58                        response.setResponseMsg("秒杀失败,商品已经售罄");

59                        response.setRefreshTime(0);

60                    }

61                }else {

62                    //需要在消费端维护一个真实的库存损耗值,用来显示是否还有未完成支付的用户

                        欢迎工作一到五年的Java工程师朋友们加入Java架构交流:681065582 提供免费的Java架构学习资料

63                    String redisRealStock = redisRepository.get("BM_MARKET_SECKILL_REAL_STOCKNUM_" + stallActivityId);

64                    int realStock = Integer.parseInt(redisRealStock == null ? "0" : redisRealStock);    //剩余的真实库存

65                    if( realStock > 0 ) {

66                        response.setIsSuccess(false);

67                        response.setResponseCode(6103);

68                        response.setResponseMsg("秒杀失败,还有部分订单未完成支付,超时将返还库存");

69                        response.setRefreshTime(0);

70                    } else {

71                        response.setIsSuccess(false);

72                        response.setResponseCode(6102);

73                        response.setResponseMsg("秒杀失败,商品已经售罄");

74                        response.setRefreshTime(0);

75                    }

76                }

77            }

78        } catch (Exception e) {

79            e.printStackTrace();

80            response.setIsSuccess(false);

81            response.setResponseCode(6102);

82            response.setResponseMsg("秒杀失败,商品已经售罄");

83            response.setRefreshTime(0);

84        } finally {

85            lock.unlock();  //释放锁

86        }

87        return response;

88    }

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容