随着项目的上线与稳定运行,有关小程序秒杀系统的工作也算是告一段落了,最近也是抽空整理整理相关资料,留下了这篇文档;
分析,在做秒杀系统的设计之初,一直在思考如何去设计这个秒杀系统,使之在现有的技术基础和认知范围内,能够做到最好;同时也能充分的利用公司现有的中间件来完成系统的实现。
我们都知道,正常去实现一个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 }