高并发系统设计实战:从秒杀超卖到限流、缓存、MQ 与数据一致性

高并发系统设计实战:从秒杀超卖到限流、缓存、MQ 与数据一致性

前言

高并发系统最难的地方,通常不是把 QPS 做高,而是在流量突然放大、依赖开始变慢、消息发生重复、服务随时可能重启的情况下,依然守住业务正确性。

以秒杀为例,10 万个请求可能在几秒内同时争抢 100 件商品。真正需要解决的不是“让 10 万个请求都进入数据库”,而是尽早过滤无效流量,只让系统能够承受、业务真正需要处理的请求继续向下游流动。同时还要保证:库存不能扣成负数,同一用户不能重复下单,消息不能因为异常而悄悄丢失,失败后能够补偿和对账。

本文以 Java、Spring Boot、MySQL、Redis 和消息队列为技术背景,从一段会超卖的代码开始,逐步演进出一条更接近生产环境的秒杀链路。重点不是背方案,而是理解每一层到底解决什么问题,又有哪些问题不能解决。

一、先确定目标:高并发不等于只追求高 QPS

一个秒杀系统通常有四个目标:

  1. 高吞吐:能够接住活动开始时的瞬时流量。
  2. 低延迟:无论成功、售罄还是被限流,都应尽快给出明确结果。
  3. 库存准确:不能超卖,也要尽量避免因为异常造成少卖。
  4. 订单不重不漏:同一资格只能产生一个有效订单,已接受的请求最终要有可追踪结果。

比技术指标更重要的是业务不变量。本文先约定三个最基本的不变量:

数据库库存不能小于 0
同一活动中,同一用户最多只能有一个有效订单
初始库存 = 数据库剩余库存 + 有效成功订单数

如果业务还有冻结、取消、退款等状态,公式要进一步拆分:

初始库存 = 可售库存 + 已冻结库存 + 已售库存 + 已退款待回补库存

系统设计和线上对账都应围绕这些不变量展开。只看接口返回成功率,不检查不变量,往往发现不了少卖、重复订单和补偿错误。

二、整体链路:让流量一层一层变小

生产环境中的秒杀链路可以抽象成下面这样:

flowchart LR U["用户请求"] --> G["网关限流"] G --> S["秒杀服务"] S --> R["Redis Lua 预扣"] R --> M["消息队列"] M --> C["订单消费者"] C --> D["MySQL"] D --> O["订单结果"] C --> X["重试/死信"] X --> B["补偿与对账"] B --> R B --> D

这条链路的关键不是组件多,而是形成流量漏斗:

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());
}

GETDECRSADD 分别执行。多个请求可能在 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 死信不是终点

消息超过自动重试上限后进入死信队列,只代表自动处理暂时停止。还需要:

  1. 记录活动编号、请求编号、用户编号、失败阶段和最后错误;
  2. 触发告警,并设置处理时限;
  3. 提供安全的重放工具;
  4. 重放前检查订单、库存和消费记录;
  5. 无法恢复时执行幂等补偿,并留下审计流水。

如果死信队列无人关注,它只是把线上故障从主队列移动到了另一个地方。

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 和中间件指标,还应建立:

  • 每个活动的预约成功、售罄、重复和限流数量;
  • 预约到订单成功的转化率和耗时分布;
  • RESERVEDEVENT_SENTORDER_CREATEDCOMPENSATED 各状态数量;
  • 待发送 Outbox 最老记录年龄;
  • MQ 最老未消费消息年龄;
  • 补偿成功、失败和重复执行次数;
  • Redis 库存、数据库库存和订单数的对账差异。

队列堆积量相同,业务影响可能完全不同。1000 条消息在每秒消费 5000 条时问题不大;1000 条消息积压 30 分钟则意味着链路已经停滞。因此“最老消息年龄”通常比单纯队列长度更有意义。

14.2 故障演练清单

上线前至少演练以下场景:

  1. Redis 延迟升高或短暂不可用,入口是否快速失败;
  2. MQ 发送超时,是否产生可恢复的 Outbox 记录;
  3. MQ 重复投递,是否只生成一笔订单;
  4. 消费者在数据库提交前后分别宕机,重启后是否正确恢复;
  5. 数据库出现慢查询或连接池耗尽,是否触发限流和告警;
  6. 消息持续积压,扩容消费者是否会反向压垮数据库;
  7. 补偿任务重复执行,库存是否只回补一次;
  8. 日志系统或非核心依赖异常,是否影响主链路;
  9. 流量超过压测容量,系统是否快速拒绝而不是整体雪崩。

演练不是只看“服务有没有挂”,而是要记录故障发现时间、告警到达时间、影响范围、自动恢复结果和人工处理步骤。

十五、高并发系统上线检查表

15.1 容量

15.2 正确性

15.3 可靠性

15.4 可观测性

15.5 应急

十六、总结

高并发系统设计可以概括为四件事:

  1. 减少竞争:用限流、资格校验、缓存和 Redis 原子预扣,让无效请求尽早结束。
  2. 守住正确性:用数据库条件更新、唯一索引、事务边界和幂等状态机兜底。
  3. 控制故障传播:用有界队列、超时、熔断、降级、隔离和带预算的重试保护资源。
  4. 允许失败但必须可恢复:用可靠事件、死信、补偿、对账和业务监控形成闭环。

Redis、MQ、分布式锁都只是工具。真正可靠的方案要明确每一层解决的问题、失败后的状态,以及由谁完成最终兜底。

对于秒杀这类场景,一条比较稳健的主线是:入口先限流,Redis Lua 原子预扣,MQ 异步削峰,消费者幂等处理,MySQL 条件更新和唯一索引保证最终正确,再用补偿与对账覆盖异常链路。

系统能够在正常情况下跑得快,只完成了一半设计;当依赖变慢、进程重启、消息重复、补偿重跑时,仍然能够解释当前状态并恢复到业务不变量,才是高并发系统真正的实战能力。

posted @ 2026-08-12 09:50  松鼠航  阅读(8)  评论(0)    收藏  举报