本文只是对个人所写的秒杀系统项目一些总结,并没有进行细致解析,如有错误之处恳请指教
一.涉及技术
SSM,Spring-boot,redis,RocketMQ,nginx
二.秒杀系统技术架构
整体流程如下:
红框内容表示该项目与之前项目(简历版本)不同之处
整体流程描述以及与之前版本不同处:
- 登录,校验用户名密码,生成唯一的token,token为key,value为用户信息,存入redis
(之前早期版本采用过springSession组件甚至更早版本采用过nginx+hash分组方法来解决session一致性问题,就原理而言SpringSession与token都是将信息存储到第三方数据库中,两者要比nginx+hash分组策略好)
从本地缓存GuavaCache,redis或者mysql中批量获取商品基本信息,单击商品进入商品详情页面
从redis或者mysql中获取商品详情信息
查看当前商品状态是否处于秒杀活动中,调用下单接口,下单请求被nginx拦截,查看当前用户请求频率是否异常
(与之前早期版本相比,早期版本是将下单+支付整合为一个流程,相当于原子操作,目前改为了下单+支付两个页面为后续项目扩大了改进空间比如取消订单等场景)创建订单成功,调用秒杀接口,查询redis中商品库存状态并尝试获取由RateLimiter组件产生的令牌,如果商品库存为空或者令牌获取失败则返回秒杀失败
消息入队,发送异步消息成功后直接返回给前端订单号
消费者端消费消息,包含创建支付订单以及库存扣减两个操作,这两个操作在一个事务中,如果其中一个操作失败的话则回滚并且消费者端捕获异常操作redis将该订单号状态置为fail
(早期版本在这里采用的是RocketMQ事务性消息,将"支付订单创建"和"库存扣减"这两个操作分别放入本地事务和消费者那端,如果本地事务"支付订单创建"失败则进行回滚并且不会进行库存扣减,但这样做的缺点是无法保证"库存扣减"是否成功,并且早期版本还有redis库存预减操作,大大增加了保持系统缓存一致性的难度,目前所采用的策略是redis只用来读取数据并且将"支付订单创建"和"库存扣减"这两个操作合并为一个事务操作)前端在收到订单号之后定时查询redis中该订单号状态,如果成功则返回下单成功,反之则返回下单失败
三.设计原则
秒杀系统的一大难点就是瞬时高并发流量的挑战 ,高并发指的是同一时刻有大量的用户请求到达服务器,服务器需要对请求进行处理,并及时返回响应信息。通过有限的服务器资源,尽可能快速地处理尽可能多的网络请求,是一个值得深入研究与探讨的话题。 对于该系统的优化思路,总结起来有以下几点:
- 限流:从客户端层面考虑,限制单个客户抢购频率;服务端层面,加强校验,识别请求是否来源于真实的客户端,并限制请求频率,防止恶意刷单;应用层面,可以使用漏桶算法或令牌桶算法实现应用级限流。
- 缓存:热点数据都从缓存获得,尽可能减小数据库的访问压力;
- 异步:客户抢购成功后立即返回响应,之后通过消息队列,异步处理后续步骤,如发短信、更新数据库等,从而缓解服务器峰值压力。
- 分流:单台服务器肯定无法应对抢购期间大量请求造成的压力,需要集群部署服务器,通过负载均衡共同处理客户端请求,分散压力。
其他:
-
数据库中数据量过大时需考虑分库分表
-
考虑系统的可扩展性
...
四.表设计
五.技术点总结
-
分布式session
最早期版本为图方便采用的时nginx+hash策略来实现多级部署环境下分布式session一致性,原理就是通过对请求过来的ip地址进行hash然后与web应用服务器数量取模选定一个特定服务器来处理该ip的请求,这样做的优点是方便简单,缺点是session存储在服务器占用服务器内存,并且同一区段的ip请求数较多的话可能都会将请求打在同一台web应用服务器上无法达到负载均衡,或者服务器宕机后请求打在其他服务器上可该服务器并没有储存session。
中期(简历版本)采用的是SpringSession组件,大致原理时是通过对IOC容器注入RedisHttpSessionConfiguration 这个Bean,该Bean创建了一个名称为 springSessionRepositoryFilter的bean ,该Bean被添加到FilterChain中,在请求打过来时会对request进行拦截并封装,然后将会话信息保存至第三方服务中,如redis,mysql中等。web应用服务器之间通过连接第三方服务来共享数据,实现Session共享。
目前版本采用的是存储Token来保存用户信心,原理与SpringSession组件基本一致。
//用户登录,返回token public String login(HttpServletResponse resp, UserVO userVO) throws BusinessException { if (userVO==null)throw new BusinessException(EmBusinessError.USER_NOT_EXIT,"用户信息为空"); String phone = userVO.getPhone(); String password = userVO.getPassword(); UserDO userFromDB = userDOMapper.selectByPhone(phone); if (userFromDB==null)throw new BusinessException(EmBusinessError.USER_NOT_EXIT); //验证密码 String passwordDB = userFromDB.getPassword(); if (!StringUtils.equals(password,passwordDB)){ throw new BusinessException(EmBusinessError.USER_MESSAGE_ERROR); }else { //生成token String token = UUIDUtil.uuid(); userVO.setUserId(userFromDB.getUserId()); //保存至redis中 redisTemplate.opsForValue().set(token,userVO); redisTemplate.expire(token,1,TimeUnit.HOURS); return token; } } -
用户下单频率限频
通过采用nginx+Redis来限制IP访问频率,通过安装以nginx为核心同时包含多个第三方模块的Web应用服务器OpenResty来完成。
local ip_block_time=300 --封禁IP时间(秒) local ip_time_out=30 --指定ip访问频率时间段(秒) local ip_max_count=20 --指定ip访问频率计数最大值(秒) local BUSINESS = ngx.var.business --nginx的location中定义的业务标识符 --连接redis local redis = require "resty.redis" local conn = redis:new() ok, err = conn:connect("*.*.*.*", 6379) conn:set_timeout(2000) --超时时间2秒 --如果连接失败,跳转到脚本结尾 if not ok then goto FLAG end --查询ip是否被禁止访问,如果存在则返回403错误代码 is_block, err = conn:get(BUSINESS.."-BLOCK-"..ngx.var.remote_addr) if is_block == '1' then ngx.exit(403) goto FLAG end --查询redis中保存的ip的计数器 ip_count, err = conn:get(BUSINESS.."-COUNT-"..ngx.var.remote_addr) if ip_count == ngx.null then --如果不存在,则将该IP存入redis,并将计数器设置为1、该KEY的超时时间为ip_time_out res, err = conn:set(BUSINESS.."-COUNT-"..ngx.var.remote_addr, 1) res, err = conn:expire(BUSINESS.."-COUNT-"..ngx.var.remote_addr, ip_time_out) else ip_count = ip_count + 1 --存在则将单位时间内的访问次数加1 if ip_count >= ip_max_count then --如果超过单位时间限制的访问次数,则添加限制访问标识,限制时间为ip_block_time res, err = conn:set(BUSINESS.."-BLOCK-"..ngx.var.remote_addr, 1) res, err = conn:expire(BUSINESS.."-BLOCK-"..ngx.var.remote_addr, ip_block_time) else res, err = conn:set(BUSINESS.."-COUNT-"..ngx.var.remote_addr,ip_count) res, err = conn:expire(BUSINESS.."-COUNT-"..ngx.var.remote_addr, ip_time_out) end end -- 结束标记 ::FLAG:: local ok, err = conn:close() -
下单按钮置灰,实时显示秒杀活动状态
function reLoad() { $("#name").text(item.name); $("#itemPrice").text(item.itemsPrice); $("#promoPrice").text(item.promoPrice); $("#stock").text(item.stock); $("#note").text(item.note); $("#img").attr("src",item.imgUrl); var formatST=Common.formatTime(item.startDate,'yyyy-MM-dd HH:mm:ss'); var startDate = new Date(formatST).valueOf(); var nowDate = Date.parse(new Date()); var delta = (nowDate-startDate)/1000; if(delta<0){ //秒杀活动还未开始,按钮无法点击 $("#promo").text("秒杀活动将于:"+formatST+"开始,倒计时:"+(-delta)+"秒"); $("#order").attr("disabled",true); }else { $("#promo").text("秒杀活动正在进行中!"); $("#order").attr("disabled",false); } } -
创建订单号
//创建订单,返回订单号 @Transactional(propagation = Propagation.REQUIRED) public OrderVO order(Integer itemId, Integer amount, UserVO user, ItemDetailVO item){ OrderDO order=new OrderDO(); String num = generateOrderNum(); order.setAmount(amount); order.setItemId(itemId); order.setOrderPrice(item.getPromoPrice()); order.setUserId(user.getUserId()); order.setOrderId(num); orderDOMapper.insertSelective(order); OrderVO orderVO=new OrderVO(); orderVO.setPhone(user.getPhone()); orderVO.setStatus(0); orderVO.setImgUrl(item.getImgUrl()); orderVO.setPromoPrice(item.getPromoPrice()); orderVO.setOrderDate(new Date()); orderVO.setOrder(num); orderVO.setUserId(user.getUserId()); orderVO.setItemId(order.getItemId()); return orderVO; } @Transactional(propagation = Propagation.REQUIRES_NEW) public String generateOrderNum(){ StringBuilder stringBuilder=new StringBuilder(); //订单号16位 //前八位为年月份yyyyMMdd, SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd"); String s1 = simpleDateFormat.format(new Date()).replace("-", ""); stringBuilder.append(s1); //中六位为依次递增序列 //依次递增的操作通过创建一个sequence表来完成 Sequence order_info = sequenceMapper.selectByName("order_info"); Integer currentValue = order_info.getCurrentValue(); order_info.setCurrentValue(order_info.getCurrentValue()+order_info.getStep()); sequenceMapper.updateByPrimaryKey(order_info); String str = String.valueOf(currentValue); for (int i=0;i<6-str.length();i++){ stringBuilder.append(0); } stringBuilder.append(str); //后两位为分库分表位,暂且定死 stringBuilder.append("00"); return stringBuilder.toString(); } -
redis维护商品库存状态,获取秒杀令牌通过消息队列进行异步下单
@ResponseBody @RequestMapping(value = "/pay",method = RequestMethod.POST) public CommenReturnType pay(@RequestParam("userId")int userId,@RequestParam("token")String token) throws BusinessException, InterruptedException { OrderVO order = (OrderVO) redisTemplate.opsForValue().get("order_" + userId); //用户校验 //... //令牌桶限流 if (!rateLimiter.tryAcquire(1000,TimeUnit.MILLISECONDS)){ throw new BusinessException(EmBusinessError.SKILL_FAIL); } //查看redis中商品库存清空标记 Long stock = (Long) redisTemplate.opsForValue().get(order.getItemName() + "_stock"); if (stock==null){ //库存key失效,分布式锁操作数据库避免缓存雪崩 //(这里采用分布式锁欠缺考虑,高并发情况下分布式锁太耗性能,后期考虑删除更换策略) try { Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", token,5,TimeUnit.SECONDS); if (lock){ ItemDetailVO item = itemService.getItemById(order.getItemId()); if (item.getStock()>0){ //如果库存大于0放入redis中 redisTemplate.opsForValue().set(order.getItemName()+"_stock",item.getStock()); }else { redisTemplate.opsForValue().set(order.getItemName()+"_stock",-1); } }else { //sleep一秒然后继续查询reids中商品库存,防止请求直接打在mysql上 Thread.sleep(1000); Long newStock = (Long) redisTemplate.opsForValue().get(order.getItemName() + "_stock"); if (newStock==null)throw new BusinessException(EmBusinessError.SKILL_FAIL_R); else if (newStock==-1)throw new BusinessException(EmBusinessError.SKILL_FAIL); } }finally { if (token.equals(redisTemplate.opsForValue().get("lock")))redisTemplate.delete("lock"); } }else if (stock==-1) { throw new BusinessException(EmBusinessError.SKILL_FAIL); } // //预减库存(已去除,目前版本redis只用于读) // Long stock = redisTemplate.opsForValue().decrement(order.getItemName() + "_stock"); // if (stock<0){ // overMap.put(order.getItemName()+"_stock",true); // throw new BusinessException(EmBusinessError.SKILL_FAIL); // } //减库存操作,该处操作为RocketMQ异步下单操作 try { payService.pay(order.getOrder(),userId,order.getItemId()); } catch (Exception e) { // redisTemplate.opsForValue().increment(order.getItemName()+"_stock"); return CommenReturnType.create("商品秒杀失败,请重新下单","fail"); } return CommenReturnType.create(null); } -
消息队列消费端消息消费
@Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { for (int i=0;i<msgs.size();i++){ MessageExt messageExt = msgs.get(i); Integer id; Integer userId; String order=""; try { //获取itemID等数据进行数据库减库存 String messageBody = new String(messageExt.getBody(), RemotingHelper.DEFAULT_CHARSET); logger.info("MQ:consumer接收消息成功:"+messageExt.getMsgId()+messageExt.getQueueId()+"消息为"+messageBody); Map<String,Object> map = JSON.parseObject(messageBody, Map.class); id = (Integer) map.get("item_stock_id"); userId = (Integer) map.get("user_id"); order = (String) map.get("order"); //数据库实行商品库存减库存操作以及支付订单创建操作 //该操作为一个事务操作 payService.pay(order,id,userId); //消费成功的话在redis中对该订单添加秒杀成功标记 redisTemplate.opsForValue().set(order,"success"); } catch (Exception e) { //消费异常的话在redis中对该订单添加秒杀失败标记 redisTemplate.opsForValue().set(order,"fail"); logger.error("MQ:接收消息异常"+e); } } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; }整体流程大致如上,还有很多细节以及问题还未解决比如redis集群搭建,nginx本地缓存,负载均衡,多机部署过程中所出现的问题以及redis与数据库之间的缓存一致性的问题,先挖些坑后续再来填吧。
参考资料: