欢迎光临
我们一直在努力

高并发下单如何做到不超卖、不丢单?Spring Boot 4 + Redis + RabbitMQ + Vue 3 订单异步处理与库存削峰实战含 Lua 原子库存预扣、Outbox 可靠消息、幂

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

高并发下单如何做到不超卖、不丢单?
Spring Boot 4 + Redis + RabbitMQ + Vue 3 订单异步处理与库存削峰实战

含 Lua 原子库存预扣、Outbox 可靠消息、幂等消费、有限重试 / DLQ、超时释放与补偿闭环

摘要

秒杀、限量抢购和大促下单真正难的不是“把请求接住”,而是同时保证库存不被扣成负数、重复请求不生成重复订单、消息故障不丢单、取消或超时后库存能够准确归还。本文从一个 Java + Vue 的订单项目出发,给出一条可以落地的异步下单链路:请求入口只做必要校验和 Redis 原子预扣,订单与 Outbox 在同一个 MySQL 本地事务中提交,消息由独立 Publisher 可靠投递到 RabbitMQ,消费者通过数据库唯一约束与消费记录保证幂等,失败消息进入有限重试与死信链路,最终由补偿任务收口。全文同时给出数据模型、核心代码、状态机、异常场景和验证方法。

技术组件

本文基线

说明

JDK

25 LTS(21 LTS 也可)

JDK 25 是当前 LTS;Spring Boot 4.1 可运行在 Java 17~26

Spring Boot

4.1.x

当前稳定主线;如果项目仍在 3.5.x,本文设计思路不变

Redis

8.6

用于热点库存、请求幂等和短期状态;关键库存操作使用 Lua 原子脚本

RabbitMQ

4.3.x

使用 Publisher Confirm、手动 ACK、有限重试与 DLQ;生产可考虑 Quorum Queue

MySQL

8.4 LTS

保存订单、库存、流水、消费记录和 Outbox,作为持久业务事实来源

Vue

3.5+

下单后展示 PROCESSING,并通过轮询、SSE 或 WebSocket 更新状态

先说结论

高并发订单系统的关键不是“Redis + MQ”这两个名词,而是把每一个失败窗口都设计清楚:Redis 已扣但数据库失败怎么办?数据库成功但消息没发出去怎么办?消息重复投递怎么办?消费者事务回滚但消息已经 ACK 怎么办?订单超时释放库存被重复执行怎么办?只要这些问题没有闭环,系统在正常演示时可能完全没问题,一到网络抖动、进程重启或流量洪峰就会暴露数据不一致。

目录

  • 一、为什么同步下单在高并发下很容易失控
  • 二、项目目标与技术边界
  • 三、整体架构:把瞬时洪峰改造成可控处理曲线
  • 四、核心数据模型:订单、库存、流水、消费记录与 Outbox
  • 五、下单主链路:从请求进入到返回 PROCESSING
  • 六、Redis Lua 原子预扣:一次完成幂等判断与数量扣减
  • 七、本地 Outbox:解决“数据库成功但消息没发出去”
  • 八、RabbitMQ 可靠投递:Confirm、Return、重试与 DLQ
  • 九、消费者幂等:事务提交成功后再 ACK
  • 十、库存一致性:缓存预扣、数据库锁定与业务流水双保险
  • 十一、订单超时关闭与库存释放
  • 十二、Vue 端如何展示异步订单状态
  • 十三、异常场景逐一推演:真正决定系统可靠性的地方
  • 十四、验证方案:不看虚假 QPS,只验证关键业务不变量
  • 十五、性能与扩展:热点库存、预取、批量投递和水平扩容
  • 十六、方案适用边界与进一步演进
  • 总结
  • 参考资料

一、为什么同步下单在高并发下很容易失控

在电子商务、票务预约、即时零售、餐饮配送和限量促销中,请求往往不是均匀到达,而是在活动开始后的几秒甚至几百毫秒内集中涌入。传统同步接口会在同一个 HTTP 请求线程里连续完成商品校验、价格计算、库存扣减、订单落库、支付单创建、消息通知等操作。访问量较小时,这种写法简单直观;峰值出现后,线程、连接池、数据库锁和磁盘 I/O 会同时成为瓶颈。

更麻烦的是,库存属于强竞争热点数据。假设同一件商品只剩 100 件,却在一秒钟内收到 5000 次下单请求,如果每个请求都先查询数据库库存再更新,就会出现大量并发读写、行锁等待甚至“先查到有库存,后更新时已经被别人抢走”的竞争窗口。单纯把数据库机器升级得更大,只是在延后问题发生的时间。

图 1  同步下单与异步削峰链路的差异

削峰的本质

消息队列并不会凭空提高数据库的极限吞吐量。它真正做的是把“瞬间必须处理完的 5000 个请求”改造成“入口先快速确认、后端按可承受速度持续处理的 5000 个任务”。系统因此从不可控的并发争抢,变成可观测、可限速、可扩容的队列积压。

二、项目目标与技术边界

本文以单商品或少量商品的高并发下单作为主线,目标不是搭一套“万能分布式事务平台”,而是用常见 Java 技术栈把最容易出事故的几个不变量守住。

业务不变量

系统必须保证

主要手段

库存不超卖

任何时刻都不能因为并发把可售库存扣成负数

Redis Lua 原子预扣 + MySQL 条件更新

请求幂等

同一个 requestId 重试多次只能得到一个业务结果

request_id 唯一索引 + Redis 请求标记

消息不静默丢失

订单事务成功后,消息最终应可被重新投递

Outbox + Publisher Confirm + RETRY 状态

消费可重复

消息重复到达不会重复创建订单或重复扣库存

event_id / order_no 唯一约束 + consume_log

补偿只执行一次

取消、超时、死信等路径重复触发也不能多加库存

stock_flow 业务唯一键 + 条件更新

状态不可逆乱跳

已支付订单不能被超时任务改成已取消

状态机 + WHERE status = ? 条件更新

这里采用“Redis 负责高并发快速决策,MySQL 保存最终业务事实,RabbitMQ 负责异步传递”的职责划分。Redis 中的库存并不是永久唯一真相,RabbitMQ 也不是数据库;真正的业务闭环仍然要由数据库订单、库存流水和补偿记录来证明。

三、整体架构:把瞬时洪峰改造成可控处理曲线

图 2  订单异步处理与库存削峰整体架构

请求到达 Spring Boot 接口后,先完成登录校验、商品状态检查、参数校验和请求频率限制。真正进入核心链路时,先由 Redis Lua 脚本原子完成“requestId 是否处理过 + 库存是否足够 + 扣减指定数量 + 写入短期请求标记”。预扣成功后,应用在 MySQL 的同一个本地事务里写入订单 PROCESSING 记录和 Outbox 事件。

HTTP 接口不等待后端所有处理完成,而是返回 202 Accepted、orderNo 和 PROCESSING 状态。Outbox Publisher 随后把事件投递到 RabbitMQ;消费者按系统可承受并发度执行数据库库存锁定、订单状态推进和业务明细创建。前端只需要查询订单状态,不需要让浏览器长时间占着最初的下单连接。

为什么不是“先发 MQ,再写订单”?

因为“消息发送”和“MySQL 事务提交”属于两个独立系统。没有分布式事务时,两者之间永远存在失败窗口。Outbox 的做法是先在同一个数据库事务里保存业务数据和“待发送事件”,再由后台可靠投递。即使应用在事务提交后立即崩溃,Outbox 记录仍然存在,重启后可以继续发送。

四、核心数据模型:订单、库存、流水、消费记录与 Outbox

一个高并发订单系统不能只靠 order 和 stock 两张表。为了让“是否处理过、库存为什么变化、消息是否已经发送、补偿是否重复执行”都能被追踪,至少需要以下几类记录。

表

关键字段

关键约束 / 作用

order_info

order_no, request_id, user_id, product_id, quantity, status

order_no、request_id 唯一;保存 PROCESSING / WAITING_PAY / PAID 等状态

stock

product_id, available, locked, version

product_id 唯一;available >= 0;条件更新避免负库存

stock_flow

flow_no, biz_type, biz_no, product_id, quantity

(biz_type,biz_no) 唯一;扣减、确认、释放都有可追踪业务流水

mq_outbox

event_id, aggregate_id, payload, status, retry_count, next_retry_at

event_id 唯一;NEW / SENDING / SENT / RETRY / DEAD

mq_consume_log

event_id, consumer_name, consumed_at

(event_id,consumer_name) 唯一;消费者幂等

order_item

order_no, product_id, quantity, price_snapshot

保存下单时的商品快照,避免后续商品改价影响历史订单

CREATE TABLE order_info (
    id            BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no      VARCHAR(40)  NOT NULL,
    request_id    VARCHAR(64)  NOT NULL,
    user_id       BIGINT       NOT NULL,
    product_id    BIGINT       NOT NULL,
    quantity      INT          NOT NULL,
    status        VARCHAR(20)  NOT NULL,
    created_at    DATETIME(3)  NOT NULL,
    updated_at    DATETIME(3)  NOT NULL,
    UNIQUE KEY uk_order_no(order_no),
    UNIQUE KEY uk_request_id(request_id),
    KEY idx_user_created(user_id, created_at),
    KEY idx_status_created(status, created_at)
);

CREATE TABLE mq_outbox (
    id             BIGINT PRIMARY KEY AUTO_INCREMENT,
    event_id       VARCHAR(64) NOT NULL,
    aggregate_id   VARCHAR(40) NOT NULL,
    event_type     VARCHAR(50) NOT NULL,
    payload        JSON        NOT NULL,
    status         VARCHAR(16) NOT NULL,
    retry_count    INT         NOT NULL DEFAULT 0,
    next_retry_at  DATETIME(3) NULL,
    created_at     DATETIME(3) NOT NULL,
    updated_at     DATETIME(3) NOT NULL,
    UNIQUE KEY uk_event_id(event_id),
    KEY idx_outbox_retry(status, next_retry_at)
);

索引设计要围绕真实查询和并发约束展开,而不是“看到字段就建索引”。例如 request_id 的唯一索引既是幂等兜底,也是应用层 Redis 标记失效后的最后一道防线;status + created_at 则服务于超时订单扫描。

五、下单主链路:从请求进入到返回 PROCESSING

  • 前端生成一次性的 requestId。用户点击重试时必须复用同一个 requestId,不能每点一次都重新生成,否则幂等就失去意义。
  • 接口校验用户身份、商品状态、购买数量和限购规则;对明显无效请求尽量在进入核心库存链路前拦截。
  • Redis Lua 原子判断请求是否重复、库存是否足够,并按 quantity 预扣,而不是固定 DECR 1。
  • MySQL 本地事务写入 order_info(PROCESSING) 与 mq_outbox(NEW)。如果事务失败,立即执行一次幂等释放;同时保留异常兜底扫描,处理进程在极端窗口崩溃的情况。
  • 接口返回 202 Accepted。此时代表“请求已受理”,并不等价于订单已经完成。
  • Outbox Publisher 投递 RabbitMQ;消费者落库成功后把订单推进到 WAITING_PAY。
  • 支付成功时用条件更新推进到 PAID;超时或用户取消时,只允许 WAITING_PAY -> CANCELLED,并释放锁定库存。
  • public record CreateOrderRequest(
            @NotNull Long productId,
            @NotNull @Min(1) Integer quantity,
            @NotBlank String requestId
    ) {}

    public record CreateOrderResponse(
            String orderNo,
            String requestId,
            String status
    ) {}

    @PostMapping("/api/orders")
    public ResponseEntity<CreateOrderResponse> create(
            @AuthenticationPrincipal LoginUser user,
            @Valid @RequestBody CreateOrderRequest request) {

        CreateOrderResponse response = orderApplicationService.create(user.id(), request);
        return ResponseEntity.accepted().body(response);
    }

    六、Redis Lua 原子预扣:一次完成幂等判断与数量扣减

    库存预扣最重要的点,是把“读库存、判断数量、扣减库存、写请求标记”放到一个原子脚本里。如果这些步骤拆成多条普通 Redis 命令,中间就可能被其他请求插入。Lua 脚本在 Redis 服务器端一次执行,可避免典型的 check-then-act 竞争窗口。

    — KEYS[1] = stock:{productId}:available
    — KEYS[2] = stock:{productId}:req:{requestId}
    — ARGV[1] = quantity
    — ARGV[2] = orderNo
    — ARGV[3] = ttlSeconds

    local qty = tonumber(ARGV[1])
    if not qty or qty <= 0 then
        return -1
    end

    if redis.call('EXISTS', KEYS[2]) == 1 then
        return 2
    end

    local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
    if stock < qty then
        return 0
    end

    redis.call('DECRBY', KEYS[1], qty)
    redis.call('SET', KEYS[2], ARGV[2], 'EX', tonumber(ARGV[3]))
    return 1

    @Service
    @RequiredArgsConstructor
    public class RedisStockService {
        private final StringRedisTemplate redisTemplate;
        private final DefaultRedisScript<Long> reserveScript;

        public ReserveResult reserve(Long productId, int quantity,
                                     String requestId, String orderNo) {
            String tag = "{" + productId + "}";
            String stockKey = "stock:" + tag + ":available";
            String reqKey = "stock:" + tag + ":req:" + requestId;

            Long result = redisTemplate.execute(
                    reserveScript,
                    List.of(stockKey, reqKey),
                    String.valueOf(quantity), orderNo, "1800");

            return ReserveResult.from(result);
        }
    }

    Redis Cluster 注意点

    Lua 脚本同时操作多个 key 时,Redis Cluster 要求相关 key 位于同一 hash slot。示例通过 {productId} hash tag 让同一商品的库存键和请求键落到同一个 slot。这个细节在单机 Redis 上看不出问题,迁移到 Cluster 后却很关键。

    如果 Redis 预扣成功后应用在写 MySQL 之前异常退出,会存在“缓存库存已经减少,但数据库没有订单”的崩溃窗口。工程上不能只依赖 catch 里的即时回滚,因为进程可能根本来不及执行 catch。更稳妥的做法是给预扣记录增加可追踪的 reservation,并由定时任务扫描超时 reservation:若数据库中不存在对应 requestId/orderNo,则执行幂等释放。

    七、本地 Outbox:解决“数据库成功但消息没发出去”

    最常见但也最隐蔽的一类丢单,发生在“订单已经提交数据库,消息却没有真正进入 RabbitMQ”。如果业务代码只是先 insert order,再 rabbitTemplate.convertAndSend,一旦网络抖动或 broker 不可用,数据库事务和消息发送就会出现分叉。反过来先发消息再提交订单,也可能出现消费者先收到消息却查不到订单。

    Outbox 的核心非常简单:订单记录和“待发送事件”都写 MySQL,并且必须在同一个本地事务里提交。这样数据库只会出现两种结果——两者都成功,或者两者都失败。真正的 MQ 发送由另一个后台任务完成。

    @Service
    @RequiredArgsConstructor
    public class OrderApplicationService {
        private final RedisStockService redisStockService;
        private final OrderTxService orderTxService;
        private final StockCompensationService compensationService;

        public CreateOrderResponse create(Long userId, CreateOrderRequest req) {
            OrderInfo old = orderTxService.findByRequestId(req.requestId());
            if (old != null) {
                return new CreateOrderResponse(old.getOrderNo(), req.requestId(), old.getStatus());
            }

            String orderNo = OrderNoGenerator.next(userId);
            ReserveResult reserve = redisStockService.reserve(
                    req.productId(), req.quantity(), req.requestId(), orderNo);

            if (reserve == ReserveResult.OUT_OF_STOCK) {
                throw new BusinessException("STOCK_NOT_ENOUGH");
            }
            if (reserve == ReserveResult.DUPLICATE) {
                OrderInfo duplicated = orderTxService.requireByRequestId(req.requestId());
                return new CreateOrderResponse(duplicated.getOrderNo(), req.requestId(), duplicated.getStatus());
            }

            try {
                orderTxService.createProcessingOrderAndOutbox(userId, req, orderNo);
            } catch (RuntimeException ex) {
                compensationService.releaseReservationOnce(orderNo, req.productId(), req.quantity());
                throw ex;
            }

            return new CreateOrderResponse(orderNo, req.requestId(), "PROCESSING");
        }
    }

    @Service
    @RequiredArgsConstructor
    public class OrderTxService {
        private final OrderMapper orderMapper;
        private final OutboxMapper outboxMapper;

        @Transactional
        public void createProcessingOrderAndOutbox(
                Long userId, CreateOrderRequest req, String orderNo) {

            orderMapper.insertProcessing(orderNo, req.requestId(), userId,
                    req.productId(), req.quantity());

            OrderCreatedEvent event = OrderCreatedEvent.of(orderNo, req);
            outboxMapper.insertNew(event.eventId(), orderNo,
                    "ORDER_CREATED", Jsons.write(event));
        }
    }

    Outbox 不是“绝对只发送一次”

    Publisher 可能在 RabbitMQ 已确认消息后、还没来得及把 Outbox 更新成 SENT 时崩溃。重启后同一事件可能再次发送。因此正确目标是“至少一次投递 + 消费端幂等”,而不是依赖一个不存在的端到端 exactly-once 幻想。

    八、RabbitMQ 可靠投递:Confirm、Return、重试与 DLQ

    RabbitMQ 的 Publisher Confirm 与消费者 ACK 是两套独立机制。Confirm 解决“生产者发出的消息是否被 broker 接受”;消费者 ACK 解决“消息是否已经被业务处理完成”。两者都需要配置,不能互相替代。

    图 3  可靠消息与补偿闭环

    • 生产端开启 Publisher Confirm。收到 ACK 后才能把对应 Outbox 更新为 SENT;收到 NACK 或发生异常时把状态改为 RETRY,并设置下一次重试时间。
    • 消息设置 mandatory,并处理 Return Callback,避免路由键错误时消息被静默丢弃。
    • 重试必须有限次。瞬时网络问题可以重试,但代码缺陷、脏数据或永久性约束错误不应该无限 requeue。
    • 超过阈值后进入 DLQ,后台保留 eventId、orderNo、异常摘要和重试次数,交给补偿任务或人工处理。
    • 生产环境需要监控 ready、unacked、publish confirm latency、consumer capacity 和 DLQ 增长趋势。

    @Scheduled(fixedDelay = 500)
    public void publishPending() {
        List<OutboxEvent> events = outboxMapper.lockNextBatch(100);
        for (OutboxEvent event : events) {
            try {
                CorrelationData cd = new CorrelationData(event.getEventId());
                rabbitTemplate.convertAndSend(
                        "order.exchange", "order.created", event.getPayload(), cd);
                // ConfirmCallback 中根据 eventId 更新 SENT / RETRY
            } catch (Exception ex) {
                outboxMapper.markRetry(event.getEventId(), retryAt(event));
            }
        }
    }

    如果部署 RabbitMQ 多节点集群,订单这类不适合丢失的消息可优先评估 Quorum Queue。无论采用哪种队列类型,可靠性都不能只靠“durable=true”:生产者仍然需要 Confirm,消费者仍然需要正确 ACK,应用自身仍然需要幂等。

    九、消费者幂等:事务提交成功后再 ACK

    原始项目里很容易出现一种顺序错误:在带 @Transactional 的监听方法内部先 basicAck,然后方法返回时事务才真正提交。这样一来,如果 ACK 已经发给 RabbitMQ,而数据库提交恰好失败,这条消息就不会再投递,业务却没有成功。

    更清晰的写法,是让监听器本身不承担数据库事务,把真正的业务处理放到另一个 Spring Bean 的 @Transactional 方法里。该方法正常返回时事务已经提交,监听器再 ACK;如果业务方法抛异常,则根据异常类型决定重试还是进入死信。

    @Component
    @RequiredArgsConstructor
    public class OrderConsumer {
        private final OrderMessageTxService txService;

        @RabbitListener(queues = "order.create.queue", ackMode = "MANUAL")
        public void consume(OrderCreatedEvent event,
                            Channel channel, Message message) throws IOException {
            long tag = message.getMessageProperties().getDeliveryTag();
            try {
                txService.process(event);   // 返回时数据库事务已经提交
                channel.basicAck(tag, false);
            } catch (RetryableBusinessException ex) {
                channel.basicNack(tag, false, false); // 交给重试/DLX,不要无限原队列 requeue
            } catch (Exception ex) {
                channel.basicNack(tag, false, false);
            }
        }
    }

    @Service
    @RequiredArgsConstructor
    public class OrderMessageTxService {
        private final ConsumeLogMapper consumeLogMapper;
        private final OrderMapper orderMapper;
        private final StockMapper stockMapper;
        private final StockFlowMapper stockFlowMapper;

        @Transactional
        public void process(OrderCreatedEvent event) {
            // 唯一键 (event_id, consumer_name) 是最终幂等屏障
            if (!consumeLogMapper.tryInsert(event.eventId(), "order-create-consumer")) {
                return;
            }

            OrderInfo order = orderMapper.lockByOrderNo(event.orderNo());
            if (order == null || !"PROCESSING".equals(order.getStatus())) {
                return;
            }

            int changed = stockMapper.lockStock(
                    event.productId(), event.quantity());
            if (changed != 1) {
                throw new BusinessException("DB_STOCK_NOT_ENOUGH");
            }

            stockFlowMapper.insertOnce(
                    "LOCK", event.orderNo(), event.productId(), event.quantity());
            orderMapper.markWaitingPay(event.orderNo());
        }
    }

    UPDATE stock
    SET available = available – #{quantity},
        locked    = locked + #{quantity},
        version   = version + 1,
        updated_at = NOW(3)
    WHERE product_id = #{productId}
      AND available >= #{quantity};

    为什么还要数据库条件扣减?

    Redis 是高并发入口的第一道库存闸门,但数据库仍应保留 available >= quantity 的条件更新。它既能防止缓存异常、人工修复或数据漂移把数据库扣成负数,也让持久层自己具备最基本的业务约束。

    十、库存一致性:缓存预扣、数据库锁定与业务流水双保险

    库存最危险的不是“短暂不一致”,而是“发生不一致后无法证明为什么”。因此每一次库存变化都应有业务流水:RESERVE、LOCK、CONFIRM、RELEASE 或 ADJUST。业务流水的唯一键与订单号绑定,保证同一个补偿动作重复执行时只有第一次真正改变库存。

    动作

    Redis

    MySQL

    幂等依据

    预扣 RESERVE

    DECRBY available

    暂不改或只记订单 PROCESSING

    requestId / orderNo

    锁定 LOCK

    保持预扣结果

    available -= qty; locked += qty

    eventId + stock_flow

    支付确认 CONFIRM

    通常不再改可售库存

    locked -= qty,进入已售语义

    paymentNo / orderNo

    取消释放 RELEASE

    INCRBY available

    available += qty; locked -= qty

    (RELEASE, orderNo) 唯一

    人工校正 ADJUST

    按修复结果同步

    写调整流水后修改

    repairNo

    如果系统规模较小,也可以把“Redis 预扣”和“MySQL 最终扣减”理解成两阶段:Redis 负责资格筛选与流量削峰,数据库负责持久确认。重点不是术语,而是每个阶段都有可查询状态、有幂等键、有失败后的反向动作。

    十一、订单超时关闭与库存释放

    图 4  订单状态机与允许的状态迁移

    超时关闭不能写成“查到 15 分钟前的订单就 update CANCELLED”。支付回调和超时任务可能并发发生,必须使用条件更新:只有 WAITING_PAY 状态才能被关闭;支付同理,只有 WAITING_PAY 才能推进到 PAID。谁先成功更新,谁获得状态迁移权。

    UPDATE order_info
    SET status = 'CANCELLED', updated_at = NOW(3)
    WHERE order_no = #{orderNo}
      AND status = 'WAITING_PAY';

    @Transactional
    public void closeAndRelease(String orderNo) {
        OrderInfo order = orderMapper.findByOrderNo(orderNo);
        if (order == null) return;

        int closed = orderMapper.closeIfWaiting(orderNo);
        if (closed != 1) return; // 已支付、已取消或被其他任务处理

        // 数据库唯一键确保同一订单 RELEASE 只插入一次
        boolean first = stockFlowMapper.tryInsertRelease(
                orderNo, order.getProductId(), order.getQuantity());
        if (!first) return;

        stockMapper.releaseLocked(order.getProductId(), order.getQuantity());
        redisStockService.release(order.getProductId(), order.getQuantity(), orderNo);
    }

    释放操作不能只依赖一个两天后自动过期的 Redis key 作为“永久幂等证据”。Redis key 适合快速挡重复请求,但最终是否已经释放,应该能够从数据库 stock_flow 查到。这样即使几天后补偿任务再次执行,也不会把库存重复加回去。

    十二、Vue 端如何展示异步订单状态

    异步系统最常见的前端误区,是仍然把“下单接口返回”理解成“订单已经完成”。更合理的交互是:接口返回 202 后立刻展示“订单处理中”,按钮进入不可重复提交状态,并保存 requestId 与 orderNo。随后通过状态查询接口轮询,或者使用 SSE / WebSocket 接收状态变化。

    const submitting = ref(false)
    const order = ref(null)

    async function submitOrder(productId, quantity) {
      if (submitting.value) return
      submitting.value = true

      const requestId = crypto.randomUUID()
      try {
        const { data } = await api.post('/api/orders', {
          productId,
          quantity,
          requestId
        })

        order.value = data
        await pollStatus(data.orderNo)
      } finally {
        submitting.value = false
      }
    }

    async function pollStatus(orderNo) {
      for (let i = 0; i < 30; i++) {
        const { data } = await api.get(`/api/orders/${orderNo}`)
        order.value = data

        if (['WAITING_PAY', 'PAID', 'CANCELLED', 'FAILED'].includes(data.status)) {
          return
        }
        await new Promise(resolve => setTimeout(resolve, Math.min(500 + i * 150, 2000)))
      }
    }

    重试时不要换 requestId

    网络超时并不代表后端一定没收到请求。前端如果每次超时都生成新的 requestId 再提交,就可能把一次用户操作变成多次合法订单请求。正确做法是把 requestId 与这次“业务意图”绑定,在用户明确发起一笔新订单之前都复用它。

    十三、异常场景逐一推演:真正决定系统可靠性的地方

    异常场景

    可能后果

    处理方式

    Redis 预扣成功,MySQL 事务失败

    缓存少库存、数据库无订单

    即时幂等释放 + 预扣超时对账兜底

    MySQL 已提交,应用发送 MQ 前崩溃

    订单停在 PROCESSING

    Outbox NEW 记录重启后继续投递

    MQ 已 Confirm,Outbox 未标 SENT 就崩溃

    同一事件可能再次发送

    消费者 eventId 幂等,重复消息安全返回

    消费者业务成功但 ACK 丢失

    消息重新投递

    consume_log / 业务唯一键挡住重复处理

    消费者 ACK 成功但 DB 提交失败

    消息永久丢失

    禁止 ACK 早于事务提交;事务 Service 返回后再 ACK

    消费者异常无限 requeue

    热点消息持续打满消费者

    有限重试 + DLQ + 告警,区分可重试/不可重试异常

    支付回调与超时任务并发

    已支付订单被取消

    状态条件更新,WAITING_PAY 只能被一个动作成功迁移

    库存释放任务重复执行

    库存被多加

    数据库 stock_flow 唯一键 + 条件更新

    Redis 与 MySQL 库存长期漂移

    可售量与持久库存不一致

    定时对账:订单/库存流水汇总与缓存比对,生成修复任务

    十四、验证方案:不看虚假 QPS,只验证关键业务不变量

    不同 CPU、Redis 拓扑、RabbitMQ 节点数、数据库磁盘和连接池参数会让 QPS 相差数倍,因此没有测试环境时给出一个漂亮的固定吞吐数字并没有意义。更值得验证的是:系统在并发、重复、故障和恢复条件下,关键业务不变量是否始终成立。

    测试

    操作

    必须满足的断言

    并发抢库存

    初始库存 100,同时提交 1000 个不同 requestId

    成功锁定数量 <= 100;Redis 与 DB 均不出现负库存

    同请求重复提交

    同一 requestId 并发提交 20~50 次

    最终只有一个 order_no;request_id 唯一索引无重复数据

    Broker 不可用

    下单时停止 RabbitMQ,恢复后再观察

    订单仍在 PROCESSING;Outbox 进入 RETRY;Broker 恢复后最终送达

    消费者宕机

    处理到一半强制停止消费者

    消息可重新投递;重启后不会重复扣库存/重复建明细

    支付与超时竞态

    同一订单同时触发支付回调和关闭任务

    最终只能是 PAID 或 CANCELLED 之一,不能来回跳

    重复补偿

    对同一 orderNo 连续执行释放 10 次

    stock_flow 只有一条 RELEASE;库存只增加一次

    @Test
    void sameRequestShouldCreateOnlyOneOrder() throws Exception {
        String requestId = UUID.randomUUID().toString();
        int concurrent = 30;

        List<Future<CreateOrderResponse>> futures = IntStream.range(0, concurrent)
                .mapToObj(i -> pool.submit(() -> service.create(
                        10001L,
                        new CreateOrderRequest(20001L, 1, requestId))))
                .toList();

        Set<String> orderNos = new HashSet<>();
        for (Future<CreateOrderResponse> f : futures) {
            orderNos.add(f.get().orderNo());
        }

        assertEquals(1, orderNos.size());
        assertEquals(1, orderMapper.countByRequestId(requestId));
    }

    建议记录的核心指标

    订单接口 P95/P99、Redis Lua 延迟、库存不足比例、Outbox NEW/RETRY 数量、RabbitMQ ready/unacked、消费失败率、DLQ 增长、PROCESSING 超时数量、库存对账差异数。只有能看到这些指标,削峰系统才算“可运维”,而不只是“能跑”。

    十五、性能与扩展:热点库存、预取、批量投递和水平扩容

    15.1 热点商品不能只看平均 QPS

    真正压垮系统的往往是单个爆款形成的 hot key。Redis 单 key 虽然处理很快,但所有请求仍然会集中到同一分片。流量继续增大时,可以根据业务复杂度评估库存分桶、令牌分片、预约资格预分配等方案。不要在业务量还很小时就过度设计;但监控必须能看出“总 QPS 不高,某个商品却异常热”的情况。

    15.2 RabbitMQ prefetch 要和消费者处理能力匹配

    prefetch 太小会限制吞吐,太大则可能让大量未确认消息堆在单个消费者内存里,并加重故障重投后的瞬时压力。应结合单条消息平均处理时间、消费者实例数和数据库连接池容量逐步压测,而不是直接复制一个固定值。

    15.3 Outbox 扫描要避免多实例重复抢同一批事件

    多实例 Publisher 可以使用 SELECT … FOR UPDATE SKIP LOCKED、状态抢占或基于分片键的任务划分领取事件。即使仍然发生重复领取,只要 eventId 稳定且消费端幂等,业务也不会重复执行。

    15.4 限流要放在库存竞争之前

    如果系统能够稳定处理每秒 3000 个库存请求,却把 5 万个请求全部放进核心链路,那么 Redis、网络和日志本身也会被无效流量消耗。网关层或接口层的令牌桶、用户级频率限制、验证码/资格校验等措施,应该尽量在热点库存脚本之前完成。

    十六、方案适用边界与进一步演进

    本文方案适合“流量突发明显、库存是热点资源、订单可以接受短暂处理中状态”的业务。它通过最终一致性换取了更高的峰值承载能力,因此不适合那些必须在单个同步事务里立刻得到强一致最终结果的极少数场景。

    场景

    当前方案是否合适

    进一步建议

    普通电商大促 / 秒杀

    合适

    加强限流、热点隔离、Quorum Queue、自动扩容与库存对账

    库存跨多个仓库分配

    需要扩展

    引入仓库维度 reservation,避免一个全局 stock 数字掩盖履约约束

    一个订单包含大量 SKU

    需要扩展

    批量库存预占、失败回滚和跨 SKU 原子性需要单独设计

    支付强一致资金账

    只可作为订单外围架构

    账务要使用独立不可变流水、严格对账和支付机构幂等号

    亿级事件流与长期重放

    RabbitMQ 未必是最优

    可评估 Kafka/Pulsar;但库存幂等与业务补偿原则仍相同

    单体小项目、访问量很低

    可能过度设计

    优先数据库条件更新 + 唯一索引,等真实瓶颈出现再引入 MQ

    如果继续往生产级演进,可以加入 API 网关限流、分布式 tracing、Prometheus + Grafana、OpenTelemetry、消息积压自动扩容、库存对账看板、DLQ 重放后台、配置中心、容器化部署和故障演练。需要注意的是,每增加一个中间件都会增加新的失败模式,所以“可观测性与恢复能力”应该和“架构复杂度”一起增长。

    总结

    高并发订单系统最容易被误解成“Redis 扣库存 + RabbitMQ 发消息”。真正决定系统能否稳定运行的,是每一个失败窗口都有明确的业务语义和恢复路径。入口阶段用 Redis Lua 把库存判断与扣减做成原子操作,MySQL 用唯一索引和条件更新守住持久层底线,订单与 Outbox 同事务解决消息起点可靠性,RabbitMQ 用 Confirm 与消费者 ACK 建立传输责任边界,消费者再通过 eventId 和业务流水实现可重复处理。

    当支付超时、消息重复、消费者重启、Broker 短暂不可用甚至应用进程在关键窗口崩溃时,系统不应该依赖“最好别出问题”,而应该能够从订单状态、Outbox、消费记录和库存流水中判断发生了什么,并让重试或补偿最终把数据收敛到正确结果。

    一句话概括整套设计

    Redis 负责快,消息队列负责缓冲,MySQL 负责留下最终证据;唯一键负责幂等,状态机负责顺序,Outbox 负责不丢消息,补偿与对账负责把异常最终收口。

    参考资料

    1. Spring Boot Reference Documentation — https://docs.spring.io/spring-boot/

    2. Oracle Java Downloads / Java SE Support Roadmap — https://www.oracle.com/java/technologies/downloads/

    3. Redis Documentation — https://redis.io/docs/latest/

    4. RabbitMQ Consumer Acknowledgements and Publisher Confirms — https://www.rabbitmq.com/docs/confirms

    5. RabbitMQ Reliability Guide — https://www.rabbitmq.com/docs/reliability

    6. RabbitMQ Release Information — https://www.rabbitmq.com/release-information

    7. MySQL 8.4 Reference Manual — https://dev.mysql.com/doc/refman/8.4/en/

    8. Vue.js Guide — https://vuejs.org/guide/

    赞(0)
    未经允许不得转载:171主机测评 » 高并发下单如何做到不超卖、不丢单?Spring Boot 4 + Redis + RabbitMQ + Vue 3 订单异步处理与库存削峰实战含 Lua 原子库存预扣、Outbox 可靠消息、幂
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址