秒杀系统(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倍该怎么处理?
非学无以广才,非志无以成学。