秒杀系统,10万人抢10件商品,背后是一整套复杂的系统设计问题:
- 如何保证不超卖
- 如何防止重复下单
- 如何在高并发下保持高吞吐
- 如何避免系统被瞬间流量击垮
- 如何从单机平滑演进到分布式架构
这篇文章我会从一段简单的秒杀系统问题代码出发,一步步拆解问题,完成三次关键演进:
单机错误实现 → 单机高并发优化 → 分布式 + Redis → 限流 & 削峰体系
原始实现(问题代码)
public class SeckillService {
// 库存
private int stock = 10;
// 已购买用户记录
private List<String> userList = new ArrayList<>();
// 秒杀方法
public void doSeckill(String userId) {
// 1. 判断用户是否已经买过
for (int i = 0; i < userList.size(); i++) {
if (userList.get(i).equals(userId)) {
System.out.println("用户已购买,拒绝重复下单");
return;
}
}
// 2. 判断库存
if (stock > 0) {
// 3. 扣减库存
stock--;
// 4. 记录购买用户
userList.add(userId);
System.out.println("秒杀成功,剩余库存:" + stock);
} else {
System.out.println("库存不足,秒杀失败");
}
}
}
一个标准秒杀流程,本质是三个操作的组合:
查重(是否买过) + 扣库存 + 记录用户
技术的核心难点在于:如何在高并发下保证这三个操作的原子性 + 一致性 + 高性能
分析这段代码,存在 4 个致命缺陷:
-
Check-then-Act 竞态条件导致超卖
- 代码中的
if (stock > 0) { stock--; }在底层分多步执行,不满足原子性。高并发下,假设库存仅剩 1 个,上千个线程同时查到stock=1并发执行扣减,会把库存扣成负数
- 代码中的
-
并发修改异常与数据丢失(线程安全)
- 使用了非线程安全的
ArrayList记录用户。多线程并发执行add()时,极易引发底层数组越界、抛出ConcurrentModificationException异常;而且会发生数据相互覆盖,导致大量已购用户记录丢失,无法防住重复购买
- 使用了非线程安全的
-
接口性能问题(O(n) 的时间复杂度)
for循环遍历List查重。随着购买的用户增加,每次请求的遍历耗时将逐步递增。会瞬间吃满服务器 CPU,导致系统吞吐量(QPS)下跌
-
单机架构局限
stock和userList被硬编码在单台机器的 JVM 堆内存中。在分布式集群下,节点间状态无法共享。
V1.0 改造(单机高并发防超卖)
- 使用 volatile 保值 stock 变量的可见性,避免重排序
- 使用
ConcurrentHashMap.newKeySet()替换ArrayList,保证线程安全,并将查询时间复杂度降为 O(1) - 引入
ReentrantLock进行双重检查锁定(DCL),在保证原子性的同时最大化性能
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SeckillServiceV1 {
// volatile 保证多线程下的内存可见性
private volatile int stock = 10;
// 线程安全的 Set,查询复杂度 O(1)
private Set<String> userSet = ConcurrentHashMap.newKeySet();
private final Lock lock = new ReentrantLock();
public void doSeckill(String userId) {
// 第一重拦截:无锁判断,快速失败 (极其重要,挡掉绝大部分无效请求)
if (stock <= 0 || userSet.contains(userId)) {
System.out.println("商品已售罄或您已购买过");
return;
}
lock.lock();
try {
// 第二重拦截:防止排队等待的线程在获取锁后重复购买或超卖
if (stock <= 0 || !userSet.add(userId)) {
System.out.println("商品已售罄或您已购买过");
return;
}
// 真正安全的扣减库存
stock--;
System.out.println("秒杀成功,剩余库存: " + stock);
} finally {
lock.unlock();
}
}
}
-
为什么不使用 Synchronized 或 Redis 分布式锁
- Synchronized 能用,但在高并发场景下性能和可控性不够好(不能中断、不能设置超时、不能公平锁、无法感知锁状态),系统容易被锁等待阻塞拖垮
- 分布式锁的网络 + IO 太重,并且复杂性高。本质上不需要“锁”,而是需要原子性,因此使用 Lua 脚本 原子执行 是更优解
-
为什么使用 volatile 而不是 AtomicInteger?
在并发编程中,像
stock--这样的一行代码,在 CPU 指令层面其实分为三个独立的步骤:- Read(读):从主内存读取
stock的值到 CPU 本地缓存。 - Modify(改):在 CPU 寄存器内将值减 1。
- Write(写):将减去 1 的新值写回主内存。
给变量加上
volatile后,相当于加上了硬件级别的内存屏障。它强制要求线程每次读取都必须去主内存拿最新值,每次修改也必须立刻刷新回主内存。它打破了多核 CPU 的本地缓存黑盒,让所有线程的数据保持同步。但是volatile无法保证原子性AtomicInteger是原子类,内部也维护了一个volatile int value,他的累加或递减操作(如decrementAndGet())基于 CAS 指令真实的秒杀逻辑是 【查重防重 + 扣减库存 + 记录用户】 这 3 个动作的组合。
AtomicInteger只能保证库存扣减是安全的。但它无法保证多个独立变量(库存 + 用户集合)组合在一起依然是原子的,如果不加锁,同一个用户同时发起两个请求,依然有可能导致超卖,示例代码如下:// 错误示范 private AtomicInteger stock = new AtomicInteger(10); private Set<String> userSet = ConcurrentHashMap.newKeySet(); public void doSeckill(String userId) { if (userSet.contains(userId)) return; // 动作1:判断是否重复下单 // 假设还剩最后 1 个库存,同一用户利用脚本发送并发请求 A 和 B,同时来到这里,都发现自己还没买过。 if (stock.decrementAndGet() >= 0) { // 动作2:A 和 B 都在极其安全地扣库存 userSet.add(userId); // 动作3:记录用户 System.out.println("秒杀成功"); } }因此,既然业务要求必须保护多个变量的组合状态一致性,就必须在外部加互斥锁(
ReentrantLock或 Redis 分布式锁)。- 有了互斥锁,代码块内部天然就串行化了,再用
AtomicInteger去扣库存就多余了,白白增加 CAS 自旋的性能开销。普通的int就足够了。 - 而把
stock声明为volatile,是为了在锁的最外层,配合if (stock <= 0)提供极速的可见性判断。一旦库存归零,锁外排队的几万个线程瞬间就能“看见”,直接return拦截掉,连抢锁的资格都不用给。这就是大名鼎鼎的 Double-Checked Locking(DCL,双重检查锁定)。
AtomicInteger的使用场景:-
纯粹的高并发计数:统计某个 API 的 QPS 请求总数、记录网站的 UV、生成自增的单据号。
-
无限制的库存扣减:如果业务仅仅只有扣减库存这一件事(不需要判断单个用户是否重复购买,不查重),就是纯粹的卖 10 万个商品谁抢到算谁的,单机环境下直接用
AtomicInteger性能最高,完全不用加任何锁。其实在极高并发的单纯计数场景下(比如统计双十一当天的订单总数),连
AtomicInteger都不推荐使用。 因为当上千个线程同时 CAS 争抢同一个AtomicInteger时,会导致大量线程失败并陷入死循环(自旋),白白打满 CPU。 在 JDK 8 之后,我会首选LongAdder。它底层采用了分段锁的思想,将单一的热点数据分散到了一个 Cell 数组中,让不同的线程去修改不同的槽位,最后再把各个槽位的值求和。这在极端高并发下,性能比AtomicInteger更高
- Read(读):从主内存读取
V2.0 分布式高并发(引入 Redis + Lua)
当应用部署多台机器时,状态(库存、购买记录)必须从 JVM 外移到分布式缓存 Redis 中。必须使用 Lua 脚本,利用 Redis 单线程执行脚本的特性,保证多条指令的绝对原子性。
-
编写 Lua 脚本 (
seckill.lua):local stockKey = KEYS[1] local userSetKey = KEYS[2] local userId = ARGV[1] -- 1. 判断是否重复购买 (SISMEMBER, O(1)) if redis.call('sismember', userSetKey, userId) == 1 then return -1 -- 返回-1代表重复下单 end -- 2. 判断库存是否大于0 local stock = tonumber(redis.call('get', stockKey)) if stock ~= nil and stock > 0 then -- 3. 扣减库存 & 记录用户 redis.call('decr', stockKey) redis.call('sadd', userSetKey, userId) return 1 -- 返回1代表秒杀成功 end return 0 -- 返回0代表库存不足 -
Java 核心调用
@Service public class SeckillServiceV2 { @Autowired private StringRedisTemplate redisTemplate; public String doSeckill(String userId, String goodsId) { // 执行 Lua 脚本,天生解决分布式超卖问题 Long result = redisTemplate.execute(seckillScript, Arrays.asList("stock:" + goodsId, "users:" + goodsId), userId); if (result == -1L) return "您已参与过该活动"; if (result == 0L) return "手慢了,商品已被抢光"; // 【注意】拿到资格后,千万不能在这里同步写 MySQL 创建订单! return "秒杀成功,正在排队生成订单..."; } }
Redis 单点能抗住大几万的 QPS,解决分布式超卖。但如果面对几十上百万的瞬时流量,Redis 网卡也会被打满,我们还需要最后一层终极演进。
V3.0 限流、削峰和数据库兜底
我们要在请求到达 DB 之前,设立多道防线
-
前端与网关层(阻挡恶意和多余流量)
-
前端防护:点击按钮立刻置灰(防手抖连点);秒杀链接动态下发(防止黑客用脚本提前刷接口)。
-
网关限流:使用 Sentinel 或 Nginx,按 IP 和 UserID 进行严格限流(如单用户1秒最多放行1次请求),被限流的请求直接触发降级,返回“活动太火爆”。
-
-
防线二:JVM 本地内存标记
10 万人抢 10 部手机,当库存为 0 后,剩下的 99,990 个请求再去查 Redis 是毫无意义的,白白浪费网络带宽。
- 优化方案:在 Java 服务中维护一个
volatile boolean isSoldOut = false;或本地 Cache。一旦 Redis Lua 返回库存不足,立刻将本地标记设为true。后续的海量请求直接在 Java 内存层被拦截,以 0 成本保护下游的 Redis。
- 优化方案:在 Java 服务中维护一个
-
MQ 异步解耦削峰(保护数据库)
Redis 扣减成功只代表用户获得了购买资格。如果此时去同步操作 MySQL 甚至加事务,MySQL 会瞬间宕机。
-
优化方案:将获得资格的
userId和goodsId封装成消息投递到 MQ,给前端快速响应“排队中”。 -
后台的订单服务(消费者)按照 MySQL 能够平稳承受的速率(例如 1000 TPS),慢慢消费消息并创建订单。
-
-
数据库兜底(防击穿)
-
防重复下单:订单表添加唯一索引
UNIQUE KEY(user_id, goods_id)。 -
防超卖(乐观锁):真正扣减物理库存时:
UPDATE goods SET stock = stock - 1 WHERE id = #{goodsId} AND stock > 0;
-