写在前面:老哥我做了10年Java,双十一扛过N次秒杀系统,被老板骂过,被运维打过,被DBA追着跑过... 今天把这些年的经验全部吐血整理出来,保证让你看完就懂,学完就会!💪
📋 目录
1. 开场白:秒杀有多可怕? 😱
想象一下这个画面:
凌晨00:00:00,你的秒杀系统上线了。商品是iPhone 16 Pro Max,原价9999,秒杀价1元,库存1000台。
00:00:01,全国100万用户同时点击"立即抢购"
00:00:02,你的服务器CPU 100%,内存爆了
00:00:03,数据库挂了,Redis也挂了
00:00:04,老板电话打来:"小王啊,系统怎么挂了?"
00:00:05,你开始怀疑人生:"我为什么要做程序员?" 😭
这就是没有设计好秒杀系统的下场!
🎯 用生活例子理解秒杀
秒杀系统就像是:
- 春运抢票 🚄:几亿人同时抢几万张票
- 明星演唱会抢票 🎤:百万粉丝抢几千张票
- 超市限时抢购 🛒:开门瞬间冲进去抢打折鸡蛋
如果没有合理的设计,结果就是:
- 人挤人,大家都进不去(系统崩溃)
- 有人拿了10个鸡蛋,结果只有5个(超卖)
- 排队排了半天,被告知没货了(用户体验差)
2. 核心挑战:三座大山 ⛰️
2.1 第一座山:高并发 🌊
场景:1000个商品,100万用户,读写比例是 1000:1
- 正常电商:1秒内可能只有100个请求
- 秒杀场景:1秒内有100万个请求!
这就像是:
正常情况:一个水龙头慢慢流水 💧
秒杀情况:消防车的水枪狂喷 🚒💦💦💦
2.2 第二座山:超卖问题 📉
超卖就是:库存只有1000,但卖出去了1100个!
为什么会超卖?
时刻T1: 用户A查询库存 = 1
时刻T2: 用户B查询库存 = 1
时刻T3: 用户A扣减库存,剩余 = 0
时刻T4: 用户B扣减库存,剩余 = -1 ❌ 完蛋!
这就像超市收银员:
- 收银员A看到货架上还有1个鸡蛋
- 收银员B也看到货架上还有1个鸡蛋
- 结果两个人都卖了出去,实际只有1个!
2.3 第三座山:用户体验 😤
用户最怕的是:
- ❌ 点了半天没反应
- ❌ 转圈圈转了5分钟
- ❌ 页面白屏死机
- ❌ 提示"系统繁忙,请稍后再试"(然后商品没了)
3. 设计原则:三个字的真理 🎯
✅ 稳(高可用)
系统不能挂!宁可慢一点,也不能崩!
✅ 准(一致性)
库存扣减必须准确,不能超卖,也不能少卖!
✅ 快(高性能)
响应要快,用户等不及!
4. 详细设计方案 🏗️
4.1 整体架构图
┌─────────────────┐
│ 用户浏览器 │
└────────┬────────┘
│
┌────────────▼───────────┐
│ CDN (静态资源) │
└────────────┬───────────┘
│
┌────────────▼───────────┐
│ Nginx (负载均衡) │
└────────────┬───────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌─────────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐
│ 秒杀服务器-1 │ │ 秒杀服务器-2│ │ 秒杀服务器-N │
└─────────┬────────┘ └──────┬──────┘ └────────┬────────┘
│ │ │
└──────────────────┼──────────────────┘
│
┌────────────▼───────────┐
│ Redis集群 (热数据) │
└────────────┬───────────┘
│
┌────────────▼───────────┐
│ 消息队列 (削峰) │
└────────────┬───────────┘
│
┌────────────▼───────────┐
│ MySQL主从 (持久化) │
└────────────────────────┘
4.2 第一招:前端防刷 🛡️
目标:挡住99%的无效请求
4.2.1 按钮置灰
// 用户点击后,按钮立即置灰5秒
let isClicking = false;
function seckillClick() {
if (isClicking) {
alert('您点击太快啦,休息一下~');
return;
}
isClicking = true;
document.getElementById('seckillBtn').disabled = true;
// 5秒后恢复
setTimeout(() => {
isClicking = false;
document.getElementById('seckillBtn').disabled = false;
}, 5000);
// 发送秒杀请求
sendSeckillRequest();
}
生活例子:就像电梯按钮,按了一次就会亮灯,短时间内按多少次都没用!
4.2.2 验证码
<!-- 秒杀前先弹出验证码 -->
<div id="captchaModal">
<img src="/captcha/generate" />
<input type="text" placeholder="请输入验证码" />
</div>
作用:
- 防止机器人刷单 🤖
- 把100万请求分散到10秒内(让用户输入验证码需要时间)
- 一个验证码就能挡住80%的流量!
4.3 第二招:Nginx限流 🚦
目标:控制流入后端的请求数量
# nginx.conf
http {
# 限流:每个IP每秒只允许10个请求
limit_req_zone $binary_remote_addr zone=seckill:10m rate=10r/s;
server {
location /api/seckill {
# 超过限制的请求直接返回503
limit_req zone=seckill burst=20 nodelay;
limit_req_status 503;
proxy_pass http://backend;
}
}
}
生活例子:就像高速公路的收费站,控制单位时间内通过的车辆数量!
效果对比:
没有限流:100万请求全部打到后端 → 后端崩溃 💥
有了限流:每秒只放1万请求进来 → 后端稳如老狗 🐕
4.4 第三招:Redis缓存库存 ⚡
核心思路:把库存放在内存里,不查数据库!
4.4.1 初始化库存
/**
* 系统启动时,把库存从MySQL加载到Redis
*/
@PostConstruct
public void initStock() {
// 假设商品ID=1001,库存1000
String stockKey = "seckill:stock:1001";
redisTemplate.opsForValue().set(stockKey, "1000");
System.out.println("✅ 库存初始化完成!");
}
4.4.2 扣减库存(重点!)
方式一:简单版(有超卖风险)❌
// ❌ 错误示范!千万别这么写!
public boolean wrongDecrStock(Long productId) {
String stockKey = "seckill:stock:" + productId;
// 问题:这三步不是原子操作!
Integer stock = (Integer) redisTemplate.opsForValue().get(stockKey);
if (stock > 0) {
redisTemplate.opsForValue().set(stockKey, stock - 1);
return true;
}
return false;
}
问题分析:
时刻T1: 线程A读取库存 = 1
时刻T2: 线程B读取库存 = 1
时刻T3: 线程A写入库存 = 0
时刻T4: 线程B写入库存 = 0 ← 两个人都成功了!超卖!
方式二:Redis原子操作(推荐)✅
/**
* 使用Redis的原子操作扣减库存
*/
public boolean decrStockAtomic(Long productId) {
String stockKey = "seckill:stock:" + productId;
// decrement是原子操作,不会超卖!
Long stock = redisTemplate.opsForValue().decrement(stockKey);
if (stock >= 0) {
System.out.println("✅ 扣减成功,剩余库存:" + stock);
return true;
} else {
// 库存不足,回滚
redisTemplate.opsForValue().increment(stockKey);
System.out.println("❌ 库存不足");
return false;
}
}
方式三:Lua脚本(最安全)🔒
/**
* 使用Lua脚本,保证原子性 + 可靠性
*/
public boolean decrStockLua(Long productId) {
String stockKey = "seckill:stock:" + productId;
String luaScript =
"local stock = redis.call('get', KEYS[1]) " +
"if tonumber(stock) > 0 then " +
" redis.call('decr', KEYS[1]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript);
redisScript.setResultType(Long.class);
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList(stockKey)
);
return result != null && result == 1;
}
为什么Lua脚本最好?
- ✅ 原子性:整个脚本作为一个整体执行,不会被打断
- ✅ 性能好:减少网络往返次数
- ✅ 逻辑清晰:判断和扣减在一起
生活例子:
- 方式一:你数钱的时候别人也在数,数完一起放回去,结果少了 ❌
- 方式二:你数钱的时候别人不能碰 ✅
- 方式三:你找个保险柜,把钱锁里面数 ✅✅
4.5 第四招:消息队列削峰 📮
问题:即使用了Redis,还是有10万请求同时扣库存成功,怎么办?
答案:用消息队列异步处理订单!
4.5.1 架构图
用户请求 Redis扣库存 消息队列 订单服务
│ │ │ │
│ ─────────────────> │ │ │
│ │ │ │
│ <───────────────── │ │ │
│ (立即返回:排队中) │ │ │
│ │ │ │
│ │ ─────────────────> │ │
│ │ (扣减成功,发消息) │ │
│ │ │ │
│ │ │ ───────────────> │
│ │ │ (消费消息) │
│ │ │ │
│ │ │ │ 创建订单
│ │ │ │ 扣减DB库存
│ │ │ <───────────── │
│ │ │ (处理完成) │
│ <────────────────────────────────────────────────────────────────── │
│ (推送结果:秒杀成功/失败) │
4.5.2 代码实现
Step 1: 秒杀接口
@RestController
@RequestMapping("/api/seckill")
public class SeckillController {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 秒杀接口
*/
@PostMapping("/execute")
public Result seckill(@RequestParam Long productId,
@RequestParam Long userId) {
// 1. 参数校验
if (productId == null || userId == null) {
return Result.fail("参数错误");
}
// 2. 检查是否重复购买
String userKey = "seckill:user:" + productId + ":" + userId;
if (Boolean.TRUE.equals(redisTemplate.hasKey(userKey))) {
return Result.fail("您已经参加过秒杀了,不能重复购买哦~");
}
// 3. Redis扣减库存(使用Lua脚本)
boolean success = decrStockLua(productId);
if (!success) {
return Result.fail("商品已售罄,下次早点来哦~");
}
// 4. 标记用户已参与
redisTemplate.opsForValue().set(userKey, "1", 24, TimeUnit.HOURS);
// 5. 发送消息到MQ(异步创建订单)
SeckillMessage message = new SeckillMessage();
message.setProductId(productId);
message.setUserId(userId);
message.setTimestamp(System.currentTimeMillis());
rabbitTemplate.convertAndSend("seckill.exchange",
"seckill.order",
message);
// 6. 立即返回(不等订单创建完成)
return Result.success("秒杀请求已提交,正在处理中...");
}
}
Step 2: 消息消费者(创建订单)
@Component
public class SeckillOrderConsumer {
@Autowired
private OrderService orderService;
@Autowired
private StockService stockService;
/**
* 消费秒杀消息,创建订单
*/
@RabbitListener(queues = "seckill.order.queue")
public void handleSeckillOrder(SeckillMessage message) {
try {
Long productId = message.getProductId();
Long userId = message.getUserId();
System.out.println("📮 收到秒杀消息:用户" + userId + " 购买商品" + productId);
// 1. 创建订单
Order order = new Order();
order.setProductId(productId);
order.setUserId(userId);
order.setOrderNo(generateOrderNo());
order.setStatus(OrderStatus.UNPAID);
order.setCreateTime(new Date());
orderService.createOrder(order);
// 2. 扣减数据库库存(最终一致性)
boolean success = stockService.decrStock(productId, 1);
if (!success) {
// 如果数据库扣减失败,回滚Redis库存
redisTemplate.opsForValue().increment("seckill:stock:" + productId);
orderService.cancelOrder(order.getOrderNo());
System.out.println("❌ 数据库库存不足,订单已取消");
return;
}
// 3. 通知用户(通过WebSocket或短信)
notifyUser(userId, "秒杀成功!请在15分钟内完成支付~");
System.out.println("✅ 订单创建成功:" + order.getOrderNo());
} catch (Exception e) {
System.err.println("❌ 处理秒杀订单失败:" + e.getMessage());
// 这里可以加入重试逻辑或死信队列
}
}
/**
* 生成订单号
*/
private String generateOrderNo() {
// 时间戳 + 随机数
return "SK" + System.currentTimeMillis() +
(int)(Math.random() * 9000 + 1000);
}
/**
* 通知用户
*/
private void notifyUser(Long userId, String message) {
// 实现WebSocket推送或短信通知
// websocketService.send(userId, message);
}
}
生活例子:
消息队列就像餐厅的点餐系统:
- 你去餐厅点餐(发起秒杀)
- 服务员给你一个号码牌:"您的号码是88号,请等候"(立即返回)
- 后厨按顺序做菜(异步处理订单)
- 做好了叫号(推送结果)
而不是:
- 你去餐厅点餐
- 站在后厨门口等着厨师做完菜 ❌(同步等待)
- 拿到菜才走
4.6 第五招:数据库防超卖 🔐
虽然Redis已经控制了,但数据库也要防范!
4.6.1 乐观锁(版本号)
/**
* 使用版本号实现乐观锁
*/
@Mapper
public interface StockMapper {
/**
* 扣减库存(乐观锁)
*
* SQL: UPDATE product_stock
* SET stock = stock - #{quantity},
* version = version + 1
* WHERE product_id = #{productId}
* AND stock >= #{quantity}
* AND version = #{version}
*/
int decrStockWithVersion(@Param("productId") Long productId,
@Param("quantity") Integer quantity,
@Param("version") Integer version);
}
@Service
public class StockService {
@Autowired
private StockMapper stockMapper;
/**
* 扣减库存(带重试)
*/
public boolean decrStock(Long productId, Integer quantity) {
int retryTimes = 3; // 最多重试3次
for (int i = 0; i < retryTimes; i++) {
// 查询当前库存和版本号
ProductStock stock = stockMapper.selectByProductId(productId);
if (stock.getStock() < quantity) {
return false; // 库存不足
}
// 尝试扣减
int affected = stockMapper.decrStockWithVersion(
productId,
quantity,
stock.getVersion()
);
if (affected > 0) {
System.out.println("✅ 数据库扣减成功");
return true;
}
// 版本号冲突,重试
System.out.println("⚠️ 版本冲突,重试第" + (i + 1) + "次");
}
return false;
}
}
4.6.2 悲观锁(行锁)
/**
* 使用行锁(悲观锁)
*/
@Mapper
public interface StockMapper {
/**
* 查询库存(加锁)
*
* SQL: SELECT * FROM product_stock
* WHERE product_id = #{productId}
* FOR UPDATE
*/
ProductStock selectByIdForUpdate(@Param("productId") Long productId);
/**
* 扣减库存
*/
int decrStock(@Param("productId") Long productId,
@Param("quantity") Integer quantity);
}
@Service
public class StockService {
@Autowired
private StockMapper stockMapper;
/**
* 扣减库存(悲观锁)
*/
@Transactional(rollbackFor = Exception.class)
public boolean decrStockPessimistic(Long productId, Integer quantity) {
// 1. 查询库存并加锁(FOR UPDATE)
ProductStock stock = stockMapper.selectByIdForUpdate(productId);
// 2. 判断库存是否充足
if (stock.getStock() < quantity) {
return false;
}
// 3. 扣减库存
int affected = stockMapper.decrStock(productId, quantity);
return affected > 0;
}
}
乐观锁 vs 悲观锁:
| 特性 | 乐观锁 | 悲观锁 |
|---|---|---|
| 加锁时机 | 更新时 | 查询时 |
| 性能 | 高(冲突少时) | 低(会阻塞) |
| 适用场景 | 冲突少 | 冲突多 |
| 实现方式 | 版本号 | FOR UPDATE |
生活例子:
- 乐观锁:超市买东西,你拿了一瓶牛奶(version=1),到收银台发现标签变了(version=2),说明被人换了,你重新去拿
- 悲观锁:你拿牛奶的时候直接抱住整个冰柜,不让别人碰,拿完才放开
秒杀场景推荐:用乐观锁!因为:
- Redis已经控制了大部分流量
- 到数据库的请求不多,冲突概率低
- 乐观锁性能更好
4.7 第六招:限流算法 🚦
4.7.1 计数器算法(最简单)
/**
* 简单计数器限流
*/
public class CounterRateLimiter {
private final int maxCount; // 时间窗口内最大请求数
private final long windowSize; // 时间窗口大小(毫秒)
private AtomicInteger count = new AtomicInteger(0);
private long windowStart = System.currentTimeMillis();
public CounterRateLimiter(int maxCount, long windowSize) {
this.maxCount = maxCount;
this.windowSize = windowSize;
}
/**
* 尝试获取令牌
*/
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 如果超过时间窗口,重置计数器
if (now - windowStart > windowSize) {
count.set(0);
windowStart = now;
}
// 判断是否超过限制
if (count.get() < maxCount) {
count.incrementAndGet();
return true;
}
return false;
}
}
缺点:临界点问题
时间窗口:[0s-1s],限制100个请求
情况:
0.9s:来了100个请求 ✅
1.0s:窗口重置
1.1s:又来了100个请求 ✅
结果:0.2秒内处理了200个请求!(限流失效)
4.7.2 滑动窗口算法(更精确)
/**
* 滑动窗口限流
*/
public class SlidingWindowRateLimiter {
private final int maxCount;
private final long windowSize;
// 使用队列记录每个请求的时间戳
private Queue<Long> requestQueue = new LinkedList<>();
public SlidingWindowRateLimiter(int maxCount, long windowSize) {
this.maxCount = maxCount;
this.windowSize = windowSize;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 移除窗口外的请求
while (!requestQueue.isEmpty() &&
now - requestQueue.peek() > windowSize) {
requestQueue.poll();
}
// 判断是否超过限制
if (requestQueue.size() < maxCount) {
requestQueue.offer(now);
return true;
}
return false;
}
}
4.7.3 令牌桶算法(推荐!)⭐
/**
* 令牌桶限流(使用Guava)
*/
import com.google.common.util.concurrent.RateLimiter;
public class TokenBucketRateLimiter {
// 每秒生成1000个令牌
private RateLimiter rateLimiter = RateLimiter.create(1000);
/**
* 尝试获取令牌
*/
public boolean tryAcquire() {
// 尝试获取1个令牌,最多等待100ms
return rateLimiter.tryAcquire(1, 100, TimeUnit.MILLISECONDS);
}
/**
* 阻塞获取令牌
*/
public void acquire() {
rateLimiter.acquire(1);
}
}
使用示例:
@RestController
public class SeckillController {
// 每秒最多处理10000个请求
private TokenBucketRateLimiter rateLimiter =
new TokenBucketRateLimiter(10000);
@PostMapping("/seckill")
public Result seckill(@RequestParam Long productId,
@RequestParam Long userId) {
// 尝试获取令牌
if (!rateLimiter.tryAcquire()) {
return Result.fail("系统繁忙,请稍后再试~");
}
// 执行秒杀逻辑
return doSeckill(productId, userId);
}
}
令牌桶算法原理:
[令牌桶] (容量100)
│
│ 每秒生成10个令牌
▼
┌───────┐
│🪙🪙🪙│
│🪙🪙🪙│
│🪙🪙🪙│
└───────┘
▲
│ 每个请求消耗1个令牌
[请求]
生活例子:
令牌桶就像游乐园的游戏币:
- 🎰 售币机每秒产生10个游戏币(固定速率)
- 🧒 小朋友来玩游戏需要1个游戏币
- 🪙 有币就能玩,没币就等着
- 💰 币最多存100个,多了就丢弃
4.8 第七招:分布式锁 🔒
场景:多个服务器同时扣减库存,如何保证同一时刻只有一个能成功?
4.8.1 Redis分布式锁(基础版)
/**
* Redis分布式锁(基础版)
*/
public class RedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 加锁
*
* @param key 锁的key
* @param value 锁的value(通常用UUID)
* @param expireTime 过期时间(秒)
* @return 是否加锁成功
*/
public boolean lock(String key, String value, long expireTime) {
// SET key value NX EX expireTime
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, value, expireTime, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 释放锁(使用Lua脚本保证原子性)
*/
public boolean unlock(String key, String value) {
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript);
redisScript.setResultType(Long.class);
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList(key),
value
);
return result != null && result == 1;
}
}
使用示例:
public boolean decrStockWithLock(Long productId) {
String lockKey = "seckill:lock:" + productId;
String lockValue = UUID.randomUUID().toString();
try {
// 1. 尝试加锁(最多等待3秒)
boolean locked = redisLock.lock(lockKey, lockValue, 10);
if (!locked) {
return false;
}
// 2. 执行业务逻辑(扣减库存)
String stockKey = "seckill:stock:" + productId;
Long stock = redisTemplate.opsForValue().decrement(stockKey);
if (stock < 0) {
redisTemplate.opsForValue().increment(stockKey);
return false;
}
return true;
} finally {
// 3. 释放锁
redisLock.unlock(lockKey, lockValue);
}
}
4.8.2 Redisson分布式锁(推荐!)⭐
/**
* Redisson分布式锁(更强大)
*/
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("your_password");
return Redisson.create(config);
}
}
@Service
public class SeckillService {
@Autowired
private RedissonClient redissonClient;
/**
* 使用Redisson分布式锁
*/
public boolean decrStockWithRedisson(Long productId) {
String lockKey = "seckill:lock:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁(最多等待3秒,锁10秒后自动释放)
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
return false;
}
// 执行业务逻辑
return decrStock(productId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
// 释放锁(只有持有锁的线程才能释放)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Redisson的优势:
- ✅ 看门狗机制:自动续期,防止业务未执行完锁就过期
- ✅ 可重入锁:同一个线程可以多次获取锁
- ✅ 公平锁:按FIFO顺序获取锁
- ✅ 红锁(RedLock):多Redis实例的分布式锁
生活例子:
分布式锁就像公共厕所的门锁:
- 🚪 有人在里面,门就锁上,外面的人进不来
- 🔑 只有里面的人才能开门
- ⏰ 如果里面的人昏倒了(程序崩溃),10分钟后自动开锁(超时释放)
- 👥 多个人排队等(公平锁)
4.9 第八招:库存预热 🔥
问题:秒杀开始前,如何保证Redis的库存已经加载好了?
/**
* 定时任务:秒杀前5分钟预热库存
*/
@Component
public class StockWarmUpTask {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper;
/**
* 每天23:55:00执行(00:00:00开始秒杀)
*/
@Scheduled(cron = "0 55 23 * * ?")
public void warmUpStock() {
System.out.println("🔥 开始预热库存...");
// 1. 查询所有秒杀商品
List<SeckillProduct> products = productMapper.selectSeckillProducts();
// 2. 加载到Redis
for (SeckillProduct product : products) {
String stockKey = "seckill:stock:" + product.getProductId();
redisTemplate.opsForValue().set(stockKey, product.getStock());
System.out.println("✅ 商品" + product.getProductId() +
" 库存" + product.getStock() + " 已加载");
}
System.out.println("🎉 库存预热完成!");
}
}
4.10 第九招:订单超时取消 ⏰
场景:用户秒杀成功但15分钟内没付款,需要释放库存
4.10.1 延迟队列方案
/**
* 创建订单时发送延迟消息
*/
public void createOrder(Order order) {
// 1. 保存订单
orderMapper.insert(order);
// 2. 发送延迟消息(15分钟后)
rabbitTemplate.convertAndSend(
"order.delay.exchange",
"order.delay.key",
order.getOrderNo(),
message -> {
// 设置消息延迟时间:15分钟 = 900000毫秒
message.getMessageProperties().setDelay(15 * 60 * 1000);
return message;
}
);
}
/**
* 消费延迟消息,检查订单是否已支付
*/
@RabbitListener(queues = "order.timeout.queue")
public void handleOrderTimeout(String orderNo) {
System.out.println("⏰ 检查订单超时:" + orderNo);
// 1. 查询订单状态
Order order = orderMapper.selectByOrderNo(orderNo);
// 2. 如果未支付,取消订单
if (order.getStatus() == OrderStatus.UNPAID) {
System.out.println("❌ 订单超时未支付,自动取消");
// 2.1 更新订单状态
order.setStatus(OrderStatus.CANCELLED);
orderMapper.updateById(order);
// 2.2 回滚库存
String stockKey = "seckill:stock:" + order.getProductId();
redisTemplate.opsForValue().increment(stockKey);
stockMapper.incrStock(order.getProductId(), 1);
// 2.3 通知用户
notifyUser(order.getUserId(), "订单已超时取消,库存已释放");
} else {
System.out.println("✅ 订单已支付,无需处理");
}
}
4.10.2 Redis过期键监听(不推荐)
/**
* 监听Redis key过期事件
*/
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
@Autowired
private OrderService orderService;
public RedisKeyExpirationListener(RedisMessageListenerContainer container) {
super(container);
}
@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = message.toString();
// 格式:order:timeout:SK1634567890123
if (expiredKey.startsWith("order:timeout:")) {
String orderNo = expiredKey.replace("order:timeout:", "");
orderService.cancelOrderIfUnpaid(orderNo);
}
}
}
为什么不推荐?
- ⚠️ Redis key过期事件不保证实时性
- ⚠️ 可能会丢失事件
- ⚠️ 不适合生产环境
推荐方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 延迟队列 | 可靠、精确 | 需要MQ支持 |
| 定时任务扫描 | 简单 | 不够实时 |
| Redis过期监听 | 轻量 | 不可靠 |
5. 代码实战 💻
5.1 完整的秒杀接口
@RestController
@RequestMapping("/api/seckill")
public class SeckillController {
@Autowired
private SeckillService seckillService;
@Autowired
private TokenBucketRateLimiter rateLimiter;
/**
* 秒杀接口
*
* 接口地址:POST /api/seckill/execute
* 参数:
* - productId: 商品ID
* - userId: 用户ID
* - captcha: 验证码
* 返回:
* - code: 200成功,其他失败
* - message: 提示信息
* - data: 订单号(成功时)
*/
@PostMapping("/execute")
public Result<String> seckill(
@RequestParam Long productId,
@RequestParam Long userId,
@RequestParam String captcha,
HttpServletRequest request) {
// ============ 第一层:前置校验 ============
// 1. 参数校验
if (productId == null || userId == null) {
return Result.fail("参数错误");
}
// 2. 验证码校验
String sessionCaptcha = (String) request.getSession()
.getAttribute("captcha");
if (!captcha.equalsIgnoreCase(sessionCaptcha)) {
return Result.fail("验证码错误");
}
// 3. 限流(令牌桶)
if (!rateLimiter.tryAcquire()) {
return Result.fail("系统繁忙,请稍后再试");
}
// ============ 第二层:业务校验 ============
// 4. 检查秒杀活动是否开始/结束
if (!seckillService.isActivityValid(productId)) {
return Result.fail("秒杀活动未开始或已结束");
}
// 5. 检查用户是否已经参与过
if (seckillService.hasUserParticipated(productId, userId)) {
return Result.fail("您已经参加过秒杀,不能重复购买");
}
// ============ 第三层:执行秒杀 ============
// 6. 执行秒杀逻辑
String orderNo = seckillService.executeSeckill(productId, userId);
if (orderNo != null) {
return Result.success(orderNo, "秒杀成功,请在15分钟内完成支付");
} else {
return Result.fail("商品已售罄");
}
}
/**
* 查询秒杀结果
*/
@GetMapping("/result")
public Result<SeckillResult> getSeckillResult(
@RequestParam Long productId,
@RequestParam Long userId) {
SeckillResult result = seckillService.getSeckillResult(productId, userId);
return Result.success(result);
}
}
5.2 核心Service实现
@Service
public class SeckillService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private RedissonClient redissonClient;
/**
* 执行秒杀
*/
public String executeSeckill(Long productId, Long userId) {
// 1. 检查库存(Redis)
String stockKey = "seckill:stock:" + productId;
Integer stock = (Integer) redisTemplate.opsForValue().get(stockKey);
if (stock == null || stock <= 0) {
return null;
}
// 2. 扣减库存(Lua脚本保证原子性)
boolean decrSuccess = decrStockByLua(productId);
if (!decrSuccess) {
return null;
}
// 3. 标记用户已参与(防止重复购买)
String userKey = "seckill:user:" + productId + ":" + userId;
redisTemplate.opsForValue().set(userKey, "1", 24, TimeUnit.HOURS);
// 4. 生成订单号
String orderNo = generateOrderNo(userId);
// 5. 发送MQ消息(异步创建订单)
SeckillMessage message = new SeckillMessage();
message.setProductId(productId);
message.setUserId(userId);
message.setOrderNo(orderNo);
message.setTimestamp(System.currentTimeMillis());
rabbitTemplate.convertAndSend(
"seckill.exchange",
"seckill.order",
message
);
// 6. 返回订单号
return orderNo;
}
/**
* 使用Lua脚本扣减库存
*/
private boolean decrStockByLua(Long productId) {
String stockKey = "seckill:stock:" + productId;
String luaScript =
"local stock = redis.call('get', KEYS[1]) " +
"if tonumber(stock) > 0 then " +
" redis.call('decr', KEYS[1]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript);
redisScript.setResultType(Long.class);
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList(stockKey)
);
return result != null && result == 1;
}
/**
* 生成订单号
*/
private String generateOrderNo(Long userId) {
// 格式:SK + 时间戳 + 用户ID后4位 + 随机4位
String timestamp = String.valueOf(System.currentTimeMillis());
String userSuffix = String.format("%04d", userId % 10000);
String random = String.format("%04d", (int)(Math.random() * 10000));
return "SK" + timestamp + userSuffix + random;
}
/**
* 检查用户是否已参与
*/
public boolean hasUserParticipated(Long productId, Long userId) {
String userKey = "seckill:user:" + productId + ":" + userId;
return Boolean.TRUE.equals(redisTemplate.hasKey(userKey));
}
/**
* 检查活动是否有效
*/
public boolean isActivityValid(Long productId) {
String activityKey = "seckill:activity:" + productId;
SeckillActivity activity = (SeckillActivity) redisTemplate
.opsForValue().get(activityKey);
if (activity == null) {
return false;
}
long now = System.currentTimeMillis();
return now >= activity.getStartTime() && now <= activity.getEndTime();
}
}
5.3 数据库表设计
-- 商品库存表
CREATE TABLE product_stock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id BIGINT NOT NULL COMMENT '商品ID',
stock INT NOT NULL DEFAULT 0 COMMENT '库存数量',
version INT NOT NULL DEFAULT 0 COMMENT '版本号(乐观锁)',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_product_id (product_id),
INDEX idx_stock (stock)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';
-- 秒杀活动表
CREATE TABLE seckill_activity (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_name VARCHAR(100) NOT NULL COMMENT '活动名称',
product_id BIGINT NOT NULL COMMENT '商品ID',
seckill_price DECIMAL(10,2) NOT NULL COMMENT '秒杀价格',
stock INT NOT NULL COMMENT '秒杀库存',
start_time DATETIME NOT NULL COMMENT '开始时间',
end_time DATETIME NOT NULL COMMENT '结束时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_product_id (product_id),
INDEX idx_start_time (start_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动表';
-- 订单表
CREATE TABLE seckill_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '订单号',
user_id BIGINT NOT NULL COMMENT '用户ID',
product_id BIGINT NOT NULL COMMENT '商品ID',
product_name VARCHAR(200) COMMENT '商品名称',
price DECIMAL(10,2) NOT NULL COMMENT '价格',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待支付 1已支付 2已取消',
pay_time DATETIME COMMENT '支付时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_order_no (order_no),
INDEX idx_user_id (user_id),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀订单表';
5.4 配置文件
# application.yml
server:
port: 8080
spring:
# Redis配置
redis:
host: 127.0.0.1
port: 6379
password: your_password
database: 0
lettuce:
pool:
max-active: 200
max-idle: 20
min-idle: 10
max-wait: 1000ms
timeout: 3000ms
# RabbitMQ配置
rabbitmq:
host: 127.0.0.1
port: 5672
username: guest
password: guest
virtual-host: /
listener:
simple:
acknowledge-mode: manual # 手动确认
concurrency: 5 # 最小消费者数量
max-concurrency: 10 # 最大消费者数量
prefetch: 1 # 每次预取1条消息
# MySQL配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/seckill?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: your_password
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 30000
# MyBatis-Plus配置
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 日志配置
logging:
level:
root: INFO
com.example.seckill: DEBUG
6. 上线前的自检清单 ✅
6.1 性能测试
# 使用JMeter压测
# 场景1:10万并发用户,持续1分钟
线程数:100000
Ramp-Up时间:10秒
持续时间:60秒
# 目标:
- TPS(每秒事务数) > 10000
- 响应时间 < 500ms
- 错误率 < 0.1%
6.2 监控告警
/**
* 监控指标
*/
public class MonitorMetrics {
// 1. 系统指标
- CPU使用率 < 80%
- 内存使用率 < 80%
- 磁盘IO < 80%
// 2. 应用指标
- QPS(每秒查询数)
- TPS(每秒事务数)
- 响应时间(P50, P95, P99)
- 错误率
// 3. 业务指标
- 库存剩余
- 订单数量
- 支付成功率
// 4. 依赖服务指标
- Redis连接数
- MySQL连接数
- RabbitMQ消息堆积量
}
6.3 容错降级
/**
* 降级策略
*/
@Service
public class SeckillFallbackService {
/**
* Redis挂了,降级到数据库
*/
@HystrixCommand(fallbackMethod = "decrStockFromDB")
public boolean decrStock(Long productId) {
// 尝试从Redis扣减
return redisService.decrStock(productId);
}
/**
* 降级方法
*/
public boolean decrStockFromDB(Long productId) {
System.out.println("⚠️ Redis降级,使用数据库");
return dbService.decrStock(productId);
}
/**
* MQ挂了,降级到同步创建订单
*/
public String createOrder(Long productId, Long userId) {
try {
// 尝试发送MQ消息
mqService.sendMessage(productId, userId);
} catch (Exception e) {
// MQ挂了,同步创建订单
System.out.println("⚠️ MQ降级,同步创建订单");
return orderService.createOrderSync(productId, userId);
}
}
}
6.4 回滚方案
/**
* 紧急回滚方案
*/
public class EmergencyRollback {
/**
* 方案1:关闭秒杀入口
*/
public void closeEntry() {
// 修改配置中心的开关
configService.set("seckill.enabled", "false");
// 前端显示:活动已结束
}
/**
* 方案2:限制流量
*/
public void limitTraffic() {
// 把限流阈值降到最低
rateLimiter.setRate(100); // 每秒只允许100个请求
}
/**
* 方案3:回滚代码
*/
public void rollbackCode() {
// 使用蓝绿部署,切换到上一个稳定版本
// 或者使用Git回滚代码重新部署
}
}
7. 总结 🎓
7.1 架构总览
┌─────────────────────────────────────────────────────────┐
│ 秒杀系统全景图 │
└─────────────────────────────────────────────────────────┘
前端层:
├─ 验证码(防刷)
├─ 按钮置灰(防重复点击)
└─ 页面静态化(CDN加速)
网关层:
├─ Nginx负载均衡
├─ 限流(每秒1万请求)
└─ 黑名单(IP/用户)
应用层:
├─ 令牌桶限流
├─ 参数校验
├─ 业务校验
└─ 执行秒杀逻辑
缓存层:
├─ Redis集群(主从 + 哨兵)
├─ 库存预热
└─ Lua脚本(原子扣减)
消息层:
├─ RabbitMQ/Kafka
├─ 异步创建订单
└─ 延迟队列(超时取消)
数据层:
├─ MySQL主从(读写分离)
├─ 乐观锁(防超卖)
└─ 分布式事务(最终一致性)
7.2 核心要点
| 层级 | 技术方案 | 作用 |
|---|---|---|
| 前端 | 验证码 + 按钮置灰 | 拦截99%无效请求 |
| 网关 | Nginx限流 | 控制进入后端的流量 |
| 应用 | 令牌桶 + 参数校验 | 二次限流 + 防非法请求 |
| 缓存 | Redis + Lua脚本 | 高性能扣库存 + 防超卖 |
| 队列 | RabbitMQ | 异步处理 + 削峰填谷 |
| 数据库 | 乐观锁 + 事务 | 最后一道防线 |
7.3 性能对比
优化前 vs 优化后:
┌─────────────┬────────────┬────────────┐
│ 指标 │ 优化前 │ 优化后 │
├─────────────┼────────────┼────────────┤
│ QPS │ 500 │ 50000 │
│ 响应时间 │ 5000ms │ 100ms │
│ 超卖概率 │ 高 │ 0 │
│ 系统稳定性 │ 经常崩溃 │ 稳如老狗 │
│ 用户体验 │ 😭 │ 😄 │
└─────────────┴────────────┴────────────┘
7.4 老程序员的忠告 💡
- 稳定压倒一切:宁可慢一点,也不能崩!崩了就是0!
- 不要相信任何人:前端可以伪造、网络可能延迟、数据库可能挂
- 多层防护:前端限流 → 网关限流 → 应用限流 → 缓存限流 → 数据库限流
- 异步能用就用:能异步的绝不同步,MQ是你的好朋友
- 监控和告警:没有监控的系统就是裸奔,出了问题都不知道
- 演练和预案:上线前必须压测,出问题要有回滚方案
7.5 面试加分项 ⭐
如果你能在面试时说出这些,基本上offer就稳了:
- 说出整体架构:前端 → 网关 → 应用 → 缓存 → 队列 → 数据库
- 说出限流方案:令牌桶算法 + 代码实现
- 说出防超卖方案:Redis Lua脚本 + 数据库乐观锁
- 说出削峰方案:消息队列异步处理
- 说出容灾方案:降级、熔断、回滚
- 说出监控方案:系统指标 + 业务指标 + 告警
8. 彩蛋:压测脚本 🎁
8.1 JMeter压测
<!-- 下载JMeter: https://jmeter.apache.org/ -->
<!-- 压测计划 -->
<TestPlan>
<ThreadGroup>
<name>秒杀压测</name>
<num_threads>10000</num_threads> <!-- 1万并发 -->
<ramp_time>10</ramp_time> <!-- 10秒内启动 -->
<duration>60</duration> <!-- 持续60秒 -->
</ThreadGroup>
<HTTPSamplerProxy>
<domain>localhost</domain>
<port>8080</port>
<path>/api/seckill/execute</path>
<method>POST</method>
<arguments>
<HTTPArgument>
<name>productId</name>
<value>1001</value>
</HTTPArgument>
<HTTPArgument>
<name>userId</name>
<value>${__Random(1,1000000)}</value>
</HTTPArgument>
<HTTPArgument>
<name>captcha</name>
<value>1234</value>
</HTTPArgument>
</arguments>
</HTTPSamplerProxy>
</TestPlan>
8.2 压测命令
# 启动JMeter(GUI模式)
./jmeter.sh
# 命令行模式(推荐)
./jmeter.sh -n -t seckill_test.jmx -l result.jtl -e -o report/
# 参数说明:
# -n: 命令行模式
# -t: 测试计划文件
# -l: 结果文件
# -e: 生成报告
# -o: 报告目录
🎉 写在最后
老哥写这篇文档整整花了3天,喝了10杯咖啡 ☕☕☕,薅掉了不少头发 🧑🦲...
如果这篇文档帮到了你,记得:
- ⭐ 给个Star(如果是在GitHub上的话)
- 📢 分享给朋友(独乐乐不如众乐乐)
- 💬 留个言(有问题欢迎骚扰)
记住:没有完美的架构,只有适合的架构!
最后送你一句话:
💪 代码写得好,工资少不了!
💰 系统不崩溃,年终奖翻倍!
祝你面试顺利,offer拿到手软!🎊
作者:10年老Java(真的有10年!)
微信:[你的微信]
GitHub:[你的GitHub]
邮箱:[你的邮箱]
声明:本文档仅供学习参考,实际项目请根据业务场景调整!
附录:参考资料 📚
- 阿里巴巴Java开发手册
- Redis官方文档
- RabbitMQ官方文档
- Spring Boot官方文档
- MyBatis-Plus官方文档
- 《高并发系统设计40问》- 极客时间
- 《亿级流量网站架构核心技术》- 张开涛
- 《Redis设计与实现》- 黄健宏
🔥🔥🔥 秒杀系统,秒的是速度,杀的是Bug!🔥🔥🔥
愿你的系统永不崩溃,愿你的代码永无Bug!
❤️ 2025年,我们继续加油!❤️