设计一个高并发秒杀系统——不是面试套路,是真实架构决策过程
设计一个高并发秒杀系统——不是面试套路,是真实架构决策过程
适读人群:做过或准备做电商/活动类系统的工程师 | 阅读时长:约19分钟 | 核心价值:秒杀系统的核心不是"高并发",而是在极端压力下保证库存数据的绝对正确
从一次差点崩溃的秒杀说起
2019 年,我参与了一个中型电商平台的限时秒杀功能设计。那次的规模不算大——备货 500 件商品,预期参与人数 5 万。
按理说这个量不算什么,但上线后第一场秒杀出了问题:500 件商品,实际下单成功了 521 件。超卖了 21 件。
我们花了整个下午处理用户投诉,联系仓库确认库存,给超卖的 21 个用户发赔偿券和道歉。这是我第一次真正意识到,秒杀系统的核心挑战不是你以为的"高并发",而是在极端并发下如何保证库存扣减的绝对原子性。
超卖是怎么发生的
先还原一下超卖的原因,这个理解是设计正确系统的前提。
我们当时的扣库存逻辑是:
// 有问题的代码——会导致超卖
public boolean deductStock(Long productId, int quantity) {
Product product = productMapper.selectById(productId);
if (product.getStock() >= quantity) {
product.setStock(product.getStock() - quantity);
productMapper.updateById(product);
return true;
}
return false;
}这段代码在单线程下没问题。在并发下是灾难:
时间线:
T1: 线程A 查询库存 = 1,判断库存够,准备扣减
T2: 线程B 查询库存 = 1,判断库存够,准备扣减
T3: 线程A 更新库存 = 0,成功
T4: 线程B 更新库存 = 0,也成功(因为 B 读到的是 T2 时刻的旧值 1)两个线程都成功了,但库存只有 1,超卖了。
解决超卖的几种方案
方案一:数据库乐观锁
// 用 version 字段做乐观锁
public boolean deductStock(Long productId, int quantity) {
Product product = productMapper.selectById(productId);
if (product.getStock() < quantity) return false;
int rows = productMapper.updateStockWithVersion(
productId,
product.getStock() - quantity,
product.getVersion() // 带上 version 作为 WHERE 条件
);
return rows > 0; // 0 说明被别人抢先更新了,CAS 失败
}
// SQL
// UPDATE product SET stock = #{newStock}, version = version + 1
// WHERE id = #{id} AND version = #{version}优点:不用外部组件,逻辑清晰。
缺点:并发越高,CAS 失败率越高,大量请求在重试,数据库压力大。
方案二:数据库悲观锁(SELECT FOR UPDATE)
@Transactional
public boolean deductStock(Long productId, int quantity) {
// 用 FOR UPDATE 加行锁
Product product = productMapper.selectForUpdate(productId);
if (product.getStock() < quantity) return false;
productMapper.updateStock(productId, product.getStock() - quantity);
return true;
}优点:逻辑简单,不会出现超卖。
缺点:锁竞争严重时,其他事务全部排队等待,吞吐量极低。秒杀场景下的并发量,这种方式直接把数据库打崩。
方案三:SQL 层面的原子扣减(正确方式)
// 直接在 SQL 里判断并扣减,一条语句原子完成
public boolean deductStock(Long productId, int quantity) {
int rows = productMapper.atomicDeductStock(productId, quantity);
return rows > 0;
}
// SQL
// UPDATE product SET stock = stock - #{quantity}
// WHERE id = #{id} AND stock >= #{quantity}这条 SQL 的 WHERE 子句里包含了"库存足够"的检查,UPDATE 是原子操作,要么更新成功,要么因为 stock < quantity 而更新 0 行。
这是数据库层面最简洁的解法,性能比乐观锁好(少一次查询),比悲观锁并发性好(不持有显式锁)。
秒杀系统的真实架构挑战
解决超卖只是第一步。真实的秒杀系统还有另外几个挑战。
挑战一:数据库扛不住洪峰
即使 SQL 写对了,每次秒杀请求都打数据库,数据库的连接数和 TPS 也有上限。5 万人同时点击,数据库直接挂。
解决思路:把库存从 MySQL 移到 Redis,用 Redis 做前置扣减
// 秒杀前,将库存预加载到 Redis
public void preloadStock(Long seckillId, int stock) {
String key = "seckill:stock:" + seckillId;
redisTemplate.opsForValue().set(key, String.valueOf(stock));
}
// 秒杀时,先用 Redis 原子扣减
public boolean tryAcquireStock(Long seckillId) {
String key = "seckill:stock:" + seckillId;
Long remaining = redisTemplate.opsForValue().decrement(key);
if (remaining < 0) {
// 超卖了,补回去(防止 Redis 库存变成很大的负数)
redisTemplate.opsForValue().increment(key);
return false;
}
return true;
}Redis 的 DECR 是原子操作,TPS 可以达到几十万,远超 MySQL。
但 Redis 库存和 MySQL 库存的一致性怎么保证?这是后面要说的问题。
挑战二:同一个用户重复下单
秒杀场景下,用户可能因为网络抖动、页面卡顿,连续点了多次购买按钮,产生多次请求。
解决方案:用户维度的幂等控制
public SeckillResult doSeckill(String userId, Long seckillId) {
// 1. 幂等检查:同一用户同一活动只能下一次单
String lockKey = "seckill:user_order:" + seckillId + ":" + userId;
Boolean firstTime = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.MINUTES);
if (!Boolean.TRUE.equals(firstTime)) {
return SeckillResult.duplicate(); // 重复请求,返回"已参与"
}
// 2. 扣减 Redis 库存
if (!tryAcquireStock(seckillId)) {
// 扣减失败,清除用户锁(让用户有机会再试,或者告知已售罄)
redisTemplate.delete(lockKey);
return SeckillResult.soldOut();
}
// 3. 异步创建订单
orderMQ.sendSeckillOrder(userId, seckillId);
return SeckillResult.success();
}挑战三:Redis 扣减成功但订单创建失败
这是最复杂的数据一致性问题。
Redis 库存扣了,消息发到 MQ 了,但 MQ 消费创建订单时失败了(比如数据库超时)。
这时候 Redis 里的库存少了 1,但实际上没有成功订单,相当于凭空少了一个库存名额。
消费端的代码要处理这个补偿逻辑:
@KafkaListener(topics = "seckill-orders")
public void consumeSeckillOrder(SeckillOrderMessage msg) {
try {
// 创建订单并扣 MySQL 库存(原子事务)
orderService.createSeckillOrder(msg.getUserId(), msg.getSeckillId());
} catch (Exception e) {
log.error("秒杀订单创建失败,开始补偿", e);
// 回补 Redis 库存
redisTemplate.opsForValue().increment("seckill:stock:" + msg.getSeckillId());
// 清除用户幂等锁(让用户可以再次参与?看业务策略)
// redisTemplate.delete("seckill:user_order:" + ...);
// 注意:是否需要抛出异常让 MQ 重试,取决于失败类型
// 如果是临时性故障,抛异常走重试;如果是永久性失败,直接补偿
}
}踩坑记录
踩坑一:Redis 库存超卖的边界情况
DECR 返回负数后,我补回了 1,但如果有 100 个请求同时拿到了负数值,每个都补了 1,库存被多补了 99。
更好的方案:用 Lua 脚本保证"检查 + 扣减"的原子性:
-- seckill_deduct.lua
local key = KEYS[1]
local current = tonumber(redis.call('GET', key))
if current and current > 0 then
redis.call('DECR', key)
return 1
end
return 0Long result = redisTemplate.execute(seckillDeductScript, List.of(stockKey));
return Long.valueOf(1).equals(result);踩坑二:活动刚开始时的惊群效应
秒杀活动倒计时结束的瞬间,几万人同时发请求,我们的 Redis 连接池被打满,出现排队超时。
根本原因:连接池配置偏小,而且没有针对秒杀接口做独立的连接池隔离。
修复:秒杀 Redis 客户端独立配置,连接池最大连接数提高到 500,并对连接等待时间设置合理超时(超过 500ms 直接返回"系统繁忙"而不是一直等)。
踩坑三:流量没有提前拦截,无效流量打到后端
每次秒杀,有大量用户在倒计时结束前疯狂刷新,请求虽然被限流拦截了,但拦截的位置在后端服务,网络和前置服务的压力依然很高。
修复:在 Nginx 层做基于 IP 的限流,把大量无效流量拦截在最前面,减少后端压力。
# Nginx 限流配置
limit_req_zone $http_x_user_id zone=seckill_user:10m rate=2r/s;
location /seckill {
limit_req zone=seckill_user burst=5 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}秒杀系统完整架构
秒杀系统的本质
秒杀系统的本质是:在极短时间内,用一个小库存,服务大量并发请求,并且库存不能错。
所有的技术手段,都是在"尽早拒绝、精准扣减、安全兜底"这三个目标上做工作。
- 尽早拒绝:能在 Nginx 拦截的,就不要到后端;能在 Redis 拦截的,就不要到数据库
- 精准扣减:扣减必须是原子操作,不能超卖,也不能因为补偿逻辑出 bug 导致少卖
- 安全兜底:Redis 不可靠时有降级方案,订单失败有补偿机制
这三点想清楚了,剩下的是实现细节。
