面试宝典第二话 -- 如何设计一个秒杀系统

秒杀系统(Flash Sale System)是一种高并发、高性能的系统设计,通常用于处理大量用户在同一时间抢购有限商品的场景。本文将详细介绍如何在Java中设计和实现一个秒杀系统。

1.需求分析

高并发处理:在秒杀开始时,系统需要能够处理大量的并发请求。

库存扣减:需要精确地扣减库存,防止超卖。

用户限购:每个用户只能购买一次。

性能优化:系统需要在高并发下保持高性能。

2.设计思路

库存预减:在Redis中预减库存,做到线程安全,同时减少数据库压力。

限流: 请求限流,另外可通过切面做(如令牌桶,IP)限制重复请求频率。

异步处理:对于预减库存成功的用户丢到消息队列异步处理订单,提升系统响应速度。

读写分离:异步消费着处理订单时读写分离,提升处理速度。

3.实现步骤

3.1 项目依赖

主要以Java21、Springboot3.2.3、redis、rabbitMQ实现,xml引入对应依赖

xml 复制代码
<parent>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-parent</artifactId>
     <version>3.2.3</version>
     <relativePath/> <!-- lookup parent from repository -->
 </parent>

<dependencies>
	<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
	<!--Spring boot Redis-->
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-data-redis</artifactId>
	</dependency>
	<!-- rabbitmq  -->
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-amqp</artifactId>
	</dependency>
</dependencies>	

3.2 yaml配置

yaml 复制代码
spring:
  data:
    redis:
      database: 0
      host: 192.168.168.131
      port: 6379
      #连接超时时间
      timeout: 5000
  rabbitmq:
#    addresses: 192.168.0.221:5672,192.168.0.221:5673
    addresses: 192.168.168.173:5672
    username: guest
    password: guest
    virtual-host: /
    listener:
      direct:
        prefetch: 1000000
      simple:
        #手动确认 当有自动确认机制 又手动ACK会报406错误
        acknowledge-mode: manual    

3.3 RabbitMQ配置

配置抢到库存的用户异步消息队列,如果想更灵活配置可以看上篇文章的RabbitMQ配置。

java 复制代码
@Configuration
public class RabbitMQConfig {
    @Bean
    public Queue orderQueue() {
        return new Queue("orderQueue", true);
    }

    @Bean
    public DirectExchange orderExchange() {
        return new DirectExchange("orderExchange");
    }

    @Bean
    public Binding binding(Queue orderQueue, DirectExchange orderExchange) {
        return BindingBuilder.bind(orderQueue).to(orderExchange).with("orderRoutingKey");
    }
}

4.秒杀接口

Java代码实现,包括库存预减和请求限流

java 复制代码
@Autowired
RedisTemplate<String, String> redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;

@PostMapping("/seckill")
public ResponseEntity<String> startSeckill(@RequestParam String productId, @RequestParam String userId) {
    // 检查用户是否已购买
    if (redisTemplate.opsForValue().get("seckill:user:" + userId) != null) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You have already purchased this product.");
    }

    // 预减库存
    Long stock = redisTemplate.opsForValue().decrement("seckill:stock:" + productId);
    if (stock < 0) {
        redisTemplate.opsForValue().increment("seckill:stock:" + productId);
        return ResponseEntity.status(HttpStatus.GONE).body("Product sold out.");
    }

    // 记录用户购买
    redisTemplate.opsForValue().set("seckill:user:" + userId, "purchased");

    // 发送订单到消息队列
    rabbitTemplate.convertAndSend("orderExchange", "orderRoutingKey", new OrderMessage(productId, userId));

    return ResponseEntity.ok("Seckill success.");
}

3.5 消息消费

消费 RabbitMQ中的订单消息,处理订单创建

java 复制代码
@Component
public class OrderConsumer {
    @Autowired
    private OrderService orderService;

    @RabbitListener(queues = "orderQueue")
    public void handleOrder(OrderMessage orderMessage) {
        orderService.createOrder(orderMessage);
        //需要考虑消费失败 丢回队列 或者消费成功确认消息...
    }
}

@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;

    public void createOrder(OrderMessage orderMessage) {
        // 创建订单
        Order order = new Order();
        order.setProductId(orderMessage.getProductId());
        order.setUserId(orderMessage.getUserId());
        order.setCreateTime(LocalDateTime.now());
        orderRepository.save(order);

        // 扣减数据库库存
        // TODO: 实现数据库库存扣减逻辑
    }
}

4.额外切面限流实现

4.1 新建一个注解

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Limit {

    // 资源名称,用于描述接口功能
    String name() default "";

    // 资源 key
    String key() default "";

    // key prefix
    String prefix() default "";

    // 时间的,单位秒
    int period();

    // 限制访问次数
    int count();

    // 限制类型 IP || 用户唯一键 || 设备标识等等
    LimitType limitType() default LimitType.IP;
}

4.2 代码接口

java 复制代码
@AnonymousGetMapping
@Operation(summary ="测试")
//对某个ip做限制 60s类只能请求10次
@Limit(key = "test", period = 60, count = 10, name = "testLimit", prefix = "limit")
public int test() {
    return "success";
}

4.3 注解切面拦截类

java 复制代码
@Slf4j
@Component
@Aspect
public class AnnotationAspect {

    @Autowired
    RedisTemplate<String, String> redisTemplate;

    /**
     * 配置切入点
     */
    @Pointcut("@annotation(com.example.demo.aspect.Limit)")
    public void limitPointcut() {
        // 该方法无方法体,主要为了让同类中其他方法使用此切入点
    }

    /**
     * 配置环绕通知,使用在方法logPointcut()上注册的切入点
     *
     * @param joinPoint join point for advice
     */
    @Around("limitPointcut()")
    public Object limitAround(ProceedingJoinPoint joinPoint) throws Throwable {
        Object result;

        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Limit limit = method.getAnnotation(Limit.class);
        //获取ip
        String ip = getIp();
        int period = limit.period();
        int count = limit.count();

        String redisKey = limit.prefix() + "_" + limit.key() + "_" + ip;
        Long increment = redisTemplate.opsForValue().increment(redisKey, 1);
        if (increment > count) {
            log.info("ip:{},name:{},次数:{},限制:{},触发限流!",ip,limit.name(),increment,count);
            return JSONObject.of("code", 429).toJSONString();
        }
        if (increment == 1) {
        	//如果是第一次则设置过期时间 限流按周期来 不按最后一次请求递增来
            redisTemplate.expire(redisKey, period, TimeUnit.SECONDS);
        }

        long time = new Date().getTime();
        log.info("请求:{},{}", method.getName(), time);
        result = joinPoint.proceed();
        log.info("请求完成:{},{},耗时:{}", method.getName(), time, new Date().getTime() - time);

        //其他日志保存等等 todo
        return result;
    }


    public static String getIp() {
        try {
            HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
            return getIp(request);
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * 获取ip地址
     */
    public static String getIp(HttpServletRequest request) {
        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("X-Real-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        String comma = ",";
        String localhost = "127.0.0.1";
        if (ip.contains(comma)) {
            ip = ip.split(",")[0];
        }
        if (localhost.equals(ip)) {
            // 获取本机真正的ip地址
            try {
                ip = InetAddress.getLocalHost().getHostAddress();
            } catch (UnknownHostException e) {
                log.error(e.getMessage(), e);
            }
        }
        return ip;
    }
}

4.4 接口测试日志

当在60s内请求超过10次时,日志如下。

java 复制代码
2024-08-08T19:24:30.169+08:00  INFO 33531 --- [io-9090-exec-10] c.example.demo.aspect.AnnotationAspect   : 请求:test,1723116270169
2024-08-08T19:24:30.172+08:00  INFO 33531 --- [io-9090-exec-10] c.e.demo.controller.RedisController      : 写入:order_expire_12345,12345
2024-08-08T19:24:30.176+08:00  INFO 33531 --- [io-9090-exec-10] c.example.demo.aspect.AnnotationAspect   : 请求完成:test,1723116270169,耗时:7
2024-08-08T19:24:30.381+08:00  INFO 33531 --- [nio-9090-exec-1] c.example.demo.aspect.AnnotationAspect   : ip:192.168.168.199,name:testLimit,次数:11,限制:10,触发限流!
2024-08-08T19:24:30.582+08:00  INFO 33531 --- [nio-9090-exec-2] c.example.demo.aspect.AnnotationAspect   : ip:192.168.168.199,name:testLimit,次数:12,限制:10,触发限流!

4.5 其他gateway限流

参考SpringCloud第五话 -- Gateway实现负载均衡、熔断、限流 这个文章吧,本文就不重复贴了。

5.总结

本文详细介绍了如何设计和实现一个秒杀系统,包括需求分析、设计思路和实现步骤。通过Redis缓存、RabbitMQ消息队列和Spring Boot框架的注解切面限流,可以实现一个高并发、高性能的秒杀系统。

以上就是本章的全部内容了,希望这篇文章对你有所帮助!

上一篇:面试宝典第一话 -- 电商平台中订单未支付过期如何实现自动关单?

下一篇:面试宝典第三话 -- 如果系统QPS突然提升10倍该怎么处理?

非学无以广才,非志无以成学。

相关推荐
duration~38 分钟前
Maven随笔
java·maven
zmgst41 分钟前
canal1.1.7使用canal-adapter进行mysql同步数据
java·数据库·mysql
跃ZHD1 小时前
前后端分离,Jackson,Long精度丢失
java
blammmp1 小时前
Java:数据结构-枚举
java·开发语言·数据结构
独行soc1 小时前
#渗透测试#SRC漏洞挖掘#深入挖掘XSS漏洞02之测试流程
web安全·面试·渗透测试·xss·漏洞挖掘·1024程序员节
暗黑起源喵1 小时前
设计模式-工厂设计模式
java·开发语言·设计模式
WaaTong2 小时前
Java反射
java·开发语言·反射
理想不理想v2 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
九圣残炎2 小时前
【从零开始的LeetCode-算法】1456. 定长子串中元音的最大数目
java·算法·leetcode
wclass-zhengge2 小时前
Netty篇(入门编程)
java·linux·服务器