高并发系统设计实战:从秒杀超卖到限流、缓存、MQ 与数据一致性
高并发系统设计实战:从秒杀超卖到限流、缓存、MQ 与数据一致性
前言
高并发系统最难的地方,通常不是把 QPS 做高,而是在流量突然放大、依赖开始变慢、消息发生重复、服务随时可能重启的情况下,依然守住业务正确性。
以秒杀为例,10 万个请求可能在几秒内同时争抢 100 件商品。真正需要解决的不是“让 10 万个请求都进入数据库”,而是尽早过滤无效流量,只让系统能够承受、业务真正需要处理的请求继续向下游流动。同时还要保证:库存不能扣成负数,同一用户不能重复下单,消息不能因为异常而悄悄丢失,失败后能够补偿和对账。
本文以 Java、Spring Boot、MySQL、Redis 和消息队列为技术背景,从一段会超卖的代码开始,逐步演进出一条更接近生产环境的秒杀链路。重点不是背方案,而是理解每一层到底解决什么问题,又有哪些问题不能解决。
一、先确定目标:高并发不等于只追求高 QPS
一个秒杀系统通常有四个目标:
- 高吞吐:能够接住活动开始时的瞬时流量。
- 低延迟:无论成功、售罄还是被限流,都应尽快给出明确结果。
- 库存准确:不能超卖,也要尽量避免因为异常造成少卖。
- 订单不重不漏:同一资格只能产生一个有效订单,已接受的请求最终要有可追踪结果。
比技术指标更重要的是业务不变量。本文先约定三个最基本的不变量:
数据库库存不能小于 0
同一活动中,同一用户最多只能有一个有效订单
初始库存 = 数据库剩余库存 + 有效成功订单数
如果业务还有冻结、取消、退款等状态,公式要进一步拆分:
初始库存 = 可售库存 + 已冻结库存 + 已售库存 + 已退款待回补库存
系统设计和线上对账都应围绕这些不变量展开。只看接口返回成功率,不检查不变量,往往发现不了少卖、重复订单和补偿错误。
二、整体链路:让流量一层一层变小
生产环境中的秒杀链路可以抽象成下面这样:
这条链路的关键不是组件多,而是形成流量漏斗:
10 万次入口请求
→ 网关过滤非法请求、异常设备和超额流量
→ 业务层过滤非活动时间、重复用户和售罄请求
→ Redis 只放行不超过可售库存的预约请求
→ MQ 按消费者能力平滑下单
→ MySQL 只处理少量有效订单
假设库存只有 100,理想状态不是数据库处理 10 万次库存更新,而是入口快速拒绝绝大多数请求,最终只有接近 100 条有效请求竞争数据库资源。
三、高并发会把哪些问题放大
| 问题 | 线上现象 | 根因 | 主要治理手段 | 最终兜底 |
|---|---|---|---|---|
| 服务过载 | RT 飙升、超时、502 | 请求量超过线程、CPU 和依赖容量 | 限流、排队、隔离、降级 | 容量红线与快速失败 |
| 线程池耗尽 | 活跃线程满、队列持续增长 | 阻塞调用慢、队列无界、任务生产过快 | 有界队列、超时、拒绝策略 | 线程池监控告警 |
| 连接池打满 | 获取连接超时、数据库雪崩 | 并发 SQL 过多或慢事务长期占用连接 | 削峰、短事务、慢 SQL 治理 | 数据库容量保护 |
| 热点 Key | Redis 单节点 CPU、带宽升高 | 大量请求集中访问一个库存 Key | 本地售罄标记、Key 拆分、限流 | Redis 热 Key 监控 |
| 缓存击穿 | 缓存失效后数据库突增 | 热点数据同时回源 | 逻辑过期、互斥回源 | 数据库限流与降级 |
| 库存超卖 | 成功订单数大于库存 | 查询与更新不是原子操作 | Lua、条件更新、正确事务边界 | 数据库 stock > 0 |
| 重复下单 | 一个用户出现多笔订单 | 重试、连点、消息重复投递 | 请求幂等、消费幂等 | 数据库唯一索引 |
| 锁竞争 | RT 上升、吞吐下降、死锁 | 临界区过大、锁顺序不一致 | 缩小锁粒度、固定加锁顺序 | 超时与死锁监控 |
| 消息丢失 | Redis 已扣但没有订单 | 发送前后进程崩溃或网络异常 | 事务消息、本地消息表 | 补偿与对账 |
| 重复消费 | 重复扣库存或创建订单 | MQ 通常至少一次投递 | 消费记录、业务状态机 | 唯一约束 |
| 消息积压 | 下单延迟持续扩大 | 生产速率长期高于消费速率 | 扩容消费者、批量处理、入口降级 | 堆积时长告警 |
| 重试风暴 | 故障时流量反而数倍增长 | 多层立即重试且没有上限 | 指数退避、抖动、重试预算 | 熔断与快速失败 |
| 数据不一致 | Redis、订单、库存对不上 | 跨组件操作无法使用单一本地事务 | 状态机、可靠事件、幂等补偿 | 定时对账 |
这些问题不能靠某一个中间件一次解决。例如 Redis Lua 能保证脚本内的原子性,却不能自动保证 Redis 和 MySQL 一致;MQ 能削峰,却不能替消费者解决幂等;分布式锁能互斥,却不天然保证数据库库存正确。
四、从一段会超卖的代码开始
4.1 “先查询、再扣减”为什么会出错
下面的代码在低并发下看起来没有问题:
/**
* 错误示例:查询库存后再执行扣减,两个操作之间存在并发窗口。
*/
public void createOrder(Long activityId, Long userId) {
Integer stock = stockMapper.selectStock(activityId);
if (stock == null || stock <= 0) {
throw new BizException("商品已售罄");
}
stockMapper.updateStock(activityId, stock - 1);
orderMapper.insert(new SeckillOrder(activityId, userId));
}
当库存只剩 1 时,两个请求可能按照下面的顺序执行:
请求 A:查询库存,得到 1
请求 B:查询库存,也得到 1
请求 A:把库存更新为 0,创建订单
请求 B:也把库存更新为 0,创建订单
最终库存看起来是 0,但实际创建了两笔订单。这是一类典型的“检查后执行”竞态:检查条件成立到真正修改数据之间存在并发窗口。
仅仅给方法加 @Transactional 也不能自动解决问题。普通快照读并不会阻止另一个事务读到相同库存;即使使用悲观锁串行化,也会让热点行成为吞吐瓶颈。
4.2 把扣减改成数据库原子条件更新
数据库层至少要使用带条件的原子更新:
UPDATE seckill_stock
SET stock = stock - 1,
version = version + 1
WHERE activity_id = #{activityId}
AND stock > 0;
Java 代码根据受影响行数判断是否抢到库存:
/**
* 使用数据库条件更新扣减库存,受影响行为 0 表示库存已不足。
*/
@Transactional(rollbackFor = Exception.class)
public Long createOrderWithDatabase(Long activityId, Long userId) {
int affectedRows = stockMapper.decreaseStock(activityId);
log.info("数据库库存扣减完成,活动编号:{},用户编号:{},影响行数:{}",
activityId, userId, affectedRows);
if (affectedRows == 0) {
throw new BizException("商品已售罄");
}
SeckillOrder order = new SeckillOrder(activityId, userId);
orderMapper.insert(order);
return order.getId();
}
这能守住数据库库存不为负,但所有请求仍会竞争同一行。库存 100、请求 10 万时,让数据库执行 10 万次更新并返回 99900 次失败并不划算。因此数据库条件更新适合作为最后一道正确性防线,而不是入口流量过滤器。
4.3 唯一索引是幂等的最后一道防线
接口防重、Redis 标记、MQ 消费记录都有可能因为异常或程序缺陷失效。订单表应继续使用业务唯一约束兜底:
ALTER TABLE seckill_order
ADD UNIQUE KEY uk_activity_user (activity_id, user_id);
唯一索引解决的是“同一活动、同一用户最多一笔订单”,数据库条件更新解决的是“库存不能为负”。两者职责不同,不能相互替代。
五、加锁能解决问题吗
5.1 synchronized 只在单个 JVM 内有效
/**
* 单机互斥示例:只对当前 JVM 中的线程生效。
*/
public synchronized Long createOrderWithLocalLock(Long activityId, Long userId) {
return createOrderWithDatabase(activityId, userId);
}
当服务只有一个实例时,它可以让请求串行执行。但生产环境通常有多个实例:请求 A 到实例 1,请求 B 到实例 2,每个 JVM 都有自己的锁,两把锁互不认识。
而且方法级锁把不同活动、不同用户全部串行化,锁粒度过大。即使只按活动加锁,一个热门活动也会退化成单线程执行。
5.2 分布式锁也不是最终答案
Redis 分布式锁把互斥范围扩展到多个实例,但生产使用时还要考虑:
- 锁的 Key 是否过粗,是否把无关请求串行化;
- 业务执行时间超过租约,锁提前过期;
- JVM 长时间 GC 或网络抖动导致持锁者停顿;
- 是否使用唯一锁值,释放时是否校验持有者;
- 自动续期失败后,原线程是否仍在修改共享数据;
- Redis 故障转移期间,锁状态是否符合业务容忍度。
因此分布式锁适合保护少量、短时、确实需要互斥的临界区,但不宜让 10 万个请求排队争抢同一把库存锁。对于库存扣减,Redis Lua 和数据库条件更新通常比“大锁串行化”更直接。
六、Redis Lua:把无效流量挡在数据库之前
6.1 为什么不能在 Java 中连续调用多条 Redis 命令
下面这种写法仍然存在并发窗口:
Integer stock = redisTemplate.opsForValue().get(stockKey);
if (stock != null && stock > 0) {
redisTemplate.opsForValue().decrement(stockKey);
redisTemplate.opsForSet().add(userKey, userId.toString());
}
GET、DECR 和 SADD 分别执行。多个请求可能在 GET 时都看到库存大于 0。Redis 的单条命令是原子的,不代表多条命令组合后仍然原子。
Lua 脚本在 Redis 内一次执行,可以把活动校验、请求幂等、用户限购、库存判断、扣减和预约记录放在同一个原子操作中。
6.2 Lua 脚本设计
-- KEYS[1] 活动状态 Key
-- KEYS[2] 活动库存 Key
-- KEYS[3] 已购买用户集合 Key
-- KEYS[4] 请求预约记录 Hash Key
-- ARGV[1] 用户编号
-- ARGV[2] 请求编号
if redis.call('EXISTS', KEYS[1]) == 0 then
return 3
end
-- 相同 requestId 再次到达时,返回已受理,避免重复扣减
if redis.call('HEXISTS', KEYS[4], ARGV[2]) == 1 then
return 0
end
if redis.call('SISMEMBER', KEYS[3], ARGV[1]) == 1 then
return 2
end
local stock = tonumber(redis.call('GET', KEYS[2]) or '-1')
if stock <= 0 then
return 1
end
redis.call('DECR', KEYS[2])
redis.call('SADD', KEYS[3], ARGV[1])
redis.call('HSET', KEYS[4], ARGV[2], ARGV[1])
return 0
返回码约定如下:
| 返回码 | 含义 | 接口处理 |
|---|---|---|
0 |
首次预约成功,或相同请求已受理 | 返回“排队中”,异步查询订单结果 |
1 |
库存不足 | 返回“已售罄” |
2 |
用户已有其他成功预约 | 返回“请勿重复参与” |
3 |
活动不存在或未初始化 | 返回“活动未开始或已结束” |
如果使用 Redis Cluster,脚本涉及的 Key 必须落在同一个 Slot。可以把活动编号放入 Hash Tag:
seckill:{1001}:status
seckill:{1001}:stock
seckill:{1001}:users
seckill:{1001}:reservations
6.3 Spring Boot 调用 Lua
/**
* 秒杀预约服务:负责调用 Redis Lua 完成原子资格校验和库存预扣。
*/
@Service
@RequiredArgsConstructor
public class SeckillReservationService {
private final StringRedisTemplate redisTemplate;
private final DefaultRedisScript<Long> reserveScript;
/**
* 提交秒杀预约,相同请求编号重复提交时不会重复扣减库存。
*/
public ReserveResult reserve(Long activityId, Long userId, String requestId) {
String tag = "{" + activityId + "}";
List<String> keys = List.of(
"seckill:" + tag + ":status",
"seckill:" + tag + ":stock",
"seckill:" + tag + ":users",
"seckill:" + tag + ":reservations"
);
Long code = redisTemplate.execute(
reserveScript,
keys,
userId.toString(),
requestId
);
ReserveResult result = ReserveResult.fromCode(code);
log.info("秒杀预约处理完成,活动编号:{},用户编号:{},请求编号:{},处理结果:{}",
activityId, userId, requestId, result.getDescription());
return result;
}
}
日志中的输入和输出采用中文语义,便于排查。但用户编号、设备标识等字段要根据公司规范脱敏;高流量接口也不应无采样地打印每次成功日志,否则日志 IO 本身可能成为瓶颈。
6.4 Redis 预扣并不等于订单已经成功
Lua 返回成功,只能说明 Redis 中已经预留资格。后续还有消息投递、消费者执行、数据库写入等步骤,所以接口更适合返回“排队中”,由客户端通过请求编号查询最终状态。
常见错误是 Redis 扣减后直接告诉用户“下单成功”。一旦 MQ 发送失败或消费者最终处理失败,用户看到的结果就和数据库事实不一致。
另一个边界是 Redis 可用性。若 Redis 故障,不应无条件把全部秒杀流量回源数据库。更稳妥的策略通常是暂停活动入口或快速失败,待依赖恢复后再决定是否继续。
七、MQ 异步削峰:把瞬时压力变成可控速率
7.1 MQ 解决什么,又引入什么
Redis 预扣成功后,把预约事件写入 MQ,订单消费者按数据库能承受的速率处理。这样入口线程不需要同步等待数据库事务完成。
但 MQ 引入了新的问题:
- Redis 已扣库存,进程在发送消息前崩溃怎么办;
- 发送超时,生产者不知道消息到底成功还是失败怎么办;
- MQ 重复投递,消费者会不会创建两笔订单;
- 消息长时间积压,用户一直看到“排队中”怎么办;
- 消费多次失败,什么时候停止自动重试。
所以异步化不是把问题消失,而是用“状态机 + 可靠事件 + 幂等 + 补偿”重新组织问题。
7.2 不可靠的发送方式
ReserveResult result = reservationService.reserve(activityId, userId, requestId);
if (result.isAccepted()) {
mqTemplate.send("seckill-order", new OrderEvent(requestId, activityId, userId));
}
如果 Redis 扣减成功后进程退出,消息没有发出,库存会被占用却永远没有订单。如果 send 返回超时,直接重发又可能产生重复消息。
7.3 方案一:使用 MQ 的事务消息
支持事务消息的 MQ 可以先发送半消息,再执行本地业务,最后提交或回滚消息;Broker 在状态不明确时回查生产者。它能减少“业务成功但事件丢失”的窗口,但仍要处理事务回查、生产者状态存储和消费者幂等。
不同消息中间件的事务语义不同,落地时应以所用中间件的官方行为为准,不能仅凭“事务消息”四个字假设端到端只执行一次。
7.4 方案二:本地消息表
通用做法是建立 Outbox 表,将“待发送事件”先可靠记录下来,再由后台任务投递 MQ。
CREATE TABLE seckill_event_outbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
event_type VARCHAR(32) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(16) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_time DATETIME NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_request_event (request_id, event_type),
KEY idx_status_retry (status, next_retry_time)
);
状态可以设计为:
INIT 已落表,等待发送
SENT Broker 已确认接收
FAILED 超过自动重试上限,等待人工或补偿处理
投递任务只扫描到期的 INIT 记录,成功后更新为 SENT,失败时增加重试次数并计算下次执行时间:
/**
* 可靠事件投递任务:发送待处理事件,并记录中文语义的投递结果。
*/
@Scheduled(fixedDelayString = "${seckill.outbox.dispatch-delay-ms:500}")
public void dispatchPendingEvents() {
List<OutboxEvent> events = outboxRepository.lockNextBatch(100);
for (OutboxEvent event : events) {
try {
mqTemplate.send("seckill-order", event.getPayload());
outboxRepository.markSent(event.getId());
log.info("秒杀事件发送成功,请求编号:{},事件编号:{}",
event.getRequestId(), event.getId());
} catch (Exception ex) {
Duration delay = retryPolicy.nextDelay(event.getRetryCount());
outboxRepository.markRetry(event.getId(), delay);
log.warn("秒杀事件发送失败,请求编号:{},事件编号:{},下次重试间隔:{}",
event.getRequestId(), event.getId(), delay, ex);
}
}
}
这套方案会在入口增加一次 Outbox 写入。如果数据库完全不可用,入口不能假装事件已经可靠接收;可以将 Redis 中的预约记录标记为待确认,由补偿任务根据 requestId 检查 Outbox 是否存在,再决定补投事件或幂等回补库存。
本地消息表的代价也要正视:需要扫描索引、控制批次、避免多实例重复抢占、清理历史数据,并监控 INIT 最老记录的滞留时长。
八、消费者幂等:不要假设消息只来一次
大多数生产级消息系统更容易提供“至少投递一次”,而不是业务意义上的“只处理一次”。Broker 可能因为 ACK 丢失而重投,消费者也可能在事务提交后、确认消息前宕机。
8.1 消费记录表
CREATE TABLE mq_consume_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
message_id VARCHAR(64) NOT NULL,
consumer_group VARCHAR(64) NOT NULL,
consumed_at DATETIME NOT NULL,
UNIQUE KEY uk_message_group (message_id, consumer_group)
);
8.2 订单、库存和消费记录放在同一事务
/**
* 秒杀订单消费者:通过消费记录、条件更新和订单唯一索引实现幂等处理。
*/
@Component
@RequiredArgsConstructor
public class SeckillOrderConsumer {
private static final String CONSUMER_GROUP = "seckill-order-consumer";
/**
* 消费预约事件;事务成功后才允许确认消息。
*/
@Transactional(rollbackFor = Exception.class)
public void consume(OrderEvent event) {
if (consumeRecordMapper.exists(event.messageId(), CONSUMER_GROUP)) {
log.info("秒杀消息已处理,跳过重复消费,消息编号:{},请求编号:{}",
event.messageId(), event.requestId());
return;
}
try {
consumeRecordMapper.insert(event.messageId(), CONSUMER_GROUP);
} catch (DuplicateKeyException duplicate) {
log.info("秒杀消息并发重复,已由其他线程处理,消息编号:{}",
event.messageId());
return;
}
int affectedRows = stockMapper.decreaseStock(event.activityId());
if (affectedRows == 0) {
throw new BizException("数据库库存不足,需要进入异常对账");
}
orderMapper.insert(new SeckillOrder(
event.requestId(), event.activityId(), event.userId()));
log.info("秒杀订单创建成功,活动编号:{},用户编号:{},请求编号:{}",
event.activityId(), event.userId(), event.requestId());
}
}
这段代码要配合两个唯一约束:消费表的 (message_id, consumer_group) 和订单表的 (activity_id, user_id)。查询防重用于减少异常,唯一索引用于处理并发竞争,两者不能颠倒职责。
如果事务回滚,消费记录、库存和订单应一起回滚。只有数据库事务提交成功,消费框架才能 ACK。若提交成功但 ACK 丢失,消息再次投递时会命中消费记录,直接返回成功。
8.3 幂等不是简单地“发现重复就 return”
真正的幂等需要定义重复请求应该返回什么:
- 原请求正在排队:返回处理中;
- 原请求已成功:返回已有订单;
- 原请求已失败且不可重试:返回最终失败原因;
- 原请求处于未知状态:触发查询或对账,而不是再次扣库存。
因此 requestId 应贯穿网关、Redis 预约、Outbox、MQ 消息、消费记录和订单,才能把整条链路串起来。
九、失败恢复:重试、死信、补偿和对账
9.1 重试必须有边界
立即、无限重试会把一次依赖故障放大成重试风暴。一个请求经过网关、服务 A、服务 B 和数据库,如果每层都重试 3 次,最坏情况下会产生远超原始流量的调用。
更稳妥的重试策略包括:
- 只重试明确可幂等的操作;
- 使用指数退避,例如 1 秒、2 秒、4 秒、8 秒;
- 加入随机抖动,避免大量任务同时醒来;
- 设置最大次数和总时间预算;
- 参数错误、库存不足等业务失败不重试;
- 连接超时等临时故障可有限重试;
- 状态不明确时先查询,再决定是否重试。
9.2 死信不是终点
消息超过自动重试上限后进入死信队列,只代表自动处理暂时停止。还需要:
- 记录活动编号、请求编号、用户编号、失败阶段和最后错误;
- 触发告警,并设置处理时限;
- 提供安全的重放工具;
- 重放前检查订单、库存和消费记录;
- 无法恢复时执行幂等补偿,并留下审计流水。
如果死信队列无人关注,它只是把线上故障从主队列移动到了另一个地方。
9.3 Redis 库存如何回补
不能看到订单不存在就直接 INCR。消息可能仍在排队,消费者也可能刚刚提交事务。回补前至少要确认:
- 预约已超过业务允许的最长处理时间;
- Outbox 和 MQ 不再处于可自动恢复状态;
- 数据库不存在有效订单;
- 该请求没有成功消费记录;
- 当前补偿编号没有执行过。
补偿应使用 Lua 原子完成“检查预约状态、增加库存、移除用户标记、记录补偿完成”,防止定时任务重复执行导致库存被多加。
RESERVED → EVENT_SENT → ORDER_CREATED
│ │
└→ COMPENSATING → COMPENSATED
状态只能按允许的方向迁移。例如已经进入 ORDER_CREATED 的请求不能再回补库存;COMPENSATED 再次收到补偿任务时应直接返回已处理。
9.4 对账公式要考虑业务状态
最基础的对账关系是:
初始库存 = 数据库剩余库存 + 有效成功订单数
Redis 预扣数 = 有效成功订单数 + 待处理数 + 已确认补偿数
真实业务存在取消、退款和冻结时,要为每种状态建立独立流水。不要只对比 Redis 的一个数字和 MySQL 的一个数字,否则即使发现差异,也无法判断差异来自正常业务流转还是系统故障。
十、缓存问题:穿透、击穿、雪崩和热 Key
秒杀活动除了库存 Key,通常还会缓存活动信息、商品信息和用户资格。高并发下常见四类问题:
10.1 缓存穿透
请求不断查询不存在的活动编号,缓存和数据库都查不到。治理方式包括入口参数校验、空值短缓存、布隆过滤器和风控限流。布隆过滤器可能存在误判,不能代替数据库事实校验。
10.2 缓存击穿
热门活动信息刚好过期,大量请求同时回源。可以使用逻辑过期配合后台刷新,或者只允许一个请求重建缓存,其余请求返回旧值或快速失败。互斥重建要设置超时,避免持锁线程异常后其他请求永久等待。
10.3 缓存雪崩
大量 Key 同一时间过期,或 Redis 整体不可用,流量集中打向数据库。常见手段是 TTL 加随机值、缓存预热、多级缓存、Redis 高可用,以及数据库侧的限流和降级。
10.4 热 Key
单个活动库存天然是热 Key。即使 Redis 总体资源充足,一个 Key 仍可能集中到单节点。可以按业务接受程度使用本地售罄标记减少无效访问,或者把大库存分桶。但库存分桶会增加总量汇总、余量倾斜和补偿复杂度,库存量不大时不应为了“架构高级”盲目拆分。
缓存的核心原则是:缓存失效时,数据库仍要有保护措施。任何“Redis 挂了就全量回源”的设计,在秒杀场景下都可能把缓存故障升级为数据库故障。
十一、系统保护:限流、超时、熔断、降级和隔离
11.1 限流要分层
网关限流保护的是系统容量,业务限购保护的是业务规则,两者不能混为一谈。
- 网关层:按接口、IP、设备、用户和活动控制入口速率;
- 服务层:按线程池、下游容量和实时错误率进行自适应保护;
- 业务层:限制同一用户参与次数,校验活动资格;
- 数据层:数据库连接数、Redis 连接数和 MQ 生产速率都要有上限。
令牌桶允许一定突发流量,适合活动开始时的短时峰值;漏桶更强调稳定输出。具体参数应根据压测结果和下游容量设置,而不是照搬固定数字。
11.2 超时要逐层收紧
如果网关超时 3 秒,服务 A 调服务 B 的超时却是 5 秒,那么客户端已经离开,服务 A 还在占用线程等待无效结果。一般应满足:
客户端超时 > 网关超时 > 上游服务超时 > 下游调用超时
还要给整条请求设置截止时间。只给每一跳单独设置 1 秒超时,一条经过 5 个依赖的链路仍可能等待接近 5 秒。
11.3 熔断和降级
下游持续超时后继续发请求,通常只会占满更多线程和连接。熔断器应在错误率或慢调用达到阈值时快速失败,并在恢复探测阶段只放少量请求。
秒杀系统可以降级为:
- 暂停新预约,返回“活动火爆,请稍后再试”;
- 只提供排队状态,不实时查询复杂订单详情;
- 关闭非核心推荐、画像和营销计算;
- Redis、MQ 等关键依赖异常时暂停活动,而不是回源数据库硬扛。
11.4 资源隔离
秒杀流量不应拖垮普通商品和订单服务。可以使用独立服务实例、线程池、连接池、MQ Topic、数据库资源配额,必要时使用独立缓存集群。
隔离并不一定意味着所有组件物理独占。核心是提前定义资源边界,使一个活动的过载不会无限占用公共资源。
十二、线程池和连接池:不要把排队当成承载能力
12.1 无界队列会隐藏过载
请求生产速度长期大于消费速度时,无界队列只会让任务越积越多,最终表现为内存增长、延迟失控,甚至 OOM。高并发系统应使用有界队列,并在队列满时执行可观测的拒绝策略。
/**
* 秒杀异步任务线程池:使用有界队列,在容量耗尽时快速拒绝。
*/
@Bean("seckillExecutor")
public ThreadPoolTaskExecutor seckillExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(16);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("seckill-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}
这里的数值只是展示需要配置哪些参数,不是生产推荐值。实际配置要结合任务的 CPU/IO 比例、单任务耗时、实例 CPU、下游连接上限和压测数据。
在 Web 请求链路中,CallerRunsPolicy 会让提交任务的请求线程自己执行任务,可能进一步占住容器线程。需要根据任务类型判断是背压还是快速拒绝,不应机械套用。
12.2 线程池不是越大越好
CPU 密集型任务线程过多会增加上下文切换;IO 密集型任务虽然可以配置更多线程,但仍受数据库连接、Redis 连接和下游吞吐限制。把线程池调到 1000,而数据库只有 50 个可用连接,只会让更多线程排队等待连接。
12.3 数据库连接池要从数据库容量反推
连接池上限应根据数据库允许连接数、实例数量、单条 SQL 耗时和目标吞吐共同确定:
应用总连接上限 = 单实例连接池上限 × 应用实例数
扩容应用实例时如果不调整连接池,可能瞬间把数据库连接数打满。需要监控活动连接、空闲连接、等待线程和获取连接耗时,并为秒杀链路设置独立或受限的数据库资源。
十三、压测:验证瓶颈如何转移,而不是制造漂亮数字
压测至少分三轮进行:
13.1 第一轮:请求直接访问数据库
目的是观察基线和失败模式:数据库 QPS、热点行锁等待、连接池等待、P95/P99 延迟,以及库存是否正确。此阶段通常会看到大量无效请求消耗数据库资源。
13.2 第二轮:Redis 预扣后同步写库
观察无效流量是否显著减少,同时关注 Redis 脚本耗时、热 Key、网络带宽和同步数据库写入是否仍限制入口延迟。
13.3 第三轮:Redis 预扣 + MQ 异步下单
观察入口延迟是否稳定、MQ 生产速率和消费速率是否匹配、堆积多久能够消化、消费者扩容后数据库是否达到新瓶颈。
三轮都要检查下面这些指标:
| 类别 | 重点指标 |
|---|---|
| 接口 | QPS、成功率、限流率、P95/P99、超时率 |
| JVM | CPU、堆内存、GC 暂停、线程数 |
| 线程池 | 活跃线程、队列长度、拒绝次数、任务耗时 |
| 连接池 | 活跃连接、等待数、获取连接耗时 |
| Redis | 命令耗时、热 Key、连接数、错误率 |
| MySQL | TPS、慢查询、锁等待、死锁、更新失败数 |
| MQ | 发送失败、消费失败、重试、堆积量、最老消息年龄 |
| 业务 | 初始库存、成功订单、重复订单、待处理、补偿次数 |
不要只报平均延迟。平均值可能掩盖尾部请求,用户体验和线程占用往往由 P95/P99 决定。也不要编造“单机百万 QPS”一类脱离硬件、数据量、脚本内容和成功率的数字。
压测结束后必须执行数据校验:库存是否为负、是否存在重复订单、成功订单数与库存变化是否一致、是否仍有长期停留在处理中状态的请求。
十四、线上监控和故障演练
14.1 技术指标必须关联业务指标
CPU 正常不代表业务正常。消费者可能因为逻辑错误把所有消息都标记失败,机器资源却很空闲。除了 JVM 和中间件指标,还应建立:
- 每个活动的预约成功、售罄、重复和限流数量;
- 预约到订单成功的转化率和耗时分布;
RESERVED、EVENT_SENT、ORDER_CREATED、COMPENSATED各状态数量;- 待发送 Outbox 最老记录年龄;
- MQ 最老未消费消息年龄;
- 补偿成功、失败和重复执行次数;
- Redis 库存、数据库库存和订单数的对账差异。
队列堆积量相同,业务影响可能完全不同。1000 条消息在每秒消费 5000 条时问题不大;1000 条消息积压 30 分钟则意味着链路已经停滞。因此“最老消息年龄”通常比单纯队列长度更有意义。
14.2 故障演练清单
上线前至少演练以下场景:
- Redis 延迟升高或短暂不可用,入口是否快速失败;
- MQ 发送超时,是否产生可恢复的 Outbox 记录;
- MQ 重复投递,是否只生成一笔订单;
- 消费者在数据库提交前后分别宕机,重启后是否正确恢复;
- 数据库出现慢查询或连接池耗尽,是否触发限流和告警;
- 消息持续积压,扩容消费者是否会反向压垮数据库;
- 补偿任务重复执行,库存是否只回补一次;
- 日志系统或非核心依赖异常,是否影响主链路;
- 流量超过压测容量,系统是否快速拒绝而不是整体雪崩。
演练不是只看“服务有没有挂”,而是要记录故障发现时间、告警到达时间、影响范围、自动恢复结果和人工处理步骤。
十五、高并发系统上线检查表
15.1 容量
15.2 正确性
15.3 可靠性
15.4 可观测性
15.5 应急
十六、总结
高并发系统设计可以概括为四件事:
- 减少竞争:用限流、资格校验、缓存和 Redis 原子预扣,让无效请求尽早结束。
- 守住正确性:用数据库条件更新、唯一索引、事务边界和幂等状态机兜底。
- 控制故障传播:用有界队列、超时、熔断、降级、隔离和带预算的重试保护资源。
- 允许失败但必须可恢复:用可靠事件、死信、补偿、对账和业务监控形成闭环。
Redis、MQ、分布式锁都只是工具。真正可靠的方案要明确每一层解决的问题、失败后的状态,以及由谁完成最终兜底。
对于秒杀这类场景,一条比较稳健的主线是:入口先限流,Redis Lua 原子预扣,MQ 异步削峰,消费者幂等处理,MySQL 条件更新和唯一索引保证最终正确,再用补偿与对账覆盖异常链路。
系统能够在正常情况下跑得快,只完成了一半设计;当依赖变慢、进程重启、消息重复、补偿重跑时,仍然能够解释当前状态并恢复到业务不变量,才是高并发系统真正的实战能力。

浙公网安备 33010602011771号