人的知识就好比一个圆圈,圆圈里面是已知的,圆圈外面是未知的。你知道得越多,圆圈也就越大,你不知道的也就越多。

0%

商品秒杀设计与实现

问题

  • 超卖

  • 高并发

  • 恶意请求

方案

数据库锁

  • 行锁

    1
    update goods set inventory = inventory-1 where id = #{goodsId} and inventory > 0

    问题:大量锁竞争时,会影响数据库性能。

  • 行锁 + 乐观锁

    1
    update goods set inventory = inventory-1 where id = #{goodsId} and inventory > 0 and version = #{version}

    问题:库存 100,且同时只有 100 人抢购商品时,实际卖出的商品可能少于 100。同样存在性能问题

分布式锁

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

  • ZooKeeper 分布式锁

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    InterProcessMutex mutex = new InterProcessMutex(zookeeperClient, "/seckill/" + goodsDto.getGoodsId());
    mutex.acquire();

    try {
    // TODO 查库存
    // TODO 减库存
    // TODO 下单
    } finally {
    mutex.release();
    }

库存预热 & 内存标记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@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 下单

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

异步下单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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 比较。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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

ThrottlingConfiguration.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@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 核数时,系统直接拒绝所有请求,这种方式是最暴力但也最有效的保护方式。

小礼物走一走,来 Github 关注我