商品秒杀设计与实现

问题

  • 超卖
  • 高并发
  • 恶意请求

方案

数据库锁

  • 行锁问题:大量锁竞争时,会影响数据库性能
update goods set inventory = inventory-1 where id = #{goodsId} and inventory > 0
  • 行锁 + 乐观锁问题:库存 100,且同时只有 100 人抢购商品时,实际卖出的商品可能少于 100。同样存在性能问题。
update goods set inventory = inventory-1 where id = #{goodsId} and inventory > 0 and version = #{version}

分布式锁

  • redis 分布式锁
    问题:不设置锁的过期时间,可能会导致锁一致得不到释放;设置锁的过期时间,又可能因为业务执行时间较长而导致锁提前释放。使用 Lua 脚本或 Redlock 都较为复杂。
  • ZooKeeper 分布式锁

问题:性能不如 Redis。

库存预热 & 内存标记

@Override
public void afterPropertiesSet() {
    List<GoodsEntity> goodsList = goodsRepository.findAll();
    goodsList.forEach(goods -> {
        redisTemplate.opsForValue().set(SECKILL_GOODS_KEY_PREFIX + goods.getId(), String.valueOf(goods.getInventory()));
        localGoodsOverMap.put(goods.getId(), false);
    });
}

...

// 读取内存标记,判断商品是否售完
if (localGoodsOverMap.get(goodsId)) {
    log.info("商品【{}】已售完,抢购失败!", goodsId);
    return;
}

*** 分布式锁开始 ***

// 递减 redis 中库存数量,判断商品是否已售完
Long inventory = redisTemplate.opsForValue().decrement(SECKILL_GOODS_KEY_PREFIX + goodsDto.getGoodsId());
if (inventory == null || inventory < 0) {
    log.info("商品【{}】已售完,抢购失败!", goodsId);
    localGoodsOverMap.put(goodsId, true);
    return;
}

// TODO 查库存
// TODO 减库存
// TODO 下单

*** 分布式锁结束 ***

异步下单

OrderEntity order = new OrderEntity();
order.setUserId(userId);
order.setGoodsId(goodsId);
order.setStatus(OrderStatus.TO_BE_PAID);
order.setGoodsNum(1);

// 异步下单
// 也可以异步更新库存,这就要求消费者逐个消费消息,不然也会出现并发问题
rabbitTemplate.convertAndSend(DIRECT_EXCHANGE, DIRECT_ROUTING_KEY, order);

...

@RabbitListener(queues = DIRECT_QUEUE, concurrency = "10")
public void process(OrderEntity order, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag) {
    log.info("Receive message by direct-queue: {}", order);

    try {
        orderRepository.save(order);
        basicAck(channel, tag);
    } catch (Exception e) {
        log.error("创建订单失败【{}】", order);
        basicNack(channel, tag);
}

/**
 * 接收消息确认
 */
private void basicAck(Channel channel, long tag) {
    try {
        channel.basicAck(tag, false);
    } catch (IOException e) {
        log.error("Ack message failure", e);
    }
}

/**
 * 拒绝消息确认
 */
private void basicNack(Channel channel, long tag) {
    try {
        channel.basicNack(tag, false, false);
    } catch (IOException e) {
        log.error("Nack message failure", e);
    }
}

按钮控制

秒杀开始之前,按钮置灰;用户抢购商品之后,按钮再次置灰。

URL 动态化

  1. 在秒杀之前,前端先请求后端获取商品秒杀地址。在后端生成随机数作为 pathId 存入缓存(缓存过期时间 60s),然后将这个随机数返回给前端。
  2. 前端获得 pathId 后,将其作为 URL 参数去请求后端秒杀服务。
  3. 后端接收 pathId 参数后,将其与缓存中的 pathId 比较。

示例代码如下:

public SeckillGoodsDto createSeckillUrl(String goodsId) {
    String randomCode = generateRandomCode(goodsId);
    redisTemplate.opsForValue().set(goodsId, randomCode);

    SeckillGoodsDto goodsDto = new SeckillGoodsDto();
    goodsDto.setGoodsId(goodsId);
    goodsDto.setRandomCode(randomCode);
    return goodsDto;

}

private String generateRandomCode(String goodsId) {
    return ...;
}

public boolean buyGoods(SeckillGoodsDto goodsDto) {
    // 校验商品 URL 随机码是否一致
    boolean isValid = validateRandomCode(goodsDto.getGoodsId(), goodsDto.getRandomCode());
    if (isValid) {
       ...
        return true;
    }
    return false;
}

private boolean validateRandomCode(String goodsId, String randomCode) {
    String cachedRandomCode = redisTemplate.opsForValue().get(goodsId);
    return randomCode.equals(cachedRandomCode);
}

用户/IP 限流

  • 前端限流
    秒杀按钮在活动之前置灰,在用户购买之后再次置灰。
  • 后端限流
    相同用户/IP,设置请求次数限制。如可以基于 Spring Cloud Gateway 添加以下配置:

application.yml

spring:
  cloud:
    gateway:
      routes:
        # 秒杀服务
        - id: seckill
          uri: lb://seckill
          filters:
            # ip 限流
            - name: RequestRateLimiter
              args:
                # 限流匹配策略
                key-resolver: '#{@ipKeyResolver}'
                # 令牌桶的填充速率:用户每秒执行多少请求
                redis-rate-limiter.replenishRate: 10
                # 令牌桶的容量:用户在一秒钟内执行的最大请求数
                # 将此值设置为零将阻塞所有请求;将此值设置为高于 replenishRate,以允许临时突发
                redis-rate-limiter.burstCapacity: 20
            # 用户限流
            - name: RequestRateLimiter
              args:
                # 限流匹配策略
                key-resolver: '#{@userIdKeyResolver}'
                # 令牌桶的填充速率:用户每秒执行多少请求
                redis-rate-limiter.replenishRate: 10
                # 令牌桶的容量:用户在一秒钟内执行的最大请求数
                # 将此值设置为零将阻塞所有请求;将此值设置为高于 replenishRate,以允许临时突发
                redis-rate-limiter.burstCapacity: 20
@Configuration
public class ThrottlingConfiguration {
    private static final String USER_ID_NAME = "userId";

    /**
     * 接口限流
     */
    @Bean
    public KeyResolver apiKeyResolver() {
        return exchange -> Mono.just(exchange.getRequest().getPath().value());
    }

    /**
     * ip 限流
     */
    @Primary
    @Bean
    public KeyResolver ipKeyResolver() {
        return exchange -> Mono.just(Objects.requireNonNull(exchange.getRequest().getRemoteAddress()).getAddress().getHostAddress());
    }

    /**
     * 用户限流(未经身份验证直接拒绝请求)
     */
    @Bean
    public KeyResolver principalNameKeyResolver() {
        return new PrincipalNameKeyResolver();
    }

    /**
     * 用户限流(要求请求路径中必须携带 userId 参数)
     */
    @Bean
    public KeyResolver userIdKeyResolver() {
        return exchange -> Mono.just(Objects.requireNonNull(exchange.getRequest().getQueryParams().getFirst(USER_ID_NAME)));
    }
}

资源静态化

  • JS/CSS 压缩,减少流量
  • CDN 就近访问

兜底方案

  • 降级
    所谓“降级”,就是当系统的容量达到一定程度时,限制或者关闭系统的某些非核心功能,从而把有限的资源保留给更核心的业务。
  • 限流
    限流就是当系统容量达到瓶颈时,我们需要通过限制一部分流量来保护系统,并做到既可以人工执行开关,也支持自动化保护的措施。
  • 拒绝服务
    当系统负载达到一定阈值时,例如 CPU 使用率达到 90% 或者系统 load 值达到 2*CPU 核数时,系统直接拒绝所有请求,这种方式是最暴力但也最有效的保护方式。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值