从 0 到 1 搭建一个分布式架构的秒杀系统,如何利用 Redis 的特性发挥它在秒杀系统中的大作用,如何利用消息队列实现请求的异步处理。带您思考实现秒杀系统过程中需要注意的点,以及需要掌握的技巧。
架构介绍
一个基于 Spring Cloud + Spring Boot 搭建的服务框架。
核心支撑组件
- 服务网关 Zuul
- 服务注册发现 Eureka + Ribbon
- 认证授权中心 Spring Security OAuth2、JWTToken
- 服务框架 Spring MVC/Boot
- 服务容错 Hystrix
- 分布式锁 Redis
- 服务调用 Feign
- 消息队列 Kafka
- 文件服务 私有云盘 / HDFS
- 富文本组件 UEditor
- 定时任务 XXL-JOB
- 配置中心 Apollo
我们都知道,要正常去实现一个 Web 端的秒杀系统,前端的处理和后端的处理一样重要;前端一般会做 CDN,后端一般会做分布式部署、限流、性能优化等一系列的操作,并完成一些网络的优化,比如 IDC 多线路(电信、联通、移动)的接入、带宽的升级、利用 DNS 域名解析的负载均衡实现多 IP 接入等。
而如果您的系统前端是基于目前很火爆的小程序,那么前端部分的优化尽可能都是在代码中完成,CDN 这一步就可以免了。
这里粗略地画了两张图,可能很多细节并不是特别到位,但整体的布局基本如此。
关于秒杀的场景特点分析
秒杀系统的场景特点
- 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增;
- 秒杀一般是访问请求量远远大于库存数量,只有少部分用户能够秒杀成功;
- 秒杀业务流程比较简单,一般就是下订单操作。
秒杀架构设计理念
- 限流:鉴于只有少部分用户能够秒杀成功,所以要限制大部分流量,只允许少部分流量进入服务后端(暂未处理)。
- 削峰:对于秒杀系统瞬时的大量用户涌入,所以在抢购开始会有很高的瞬时峰值。实现削峰的常用方法有利用缓存或消息中间件等技术。
- 异步处理:对于高并发系统,采用异步处理模式可以极大地提高系统并发量,异步处理就是削峰的一种实现方式。
- 内存缓存:秒杀系统最大的瓶颈最终都可能会是数据库的读写,主要体现在的磁盘的 I/O,性能会很低,如果能把大部分的业务逻辑都搬到缓存来处理,效率会有极大的提升。
- 可拓展:如果需要支持更多的用户或更大的并发,将系统设计为弹性可拓展的,如果流量来了,拓展机器就好。
秒杀设计思路
这里主讲后端的处理部分。
- 秒杀相关活动页面的相关接口,所有查询能加缓存的,全部添加 Redis 缓存。
- 活动相关真实库存、锁定库存、限购、下单处理状态等全放 Redis。
- 当有请求进来时,首先通过 Redis 原子自增的方式记录当前请求数。当请求超过一定量,比如说库存的 10 倍之后,后面进入的请求则直接返回活动太火爆的响应;而能进入抢购的请求,则首先进入活动 ID 为粒度的分布式锁。
- 第一步进行用户购买的重复性校验,满足条件进入下一步,否则返回已下单的提示。
- 第二步,判断当前可锁定的库存是否大于购买的数量,满足条件进入下一步,否则返回已售罄的提示。
- 第三步,锁定当前请求的购买库存,从锁定库存中减除,并将下单的请求放入消息队列。
- 第四步,在 Redis 中标记一个 Polling 的 Key(用于轮询的请求接口判断用户是否下订单成功),在消息队列的消费端消费完成创建订单之后需要删除该 Key,并且维护一个活动 ID + 用户 ID 的 Key,防止重复购买。
- 第五步,消息队列消费,创建订单,创建订单成功则扣减 Redis 中的真实库存,并且删除 Polling 的 Key。如果下单过程出现异常,返还锁定库存,提示用户下单失败。
- 第六步,提供一个轮询接口,给前端在完成抢购动作后,检查最终下订单操作是否成功,主要判断依据是 Redis 中的 Polling 的 Key 的状态。
- 整个流程会将所有到后端的请求拦截的在 Redis 的缓存层面,除了最终能下订单的库存限制订单会与数据库存在交互外,基本上无其他的交互,将数据库 I/O 压力降到了最低。
如上,讲的实现方案是可以简述为:
控制请求放行数,通过分布式锁控制控制库存超卖。
那么,接下来,要介绍另外一种方案,我先讲述整个处理流程。
- 秒杀相关活动页面的相关接口,所有查询能加缓存的,全部添加 Redis 缓存。
- 活动相关真实库存、锁定库存、限购、下单处理状态等全放 Redis。
- 当有请求进来时,从缓存中活动开始时间判断活动是否开始,未开始直接返回提示;下一步同样定义一个以活动 ID 为粒度的 Redis 计数器,放行活动库存的 N 倍(自定义)请求进入真正的业务处理,其余的直接返回提示已售罄。
- 而被放行的请求,则放入到消息队列中,返回一个请求成功的响应,等待前端继续发起轮询的请求。
- 接下来是消息队列中的处理,不断从队列中取出消息进行消费,并通过分布式锁来控制库存的超卖等情况。当库存被消耗完后,则提示商品已售罄,活动结束;若能够锁定库存的请求,则返回一个下单资格码,提示用户已经取得秒杀的资格,可以选择下单秒杀;当用户请求下单时,则带着下单资格码重新请求,并完成下单操作(这里需要检验下单资格码是否与用户对应)。
这里省略了部分细节的讲解。
接下来给出流程示意图:
关于限流
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 地址:
项目集成了 Swagger 的接口管理,同时增加了一个简单的 Demo 页面的请求案例。
项目启动后访问 http://localhost:8080/swagger-ui.html。
Web 版的秒杀地址:http://localhost:8080/views/index.html。
敲黑板,划重点
如上的 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 的性能测试报告,仅仅只是在单机下秒杀请求接口的测试就能达到如下的结果。
设置线程组参数:
设置请求参数:
运行结果: