面试官:我们来聊聊你们公司做的那个“618大促秒杀”项目,能详细说说整个秒杀模块是怎么设计和实现的吗?
面试者:好的,没问题。
我们做的这个秒杀系统是为电商平台“优购商城”设计的,主要支持每年618、双11等大促活动。比如:iPhone 15 原价6999,秒杀价1元,限量100台。
这种场景下,瞬间可能有几十万甚至上百万用户同时点击“立即秒杀”,系统必须扛得住、不崩溃、不超卖、用户体验还不能太差。
📌 一、整体架构:我们用了“五层防护”架构
用户 → Nginx → API网关 → 秒杀服务 → Redis → RabbitMQ → MySQL → Canal → 通知- Nginx:做负载均衡 + 限流(防刷)
- API网关:统一鉴权、黑白名单、接口限流
- 秒杀服务:核心逻辑,判断能不能抢
- Redis:提前把库存放进去,快速扣减(抗住90%请求)
- RabbitMQ:异步下单,削峰填谷
- MySQL:最终落单,保证数据持久
- Canal:监听订单表,发短信/APP通知
✅ 这个架构的核心思想是:前端快,中间缓,后端稳。
📌 二、数据库表设计(真实生产表结构)
我们设计了3张核心表:
1. seckill_activity:秒杀活动表
sql
CREATE TABLE `seckill_activity` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`title` VARCHAR(100) NOT NULL COMMENT '活动标题,如:iPhone 15 1元秒杀',
`product_id` BIGINT NOT NULL COMMENT '商品ID',
`start_time` DATETIME NOT NULL COMMENT '开始时间',
`end_time` DATETIME NOT NULL COMMENT '结束时间',
`total_stock` INT NOT NULL COMMENT '总库存,如100',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始,1进行中,2已结束',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_product_id (product_id),
INDEX idx_time (start_time, end_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;📌 说明:
status字段用于控制活动状态,避免用户在未开始或已结束时还能请求。
2. seckill_product:秒杀商品详情
sql
CREATE TABLE `seckill_product` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`activity_id` BIGINT NOT NULL,
`product_id` BIGINT NOT NULL,
`seckill_price` DECIMAL(10,2) NOT NULL COMMENT '秒杀价,如1.00',
`stock` INT NOT NULL COMMENT '可用库存',
`version` INT DEFAULT 0 COMMENT '乐观锁版本号',
FOREIGN KEY (activity_id) REFERENCES seckill_activity(id),
FOREIGN KEY (product_id) REFERENCES product(id),
UNIQUE KEY uk_activity_product (activity_id, product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;📌 说明:
version是乐观锁,防止MySQL层超卖。
3. seckill_order:秒杀订单表
sql
CREATE TABLE `seckill_order` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`product_id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`order_no` VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号,雪花算法生成',
`amount` DECIMAL(10,2) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0创建中,1成功,2失败',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;📌 说明:
order_no全局唯一,防止重复下单;status用于对账。
📌 三、Redis 缓存设计(真实Key规范)
| Key | 类型 | 说明 |
|---|---|---|
seckill:info:1001 | Hash | 活动1001的商品信息(title, price, time) |
seckill:stock:2001 | String | 商品2001的剩余库存(用于预减) |
seckill:user:10001:2001 | String | 用户10001是否已参与过商品2001的秒杀 |
seckill:order:no:20250823123456 | String | 订单号是否存在(防重) |
✅ 所有Key的TTL设置为:活动结束时间 + 1小时,避免长期占用内存。
📌 四、缓存预热(真实流程)
问题:如果活动一开始,Redis里没有数据,所有请求直接打到MySQL,系统就崩了。
解决方案:我们做了缓存预热。
预热时机:
- 活动开始前 10分钟,定时任务自动触发。
预热内容:
java
// 伪代码
List<SeckillActivity> activities = activityService.getStartingSoon();
for (SeckillActivity act : activities) {
SeckillProduct product = productMapper.selectByActId(act.getId());
// 写入商品信息
redis.set("seckill:info:" + act.getId(), product);
// 写入库存
redis.set("seckill:stock:" + product.getProductId(), product.getStock());
}预热失败怎么办?
- 重试3次;
- 仍失败 → 钉钉告警 → 运维人工介入;
- 设置“降级开关”,预热失败则关闭秒杀入口。
📌 五、前端优化(真实用户体验策略)
我们做了4点优化,让用户“感觉快”:
- 页面静态化:秒杀页面提前生成HTML,不走后端渲染;
- 按钮置灰:活动未开始时,“立即秒杀”按钮不可点击;
- 滑块验证码:点击后弹出滑块,增加脚本成本;
- 异步返回:不立即返回订单号,而是说“秒杀成功,请等待出单”,避免用户反复刷新。
✅ 这些优化让90%的用户在100ms内得到响应,体验很好。
📌 六、核心流程(真实请求处理链路)
1. 用户点击“立即秒杀”
↓
2. Nginx:限流(单IP QPS ≤ 2)
↓
3. API网关:校验登录 + 黑名单拦截
↓
4. 秒杀服务:
- 检查活动是否开始(查Redis)
- 检查用户是否已参与(Redis)
- Lua脚本预减库存:
if redis.get(stock) > 0 then
redis.decr(stock)
return 1
else
return 0
end
↓
成功 → 发MQ消息 → 返回“秒杀成功”
失败 → 返回“已售罄”
↓
5. RabbitMQ消费者:
- 查MySQL,防止重复下单
- UPDATE seckill_product SET stock = stock - 1, version = version + 1 WHERE id = ? AND stock >= 1 AND version = ?
- 成功:插入订单表 status=1
失败:插入订单表 status=2
↓
6. Canal监听订单表 → 发短信:“恭喜您抢到iPhone!”📌 七、难点与解决方案(真实项目踩坑总结)
| 难点 | 问题描述 | 解决方案 |
|---|---|---|
| 🔴 超卖 | 多个请求同时扣库存,导致库存为负 | 1. Redis Lua 脚本原子扣减 2. MySQL 乐观锁 + version字段 3. 唯一索引防重复下单 |
| 🔴 热点Key | 某个商品太火,seckill:stock:2001 成为热点 | 库存分段:seckill:stock:2001:0 ~ 9,随机选段减库存 |
| 🔴 Redis宕机 | 缓存不可用,请求打穿到MySQL | 1. Redis Cluster高可用 2. 本地缓存降级(Caffeine) 3. 降级开关控制 |
| 🔴 MQ积压 | 下单消息太多,处理不过来 | 1. 消费者扩容 2. 死信队列 + 重试机制 3. Prometheus监控告警 |
| 🔴 数据不一致 | Redis库存为0,MySQL还有库存 | 1. 每日凌晨对账任务 2. 差异>10条则告警 3. 提供“同步库存”人工工具 |
| 🔴 脚本刷单 | 黑产用脚本疯狂抢购 | 1. 滑块验证码 2. 设备指纹 3. 用户级限流(QPS≤1) |
📌 八、监控与压测(真实运维保障)
| 项目 | 做法 |
|---|---|
| 监控 | Prometheus + Grafana 监控: - Redis内存/连接数 - MQ队列长度 - 秒杀成功率 |
| 告警 | 钉钉/企业微信: - 预热失败 - MQ积压>1000条 - 缓存命中率<95% |
| 压测 | JMeter全链路压测: - 目标QPS:5万 - RT(99%)< 200ms - 提前3天演练 |
📌 九、总结:我们是怎么做到“零超卖、高可用”的?
- Redis预减库存:抗住90%的请求;
- MQ异步下单:削峰填谷,保护数据库;
- MySQL乐观锁:最终一致性保障;
- 缓存预热 + 降级开关:确保系统稳定;
- 对账 + 人工补偿:兜底,万无一失。
✅ 这套系统在去年双11支持了 8万QPS,零超卖,无重大故障,用户满意度很高。
完整项目结构
新建一个 Spring Boot 项目,依次创建这些文件。
1. pom.xml —— Maven 依赖
xml
<?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>
<groupId>com.example</groupId>
<artifactId>seckill-demo</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Seckill Demo</name>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.12</version>
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.21</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>2. SeckillApplication.java —— 启动类
java
package com.example.seckill;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SeckillApplication {
public static void main(String[] args) {
SpringApplication.run(SeckillApplication.class, args);
}
}3. application.yml —— 配置文件
yaml
server:
port: 8080
spring:
redis:
host: 127.0.0.1
port: 6379
timeout: 5s
lettuce:
pool:
max-active: 20
rabbitmq:
host: 127.0.0.1
port: 5672
username: guest
password: guest
virtual-host: /
listener:
simple:
retry:
enabled: true
max-attempts: 3
max-interval: 10000ms
datasource:
url: jdbc:mysql://localhost:3306/seckill_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver4. model/SeckillOrder.java —— 订单实体
java
package com.example.seckill.model;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
public class SeckillOrder {
private Long id;
private Long userId;
private Long productId;
private Long activityId;
private String orderNo;
private BigDecimal amount;
private Integer status; // 0创建中,1成功,2失败
private Date createdAt;
}5. model/SeckillResult.java —— 统一返回格式
java
package com.example.seckill.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SeckillResult {
private boolean success;
private String message;
public static SeckillResult success(String msg) {
return new SeckillResult(true, msg);
}
public static SeckillResult fail(String msg) {
return new SeckillResult(false, msg);
}
}6. mq/SeckillMessage.java —— MQ消息体
java
package com.example.seckill.mq;
import java.io.Serializable;
public class SeckillMessage implements Serializable {
private Long userId;
private Long productId;
private Long activityId;
private Long timestamp;
// 省略 getter/setter/toString
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public Long getActivityId() { return activityId; }
public void setActivityId(Long activityId) { this.activityId = activityId; }
public Long getTimestamp() { return timestamp; }
public void setTimestamp(Long timestamp) { this.timestamp = timestamp; }
@Override
public String toString() {
return "SeckillMessage{" +
"userId=" + userId +
", productId=" + productId +
", activityId=" + activityId +
", timestamp=" + timestamp +
'}';
}
}7. mq/RabbitConfig.java —— RabbitMQ 配置
java
package com.example.seckill.mq;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class RabbitConfig {
public static final String SECKILL_ORDER_QUEUE = "seckill.order.queue";
public static final String SECKILL_DEAD_QUEUE = "seckill.dead.queue";
@Bean
public Queue orderQueue() {
return QueueBuilder.durable(SECKILL_ORDER_QUEUE).build();
}
@Bean
public Queue deadQueue() {
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", "");
args.put("x-dead-letter-routing-key", SECKILL_ORDER_QUEUE);
args.put("x-message-ttl", 5000); // 5秒后重试
return QueueBuilder.durable(SECKILL_DEAD_QUEUE).withArguments(args).build();
}
@Bean
public DirectExchange exchange() {
return new DirectExchange("");
}
@Bean
public Binding binding() {
return BindingBuilder.bind(orderQueue()).to(exchange()).with(SECKILL_ORDER_QUEUE);
}
}8. mapper/SeckillOrderMapper.java —— MyBatis Mapper
java
package com.example.seckill.mapper;
import org.apache.ibatis.annotations.*;
import com.example.seckill.model.SeckillOrder;
@Mapper
public interface SeckillOrderMapper {
@Insert("INSERT INTO seckill_order (user_id, product_id, activity_id, order_no, amount, status, created_at) " +
"VALUES (#{userId}, #{productId}, #{activityId}, #{orderNo}, #{amount}, #{status}, #{createdAt})")
void insert(SeckillOrder order);
@Select("SELECT COUNT(*) FROM seckill_order WHERE user_id = #{userId} AND product_id = #{productId}")
int countByUserAndProduct(@Param("userId") Long userId, @Param("productId") Long productId);
@Update("UPDATE seckill_product SET stock = stock - #{count}, version = version + 1 " +
"WHERE id = #{activityId} AND stock >= #{count} " +
"AND version = (SELECT version FROM (SELECT version FROM seckill_product WHERE id = #{activityId}) AS tmp)")
int reduceStockWithVersion(@Param("activityId") Long activityId, @Param("count") Integer count);
}9. util/SnowflakeIdGenerator.java —— 雪花ID生成器
java
package com.example.seckill.util;
import cn.hutool.core.lang.Snowflake;
import cn.hutool.core.util.IdUtil;
public class SnowflakeIdGenerator {
private static final Snowflake snowflake = IdUtil.createSnowflake(1, 1);
public static long nextId() {
return snowflake.nextId();
}
public static String nextIdStr() {
return String.valueOf(nextId());
}
}10. service/SeckillService.java —— 核心服务
java
package com.example.seckill.service;
import com.example.seckill.mq.SeckillMessage;
import com.example.seckill.mapper.SeckillOrderMapper;
import com.example.seckill.model.SeckillResult;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
@Service
public class SeckillService {
@Resource
private RedisTemplate<String, String> redisTemplate;
@Resource
private RabbitTemplate rabbitTemplate;
@Resource
private SeckillOrderMapper orderMapper;
private static final String LUA_REDUCE_STOCK =
"local stock = redis.call('GET', KEYS[1])\n" +
"if not stock or tonumber(stock) <= 0 then\n" +
" return 0\n" +
"end\n" +
"redis.call('DECR', KEYS[1])\n" +
"return 1";
public SeckillResult handleSeckill(Long userId, Long productId) {
try {
if (userId == null || productId == null) {
return SeckillResult.fail("参数错误");
}
Long activityId = 1001L; // 模拟活动ID
// 检查活动状态
String status = redisTemplate.opsForValue().get("seckill:status:" + activityId);
if (!"1".equals(status)) {
return SeckillResult.fail("活动未开始或已结束");
}
// 防重:用户是否已参与
String userKey = "seckill:user:" + userId + ":" + productId;
Boolean hasParticipated = redisTemplate.hasKey(userKey);
if (Boolean.TRUE.equals(hasParticipated)) {
return SeckillResult.fail("您已参与过该活动");
}
// Redis 预减库存
String stockKey = "seckill:stock:" + productId;
List<String> keys = Collections.singletonList(stockKey);
Long result = (Long) redisTemplate.execute(
new DefaultRedisScript<>(LUA_REDUCE_STOCK, Long.class),
keys
);
if (result == 1L) {
redisTemplate.opsForValue().set(userKey, "1", java.time.Duration.ofHours(24));
SeckillMessage message = new SeckillMessage();
message.setUserId(userId);
message.setProductId(productId);
message.setActivityId(activityId);
message.setTimestamp(System.currentTimeMillis());
rabbitTemplate.convertAndSend("seckill.order.queue", message);
return SeckillResult.success("秒杀成功,请等待出单");
} else {
return SeckillResult.fail("手慢了,已售罄");
}
} catch (Exception e) {
e.printStackTrace();
return SeckillResult.fail("系统繁忙,请稍后再试");
}
}
}11. controller/SeckillController.java —— 控制器
java
package com.example.seckill.controller;
import com.example.seckill.model.SeckillResult;
import com.example.seckill.service.SeckillService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
@RestController
@RequestMapping("/api/seckill")
public class SeckillController {
@Resource
private SeckillService seckillService;
@PostMapping("/execute")
public SeckillResult execute(
@RequestParam Long userId,
@RequestParam Long productId) {
return seckillService.handleSeckill(userId, productId);
}
}12. mq/SeckillOrderConsumer.java —— 消费者
java
package com.example.seckill.mq;
import com.example.seckill.mapper.SeckillOrderMapper;
import com.example.seckill.model.SeckillOrder;
import com.example.seckill.util.SnowflakeIdGenerator;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.AmqpHeaders;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Date;
@Component
public class SeckillOrderConsumer {
@Autowired
private SeckillOrderMapper orderMapper;
@Autowired
private RabbitTemplate rabbitTemplate;
@RabbitListener(queues = RabbitConfig.SECKILL_ORDER_QUEUE)
public void listen(SeckillMessage message,
org.springframework.amqp.core.Message amqpMessage,
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) {
try {
// 防重
int count = orderMapper.countByUserAndProduct(message.getUserId(), message.getProductId());
if (count > 0) {
System.out.println("重复下单,跳过: " + message.getUserId());
rabbitTemplate.getRabbitAdmin().getRabbitTemplate().getChannel().basicAck(deliveryTag, false);
return;
}
// 扣库存
int updated = orderMapper.reduceStockWithVersion(message.getActivityId(), 1);
boolean success = updated == 1;
// 创建订单
SeckillOrder order = new SeckillOrder();
order.setUserId(message.getUserId());
order.setProductId(message.getProductId());
order.setActivityId(message.getActivityId());
order.setOrderNo(SnowflakeIdGenerator.nextIdStr());
order.setAmount(new BigDecimal("1.00"));
order.setStatus(success ? 1 : 2);
order.setCreatedAt(new Date());
orderMapper.insert(order);
rabbitTemplate.getRabbitAdmin().getRabbitTemplate().getChannel().basicAck(deliveryTag, false);
} catch (Exception e) {
e.printStackTrace();
try {
// 简单重试逻辑(实际可用死信队列)
Integer retry = (Integer) amqpMessage.getMessageProperties().getHeaders().get("x-retry-count");
retry = retry == null ? 0 : retry;
if (retry < 3) {
amqpMessage.getMessageProperties().getHeaders().put("x-retry-count", retry + 1);
rabbitTemplate.convertAndSend(RabbitConfig.SECKILL_ORDER_QUEUE, amqpMessage);
}
rabbitTemplate.getRabbitAdmin().getRabbitTemplate().getChannel().basicNack(deliveryTag, false, false);
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}✅ 数据库建表 SQL
sql
-- 创建数据库
CREATE DATABASE IF NOT EXISTS seckill_db CHARACTER SET utf8mb4;
USE seckill_db;
-- 秒杀商品表
CREATE TABLE seckill_product (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
seckill_price DECIMAL(10,2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_product (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 秒杀订单表
CREATE TABLE seckill_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
activity_id BIGINT NOT NULL,
order_no VARCHAR(32) UNIQUE NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;✅ 如何测试?
- 启动 MySQL,执行建表 SQL
- 启动 Redis
- 启动 RabbitMQ
- 运行 Spring Boot 项目
- 使用 Postman 或 curl 发起请求:
bash
POST http://localhost:8080/api/seckill/execute?userId=1001&productId=2001