目录
五、关于InitSeckillShopsRedisDataTask中显式设置类加载器的作用
一、业务需求
假设我们有一个电商平台,现计划开展一个秒杀活动。
核心要求是一人一单且不能超卖。
请你设计并实现该业务。
二、分析
(一)sql表是否需要改动?
考虑到电商平台已有用户表和商品表,但秒杀活动有其特殊性:
- 秒杀商品的库存管理需要高性能和独立性,不宜直接操作海量数据的常规商品表。因此,需要**新增秒杀商品表 (
shop_seckill)**来管理秒杀商品的专属信息。 - 秒杀场景下,为了提升并发处理能力,避免下单时立即操作库存带来的性能瓶颈,应新增秒杀订单表 (
order)。秒杀订单处理流程将优先保证快速响应,库存扣减采用异步方式。
最后, 为应对写入压力,对这两张表进行水平分表。采用对10取模的方式,将每张表拆分为10张物理表,以分散单表负载。
(二)如何应对高并发?
对于高并发的接口,我们频繁对DB进行操作的话不仅对性能会有非常大的影响,甚至可能使其宕机,这是极有可能发生而且不可逆的现象。所以我们的目标是尽量使主线程不对DB进行操作。
- 在秒杀活动开始前,通过定时任务将秒杀商品的核心数据预热加载到Redis缓存中,确保活动开始时可直接读取缓存数据,避免大量请求直接穿透到数据库。
- 然后因为我们不对DB进行操作,所以库存方面我们可以在redis中为每个秒杀商品维护一个库存键,只要库存大于0进行扣减,反之则拒绝请求。
- 用户下单成功后,主线程仅做必要校验和redis库存扣减,生成的订单数据不直接写入数据库,而是发送到消息队列进行异步处理。
- 在秒杀活动结束后,通过定时任务读取redis中记录的最终库存变化数据。该任务异步更新秒杀商品表中的实际库存。
- 在进入核心业务逻辑接口前,设置拦截器对请求参数进行格式校验,快速过滤掉明显不合法或恶意请求,减轻后端压力。
(三)如何实现一人一单和防止超卖?
针对一人一单,我们可以在中为每个秒杀商品维护一个Set结构的键,用于存储已成功下单的用户ID,在每次发起下单请求的时候进行校验即可。
针对超卖问题,由于超卖是因为高并发线程问题所引发的,所以我们应该确保获取库存以及删减库存的原子性操作,所以在这里我们要用到Lua脚本。
为了减少频繁的网络请求,所以我们应该将对redis的所有操作都封装到脚本中,将多次请求缩减到一次。
(四)所引发的问题?
对于MQ,最常见的问题就是消息发送失败以及重复消费,对此我们应该启用RabbitMQ的生产者确认机制,当消息发送失败或者消息未被确认的时候立即重新发送,连续三次失败后我们就应该回滚并返回错误结果。
然后是重复消费问题,我们应该在接收到消息之后进行幂等性检查,也就是在Redis中为每条消费的消息设置一个短暂的标记键,每次收到消息时进行校验,如果已经有数据则直接跳过该消息,反之则进行后续操作。最后在操作完成后删除标记键,如果操作过程中出现异常,则立刻进行数据回滚并返回下单失败的提示。
然后是redis,如果在秒杀过程中redis宕机,很容易想到用DB作为备用方案,但是这反而会导致同一时间大量的请求打到DB上反而压垮DB,更合理的策略是立即熔断,返回秒杀活动结束或系统繁忙的错误提示,停止接受新的秒杀请求。
但此时肯定有部分订单是生成成功的,所以我们应该继续执行更新库存的,但是此时redis已经宕机了,无法读取到库存数据,那该怎么办呢。这时我们可以直接读取DB中秒杀商品表中对应商品的订单数量,然后通过计算进行更新即可。
三、具体流程图

模拟实际情况的性能如下:

四、具体代码实现
想直接了解具体实现请跳转至(七)核心代码
(一)相关依赖以及
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.2</version> <relativePath/> </parent>
<groupId>com.yilena</groupId> <artifactId>seckill</artifactId> <version>1.0-SNAPSHOT</version>
<properties> <maven.compiler.source>21</maven.compiler.source> <maven.compiler.target>21</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--mysql--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.23</version> </dependency> <!--mybatis--> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.4</version> </dependency> <!--lombok--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> <optional>true</optional> </dependency> <!--redis--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--hutool工具包--> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.24</version> </dependency> <!--rabbitMQ--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> <!--sharding-jdbc--> <dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc</artifactId> <version>5.5.0</version> <exclusions> <exclusion> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-test-util</artifactId> </exclusion> </exclusions> </dependency> <!--druid--> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.18</version> </dependency> </dependencies>
<build> <pluginManagement> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>21</source> <target>21</target> <encoding>UTF-8</encoding> <annotationProcessorPaths> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> </pluginManagement> </build></project>server: port: 8080
spring: profiles: include: dev main: allow-circular-references: true
# RabbitMQ 配置 rabbitmq: host: ${yilena.mq.host} port: 5672 username: ${yilena.mq.username} password: ${yilena.mq.password} virtual-host: / connection-timeout: 5000 cache: channel.size: 50 channel.checkout-timeout: 1000 publisher-returns: true
# Redis 配置 data: redis: host: ${yilena.redis.host} port: ${yilena.redis.port} database: ${yilena.redis.database} password: ${yilena.redis.password}
# ShardingSphere 配置 datasource: driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver url: jdbc:shardingsphere:classpath:sharding.yaml
# MyBatis 配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 雪花算法配置sf: work-i-d: 3 sequence-length: 6 data-center-index: 6 work-i-d-index: 8 time-stamp-index: 12
# 日志配置logging: level: org.apache.shardingsphere: DEBUG com.alibaba.druid: DEBUG org.springframework.jdbc: DEBUG com.zaxxer.hikari: DEBUGdataSources: ds_0: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://${你自己的数据库地址}/seckill username: ${用户名} password: ${密码}
rules: - !SHARDING tables: user: actualDataNodes: ds_0.user order: actualDataNodes: ds_0.order_${0..9} tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: user_mod shop_seckill: actualDataNodes: ds_0.shop_seckill_${0..9} tableStrategy: standard: shardingColumn: id shardingAlgorithmName: id_mod
shardingAlgorithms: user_mod: type: INLINE props: algorithm-expression: order_${user_id % 10} id_mod: type: INLINE props: algorithm-expression: shop_seckill_${id % 10}
props: sql-show: true check-table-metadata-enabled: true
mode: type: Standalone(二)配置类
1.Redis配置类
package com.yilena.seckill.config;
import lombok.extern.slf4j.Slf4j;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.core.io.ClassPathResource;import org.springframework.data.redis.connection.RedisConnectionFactory;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.data.redis.core.script.DefaultRedisScript;import org.springframework.data.redis.core.script.RedisScript;import org.springframework.data.redis.listener.RedisMessageListenerContainer;import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration@Slf4jpublic class RedisConfig {
@Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { log.info("开始创建redis模板对象..."); RedisTemplate redisTemplate = new RedisTemplate(); //设置redis的连接工厂对象 redisTemplate.setConnectionFactory(redisConnectionFactory); //设置redis key的序列化器 redisTemplate.setKeySerializer(new StringRedisSerializer()); return redisTemplate; }
@Bean(name = "seckillScript") public RedisScript<Long> seckillScript() { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("scripts/seckill.lua")); // 确保路径正确 script.setResultType(Long.class); return script; }}2.RabbitTemplate配置类
package com.yilena.seckill.config;
import com.yilena.seckill.constant.RabbitMqConstant;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.amqp.core.Binding;import org.springframework.amqp.core.BindingBuilder;import org.springframework.amqp.core.DirectExchange;import org.springframework.amqp.core.Queue;import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;import org.springframework.amqp.rabbit.connection.ConnectionFactory;import org.springframework.amqp.rabbit.core.RabbitTemplate;import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;import org.springframework.amqp.support.converter.MessageConverter;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
@Slf4j@Configuration@RequiredArgsConstructorpublic class RabbitMqConfig {
private final ConnectionFactory connectionFactory;
@Bean public MessageConverter jsonMessageConverter() { return new Jackson2JsonMessageConverter(); }
@Bean public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { // 实例化 RabbitTemplate template = new RabbitTemplate(connectionFactory); // 设置消息转换器为jsonMessageConverter,确保消息以JSON格式进行发送和接收 template.setMessageConverter(jsonMessageConverter()); return template; }
// 创建监听容器工厂 @Bean public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) { SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); // 设置连接工厂,用于后续创建的监听器容器连接到RabbitMQ factory.setConnectionFactory(connectionFactory); factory.setMessageConverter(jsonMessageConverter()); return factory; }
// 创建交换机 @Bean public DirectExchange directExchange() { return new DirectExchange(RabbitMqConstant.SECKILL_EXCHANGE); }
// 创建队列 @Bean public Queue seckillQueue() { return new Queue(RabbitMqConstant.SECKILL_QUEUE); }
// 绑定队列和交换机 @Bean public Binding binding(DirectExchange directExchange , Queue seckillQueue) { return BindingBuilder.bind(seckillQueue) .to(directExchange) .with(RabbitMqConstant.SECKILL_BINDING_KEY); }}package com.yilena.seckill.config;
import com.yilena.seckill.service.RetryService;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.amqp.rabbit.core.RabbitTemplate;import org.springframework.beans.BeansException;import org.springframework.beans.factory.config.BeanPostProcessor;import org.springframework.stereotype.Component;
/*** 重试机制*/@Component@Slf4j@RequiredArgsConstructorpublic class RabbitTemplateCallbackConfigurer implements BeanPostProcessor {
private final RetryService retryService;
// 在监听器初始化后才会触发 @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { // 检查是否是RabbitTemplate if (bean instanceof RabbitTemplate) { RabbitTemplate rabbitTemplate = (RabbitTemplate) bean; // 设置确认回调 rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> { // 当消息发送未被确认时,会调用此方法 if (!ack) { log.error("消息发送失败: {}", correlationData != null ? correlationData.getId() : "unknown"); retryService.asyncRetry(correlationData); } }); } // 返回处理后的bean实例 return bean; }}3.和过滤器的配置类
package com.yilena.seckill.config;
import com.yilena.seckill.filter.CachedBodyFilter;import org.springframework.boot.web.servlet.FilterRegistrationBean;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.core.Ordered;
@Configurationpublic class FilterConfig {
@Bean public FilterRegistrationBean<CachedBodyFilter> requestCachingFilterRegistration() { FilterRegistrationBean<CachedBodyFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(new CachedBodyFilter()); // 设置拦截路径 registration.addUrlPatterns("/seckill/buy"); // 设置最高优先级,确保先于其他Filter执行 registration.setOrder(Ordered.HIGHEST_PRECEDENCE); return registration; }}package com.yilena.seckill.config;
import com.yilena.seckill.intercepter.SeckillInterceptor;import lombok.RequiredArgsConstructor;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.InterceptorRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration@RequiredArgsConstructorpublic class WebMvcInterceptorConfig implements WebMvcConfigurer {
private final SeckillInterceptor seckillInterceptor;
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(seckillInterceptor) .addPathPatterns("/**"); }}这里需要说明一下,过滤器的作用是缓存请求体,以便后续拦截器解析请求体中的ID数据从而拦截不合法请求。
4.雪花算法生成ID配置
package com.yilena.seckill.config;
import com.yilena.seckill.utils.SnowFlake;import com.yilena.seckill.utils.SnowFlakeProperties;import org.springframework.boot.context.properties.EnableConfigurationProperties;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
@Configuration@EnableConfigurationProperties(SnowFlakeProperties.class)public class SnowFlakeConfig {
@Bean public SnowFlake snowFlake(SnowFlakeProperties properties) { return new SnowFlake(properties); }}(三)常量类
1.MQ相关常量
package com.yilena.seckill.constant;
public class RabbitMqConstant { public static final String SECKILL_EXCHANGE = "seckill.exchange"; public static final String SECKILL_QUEUE = "seckill.queue"; public static final String SECKILL_BINDING_KEY = "seckill.binding.key";}2.Redis相关常量
package com.yilena.seckill.constant;
public class RedisKeyConstant { public static final String SHOP_SECKILL_MSG_KEY = "shop:seckill:msg:"; public static final String SHOP_SECKILL_COUNT_KEY = "shop:seckill:count:"; public static final String SHOP_SECKILL_ORDER_KEY = "shop:seckill:order:"; public static final String SHOP_SECKILL_ORDER_MQ_KEY = "shop:seckill:order:mq:";}(四)工具类
1.缓存请求体工具类
package com.yilena.seckill.utils;
import jakarta.servlet.ReadListener;import jakarta.servlet.ServletInputStream;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;import java.io.ByteArrayInputStream;import java.io.IOException;import java.io.InputStream;import java.io.InputStreamReader;
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
// 用于缓存请求体序列化的字节数组 private final byte[] cachedBody;
// 构造函数,将请求体序列化到cachedBody中 public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { super(request); InputStream inputStream = request.getInputStream(); this.cachedBody = inputStream.readAllBytes(); }
@Override public ServletInputStream getInputStream() { return new CachedBodyServletInputStream(cachedBody); }
// 重写getReader方法,返回可重复读取的BufferedReader @Override public BufferedReader getReader() { ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cachedBody); return new BufferedReader(new InputStreamReader(byteArrayInputStream)); }
// 提供可重复读取的ServletInputStream public static class CachedBodyServletInputStream extends ServletInputStream { private final ByteArrayInputStream inputStream;
public CachedBodyServletInputStream(byte[] cachedBody) { this.inputStream = new ByteArrayInputStream(cachedBody); }
@Override public int read() { return inputStream.read(); }
@Override public boolean isFinished() { return inputStream.available() == 0; }
@Override public boolean isReady() { return true; }
@Override public void setReadListener(ReadListener listener) { throw new UnsupportedOperationException(); } }}2.线程池工具类
package com.yilena.seckill.utils;
import java.util.ArrayList;import java.util.List;import java.util.concurrent.CompletableFuture;
public class ThreadPoolUtil {
public static <T> List<List<T>> slicingData(List<T> dataList, Integer splitSize) {
int dataSize = dataList.size(); int groupSize = dataSize / splitSize; if (dataSize % splitSize != 0) { groupSize = groupSize + 1; }
List<List<T>> chunks = new ArrayList<>(groupSize); for (int i = 0; i < groupSize; i++) { int fromIndex = i * splitSize; int toIndex = fromIndex + splitSize; List<T> suppliesReadData; if (i == groupSize - 1) { suppliesReadData = dataList.subList(fromIndex, dataList.size()); } else { suppliesReadData = dataList.subList(fromIndex, toIndex); } chunks.add(suppliesReadData); } return chunks; }
public static void allFuturesWait(List<CompletableFuture<Void>> completableFutures) { // 等待所有异步任务完成后,可以继续进行下一步操作 CompletableFuture<Void> allFutures = CompletableFuture.allOf(completableFutures.toArray(new CompletableFuture[0])); allFutures.join(); }}3.雪花算法的生成以及检验相关工具类
package com.yilena.seckill.utils;
import org.springframework.stereotype.Component;
@Componentpublic class SnowFlake {
private SnowFlakeProperties properties;
public SnowFlake() { }
public SnowFlake(SnowFlakeProperties properties) { this.properties = properties; } public synchronized long getID(){ //获得当前时间 long now = System.currentTimeMillis(); long lastTimeStamp = properties.getLastTimeStamp(); //当前系统时间小于上一次记录时间 if (now < lastTimeStamp){ throw new RuntimeException("时钟回拨异常"); } //相同时间 要序列号进制增量 if (now == lastTimeStamp){ //防止溢出 long sequence = properties.getSequence(); sequence = (sequence + 1) & properties.getSequenceMask(); //更新sequence 的值 properties.setSequence(sequence); if (sequence == 0L){ //溢出处理 try { Thread.sleep(1L); } catch (InterruptedException e) { throw new RuntimeException(e); } //获取下一毫秒时间 (有锁) now = System.currentTimeMillis(); } }else { //置0 properties.setSequence(0L); } //记录当前时间 properties.setLastTimeStamp(now); return ((now - properties.getStartTimeStamp()) << properties.getTimeStampIndex()) | (properties.getDataCenterID() << properties.getDataCenterIndex())| (properties.getWorkID() << properties.getWorkIDIndex()) | properties.getSequence(); }}package com.yilena.seckill.utils;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.Setter;import org.springframework.boot.context.properties.ConfigurationProperties;
@Data@AllArgsConstructor@ConfigurationProperties(prefix = "sf")public class SnowFlakeProperties { // 起始时间戳 private long startTimeStamp = 1577808000L; // 机器ID private long workID; // 数据中心ID private long dataCenterID = 0; // 序列号 private long sequence = 0L; // 数据中心位移位数 private long dataCenterIndex; // 机器ID位移位数 private long workIDIndex; // 时间戳位移位数 private long timeStampIndex; // 记录上一次时间戳 private long lastTimeStamp = -1L; // 序列号长度 private long sequenceLength;
// 序列号掩码(动态计算) private long sequenceMask;
public SnowFlakeProperties() { this.sequence = 0L; }
// 当sequenceLength被设置时自动计算sequenceMask public void setSequenceLength(long sequenceLength) { this.sequenceLength = sequenceLength; this.sequenceMask = ~(-1L << sequenceLength); }}package com.yilena.seckill.utils;
public class SnowFlakeValidator {
public static boolean isSnowFlakeID(long id, SnowFlakeProperties props) { // 验证序列号:必须在 0 ~ sequenceMask 之间 long sequenceMask = props.getSequenceMask(); long sequence = id & sequenceMask; if (sequence < 0 || sequence > sequenceMask) { return false; }
// 验证数据中心ID long dataCenterBits = props.getWorkIDIndex() - props.getDataCenterIndex(); long dataCenterMask = (1L << dataCenterBits) - 1; long dataCenterID = (id >> props.getDataCenterIndex()) & dataCenterMask; if (dataCenterID != props.getDataCenterID()) { return false; }
// 验证机器ID long workIDBits = props.getTimeStampIndex() - props.getWorkIDIndex(); long workIDMask = (1L << workIDBits) - 1; long workID = (id >> props.getWorkIDIndex()) & workIDMask; if (workID != props.getWorkID()) { return false; }
// 验证时间戳 long timeStamp = (id >> props.getTimeStampIndex()) + props.getStartTimeStamp(); if (timeStamp < props.getStartTimeStamp()) { return false; }
return true; }}(五)
package com.yilena.seckill.entity.po;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data@AllArgsConstructor@NoArgsConstructorpublic class Order { private Long id; private Long userId; private Long shopId; private LocalDateTime createTime; private Integer status;}package com.yilena.seckill.entity.po;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;import lombok.RequiredArgsConstructor;
import java.time.LocalDateTime;
@Data@NoArgsConstructor@AllArgsConstructorpublic class ShopSeckill { private Long id; private Integer count; private LocalDateTime beginTime; private LocalDateTime endTime;}package com.yilena.seckill.entity.po;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
@Data@NoArgsConstructor@AllArgsConstructorpublic class User { private Long id;}package com.yilena.seckill.entity;
import com.fasterxml.jackson.annotation.JsonInclude;import com.fasterxml.jackson.annotation.JsonProperty;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data@AllArgsConstructor@NoArgsConstructor@JsonInclude(JsonInclude.Include.NON_NULL)public class Result implements Serializable { @JsonProperty("code") private Integer code; @JsonProperty("msg") private String msg; @JsonProperty("data") private Object data;
public static Result success() { Result result = new Result(); result.code = 1; result.msg = "success"; return result; }
public static Result success(Object object) { Result result = new Result(); result.data = object; result.code = 1; result.msg = "success"; return result; }
public static Result error(String msg) { Result result = new Result(); result.msg = msg; result.code = 0; return result; }
}(六)过滤器和拦截器
package com.yilena.seckill.filter;
import com.yilena.seckill.utils.CachedBodyHttpServletRequest;import jakarta.servlet.*;import jakarta.servlet.http.HttpServletRequest;import org.springframework.core.annotation.Order;import org.springframework.stereotype.Component;import java.io.IOException;
@Component@Order(1) // 确保在其他过滤器之前执行public class CachedBodyFilter implements Filter {
// 缓存秒杀请求的请求体 @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 将通用的ServletRequest转换为HTTP请求对象,以便获取HTTP特定的功能和属性 HttpServletRequest httpRequest = (HttpServletRequest) request; // 包装原始请求,以缓存请求体,这是为了在后续处理中能够多次读取请求内容 CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(httpRequest); // 将包装后的请求对象传递给过滤链中的下一个过滤器 chain.doFilter(cachedRequest, response); }}package com.yilena.seckill.intercepter;
import com.fasterxml.jackson.databind.ObjectMapper;import com.yilena.seckill.utils.SnowFlakeProperties;import com.yilena.seckill.utils.SnowFlakeValidator;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Component;import org.springframework.web.servlet.HandlerInterceptor;import org.springframework.web.util.ContentCachingRequestWrapper;import com.yilena.seckill.entity.po.Order;
import java.io.BufferedReader;
@Slf4j@Component@RequiredArgsConstructorpublic class SeckillInterceptor implements HandlerInterceptor {
private final SnowFlakeProperties snowFlakeProperties; private final ObjectMapper objectMapper;
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String url = request.getRequestURL().toString();
// 匹配秒杀下单接口 if (url.contains("/seckill/buy")) { // 从包装请求中读取请求体 StringBuilder body = new StringBuilder(); BufferedReader reader = request.getReader(); String line; while ((line = reader.readLine()) != null) { body.append(line); }
try { // 反序列化为Order对象,提取userId和shopId Order order = objectMapper.readValue(body.toString(), Order.class); Long userId = order.getUserId(); Long shopId = order.getShopId();
// 同步检测雪花ID合法性 if (userId == null || !SnowFlakeValidator.isSnowFlakeID(userId, snowFlakeProperties) || ! SnowFlakeValidator.isSnowFlakeID(shopId, snowFlakeProperties)) { log.error("非法用户ID:{},拦截秒杀请求", userId); response.sendError(HttpServletResponse.SC_BAD_REQUEST, "用户ID或商品ID非法"); return false; } } catch (Exception e) { log.error("请求体解析或ID检测失败:{}", e.getMessage()); response.sendError(HttpServletResponse.SC_BAD_REQUEST, "请求格式错误"); return false; } } return true; }}(七)核心代码
1.controller层
package com.yilena.seckill.controller;
import com.yilena.seckill.entity.Result;import com.yilena.seckill.entity.po.Order;import com.yilena.seckill.service.SeckillService;import com.yilena.seckill.utils.SnowFlake;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.web.bind.annotation.*;
@Slf4j@RestController@RequestMapping("/seckill")@RequiredArgsConstructorpublic class SeckillController {
private final SeckillService seckillService;
@PostMapping( "/buy") public Result buy(@RequestBody Order order){ log.info("用户id为{},下单了秒杀商品:{}", order.getUserId(), order.getShopId()); return seckillService.buy(order); }}2.service层
package com.yilena.seckill.service.serviceImpl;
import cn.hutool.json.JSONUtil;import com.yilena.seckill.constant.RabbitMqConstant;import com.yilena.seckill.constant.RedisKeyConstant;import com.yilena.seckill.dao.SeckillDao;import com.yilena.seckill.entity.Result;import com.yilena.seckill.entity.po.Order;import com.yilena.seckill.entity.po.ShopSeckill;import com.yilena.seckill.service.SeckillService;import com.yilena.seckill.utils.SnowFlake;import jakarta.annotation.Resource;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.amqp.core.MessageDeliveryMode;import org.springframework.amqp.rabbit.connection.CorrelationData;import org.springframework.amqp.rabbit.core.RabbitTemplate;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.data.redis.core.script.RedisScript;import org.springframework.stereotype.Service;
import java.time.LocalDateTime;import java.util.Collections;
@Slf4j@Service@RequiredArgsConstructorpublic class SeckillServiceImpl implements SeckillService {
private final StringRedisTemplate stringRedisTemplate; private final SnowFlake snowFlake; private final RabbitTemplate rabbitTemplate;
@Resource(name = "seckillScript") private RedisScript<Long> seckillScript;
@Override public Result buy(Order order) {
// 获取订单信息 Long shopId = order.getShopId(); Long userId = order.getUserId();
//执行lua脚本,执行下单逻辑 Long result = stringRedisTemplate.execute( seckillScript, Collections.emptyList(), userId.toString(), shopId.toString(), String.valueOf(LocalDateTime.now() .atZone(java.time.ZoneId.systemDefault()) .toInstant() .toEpochMilli()) );
// 处理可能的null返回值 if (result == null) { log.error("Lua脚本返回null, shopId: {}", shopId); return Result.error("系统异常,请重试"); }
int resultCode = result.intValue();
// 扩展错误处理 switch (resultCode) { case 500: // 下单完成,生成订单ID Long orderId = snowFlake.getID(); order.setId(orderId); order.setCreateTime(LocalDateTime.now()); order.setStatus(0);
// 将订单信息发送到mq,异步处理 rabbitTemplate.convertAndSend( RabbitMqConstant.SECKILL_EXCHANGE, RabbitMqConstant.SECKILL_BINDING_KEY, order, message -> { // 消息持久化, 防止消息丢失 message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); return message; }, // 生成关联id,便于重试 new CorrelationData(order.getId().toString()) );
// 先返回订单信息交给前端进行响应,优化用户体验 return Result.success(order);
case 400: return Result.error("当前商品已售空!"); case 401: return Result.error("商品不存在"); case 402: return Result.error("秒杀活动尚未开始!"); case 403: return Result.error("相同商品仅限一单!"); case 404: return Result.error("商品库存信息异常!"); default: log.warn("未知返回码: {}", resultCode); return Result.error("秒杀失败,请重试"); } }}3.Lua脚本
local userId = ARGV[1]local shopId = ARGV[2]local currentTime = tonumber(ARGV[3])
local countKey = "shop:seckill:count:" .. shopIdlocal orderKey = "shop:seckill:order:" .. shopIdlocal shopKey = "shop:seckill:msg:" .. shopId
-- 检查商品是否存在if redis.call('exists', shopKey) == 0 then return 401 -- 商品不存在end
-- 解析商品信息local beginTime = tonumber(redis.call('hget', shopKey, 'beginTime'))local endTime = tonumber(redis.call('hget', shopKey, 'endTime'))
-- 校验活动时间if currentTime < beginTime or currentTime > endTime then return 402 -- 活动未开始/已结束end
-- 订单重复性检查if redis.call("SISMEMBER", orderKey, userId) == 1 then return 403 -- 订单重复end
-- 库存键存在性检查if redis.call("EXISTS", countKey) == 0 then return 404 -- 库存键不存在end
local count = redis.call("GET", countKey)if tonumber(count) > 0 then redis.call("DECR", countKey) redis.call("SADD", orderKey, userId) return 500 -- 成功else return 400 -- 库存不足end注意这里Lua脚本存放的路径要与redis配置类里的一致!
4.MQ
package com.yilena.seckill.mq;
import com.yilena.seckill.constant.RabbitMqConstant;import com.yilena.seckill.constant.RedisKeyConstant;import com.yilena.seckill.dao.OrderDao;import com.yilena.seckill.dao.UserDao;import com.yilena.seckill.entity.po.Order;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.amqp.rabbit.annotation.RabbitListener;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.stereotype.Component;
@Slf4j@Component@RequiredArgsConstructorpublic class SeckillQueueListener {
private final OrderDao orderDao; private final UserDao userDao; private final StringRedisTemplate stringRedisTemplate;
@RabbitListener(queues = RabbitMqConstant.SECKILL_QUEUE) public void handleSeckillQueue(Order order) { log.info("秒杀商品信息队列接收到信息,开始处理id为 {} 的订单", order.getId()); // 幂等性检查,避免重复消费 String key = RedisKeyConstant.SHOP_SECKILL_ORDER_MQ_KEY + order.getId(); Boolean isNew = stringRedisTemplate.opsForValue().setIfAbsent(key, "processing");
// 如果返回false,则说明该订单已经被处理过,直接返回 if (Boolean.FALSE.equals(isNew)) { log.warn("订单重复消费: {}", order.getId()); return; }
try { // 检验用户id是否存在 Integer isExist = userDao.isExist(order.getUserId()); // 用户不存在,回滚数据 if (isExist == 0) { throw new RuntimeException("用户不存在"); } // 处理订单 order.setStatus(1); orderDao.insert(order); } catch (Exception e) { log.error("订单处理失败: {}", order.getId(), e); // 回滚Redis库存 stringRedisTemplate.opsForValue().increment( RedisKeyConstant.SHOP_SECKILL_COUNT_KEY + order.getShopId() ); // 移除订单记录 stringRedisTemplate.opsForSet().remove( RedisKeyConstant.SHOP_SECKILL_ORDER_KEY + order.getShopId(), order.getUserId().toString() ); } finally { // 处理完成后删除标记 stringRedisTemplate.delete(key); } }}因为不会出现重复下单,所以如果isNew为false那只能是重复消费的情况。
5.两个定时任务
package com.yilena.seckill.task;
import cn.hutool.json.JSONUtil;import com.yilena.seckill.constant.RedisKeyConstant;import com.yilena.seckill.dao.SeckillDao;import com.yilena.seckill.entity.po.ShopSeckill;import com.yilena.seckill.utils.ThreadPoolUtil;import jakarta.annotation.PreDestroy;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.scheduling.annotation.Scheduled;import org.springframework.stereotype.Component;
import java.time.LocalDateTime;import java.util.ArrayList;import java.util.List;import java.util.Set;import java.util.concurrent.CompletableFuture;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;import java.util.concurrent.TimeUnit;
@Slf4j@Component@RequiredArgsConstructorpublic class InitSeckillShopsRedisDataTask {
private final SeckillDao seckillDao; private final StringRedisTemplate stringRedisTemplate; private final ExecutorService executor = Executors.newFixedThreadPool(10);
// 销毁线程池 @PreDestroy public void destroy() { // 禁止新任务提交,等待已提交任务执行完毕 executor.shutdown(); try { // 最多再等待60秒,如果超时直接强制关闭线程池 if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } }
// 假设10点开启秒杀活动 @Scheduled (cron = "0 50 9 * * ?") public void initSeckillShopsRedisData (){
// 显式设置类加载器,如果对这条代码有疑惑的话请看本博客目录第五个主标题 Thread.currentThread().setContextClassLoader(this.getClass().getClassLoader());
log.info("开始预热秒杀商品数据,执行时间:{}", LocalDateTime.now());
// 删除之前的缓存 deleteKeysByPattern(RedisKeyConstant.SHOP_SECKILL_COUNT_KEY + "*"); deleteKeysByPattern(RedisKeyConstant.SHOP_SECKILL_MSG_KEY + "*"); deleteKeysByPattern(RedisKeyConstant.SHOP_SECKILL_ORDER_KEY + "*");
// 因为秒杀商品一般数量不会太多,所以不需要怕oom问题 List<ShopSeckill> allSeckillShops = seckillDao.getAllSeckillShops();
// 进行分片 List<List<ShopSeckill>> splitData = ThreadPoolUtil.slicingData(allSeckillShops, 100);
// 开启线程并行进行数据预热,加快速度,防止秒杀开始前未预热完成 splitData.forEach(batch -> { List<CompletableFuture<Void>> futures = new ArrayList<>();
batch.forEach(shopSeckill -> { CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { try { // 库存预热 stringRedisTemplate.opsForValue().set(RedisKeyConstant.SHOP_SECKILL_COUNT_KEY + shopSeckill.getId(), shopSeckill.getCount().toString()); // 信息预热 stringRedisTemplate.opsForHash().put(RedisKeyConstant.SHOP_SECKILL_MSG_KEY + shopSeckill.getId(),"id" ,shopSeckill.getId().toString()); stringRedisTemplate.opsForHash().put(RedisKeyConstant.SHOP_SECKILL_MSG_KEY + shopSeckill.getId(),"count" ,shopSeckill.getCount().toString()); stringRedisTemplate.opsForHash().put(RedisKeyConstant.SHOP_SECKILL_MSG_KEY + shopSeckill.getId(),"beginTime" ,String.valueOf(shopSeckill.getBeginTime() .atZone(java.time.ZoneId.systemDefault()) .toInstant() .toEpochMilli())); stringRedisTemplate.opsForHash().put(RedisKeyConstant.SHOP_SECKILL_MSG_KEY + shopSeckill.getId(),"endTime" ,String.valueOf(shopSeckill.getEndTime() .atZone(java.time.ZoneId.systemDefault()) .toInstant() .toEpochMilli())); }catch (Exception e){ log.error("预热秒杀商品数据失败,商品ID:{}", shopSeckill.getId(), e); } }, executor); futures.add(future); });
// 等待当前批次完成 ThreadPoolUtil.allFuturesWait(futures); });
log.info("预热秒杀商品数据完成,结束时间:{}", LocalDateTime.now()); }
private void deleteKeysByPattern(String pattern) { Set<String> keys = stringRedisTemplate.keys(pattern); if (keys != null && !keys.isEmpty()) { stringRedisTemplate.delete(keys); } }
}package com.yilena.seckill.task;
import cn.hutool.json.JSONUtil;import com.yilena.seckill.constant.RedisKeyConstant;import com.yilena.seckill.dao.OrderDao;import com.yilena.seckill.dao.SeckillDao;import com.yilena.seckill.utils.ThreadPoolUtil;import jakarta.annotation.PreDestroy;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.scheduling.annotation.Scheduled;import org.springframework.stereotype.Component;
import java.time.LocalDateTime;import java.util.ArrayList;import java.util.List;import java.util.concurrent.CompletableFuture;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;import java.util.concurrent.TimeUnit;
@Slf4j@Component@RequiredArgsConstructorpublic class UpdateShopCountTask {
private final SeckillDao seckillDao; private final StringRedisTemplate stringRedisTemplate; private final OrderDao orderDao; private final ExecutorService executor = Executors.newFixedThreadPool(10);
// 销毁线程池 @PreDestroy public void destroy() { // 禁止新任务提交,等待已提交任务执行完毕 executor.shutdown(); try { // 最多再等待60秒,如果超时直接强制关闭线程池 if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } }
// 假设十点十分结束秒杀活动 @Scheduled (cron = "0 10 10 * * ?") public void updateShopCount () { log.info("开始更新秒杀活动DB库存余额,执行时间:{}", LocalDateTime.now());
// 获取所有秒杀商品id List<Long> shopIds = seckillDao.getAllShopIds();
// 进行分片, 提高效率 List<List<Long>> splitData = ThreadPoolUtil.slicingData(shopIds, 100);
// 开启线程并行进行数据更新 splitData.forEach(batch -> { List<CompletableFuture<Void>> futures = new ArrayList<>();
batch.forEach(shopId -> { CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { try { // 检查redis中的库存余额 String countStr = stringRedisTemplate.opsForValue().get(RedisKeyConstant.SHOP_SECKILL_COUNT_KEY + shopId); if (countStr != null) { // 获取redis中的库存余额 Integer count = Integer.parseInt(countStr); // 更新DB库存余额 seckillDao.updateShopCount(shopId, count); }else{ // redis数据可能出了问题,所以需要通过计算DB中对应订单的数量来获取库存余额 Integer count = orderDao.getShopOrderCount(shopId); seckillDao.updateShopCountByOrderCount(shopId, count); } }catch (Exception e){ log.error("更新秒杀活动DB库存余额失败,商品ID:{}", shopId , e); } }, executor); futures.add(future); });
// 等待当前批次完成 ThreadPoolUtil.allFuturesWait(futures); });
log.info("更新秒杀活动DB库存余额完成,结束时间:{}", LocalDateTime.now()); }}至于,都比较简单所以没什么好提的,唯一需要注意的就是在更新库存的定时任务里,当redis数据丢失后执行的这条代码:orderDao.getShopOrderCount(shopId),其SQL语句要记得过滤掉订单创建时间在秒杀活动开始前的订单数据。
五、关于InitSeckillShopsRedisDataTask中显式设置类加载器的作用
这是我在写代码时间Sharding-JDBC遇到的坑,具体作用可以看我这条博客,里面有详细解释。
Sharding-JDBC 定时任务 SQL 无响应的解决方案-CSDN博客
如果你有更好的方案,请在评论区告诉我!

码文不易,留个赞再走呗
原文链接: 一个简单高效的秒杀方案的实现 作者: Yilena
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时










