如何设计一个高并发的秒杀方案

377 阅读6分钟

需要考虑的事项

如何防止超卖

如何基于redis与MQ实现秒杀下单

如何快速相应用户请求

如何将多个商品库存均匀的放在不同的redis集群中,并发下单时如何正确的读取对应缓存

如何避免将单个秒杀商品的数量放在同一个redis中

如何保证MQ消息的可靠性

如何保证消息的幂等性

下单后的消息,大量堆积在MQ中怎么办

如何防止超卖

  • 使用分布式锁(如 Redis 的分布式锁)来控制并发下的库存扣减,防止超卖。
  • 使用 Redis 进行库存的预减操作,因为 Redis 是内存数据库,读写速度极快,可以有效减轻数据库的压力。
  @GetMapping("/start")
    public String startSeckill() {
        // 尝试获取分布式锁
        Boolean locked = redisTemplate.opsForValue().setIfAbsent(LOCK_KEY, "locked", 10, TimeUnit.SECONDS);
        if (locked!= null && locked) {
            try {
                // 从 Redis 中获取库存
                String stockStr = redisTemplate.opsForValue().get(STOCK_KEY);
                int stock = Integer.parseInt(stockStr);
                if (stock > 0) {
                    // 库存减 1
                    redisTemplate.opsForValue().decrement(STOCK_KEY);
                    return "秒杀成功";
                } else {
                    return "秒杀失败,库存不足";
                }
            } finally {
                // 释放锁
                redisTemplate.delete(LOCK_KEY);
            }
        } else {
            return "系统繁忙,请稍后再试";
        }
    }

如何基于redis与MQ实现秒杀下单

从 Redis 中获取库存减一之后,立马将消息写入到MQ中,直接返回抢购成功

 // 库存减 1
 redisTemplate.opsForValue().decrement(STOCK_KEY);
 // 发送消息到 RabbitMQ 队列
rabbitTemplate.convertAndSend(QUEUE_NAME, requestId);

如何快速相应用户请求

成功扣减库存并将订单消息写入MQ之后。立马提示用户并在前端引导用户去付款页面,出现“付款页面”需要等MQ中的消息入到DB库之后,用户才能点击付款,否则还是抢购失败。在这一步可能有大量的消息堆积到MQ中

如何将多个商品库存均匀的放在不同的redis集群中,并发下单时如何正确的读取对应缓存

解决思路:

  1. 自定义 Redis Key 分配策略:

    • 为不同商品生成唯一的 Redis Key,并根据一定的规则将这些 Key 分配到不同的 Redis 集群节点上。例如,可以使用商品 ID 的哈希值对 Redis 集群节点数量取模,将不同商品的库存信息分散存储到不同节点,避免热点数据集中在一个节点。
    • 可以使用 Redis 的 Hash Tag 机制,确保相关数据存储在同一个 Redis 节点,以提高事务操作的效率(如库存扣减和分布式锁操作)。
  2. 并发下单时读取缓存:

    • 当用户发起秒杀请求时,根据商品 ID 计算出对应的 Redis 节点。
    • 通过 Spring Boot 的 Redis 集群配置,将请求路由到正确的 Redis 节点读取库存信息。
    • 使用分布式锁确保并发情况下库存扣减的一致性。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/seckill")
public class SeckillController {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    private static final int REDIS_CLUSTER_SIZE = 3; // 假设 Redis 集群有 3 个节点

    @GetMapping("/start")
    public String startSeckill(Long productId) {
        // 计算商品库存存储的 Redis 节点
        int nodeIndex = (int) (productId % REDIS_CLUSTER_SIZE);
        String stockKey = getStockKey(productId, nodeIndex);
        String lockKey = getLockKey(productId, nodeIndex);
        String requestId = UUID.randomUUID().toString();

        // 获取分布式锁
        Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 10, TimeUnit.SECONDS);
        if (locked!= null && locked) {
            try {
                String stockStr = redisTemplate.opsForValue().get(stockKey);
                int stock = Integer.parseInt(stockStr);
                if (stock > 0) {
                    // 库存减 1
                    redisTemplate.opsForValue().decrement(stockKey);
                     // 发送消息到 RabbitMQ 队列
                    rabbitTemplate.convertAndSend(QUEUE_NAME, requestId);
                    return "秒杀成功";
                } else {
                    return "秒杀失败,库存不足";
                }
            } finally {
                // 释放锁
                redisTemplate.delete(lockKey);
            }
        } else {
            return "系统繁忙,请稍后再试";
        }
    }

    private String getStockKey(Long productId, int nodeIndex) {
        return "{seckill_stock}" + productId + "_node_" + nodeIndex;
    }

    private String getLockKey(Long productId, int nodeIndex) {
        return "{seckill_lock}" + productId + "_node_" + nodeIndex;
    }
}

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisNode;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.util.Arrays;
import java.util.List;

@Configuration
public class RedisClusterConfig {

    @Value("${spring.redis.cluster.nodes}")
    private String clusterNodes;

    @Bean
    public JedisConnectionFactory jedisConnectionFactory() {
        List<String> nodes = Arrays.asList(clusterNodes.split(","));
        RedisClusterConfiguration redisClusterConfiguration = new RedisClusterConfiguration(nodes);
        return new JedisConnectionFactory(redisClusterConfiguration);
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(jedisConnectionFactory());
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new StringRedisSerializer());
        return template;
    }

    @Bean
    public StringRedisTemplate stringRedisTemplate() {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(jedisConnectionFactory());
        return template;
    }
}
# application.properties 中的 Redis 集群配置示例 
spring.redis.cluster.nodes=127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002

如何保证MQ消息的可靠性

  • 可以利用消息的事务例如rocketMq的事务
  • 利用消息的持久化,和确认机制

如何保证消息的幂等性

解决思路:

  1. 生成唯一消息标识

    • 在将消息发送到 MQ 时,为每条消息生成一个唯一的标识符,该标识符可以是一个 UUID 或者基于业务规则生成的唯一值,如订单号、请求 ID 等。
    • 确保这个唯一标识符可以唯一标识这个消息,并且在后续的消息处理过程中可以方便地使用。
  2. 使用 Redis 存储已处理消息

    • 在消息消费者端,使用 Redis 存储已经处理过的消息的唯一标识符。
    • 当收到一条消息时,首先检查该消息的唯一标识符是否已经存在于 Redis 中。
    • 如果存在,说明该消息已经被处理过,直接丢弃该消息,避免重复处理;如果不存在,说明该消息是新消息,继续处理并将其唯一标识符存储到 Redis 中。
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
@RabbitListener(queues = "seckill_queue")
public class SeckillMessageConsumer {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    private static final String PROCESSED_MESSAGE_KEY = "seckill_processed_message:";

    @RabbitHandler
    public void processMessage(String message) {
        // 假设 message 是一个包含了唯一标识符的消息,这里可以是一个 JSON 字符串,其中包含了唯一标识符,如 {"messageId":"unique_id", "content":"seckill_request"}
        String messageId = extractMessageId(message);
        String processedKey = PROCESSED_MESSAGE_KEY + messageId;
        Boolean processed = redisTemplate.opsForValue().setIfAbsent(processedKey, "processed", 1, TimeUnit.DAYS);
        if (processed!= null && processed) {
            // 消息未处理过,进行处理
            try {
                System.out.println("Processing message: " + message);
                // 这里添加具体的业务逻辑,如订单处理、库存更新等操作
                // 处理完成后,消息被标记为已处理,存储在 Redis 中
            } catch (Exception e) {
                // 处理异常情况,例如记录日志或回滚操作
                System.err.println("Error processing message: " + e.getMessage());
            }
        } else {
            System.out.println("Message already processed, skipping: " + message);
        }
    }

    private String extractMessageId(String message) {
        // 这里需要根据消息的具体格式提取唯一标识符
        // 假设消息是一个 JSON 字符串,可以使用 JSON 解析库(如 Jackson)提取 messageId
        // 这里简单示例,假设 messageId 是字符串的前部分
        return message.split(":")[0];
    }
}

下单后的消息,大量堆积在MQ中怎么办

解决思路:

  1. 扩容消费者

    • 增加消息队列消费者的数量,让更多的消费者同时处理消息,提高消息的处理速度,从而减少消息堆积。
    • 可以通过动态调整消费者的数量,根据消息堆积的程度和系统的处理能力进行灵活配置。
  2. 丢弃消息: 当监测到消息超过1分钟没有被消费时,就证明此时有大量的消息堆积在MQ中,可以采用将消息丢弃,并将库存还原,提示用户付款失败

  3. 写入redis缓存: 当监测到消息超过1分钟没有被消费时,就证明此时有大量的消息堆积在MQ中,此时可以将消息先写入到缓存中,并开启一个定时任务再去消费