1 Star 0 Fork 0

QiaoWang / 学习笔记

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
秒杀.md 15.17 KB
一键复制 编辑 原始数据 按行查看 历史
乔旺 提交于 2022-06-29 16:26 . 'init'

秒杀

如何快速的完成一个秒杀的功能?

瞬间高并发。

限流 + 异步 + 缓存(页面静态化)+ 独立部署

具体方案

  1. 前端限流:一些高并发的网站直接在前端页面开始限流
  2. nginx限流,令牌算法、漏桶算法
  3. 网关限流、限流的过滤器
  4. 代码中使用分布式信号量
  5. rabbitmq限流(channel.basicQos(1))、保证发挥所有服务器的性能。

数据表结构

  1. 秒杀场次(增删改查)---- 场次的开始时间-----场次的结束时间。
  2. 给场次关联商品。 场次商品关联表(商品id, 场次id,商品秒杀价格,商品秒杀库存,每人购买的限制)

秒杀流程

一、秒杀商品定时上架

提前一天,每天晚上3点上架最近三天要秒杀的商品。

通过定时任务触发事件,要秒杀的商品放入redis中。

Qartz : 实现定时任务。

Spring Schedule: 实现定时任务。

image-20220309173606395

SeckillService
// 1. 扫描最近三天所有需要参与秒杀的活动
// 2. 查询出秒杀活动关联的所有商品。

// 3. 将商品数据缓存到Redis
// 3.1 缓存活动信息
前缀: seckill_sessions_
key: 开始时间long_结束时间long
value: [场次id_商品的id]数组

// 3.2 缓存活动关联的商品信息
前缀:seckill_skus  hash结构保存
field: 场次id_商品的id(防止商品id重复)
value: 商品详情数据(秒杀数据、商品数据、随机码)

// 4. 设置随机码
随机码: 防止一直循环访问秒杀请求,通过UUID生成

// 5. 获取redisson 信号量(限流),使用库存作为分布式
seckill:stock:+商品随机码
商品可以秒杀的数量作为信号量

redissonClient.getSemaphore() // 获取信号量
semaphore.trySetPermits(商品的库存量)

TODO
1. 幂等性问题(分布式)
场次上架完了,下次再执行定时任务,不能重复上架了。
增加分布式锁。 seckill:upload:lock

代码层面,判断是否存在对应的活动的key,如果存在则不用执行了,不存在才需要执行。
活动会话  活动商品都要 进行判断。

代码说明:

定时任务

@Scheduled(cron = "0 0 0-8 * * ?")
public void uploadSeckillSkuLatest3Day() {

    log.info("\n上架秒杀商品的信息");

    // 1.重复上架无需处理 
    //   加上分布式锁 状态已经更新 释放锁以后其他人才获取到最新状态
    RLock lock = redissonClient.getLock(upload_lock);

    lock.lock(10, TimeUnit.SECONDS);
    try {
        //此处调用:远程调用 最近三天 最近三天 秒杀商品信息
        seckillService.uploadSeckillSkuLatest3Day();
    } finally {
        lock.unlock();
    }
}

service主体代码块:

@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {
 
	@Autowired
	private CouponFeignService couponFeignService;
 
	@Autowired
	private StringRedisTemplate stringRedisTemplate;
 
	@Autowired
	private ProductFeignService productFeignService;
 
	@Autowired
	private RedissonClient redissonClient;
 
	@Autowired
	private RabbitTemplate rabbitTemplate;
 
	private final String SESSION_CACHE_PREFIX = "seckill:sessions:";
 
	private final String SKUKILL_CACHE_PREFIX = "seckill:skus:";
 
	// +商品随机码
	private final String SKUSTOCK_SEMAPHONE = "seckill:stock:";
 
 
 
	@Override
	public void uploadSeckillSkuLatest3Day() {
 
        // 远程调用:
		// 1.扫描最近三天要参加秒杀的商品
		R r = couponFeignService.getLate3DaySession();
 
		if(r.getCode() == 0){
			List<SeckillSessionsWithSkus> sessions = r.getData(new TypeReference<List<SeckillSessionsWithSkus>>() {});
			// 2.缓存活动信息
			saveSessionInfo(sessions);
			// 3.缓存活动的关联的商品信息
			saveSessionSkuInfo(sessions);
		}
 
	}
 
	/**
	 * 2.缓存活动信息
 	 */
	private void saveSessionInfo(List<SeckillSessionsWithSkus> sessions){
 
		if(sessions != null){
 
			sessions.stream().forEach(session -> {
				long startTime = session.getStartTime().getTime();
				long endTime = session.getEndTime().getTime();
 
				String key = SESSION_CACHE_PREFIX + startTime + "_" + endTime;
				Boolean hasKey = stringRedisTemplate.hasKey(key);
				if(!hasKey){
					// 获取所有商品id
					List<String> collect = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId() + "-" + item.getSkuId()).collect(Collectors.toList());
					// 缓存活动信息
					stringRedisTemplate.opsForList().leftPushAll(key, collect);
				}
			});
		}
	}
 
	private void saveSessionSkuInfo(List<SeckillSessionsWithSkus> sessions){
		if(sessions != null){
			sessions.stream().forEach(session -> {
				BoundHashOperations<String, Object, Object> ops = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
				session.getRelationSkus().stream().forEach(seckillSkuVo -> {
					// 1.商品的随机码
					String randomCode = UUID.randomUUID().toString().replace("-", "");
					if(!ops.hasKey(seckillSkuVo.getPromotionSessionId() + "-" + seckillSkuVo.getSkuId())){
						// 2.缓存商品
						SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
						BeanUtils.copyProperties(seckillSkuVo, redisTo);
						// 3.sku的基本数据 sku的秒杀信息
						R info = productFeignService.skuInfo(seckillSkuVo.getSkuId());
						if(info.getCode() == 0){
							SkuInfoVo skuInfo = info.getData("skuInfo", new TypeReference<SkuInfoVo>() {});
							redisTo.setSkuInfoVo(skuInfo);
						}
						// 4.设置当前商品的秒杀信息
						redisTo.setStartTime(session.getStartTime().getTime());
						redisTo.setEndTime(session.getEndTime().getTime());
 
						redisTo.setRandomCode(randomCode);
 
						ops.put(seckillSkuVo.getPromotionSessionId() + "-" + seckillSkuVo.getSkuId(), JSON.toJSONString(redisTo));
						// 如果当前这个场次的商品库存已经上架就不需要上架了
						// 5.使用库存作为分布式信号量  限流
						RSemaphore semaphore = redissonClient.getSemaphore(SKUSTOCK_SEMAPHONE + randomCode);
						semaphore.trySetPermits(seckillSkuVo.getSeckillCount().intValue());
					}
				});
			});
		}
	}
}

二、秒杀功能

  1. 获取秒杀商品数据 (1)取当前时间,根据keys * 找到Redis存储的最近三天所有秒杀场次,找到当前时间所匹配时间段的场次。

    (2)根据场次查询所有的秒杀商品。(秒杀当时需要返回它的随机码, 如果是活动预告的,则不需要返回随机码)

  @Override
  public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {

      // 1.确定当前时间属于那个秒杀场次
      long time = new Date().getTime();

      // 定义一段受保护的资源
      try (Entry entry = SphU.entry("seckillSkus")) {
          Set<String> keys = stringRedisTemplate.keys(SESSION_CACHE_PREFIX + "*");
          for (String key : keys) {
              // seckill:sessions:1593993600000_1593995400000
              String replace = key.replace("seckill:sessions:", "");
              String[] split = replace.split("_");
              long start = Long.parseLong(split[0]);
              long end = Long.parseLong(split[1]);
              if (time >= start && time <= end) {
                  // 2.获取这个秒杀场次的所有商品信息
                  List<String> range = stringRedisTemplate.opsForList().range(key, 0, 100);
                  BoundHashOperations<String, String, String> hashOps = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
                  List<String> list = hashOps.multiGet(range);
                  if (list != null) {
                      return list.stream().map(item -> {
                          SeckillSkuRedisTo redisTo = JSON.parseObject(item, SeckillSkuRedisTo.class);
//						redisTo.setRandomCode(null);
                          return redisTo;
                      }).collect(Collectors.toList());
                  }
                  break;
              }
          }
      } catch (BlockException e) {
          log.warn("资源被限流:" + e.getMessage());
      }
      return null;
  }
  1. 核心关注点
  • 服务单一职责,独立部署 秒杀服务即使自己扛不住,挂掉,也不要影响别人。

  • 秒杀链接加密(随机码实现) 防止恶意攻击,模拟秒杀请求,1000次/s攻击。 防止链接暴露,自己工作人员,提前秒杀商品。

  • 库存预热+快速扣减库存信息通过定时任务放到redis,并且库存作为信号量 秒杀读多写少,无需每次实时校验库存,我们库存预热,放到redis中,信号量控制进来秒杀的请求。

  • 动静分离

    nginx做好动静分离,保证秒杀和商品详情页的动态请求才打到后端的服务集群,使用CDN网络,分担本集群压力。

  • 恶意请求拦截 识别非法攻击请求并进行拦截,脚本发送请求

  • 流量错峰 使用各种手段,将流量分担到更大宽度的时间点,比如验证码,加入购物车。

  • 限流、熔断、降级 前端限流+后端限流(前端:秒杀按钮隔1s才能再次点击,后端:sentinel) 限制次数,限制总量,快速失败降级运行,熔断隔离防止雪崩。

  • 队列削峰

    一万个商品,每个1000件秒杀,双11所有秒杀成功的请求,进入队列。慢慢创建订单,扣减库存即可。

  1. 秒杀请求格式

    xxxx.com/kill?killId=session_id&code=xxxx&num=1
    killId = 秒杀场次ID + 商品SKUID
    code = 商品的随机码
    num = 秒杀的数量
  2. 秒杀操作流程细节:

    image-20220310104530675

    前端限流:
    1. 秒杀按钮,点击前需要进行是否登录判断。
    
    后端限流:
    2. 结合SpringSession + 拦截器,判断是否登录,如果没有登录,先跳转到登录页
     
    秒杀Service方法
    3. 获取秒杀商品的详细信息
    3.1 如果查询不到,直接结束。
    3.2 查询到就开始校验。 秒杀时间范围是否正确。
    3.3 校验 随机码 和 商品ID。 缓存中 和 传输过来的随机码要一样
    3.4 验证购物数量是否合理,秒杀购买数量限制, 是否大于 Num
    3.5 判断是否购买过该商品。幂等性处理。只要秒杀成功,就去占位。setNX 占位。
    	key : userId_sessionId_skuId
    	value: num
    	超时时间:活动结束时间 - 当前时间。 (自动过期)
    
    3.6 通过分布式信号量来减库存。
    semaphore.tryAcquire(num, 100, TimeUnit.millseconds),如果信号量可以获取成功,就秒杀成功。
    没有获取到就秒杀失败。
    
    3.7 快速下单,发送MQ消息 10ms
    IdWorker.getTimeId() // 返回订单号 

代码说明:

@Autowired
private SeckillService seckillService;

@GetMapping("/kill")
public String secKill(@RequestParam("killId") String killId, // session_skuID
                      @RequestParam("key") String key,
                      @RequestParam("num") Integer num, Model model){
    String orderSn = seckillService.kill(killId,key,num);
    // 1.判断是否登录
    model.addAttribute("orderSn", orderSn);
    return "success";
}


@Override
public String kill(String killId, String key, Integer num) {
    MemberRespVo memberRsepVo = LoginUserInterceptor.loginUser.get();

    // 1.获取当前秒杀商品的详细信息
    BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREDIX);
    String json = hashOps.get(killId);
    System.out.println(killId);
    System.out.println(json);
    if(StringUtils.isEmpty(json)){
        return null;
    }else{
        SecKillSkuRedisTo redisTo = JSON.parseObject(json, SecKillSkuRedisTo.class);
        // 校验合法性
        long time = new Date().getTime();
        if(time >= redisTo.getStartTime() && time <= redisTo.getEndTime()){
            // 1.校验随机码跟商品id是否匹配
            String randomCode = redisTo.getRandomCode();
            String skuId = redisTo.getPromotionSessionId() + "_" + redisTo.getSkuId();

            if(randomCode.equals(key) && killId.equals(skuId)){
                // 2.说明数据合法
                Integer limit = redisTo.getSeckillLimit();
                if(num <= limit){
                    // 3.验证这个人是否已经购买过了
                    String redisKey = memberRsepVo.getId() + "_" + skuId;
                    // 让数据自动过期
                    long ttl = redisTo.getEndTime() - redisTo.getStartTime();

                    Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl<0?0:ttl, TimeUnit.MILLISECONDS);
                    if(aBoolean){
                        // 占位成功 说明从来没买过
                        RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMPHORE + randomCode);

                        boolean  acquire = semaphore.tryAcquire(num);
                        if(acquire){
                            // 秒杀成功
                            // 快速下单 发送MQ
                            String orderSn = IdWorker.getTimeId() + UUID.randomUUID().toString().replace("-","").substring(7,8);
                            SecKillOrderTo orderTo = new SecKillOrderTo();
                            orderTo.setOrderSn(orderSn);
                            orderTo.setMemberId(memberRsepVo.getId());
                            orderTo.setNum(num);
                            orderTo.setSkuId(redisTo.getSkuId());
                            orderTo.setSeckillPrice(redisTo.getSeckillPrice());
                            orderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
                            rabbitTemplate.convertAndSend("order-event-exchange","order.seckill.order", orderTo);
                            return orderSn;
                        }
                    }else {
                        return null;
                    }
                }
            }else{
                return null;
            }
        }else{
            return null;
        }
    }
    return null;
}

image-20220310112008136

结合MQ下单功能

引入RabbitMQ,开启发送端确认、和消费者ACK机制。

通过rabbitTemplate.convertandSend 发送消息到队列
快速订单对象:
	订单号
	活动场次ID
	商品ID
	会员ID
	购买数量
	
订单服务
	@Bean 创建 Queue 和 QueueBinding
	订单服务对 秒杀队列进行监听 @RabbitListener
	进行创建秒杀单。
		
/**
 * @author WGR
 * @create 2020/8/19 -- 22:54
 */
@RabbitListener(queues = "order.seckill.order.queue")
@Component
public class OrderSecKillListener {
 
    @Autowired
    private OrderService orderService;
 
    @RabbitHandler
    public void listener(SecKillOrderTo secKillOrderTo, Channel channel, Message message) throws IOException {
        try {
            // 创建秒杀单的信息
            orderService.createSecKillOrder(secKillOrderTo);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}
Java
1
https://gitee.com/qiaowang/source_code.git
git@gitee.com:qiaowang/source_code.git
qiaowang
source_code
学习笔记
master

搜索帮助