这篇文章是我学习秒杀系统时整理的笔记。我为什么拿秒杀当高并发入门的第一个案例?因为它把高并发里最典型的问题一次性全占了:瞬时流量、热点数据、库存一致性、异步削峰。把秒杀搞明白,分布式系统里缓存、消息队列、数据库一致性这些核心知识就全串起来了。
笔记按"先看整体架构,再逐层拆代码"的顺序写,代码按 Spring Boot 落地,能写真实代码的写真实代码,不适合写代码的用伪代码说明。
一、先看整体架构:流量是怎么一层层被削掉的
浏览器 / App
│ ① 商品页静态化 + CDN(读不回源)
│ ② 答题/验证码、按钮防重、一次性 token(削峰 + 挡脚本)
▼
Nginx 层(limit_req 按 IP 限流)
▼
网关层(Spring Cloud Gateway + Sentinel:限流、鉴权、黑名单)
▼
秒杀应用(Spring Boot)
│ ③ 活动时间校验 + 一人一单校验(Redis SETNX)
│ ④ 库存预热 + Lua 原子扣减 ← 在这里拦掉 99% 的请求
▼ 扣减成功才继续
RabbitMQ(削峰填谷,异步下单)
▼
消费端:DB 乐观锁扣库存(防超卖兜底)→ 创建订单
▼ 失败 / 支付超时
库存回滚(Redis INCRBY 还回去)+ 订单状态机理解这张图,关键是一句话:每一层都在把请求量往下一级砍一个数量级。秒杀平时没什么流量,活动开始那一秒涌进平时几千倍的请求,但真实可售库存可能只有几百。所以架构的核心不是"让所有请求都打到数据库",而是分层拦截、逐级削峰、异步落库、最终一致——到数据库时,请求数已经和真实库存同量级了。
二、数据模型:先把三张表建出来
秒杀只需要三张表:商品(活动信息)、库存(独立成表)、订单(状态机)。库存单独拆一张表而不是塞在商品表里,是为了后面能用 UPDATE ... WHERE stock >= 1 做乐观锁扣减——这个写法只有独立库存表才好写、才好加索引。
-- 秒杀商品(活动信息)
CREATE TABLE seckill_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
item_name VARCHAR(64) NOT NULL,
price DECIMAL(10,2) NOT NULL,
start_time DATETIME NOT NULL COMMENT '开抢时间',
end_time DATETIME NOT NULL COMMENT '结束时间',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 库存表(乐观锁扣减的目标)
CREATE TABLE seckill_stock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
item_id BIGINT NOT NULL,
stock INT NOT NULL COMMENT '剩余库存',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
UNIQUE KEY uk_item (item_id)
);
-- 订单表(状态机:0待支付 / 1已支付 / 2已取消)
CREATE TABLE seckill_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
expire_time DATETIME NOT NULL COMMENT '支付超时时间',
UNIQUE KEY uk_order_no (order_no),
KEY idx_user (user_id)
);对应的 Maven 依赖就四样:spring-boot-starter-data-redis、spring-boot-starter-amqp、mybatis-plus-boot-starter、spring-cloud-starter-alibaba-sentinel,后面每一层会用到对应的那个。
三、缓存层:库存预热 + Lua 原子扣减(最关键的一层)
3.1 为什么要把库存预热到 Redis
活动开始的瞬间,如果每个请求都去数据库"查库存、扣库存",数据库必挂。所以库存要先从 DB 搬进 Redis,让绝大多数请求在内存层就被处理掉。
预热要注意一个细节:用 setIfAbsent 而不是 set。因为 key 一旦存在就说明库存已经在用了,如果每次都 set,活动结束后再跑一次预热就会把已扣减的库存覆盖回去,数据就乱了:
@Component
@Slf4j
public class StockPreheatRunner implements ApplicationRunner {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void run(ApplicationArguments args) {
// 查 DB 里所有"即将开始"的活动商品
List<SeckillItem> items = itemMapper.selectUpcomingItems();
items.forEach(item -> {
String key = "seckill:stock:" + item.getId();
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(key, String.valueOf(item.getStock()));
if (Boolean.TRUE.equals(ok)) {
log.info("预热库存 itemId={} stock={}", item.getId(), item.getStock());
}
});
}
}预热时机一般交给定时任务(xxl-job / SnailJob 等)在开抢前几分钟触发,而不是跟服务启动走——防止库存提前暴露。
3.2 为什么扣减必须用 Lua 脚本(防超卖的核心)
防超卖的本质是:"判断库存够不够"和"扣减库存"这两个动作必须是原子的。
初学者最容易写的版本是"先 GET 再 DECR":
// ❌ 错误示范:两条命令之间能插入其他请求,并发下必然超卖
int stock = Integer.parseInt(redisTemplate.opsForValue().get(key));
if (stock >= 1) {
redisTemplate.opsForValue().decrement(key);
}假设库存只剩 1,两个请求同时 GET 都读到 1,都满足 >= 1,于是都执行 DECR——库存变成 -1,超卖了。
正确做法是用 Lua 脚本。Redis 是单线程执行脚本的,脚本跑完之前不会执行任何其他命令,所以"判断 + 扣减"整体是原子的:
-- KEYS[1] = seckill:stock:{itemId}
-- ARGV[1] = 本次扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then
return -1 -- 库存未预热
end
if stock < tonumber(ARGV[1]) then
return 0 -- 库存不足
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1 -- 扣减成功Java 侧封装成 Service,返回值约定:1 扣减成功、0 库存不足、-1 未预热:
@Service
@Slf4j
public class StockService {
private static final String DEDUCT_LUA =
"local stock = tonumber(redis.call('GET', KEYS[1])) " +
"if stock == nil then return -1 end " +
"if stock < tonumber(ARGV[1]) then return 0 end " +
"redis.call('DECRBY', KEYS[1], ARGV[1]) " +
"return 1";
private final DefaultRedisScript<Long> script =
new DefaultRedisScript<>(DEDUCT_LUA, Long.class);
private final StringRedisTemplate redisTemplate;
public StockService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* @return 1 扣减成功;0 库存不足;-1 未预热
*/
public int deduct(Long itemId, int count) {
Long result = redisTemplate.execute(
script,
List.of("seckill:stock:" + itemId),
String.valueOf(count));
return result == null ? -1 : result.intValue();
}
/** 回滚(少卖时用,后面章节会用到) */
public void rollback(Long itemId, int count) {
redisTemplate.opsForValue().increment("seckill:stock:" + itemId, count);
}
}这一层拦掉的量是最夸张的:库存 500,第 501 个请求起在 Redis 就被 return 0 挡掉,根本走不到消息队列和数据库。
四、接口层:一次性 token + 秒杀主流程
4.1 token 解决什么问题
token 解决的是防连点 + 防脚本刷单。用户点"立即抢购"先向后端申请一个 token(10 秒有效、一人一商品一个),下单时必须携带,用一次就删。没这个设计,脚本一秒钟能刷几十次请求,把后端请求量放大一个量级:
@RestController
@RequestMapping("/seckill")
@Slf4j
public class SeckillController {
@Autowired private StringRedisTemplate redisTemplate;
@Autowired private SeckillService seckillService;
/** 1. 先拿 token(10 秒有效,一人一商品一个) */
@PostMapping("/token")
public Result<String> getToken(@RequestParam Long itemId,
@RequestParam Long userId) {
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(
"seckill:token:" + userId + ":" + itemId,
token, 10, TimeUnit.SECONDS);
return Result.ok(token);
}
/** 2. 秒杀下单 */
@PostMapping("/order")
public Result<String> seckill(@RequestParam Long itemId,
@RequestParam Long userId,
@RequestParam String token) {
String key = "seckill:token:" + userId + ":" + itemId;
String cached = redisTemplate.opsForValue().get(key);
if (cached == null || !cached.equals(token)) {
return Result.fail("非法请求或已提交");
}
redisTemplate.delete(key); // token 一次性
return seckillService.seckill(itemId, userId);
}
}4.2 主流程:把每一步的职责拆清楚
@Service
@Slf4j
public class SeckillService {
@Autowired private StockService stockService;
@Autowired private RabbitTemplate rabbitTemplate;
public Result<String> seckill(Long itemId, Long userId) {
// ① 活动时间校验(活动信息放 Redis 缓存,避免查 DB)
if (!isInSeckillWindow(itemId)) {
return Result.fail("活动未开始或已结束");
}
// ② 一人一单:SETNX 成功才继续,防止同一用户重复抢
Boolean first = redisTemplate.opsForValue()
.setIfAbsent("seckill:user:" + userId + ":" + itemId, "1",
1, TimeUnit.HOURS);
if (!Boolean.TRUE.equals(first)) {
return Result.fail("您已参与过该秒杀");
}
// ③ Redis Lua 原子扣减(核心拦截点)
int code = stockService.deduct(itemId, 1);
if (code == -1) {
return Result.fail("活动火爆,请稍后再试"); // 库存未预热 → 降级
}
if (code == 0) {
return Result.fail("已抢光"); // 拦截 99% 请求
}
// ④ 发 MQ,异步落库(立即返回"排队中")
SeckillOrderMessage msg = SeckillOrderMessage.of(itemId, userId, orderNo());
rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE,
RabbitConfig.ROUTING_KEY, msg);
return Result.ok("排队中,请稍后查看结果");
}
}注意:走到第 ④ 步之前,一次请求全程没碰过数据库——这是秒杀能扛住瞬时洪峰的根本。
五、异步层:RabbitMQ 削峰落库
5.1 为什么必须异步落库
"扣库存"和"创建订单"如果同步做,一次请求就占一次数据库连接,活动瞬间连接池直接被打满。所以要把这两个动作拆开:Redis 扣完库存立刻返回"排队中",真正落库交给消费者按自己的节奏慢慢来。消息队列在这里既是削峰填谷的缓冲,也是解耦点。
@Configuration
public class RabbitConfig {
public static final String EXCHANGE = "seckill.exchange";
public static final String QUEUE = "seckill.order.queue";
public static final String ROUTING_KEY = "seckill.order";
@Bean
public Queue orderQueue() {
return QueueBuilder.durable(QUEUE).build(); // 持久化,防重启丢消息
}
@Bean
public DirectExchange exchange() {
return new DirectExchange(EXCHANGE, true, false);
}
@Bean
public Binding binding() {
return BindingBuilder.bind(orderQueue()).to(exchange()).with(ROUTING_KEY);
}
}5.2 消费者:DB 乐观锁兜底 + 事务落库
@Component
@Slf4j
public class OrderConsumer {
@Autowired private StockMapper stockMapper;
@Autowired private OrderMapper orderMapper;
@Autowired private StockService stockService;
@RabbitListener(queues = RabbitConfig.QUEUE)
@Transactional(rollbackFor = Exception.class)
public void onCreateOrder(SeckillOrderMessage msg) {
// ① DB 乐观锁扣库存(防超卖的最后一道防线)
// UPDATE seckill_stock SET stock=stock-1, version=version+1
// WHERE item_id=#{itemId} AND stock >= 1
int rows = stockMapper.deductStock(msg.getItemId());
if (rows == 0) {
// ② 扣不到 → 回滚 Redis 预扣,避免"少卖"
stockService.rollback(msg.getItemId(), 1);
log.warn("DB 扣减失败,回滚 Redis 预扣 itemId={}", msg.getItemId());
return;
}
// ③ 创建订单(status=0 待支付,15 分钟后过期)
orderMapper.insert(SeckillOrder.builder()
.orderNo(msg.getOrderNo())
.userId(msg.getUserId())
.itemId(msg.getItemId())
.status(0)
.expireTime(LocalDateTime.now().plusMinutes(15))
.build());
log.info("下单成功 orderNo={}", msg.getOrderNo());
}
}5.3 Mapper:乐观锁扣减(为什么 WHERE stock >= 1)
<update id="deductStock">
UPDATE seckill_stock
SET stock = stock - 1,
version = version + 1
WHERE item_id = #{itemId}
AND stock >= 1
</update>WHERE stock >= 1 保证永远扣不成负数,影响行数为 0 就是扣减失败。这行 SQL 是数据库层面的"最后一道防线"——就算 Redis 数据被误改,DB 也不会超卖。到这里,防超卖就有两道独立的闸门了:Redis Lua 预扣(乐观、高性能)+ DB 乐观锁(权威、兜底)。
六、库存回滚与超时关单
6.1 "少卖"是怎么产生的
用户抢到但 15 分钟不付款,库存不还回去,就会出现"看着有货却抢不到"的少卖问题。所以要有超时关单:定期扫出过期未支付的订单,置为已取消,并把库存加回去——DB 和 Redis 两边都要加,保持两个库存源一致:
@Component
@Slf4j
public class OrderCloseTask {
@Scheduled(fixedDelay = 30_000) // 每 30 秒扫一次
@Transactional(rollbackFor = Exception.class)
public void closeExpiredOrders() {
// ① 查出所有 status=0 且 expire_time < now 的订单
List<SeckillOrder> expired = orderMapper.selectExpired();
for (SeckillOrder order : expired) {
// ② 置为已取消(用状态条件防止并发重复关单)
int rows = orderMapper.cancelIfPending(order.getOrderNo());
if (rows > 0) {
// ③ 回滚库存(DB 加回 + Redis 加回)
stockMapper.increaseStock(order.getItemId(), 1);
stockService.rollback(order.getItemId(), 1);
}
}
}
}如果想要"秒级关单",可以用 RabbitMQ 延迟队列(TTL + 死信交换机):下单时发一条延迟 15 分钟的消息,到期检查未支付就关单。效果更实时,代价是多一张延迟交换机配置。入门阶段用定时任务足够。
七、稳定性:限流 / 熔断 / 降级
7.1 Sentinel 接口限流 + 熔断
在秒杀接口上用 @SentinelResource 挂两层兜底:限流走 blockHandler,业务异常走 fallback,都是友好返回而不是把错误堆给用户:
@SentinelResource(
value = "seckill-order", // 资源名,在控制台配置 QPS 阈值
blockHandler = "seckillBlock", // 触发限流时执行
fallback = "seckillFallback") // 业务异常时执行
public Result<String> seckill(Long itemId, Long userId) {
// ...主流程
}
// 限流兜底
public Result<String> seckillBlock(Long itemId, Long userId, BlockException e) {
return Result.fail("活动太火爆,请稍后再试");
}
// 熔断兜底
public Result<String> seckillFallback(Long itemId, Long userId, Throwable t) {
return Result.fail("系统繁忙,请稍后再试");
}7.2 网关和 Nginx 再叠两层
网关按用户维度限流(Spring Cloud Gateway + RequestRateLimiter):
spring:
cloud:
gateway:
routes:
- id: seckill-route
uri: lb://seckill-service
predicates:
- Path=/seckill/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100 # 每秒填充令牌
redis-rate-limiter.burstCapacity: 200 # 桶容量
key-resolver: "#{@userKeyResolver}" # 按 userId 维度Nginx 最外层再按 IP 限:limit_req_zone $binary_remote_addr zone=seckill:10m rate=5r/s;。越靠外层的限流越粗,但越能扛。
7.3 降级的三条底线
- Redis 不可用:Lua 扣减直接降级为"活动火爆",绝不穿透到数据库扛全量——这是最容易犯的错,Redis 一挂就想着"还有 DB 兜底",结果 DB 直接被冲垮。
- MQ 积压:不必慌,消费端限流 + 加消费者实例即可,用户轮询结果接口就行,秒杀本来就不追求实时。
- DB 压力大:查询订单结果的接口加缓存,别让"查结果"变成新的读洪峰。
八、秒杀常见的坑(整理成对照表)
| # | 坑 | 根因 | 解法 |
|---|---|---|---|
| 1 | 超卖 | "判断 + 扣减"不是原子的 | Redis Lua 原子扣减 + DB 乐观锁双保险 |
| 2 | 少卖 | 扣了 Redis,DB 失败不回滚 | 消费失败/关单统一 INCRBY 回滚 |
| 3 | 一人多单 | 无防重 | 一次性 token + SETNX user:item |
| 4 | 热点 key | 单库存 key 被打满 | 库存分桶 seckill:stock:{itemId}:{0..9},取模路由 |
| 5 | 数据库连接打满 | 同步落库 | MQ 削峰 + 消费限流 |
| 6 | 缓存与 DB 不一致 | 两边各写各的 | 单向链路:Redis 预扣 → DB 权威 → 失败回滚 |
| 7 | 消息重复消费 | 网络重投 | 订单表 order_no 唯一键 + 状态机(幂等) |
| 8 | 活动时间被绕过 | 客户端控制 | 服务端校验活动窗口 + 库存预热时间窗 |
小结
整理完这份笔记,我最大的感受是:秒杀系统没有黑科技,就是把每一层的基本功排好队。每一层用一个组件、守住一段关键代码:
| 层 | 组件 | 关键代码点 |
|---|---|---|
| 最外层拦截 | Nginx + Gateway + Sentinel | 限流、鉴权、token |
| 缓存层 | Redis + Lua 脚本 | setIfAbsent 预热、DECRBY 原子扣减 |
| 异步层 | RabbitMQ | 持久化队列 + @RabbitListener 消费 |
| 数据层 | MyBatis-Plus + MySQL | WHERE stock >= 1 乐观锁、@Transactional |
| 稳定性 | Sentinel + 定时任务 | @SentinelResource、超时关单回滚 |
而贯穿始终的就三句话:尽早拦截、异步解耦、最终一致。学的时候建议按这个顺序把每一层亲手实现一遍,比看十遍文章都管用。
