一、整体流程回顾
- 前端下单请求进来,先查 Redis 库存
- 执行
DECR原子扣减:- 结果 < 0 → 库存不足,直接返回失败
- 结果 ≥ 0 → 扣减成功,发送消息到 MQ
- MQ 消费者消费消息,异步更新数据库库存
- 配套:消息重试、定时对账、DB 乐观锁防重复扣减
二、核心依赖(pom.xml 关键)
<!-- Spring Data 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>
<!-- MyBatis/MyBatis-Plus 操作DB -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
三、常量 & MQ 队列配置
1. 常量类
public class StockConstant {
// Redis 库存 key 前缀
public static final String REDIS_STOCK_PREFIX = "product:stock:";
// MQ 队列名称
public static final String STOCK_SYNC_QUEUE = "stock.sync.queue";
}
2. RabbitMQ 配置(队列、交换机、绑定)
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitConfig {
@Bean
public Queue stockSyncQueue() {
// 持久化队列,宕机不丢失消息
return new Queue(StockConstant.STOCK_SYNC_QUEUE, true);
}
}
四、库存实体 & Mapper(DB 层)
1. 商品库存实体(带乐观锁)
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
@Data
public class ProductStock {
private Long id;
// 商品ID
private Long productId;
// 数据库真实库存
private Integer stock;
// 乐观锁版本号,防止异步重复扣减
@Version
private Integer version;
}
2. Mapper 接口(乐观锁更新库存)
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
public interface ProductStockMapper extends BaseMapper<ProductStock> {
/**
* 扣减数据库库存(乐观锁)
* @param productId 商品ID
* @param deductNum 扣减数量(这里秒杀固定为1)
* @param version 版本号
* @return 影响行数,0=更新失败(重复扣/版本过期)
*/
int deductStock(@Param("productId") Long productId,
@Param("deductNum") Integer deductNum,
@Param("version") Integer version);
}
3. Mapper XML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xxx.mapper.ProductStockMapper">
<update id="deductStock">
UPDATE product_stock
SET stock = stock - #{deductNum}, version = version + 1
WHERE product_id = #{productId}
AND version = #{version}
AND stock >= #{deductNum}
</update>
</mapper>
五、下单接口:Redis 预扣库存 + 发 MQ(核心入口)
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
public class StockService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RabbitTemplate rabbitTemplate;
/**
* 秒杀下单:Redis 原子预扣库存
* @param productId 商品ID
* @return true=扣减成功,false=库存不足
*/
public boolean seckillDeductStock(Long productId) {
String redisKey = StockConstant.REDIS_STOCK_PREFIX + productId;
// 1. Redis DECR 原子扣减,单线程保证不会超卖
Long remainStock = stringRedisTemplate.opsForValue().decrement(redisKey);
// 2. 剩余库存 < 0,扣减失败,回滚+返回
if (remainStock < 0) {
// 把多扣的加回来
stringRedisTemplate.opsForValue().increment(redisKey);
return false;
}
// 3. 扣减成功,发送消息到MQ,异步同步DB
StockSyncMsg msg = new StockSyncMsg();
msg.setProductId(productId);
msg.setDeductNum(1); // 单次秒杀扣1件
rabbitTemplate.convertAndSend(StockConstant.STOCK_SYNC_QUEUE, msg);
return true;
}
}
消息实体(MQ 传输对象)
import lombok.Data;
import java.io.Serializable;
@Data
public class StockSyncMsg implements Serializable {
// 商品ID
private Long productId;
// 本次扣减数量
private Integer deductNum;
}
六、MQ 消费者:异步同步数据库库存
开启消息重试、手动 ACK,保证最终一致性
import com.rabbitmq.client.Channel;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.support.AmqpHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.io.IOException;
@Component
public class StockSyncConsumer {
@Resource
private ProductStockMapper stockMapper;
/**
* 监听库存同步队列,异步更新DB
* 手动ACK:失败不删除消息,自动重试
*/
@RabbitListener(queues = StockConstant.STOCK_SYNC_QUEUE)
public void consume(StockSyncMsg msg, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException {
Long productId = msg.getProductId();
int deductNum = msg.getDeductNum();
try {
// 1. 查询当前DB库存+版本号
ProductStock stock = stockMapper.selectById(productId);
if (stock == null || stock.getStock() < deductNum) {
// DB已无库存,拒绝消息,不再重试
channel.basicNack(deliveryTag, false, false);
return;
}
// 2. 乐观锁更新库存
int rows = stockMapper.deductStock(productId, deductNum, stock.getVersion());
if (rows > 0) {
// 更新成功,手动ACK
channel.basicAck(deliveryTag, false);
} else {
// 版本冲突/重复扣减,丢弃消息
channel.basicNack(deliveryTag, false, false);
}
} catch (Exception e) {
e.printStackTrace();
// 异常:拒绝并重回队列,等待重试
channel.basicNack(deliveryTag, false, true);
}
}
}
七、前置初始化:秒杀前加载库存到 Redis
秒杀活动开始前,必须先把 DB 库存全量载入 Redis,否则 DECR 直接变成负数:
@Service
public class StockInitService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private ProductStockMapper stockMapper;
/**
* 秒杀预热:DB库存 → 全量加载到Redis
*/
public void loadStockToRedis(Long productId) {
ProductStock stock = stockMapper.selectById(productId);
if (stock != null) {
String key = StockConstant.REDIS_STOCK_PREFIX + productId;
stringRedisTemplate.opsForValue().set(key, stock.getStock().toString());
}
}
}
八、兜底方案(代码思路,可直接实现)
1. 定时对账任务(解决 Redis/DB 数据不一致)
使用 @Scheduled 定时比对:
-
定时拉取所有参与秒杀商品
-
分别查询 Redis 库存、DB 库存
-
差值不为 0 → 以DB 为准修正 Redis 库存
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;@Slf4j
@Component
public class StockCheckTask {@Resource private StringRedisTemplate stringRedisTemplate; @Resource private ProductStockMapper productStockMapper; // Redis 库存 key 前缀(和你业务保持一致) private static final String REDIS_STOCK_PREFIX = "product:stock:"; /** * 定时对账:每 5 分钟执行一次 * 核心逻辑:以 DB 为最终权威,修正 Redis 库存 */ @Scheduled(fixedRate = 1000 * 60 * 5) public void stockReconciliation() { log.info("===== 开始执行 Redis <-> DB 库存定时对账 ====="); try { // 1. 查询所有需要参与秒杀/库存对账的商品(你可以加条件:只查活动商品) List<ProductStock> productStockList = productStockMapper.selectList(null); for (ProductStock dbStock : productStockList) { Long productId = dbStock.getProductId(); Integer realDbStock = dbStock.getStock(); // DB 真实库存(权威值) // 2. 拼接 Redis key String redisKey = REDIS_STOCK_PREFIX + productId; // 3. 获取 Redis 当前库存 String redisStockStr = stringRedisTemplate.opsForValue().get(redisKey); if (redisStockStr == null) { log.warn("商品{} Redis 库存不存在,自动从DB同步", productId); stringRedisTemplate.opsForValue().set(redisKey, String.valueOf(realDbStock)); continue; } // 转成数字 int redisStock = Integer.parseInt(redisStockStr); // 4. 对比:不一致 → 强制以 DB 为准修正 Redis if (redisStock != realDbStock) { log.warn("商品{} 库存不一致!DB={},Redis={},自动修正 Redis", productId, realDbStock, redisStock); // 修正 Redis = DB 真实库存(最终一致性兜底) stringRedisTemplate.opsForValue().set(redisKey, String.valueOf(realDbStock)); } } log.info("===== 库存对账执行完成 ====="); } catch (Exception e) { log.error("===== 库存对账异常 =====", e); } }}
这个对账任务的核心作用
- Redis 扣多了 → 自动把 Redis 改成 DB 真实值
- Redis 丢数据 / 宕机恢复 → 自动补全库存
- MQ 消费失败、漏同步 → 最终能修复一致
- 绝对防止超卖 :永远以 DB 为权威数据源
必须开启的配置(application.yml)
spring:
application:
name: seckill-demo
# 开启定时任务
task:
scheduling:
enabled: true
启动类必须加注解
@SpringBootApplication
@EnableScheduling // 必须加!
@EnableRabbit
public class SeckillApplication {
public static void main(String[] args) {
SpringApplication.run(SeckillApplication.class, args);
}
}
2. 防超卖总结
- Redis
DECR原子操作 + 单线程模型:拦截绝大部分超卖 - DB 乐观锁:兜底防止异步重复扣减
- 消息重试 + 定时对账:保证最终一致性
九、使用顺序(上线步骤)
- 秒杀预热:调用
loadStockToRedis()将 DB 库存载入 Redis - 前端请求 → 调用
seckillDeductStock()预扣库存 - MQ 消费者自动异步更新 DB
- 定时任务自动对账修复数据偏差