找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4347

积分

0

好友

569

主题
发表于 2 小时前 | 查看: 4| 回复: 0

如果把普通电商系统比作“长跑”,那秒杀系统更像“百米冲刺”。
它不是流量大一点的下单系统,而是一种必须围绕瞬时洪峰、热点集中、强一致边界与快速失败机制重新设计的高并发系统。

很多团队第一次做秒杀,都会踩进同一个坑:
把秒杀当成“给下单接口加个活动类型”的功能改造,结果活动开始后的 3 秒内,应用线程池打满、数据库连接数耗尽、Redis 热点分片飙高、消息队列开始堆积,最后页面上同时出现“下单成功”“库存不足”“请稍后重试”三种互相矛盾的提示。

这篇文章不讲八股,不停留在“Redis + MQ + 限流”的口号层,而是从生产架构视角,系统回答下面几个关键问题:

  • 秒杀系统到底在和什么对抗?
  • 为什么很多 Demo 能跑通、上线却扛不住?
  • 如何设计一条可扩展、可治理、可回放、可对账的秒杀链路?
  • 如何从单体系统逐步演进到可支撑十万级到百万级 QPS 的生产架构?

文章会围绕 7 个核心设计思路展开,并补齐原理拆解、架构升级、工程落地以及实战场景与运营流程。你可以在 云栈社区 找到更多关于此类高并发架构的深度讨论。


一、先统一认知:秒杀系统真正难在哪里

秒杀场景与普通交易场景有 5 个本质差异:

  1. 流量不是均匀到达,而是瞬时爆发
  2. 热点不是分散的,而是极度集中在少量活动商品
  3. 用户心智不是“慢慢浏览”,而是“立刻响应”
  4. 系统目标不是让所有请求都成功,而是让系统整体稳定
  5. 业务一致性不是每一步都强一致,而是核心链路强约束、外围链路最终一致

这意味着秒杀系统的首要目标并不是“高成功率”,而是下面这个优先级:

  1. 绝不把主站拖死
  2. 绝不出现大规模超卖
  3. 尽可能快地给用户明确反馈
  4. 对成功请求完成后续订单闭环
  5. 对异常链路可补偿、可对账、可追踪

从架构角度看,秒杀本质上是在做四件事:

  • 把无效流量挡在外面
  • 把热点操作收敛成极少数原子动作
  • 把同步链路压缩到最短
  • 把重操作异步化并可恢复

二、秒杀系统的目标架构:不是“一个接口”,而是一条分层流水线

先看一版适合生产环境的整体架构。这条链路可以拆成 4 层:

  • 流量承接层:CDN、WAF、网关、静态化页面、访问令牌
  • 请求裁剪层:资格校验、限流、热点隔离、库存预减
  • 事务处理层:异步下单、幂等控制、订单落库、支付联动
  • 治理恢复层:监控、补偿、对账、回放、熔断、降级

如果没有这四层,系统看起来只是“能不能抢到”;
有了这四层,系统才真正进入“扛得住、查得清、修得回”的工程化阶段。

三、7 大核心设计思路

1. 动静分离与流量前置拦截:先挡住 99% 无效请求

1.1 原理

秒杀活动开始前,真正需要进入核心交易链路的请求非常少。大部分请求其实只是:

  • 打开活动页
  • 刷新倒计时
  • 轮询按钮状态
  • 查看商品信息
  • 反复点击抢购按钮

如果这些请求全部回源到应用服务,后端还没开始卖,系统就先被“围观流量”打死了。

所以第一原则是:能静态化就不要动态化,能边缘化就不要中心化。

1.2 架构做法

  • 商品详情页静态化,提前发布到 CDN
  • 活动页中的图片、样式、脚本全部走静态资源域名
  • 秒杀开始时间由服务端统一签发,客户端只负责展示
  • 抢购按钮不是页面加载就能点,而是依赖服务端下发动态令牌
  • 接口层增加验证码、行为校验、设备指纹、频控,过滤脚本刷单

1.3 页面与接口分离

推荐把页面拆成两类:

  • 活动展示面:全静态,承接海量浏览流量
  • 交易入口面:短接口、轻逻辑、强保护,只负责进入抢购流程

1.4 Nginx / Gateway 示例

limit_req_zone $binary_remote_addr zone=seckill_req:10m rate=200r/s;
limit_conn_zone $binary_remote_addr zone=seckill_conn:10m;

server {
    listen 80;
    server_name seckill.example.com;

    location /static/ {
        root /data/www;
        expires 5m;
        add_header Cache-Control "public, max-age=300";
    }

    location /api/seckill/token {
        limit_req zone=seckill_req burst=100 nodelay;
        limit_conn seckill_conn 20;
        proxy_connect_timeout 500ms;
        proxy_read_timeout 800ms;
        proxy_pass http://seckill_gateway;
    }

    location /api/seckill/submit {
        limit_req zone=seckill_req burst=50 nodelay;
        limit_conn seckill_conn 10;
        proxy_connect_timeout 500ms;
        proxy_read_timeout 800ms;
        proxy_pass http://seckill_gateway;
    }
}

1.5 工程经验

  • 页面静态化不是优化项,而是准入门槛
  • 秒杀开始时间必须由服务端口径统一,避免客户端时间漂移
  • 按钮放开前先发资格令牌,再发提交权限,可减少无效提交
  • 风控不要放在订单后面做,要前移到“进入抢购前”

2. 多级缓存与热点隔离:绝不让数据库直面秒杀流量

2.1 原理

数据库适合持久化,不适合承担秒杀瞬时写冲击。
尤其是热点 SKU 的库存查询、资格判断、活动状态读取,如果都落到 MySQL,几千并发就足够把连接池压满。

因此秒杀系统通常采用三层缓存模型:

  • L1 本地缓存:极低延迟,承接热点元数据
  • L2 Redis 缓存:承接跨节点共享状态和原子扣减
  • L3 MySQL 持久层:只做最终订单、库存流水、对账依据

2.2 建议缓存对象

  • 活动配置
  • SKU 热点标记
  • 商品上下架状态
  • 用户资格结果
  • 风控结果短缓存
  • 库存余量
  • 单人限购记录

2.3 库存预热

活动开始前,不是把用户流量“导向数据库”,而是把库存“预加载到缓存”。

@Service
public class StockWarmupService {

    private final StringRedisTemplate redisTemplate;

    public StockWarmupService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void warmUp(long activityId, long skuId, int totalStock) {
        String stockKey = stockKey(activityId, skuId);
        String soldFlagKey = soldFlagKey(activityId, skuId);

        redisTemplate.opsForValue().set(stockKey, String.valueOf(totalStock));
        redisTemplate.delete(soldFlagKey);
    }

    private String stockKey(long activityId, long skuId) {
        return "seckill:stock:" + activityId + ":" + skuId;
    }

    private String soldFlagKey(long activityId, long skuId) {
        return "seckill:soldout:" + activityId + ":" + skuId;
    }
}

2.4 热点隔离

秒杀的热点不是“很多商品都热”,而是“一个商品极热”。
这会带来两个典型问题:

  • Redis 单分片被某个 Key 打爆
  • 应用节点的某类校验逻辑被单 SKU 压垮

应对方式:

  • 热点商品路由到独立 Redis 分片
  • 热点商品在网关单独限流
  • 热点元数据放本地缓存,减少 Redis 读压力
  • 对于极端热点场景,可引入库存分桶或令牌分桶

2.5 缓存不是越多越好

缓存一旦层次增加,就会带来状态同步问题。因此有两个原则:

  • 读多写少的数据尽量多级缓存
  • 强一致要求高的数据尽量单一写入口

库存就是典型例子:
它可以被预热到 Redis,但真正扣减动作必须只认一个原子入口,不能“本地缓存减一次,Redis 再减一次”。

3. Redis 原子扣减与资格校验合并:核心状态必须一次完成

3.1 为什么“查库存再扣库存”一定会出事

这是秒杀系统最常见、也最致命的错误:

Integer stock = Integer.valueOf(redisTemplate.opsForValue().get(stockKey));
if (stock > 0) {
    redisTemplate.opsForValue().decrement(stockKey);
    // 创建订单
}

并发下,多个线程可能同时读到 stock > 0,然后都去扣减,结果就是超卖。

所以在秒杀系统里,库存判断、限购判断、重复购买判断必须收敛成一个原子操作。

3.2 Lua 脚本示例

下面给出一版更接近生产的 Lua 脚本,它同时完成多项检查和原子扣减。

-- KEYS[1] stockKey
-- KEYS[2] userSetKey
-- KEYS[3] orderMarkKeyPrefix
-- ARGV[1] userId
-- ARGV[2] limitCount
-- ARGV[3] requestId

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

local userId = ARGV[1]
local limitCount = tonumber(ARGV[2])
local requestId = ARGV[3]
local orderMarkKey = KEYS[3] .. userId

if redis.call('exists', orderMarkKey) == 1 then
    return -2
end

local bought = tonumber(redis.call('sismember', KEYS[2], userId))
if bought == 1 then
    return -3
end

redis.call('decrby', KEYS[1], 1)
redis.call('sadd', KEYS[2], userId)
redis.call('setex', orderMarkKey, 600, requestId)
return 1

3.3 Java 封装

@Service
public class SeckillStockService {

    private final StringRedisTemplate redisTemplate;
    private final DefaultRedisScript<Long> stockDeductScript;

    public SeckillStockService(StringRedisTemplate redisTemplate,
                               DefaultRedisScript<Long> stockDeductScript) {
        this.redisTemplate = redisTemplate;
        this.stockDeductScript = stockDeductScript;
    }

    public DeductResult deduct(long activityId, long skuId, long userId, String requestId) {
        String stockKey = "seckill:stock:" + activityId + ":" + skuId;
        String userSetKey = "seckill:users:" + activityId + ":" + skuId;
        String orderMarkKeyPrefix = "seckill:req:" + activityId + ":" + skuId + ":";

        Long result = redisTemplate.execute(
                stockDeductScript,
                List.of(stockKey, userSetKey, orderMarkKeyPrefix),
                String.valueOf(userId),
                "1",
                requestId
        );

        if (result == null) {
            return DeductResult.systemBusy();
        }
        if (result == 1L) {
            return DeductResult.success();
        }
        if (result == -1L) {
            return DeductResult.soldOut();
        }
        if (result == -2L || result == -3L) {
            return DeductResult.duplicate();
        }
        return DeductResult.systemBusy();
    }
}

3.4 为什么要把资格校验也并入原子步骤

如果先校验用户资格,再单独扣库存,中间仍然有竞态窗口。
一旦资格校验通过后请求堆积,后续扣减可能已经无库存,用户就会收到前后不一致的提示。

更合理的做法是:

  • 前置接口只做轻量资格筛选
  • 核心提交流程里,把必要校验与扣减合并成一次原子判断

3.5 “预减库存”不是最终库存

Redis 扣减成功,只表示用户拿到了一个“下单资格”或“库存令牌”,还不代表订单已经最终成功。

因此建议在业务上区分两种状态:

  • 预占成功:用户抢购成功,进入下单队列
  • 订单成功:订单创建并持久化完成

这个区分非常重要,它直接决定你的提示文案、客服口径、对账方式和补偿逻辑。

4. 异步削峰与订单解耦:让同步链路只做“最小闭环”

4.1 原理

秒杀请求不应该同步完成所有事情。
如果在用户点击“立即抢购”后,还同步做了落订单、扣数据库库存、写订单明细等一系列动作,那么吞吐上限会被每一步最慢的 IO 绑定。秒杀系统需要的不是“功能一步到位”,而是“核心结果先快速返回,重操作异步处理”。

4.2 标准链路

同步链路只保留三件事:

  1. 校验请求是否合法
  2. 原子扣减 Redis 预库存
  3. 投递 消息队列,返回“抢购成功,正在创建订单”

4.3 提交流程图

(此处省略流程图,专注于代码和逻辑阐述)

4.4 MQ 消息体设计

public class SeckillOrderMessage {
    private String requestId;
    private Long activityId;
    private Long skuId;
    private Long userId;
    private Long timestamp;
}

关键点:

  • requestId 用于全链路幂等
  • activityId + skuId + userId 是业务唯一语义
  • 消息体要足够小,避免大对象放大队列压力

4.5 生产级消费者处理

@Service
public class SeckillOrderConsumer {

    private final IdempotentService idempotentService;
    private final OrderApplicationService orderApplicationService;

    public SeckillOrderConsumer(IdempotentService idempotentService,
                                OrderApplicationService orderApplicationService) {
        this.idempotentService = idempotentService;
        this.orderApplicationService = orderApplicationService;
    }

    @KafkaListener(topics = "seckill-order", groupId = "seckill-order-group")
    public void onMessage(SeckillOrderMessage message) {
        String idempotentKey = "seckill:order:create:" + message.getRequestId();
        boolean first = idempotentService.tryStart(idempotentKey, Duration.ofMinutes(30));
        if (!first) {
            return;
        }

        try {
            orderApplicationService.createSeckillOrder(message);
            idempotentService.markSuccess(idempotentKey);
        } catch (DuplicateKeyException ex) {
            idempotentService.markSuccess(idempotentKey);
        } catch (Exception ex) {
            idempotentService.clear(idempotentKey);
            throw ex;
        }
    }
}

4.6 为什么异步链路也要幂等

因为消息系统至少会带来三类重复:生产端重试、Broker 重投、消费端处理成功但 ACK 丢失。所以生产级设计里,幂等不能只靠 Redis,数据库层也必须有唯一约束。

例如订单表上应建立:

  • uk_activity_user_sku(activity_id, user_id, sku_id)
  • uk_request_id(request_id)

5. 限流、熔断、降级与隔离:高并发系统首先学会“拒绝”

5.1 原理

在秒杀系统里,不是所有请求都值得处理。
如果系统上限是每秒处理 5 万次有效抢购,而入口在高峰期来了 30 万次请求,那么最佳策略不是“尽量都试一下”,而是快速、明确、分层地拒绝大部分请求。

5.2 四层限流模型

建议至少做四层保护:

  1. 边缘层限流:WAF、CDN、Nginx
  2. 网关层限流:用户级、设备级、接口级、SKU 级
  3. 应用层限流:线程池、信号量、热点参数限流
  4. 依赖层保护:Redis、MQ、DB 连接池保护

5.3 Sentinel 示例

@RestController
@RequestMapping("/api/seckill")
public class SeckillController {

    private final SeckillFacade seckillFacade;

    public SeckillController(SeckillFacade seckillFacade) {
        this.seckillFacade = seckillFacade;
    }

    @PostMapping("/submit")
    @SentinelResource(value = "submitSeckill", blockHandler = "blockHandler")
    public ApiResponse<SeckillSubmitResponse> submit(@RequestBody SeckillSubmitRequest request,
                                                     @RequestHeader("X-USER-ID") Long userId) {
        return ApiResponse.success(seckillFacade.submit(request, userId));
    }

    public ApiResponse<SeckillSubmitResponse> blockHandler(SeckillSubmitRequest request,
                                                           Long userId,
                                                           BlockException ex) {
        return ApiResponse.fail("系统繁忙,请稍后重试");
    }
}

5.4 服务隔离

秒杀不能和普通下单共用一套线程池、数据库连接池、Redis 连接池。
否则大促一来,不只是秒杀挂,连日常订单、支付查询、用户中心都会被拖垮。

建议至少做这些隔离:

  • 秒杀接入服务独立部署
  • 秒杀订单消费者独立消费组
  • 秒杀连接池参数独立
  • 秒杀 Redis 分片独立
  • 秒杀 MQ Topic 独立
  • 秒杀监控看板独立

5.5 降级策略

秒杀系统要预设降级档位,而不是事故时临时拍脑袋:

  • 一级降级:关闭评论、推荐、优惠券联动等非核心能力
  • 二级降级:关闭部分活动入口,只保留核心商品
  • 三级降级:直接返回“已售罄”或“排队中”
  • 四级降级:活动紧急熔断,人工介入

通过配置中心控制开关:

seckill:
  switches:
    token-check: true
    risk-check: true
    async-order: true
    allow-submit: true

6. 数据一致性与补偿机制:秒杀不是“0 失败”,而是“失败可恢复”

6.1 一致性边界

秒杀系统最容易出现误解的一点是:只要 Redis 扣减成功,就以为交易完成了。

实际上真正的业务闭环至少包括:Redis 预占库存成功、MQ 投递成功、订单创建成功、支付超时自动关闭或支付完成、库存最终核销与对账成功。其中任何一步失败,都可能形成状态不一致。

6.2 建议的数据状态模型

活动库存状态

  • INIT:未预热
  • READY:已预热,待开始
  • RUNNING:抢购中
  • SOLD_OUT:缓存库存售空
  • CLOSED:活动结束

订单状态

  • PENDING_CREATE:已抢到资格,等待订单落库
  • UNPAID:订单已创建,待支付
  • PAID:支付成功
  • CANCELLED:超时取消
  • FAILED:创建失败待补偿

6.3 本地消息表保障

如果你的 MQ 不支持事务消息,或者你不希望完全依赖 MQ 事务能力,可以采用“本地消息表 + 重试投递”的经典方案。

示例表结构

CREATE TABLE seckill_order_message (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    request_id VARCHAR(64) NOT NULL,
    activity_id BIGINT NOT NULL,
    sku_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    payload_json JSON NOT NULL,
    send_status TINYINT NOT NULL DEFAULT 0,
    retry_count INT NOT NULL DEFAULT 0,
    next_retry_time DATETIME NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_request_id (request_id),
    KEY idx_retry (send_status, next_retry_time)
);

事务写入

@Transactional
public void persistOrderTask(SeckillOrderMessage message) {
    seckillMessageRepository.insert(message);
}

后台任务定时扫描 send_status = 0next_retry_time <= now() 的记录,重新投递。

6.4 订单创建失败如何补偿

推荐方案:

  • 订单创建失败时记录失败原因
  • 对可重试异常进行有限次重试
  • 超过阈值进入死信队列
  • 补偿任务根据失败状态决定是否回补 Redis 库存

要注意:不是所有失败都可以直接回补库存。

如果消息重复消费导致数据库唯一键冲突,那么库存未必应该回补,因为业务上订单可能已经成功。因此回补前必须先核实订单是否存在、是否已支付、是否只是消息重复。

6.5 对账机制

秒杀系统必须有 T+0 和 T+1 两类对账:

  • T+0 实时对账:活动中发现库存异常、消息堆积、订单失败
  • T+1 批量对账:按日核对库存、订单、支付、退款

重点核对三组数据:

  1. Redis 预减量 vs 订单成功量
  2. 订单成功量 vs 支付成功量
  3. 取消订单回补量 vs 最终可售库存

7. 可扩展架构设计:从 1 场活动到 1000 个活动并行

7.1 为什么“扛住一次活动”不等于“具备平台能力”

很多系统第一次秒杀能抗住,是因为只开了 1 个 SKU、只在单地区放量、人工盯盘强控流。但如果系统要支持多场活动、多租户商家同时开秒杀、不同业务线共用能力中心,那么架构就必须从“单活动方案”升级成“平台化能力”。

7.2 领域拆分建议

建议至少拆出这些服务边界:

  • 活动中心:活动配置、规则、时间窗、资格规则
  • 秒杀接入层:令牌、限流、资格初筛、库存预减
  • 订单中心:订单创建、状态流转、支付联动
  • 库存中心:持久库存、冻结库存、回补库存
  • 风控中心:验证码、设备指纹、反刷、黑白名单
  • 对账中心:差异发现、自动补偿、人工工单

7.3 按热点拆分,而不是只按业务拆分

在秒杀系统里,热点治理比传统微服务拆分更重要。
所以你会看到一种典型架构:

  • 普通商品走标准交易链路
  • 秒杀商品走专用接入链路
  • 极端热点商品走独立集群或独立分片

这是一种“按流量特征分治”的架构思想。

7.4 分库分表建议

订单量足够大时,单表一定会成为瓶颈。
建议按用户维度或订单维度分库分表。

示例:

  • order_00 ~ order_63
  • 路由规则:user_id % 64

这样有两个好处:同一用户订单查询路由稳定,热点 SKU 不会把单张订单表打爆。库存表则通常不建议高频实时写,而是通过异步汇总或流水账方式更新。

7.5 容器化与弹性扩缩容

Kubernetes 只是“扩机器”的工具,不是性能魔法。
如果你的系统仍然同步重 IO、无缓存、无削峰,那么扩多少 Pod 都只是在放大雪崩。

合理的弹性策略应该建立在接入层无状态、Redis / MQ 可横向扩展、订单消费者可按分区扩容以及关键依赖有就绪探针和预热机制的基础之上。

HPA 示例

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: seckill-access-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: seckill-access
  minReplicas: 4
  maxReplicas: 80
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 60
  - type: Pods
    pods:
      metric:
        name: app_requests_per_second
      target:
        type: AverageValue
        averageValue: "1500"

7.6 冷启动与预热

很多团队扩容失败,不是因为 Pod 起不来,而是新 Pod 刚接流量时 JIT 未预热、本地缓存为空、连接池尚未建立、配置还没同步完成。所以生产上需要在流量接入前完成热点配置预加载、连接池初始化、Lua 脚本加载、JVM 预热和链路健康检查。

四、生产级代码方案:一条可落地的秒杀主流程

下面给出一套更接近生产的主流程设计。

4.1 提交接口入参

public class SeckillSubmitRequest {
    @NotNull
    private Long activityId;

    @NotNull
    private Long skuId;

    @NotBlank
    private String token;

    @NotBlank
    private String requestId;
}

4.2 门面服务

@Service
public class SeckillFacade {

    private final SeckillTokenService tokenService;
    private final SeckillStockService stockService;
    private final SeckillMessageProducer messageProducer;

    public SeckillFacade(SeckillTokenService tokenService,
                         SeckillStockService stockService,
                         SeckillMessageProducer messageProducer) {
        this.tokenService = tokenService;
        this.stockService = stockService;
        this.messageProducer = messageProducer;
    }

    public SeckillSubmitResponse submit(SeckillSubmitRequest request, Long userId) {
        tokenService.validateAndConsume(request.getActivityId(), request.getSkuId(), userId, request.getToken());

        DeductResult deductResult = stockService.deduct(
                request.getActivityId(),
                request.getSkuId(),
                userId,
                request.getRequestId()
        );

        if (!deductResult.isSuccess()) {
            return SeckillSubmitResponse.failed(deductResult.getMessage());
        }

        SeckillOrderMessage message = new SeckillOrderMessage();
        message.setRequestId(request.getRequestId());
        message.setActivityId(request.getActivityId());
        message.setSkuId(request.getSkuId());
        message.setUserId(userId);
        message.setTimestamp(System.currentTimeMillis());
        messageProducer.send(message);

        return SeckillSubmitResponse.accepted("抢购成功,正在创建订单");
    }
}

4.3 令牌服务

令牌机制的作用不是加密,而是把可提交资格收口到服务端控制之下

@Service
public class SeckillTokenService {

    private final StringRedisTemplate redisTemplate;

    public SeckillTokenService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void validateAndConsume(Long activityId, Long skuId, Long userId, String token) {
        String key = "seckill:token:" + activityId + ":" + skuId + ":" + userId;
        String cached = redisTemplate.opsForValue().get(key);
        if (cached == null || !cached.equals(token)) {
            throw new BizException("请求令牌无效");
        }
        Boolean deleted = redisTemplate.delete(key);
        if (!Boolean.TRUE.equals(deleted)) {
            throw new BizException("请求令牌已失效");
        }
    }
}

4.4 订单创建事务

@Service
public class OrderApplicationService {

    private final OrderRepository orderRepository;

    public OrderApplicationService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void createSeckillOrder(SeckillOrderMessage message) {
        if (orderRepository.existsByRequestId(message.getRequestId())) {
            return;
        }

        OrderEntity entity = new OrderEntity();
        entity.setRequestId(message.getRequestId());
        entity.setActivityId(message.getActivityId());
        entity.setSkuId(message.getSkuId());
        entity.setUserId(message.getUserId());
        entity.setOrderStatus("UNPAID");
        entity.setOrderSource("SECKILL");
        orderRepository.save(entity);
    }
}

4.5 订单表设计

CREATE TABLE t_order_00 (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    request_id VARCHAR(64) NOT NULL,
    activity_id BIGINT NOT NULL,
    sku_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    order_status VARCHAR(32) NOT NULL,
    order_source VARCHAR(32) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_request_id (request_id),
    UNIQUE KEY uk_activity_user_sku (activity_id, user_id, sku_id),
    KEY idx_user_ctime (user_id, created_at)
);

这两个唯一键分别解决了请求幂等和单用户单商品单活动只下一单的问题。

五、真实业务场景拆解:不是“能扣库存”就够了

场景 1:活动开始瞬间 20 万人同时抢 1000 件商品

目标

  • 大部分用户在 100ms 到 300ms 内获得明确反馈
  • 核心服务不被拖死
  • 无超卖

关键策略

  • 静态页 + 令牌机制
  • Redis 原子预减
  • MQ 异步下单
  • 提示语义区分“抢购成功”和“订单成功”

常见错误

  • 用户看到“成功”其实只是接口返回成功,订单后来失败
  • 售罄提示过慢,导致无效请求持续打满系统

场景 2:同一用户多端重复点击、重复提交

风险

  • 用户 App 点一次,前端重试一次,网关重试一次,最终进来 3 个请求
  • 同一账号在手机和 PC 同时提交

解决

  • 接口层要求 requestId
  • Redis 原子去重
  • MQ 消费幂等
  • 数据库唯一约束兜底

这个场景的核心思想是:幂等必须分层设计,不能指望某一层单独解决所有重复问题。

场景 3:用户抢到后一直不支付,库存被长期占住

两种主流策略

  1. 抢到即建单,支付超时自动取消并回补
  2. 抢到只占资格,用户确认后再建单

电商秒杀通常更偏向第一种,因为用户希望“先抢先占”。
这时要配合订单超时关闭任务、回补库存规则和已售罄状态重新开放逻辑。

关键注意

如果活动库存非常少,而支付超时配置过长,会造成“表面售罄,实际支付率很低”的问题。因此活动策略要结合业务目标设置,例如:3 分钟支付超时、取消订单统一异步回补、回补库存是否重新放量由运营策略决定。

场景 4:Redis 扣减成功,但 MQ 投递失败

风险

  • 用户以为抢到了
  • Redis 已少 1
  • 订单却没有创建

解决

  • MQ 事务消息
  • 本地消息表
  • 投递失败补偿扫描
  • 异常订单修复任务

这个问题就是为什么我们前面强调:Redis 预减成功不等于交易完成。

场景 5:极热 SKU 导致 Redis 单分片打满

风险

  • 其他商品也跟着受影响
  • 整个 Redis 集群吞吐下降

解决思路

  • 热点 SKU 独立分片
  • 提前识别热点商品
  • 网关按 SKU 维度限流
  • 必要时引入分桶库存

对于千万级流量大促,热点治理往往比“是否使用 Redis Cluster”更重要。

六、流程知识:活动前、中、后的完整运营与技术协同

秒杀系统不是只靠代码上线就能稳定运行,它高度依赖流程化治理。

6.1 活动前流程

技术侧

  1. 活动配置审核
  2. 库存预热到 Redis
  3. 热点商品识别与路由绑定
  4. Lua 脚本校验与预加载
  5. 压测验证入口、Redis、MQ、DB 指标
  6. 监控大盘与告警阈值确认
  7. 降级开关演练
  8. 补偿任务、对账任务联调

运营侧

  1. 活动时间确认
  2. 商品池确认
  3. 放量节奏确认
  4. 异常预案与客服口径确认

推荐检查清单

  • 页面是否已静态化并完成 CDN 预热
  • 热点 SKU 是否已单独限流
  • Redis 库存与数据库库存是否一致
  • MQ 积压阈值与死信策略是否验证
  • 支付超时回补逻辑是否演练
  • 配置中心开关是否可实时生效

6.2 活动中流程

重点盯盘指标

  • 网关 QPS、拒绝率、RT P99
  • Redis CPU、命中率、热点 Key、慢查询
  • Lua 执行耗时
  • MQ 生产成功率、消费延迟、积压量
  • 订单创建成功率
  • 支付转化率
  • 售罄时间与回补量

异常处理顺序

  1. 先保主链路
  2. 再限热点流量
  3. 再关非核心功能
  4. 最后考虑临时扩容

为什么扩容排在后面?因为很多秒杀事故不是“机器不够”,而是“架构没有削峰,扩容只是延后崩溃”。

6.3 活动后流程

必做事项

  1. 对账
  2. 异常订单修复
  3. 退款与回补检查
  4. 热点指标复盘
  5. 配置清理
  6. 容量模型修正

复盘要回答的问题

  • 真正瓶颈在入口、缓存、队列还是数据库?
  • 用户失败主要发生在哪一层?
  • 是否出现局部热点倾斜?
  • 降级动作是否及时有效?
  • 哪些告警应该提前 5 分钟触发?

七、监控与压测:没有可观测性,就没有生产级秒杀

7.1 必备指标体系

业务指标

  • 抢购请求总量
  • 抢购成功率
  • 重复请求率
  • 售罄时间
  • 订单创建成功率
  • 支付成功率

系统指标

  • 接口 RT P50 / P95 / P99
  • JVM CPU、GC、线程池队列长度
  • Redis QPS、CPU、网络吞吐、慢日志
  • MQ 积压量、消费延迟、失败重试数
  • DB 连接池占用、慢 SQL、死锁次数

治理指标

  • 限流触发次数
  • 降级开关开启次数
  • 熔断次数
  • 补偿任务执行成功率
  • 对账差异条数

7.2 压测不是只压接口

生产级压测至少包括:页面访问压测、令牌发放压测、秒杀提交压测、Redis Lua 压测、MQ 消费压测、订单落库压测以及支付回调与超时取消压测。

7.3 容量评估公式

一个简单估算思路:

  • 假设活动峰值 UV 为 100 万
  • 10% 用户在 10 秒内集中点击
  • 则入口峰值约为 10 万请求每秒
  • 如果只有 5% 请求最终进入有效扣减,核心库存链路峰值约为 5000 到 10000 QPS

也就是说,入口流量和核心交易流量不是一个量级。架构设计的关键就是把这两个量级彻底分开。

八、从单体到百万 QPS 的 7 步演进路线

下面给出一条典型演进路径。

第 1 步:单体应用 + MySQL 直连库存

特征:开发快、上线快、几百到几千 QPS 就会触顶。
问题:DB 成为瓶颈、超卖概率高、事务链路过长。

第 2 步:引入 Redis 缓存库存

收益:查询压力从 DB 转移到 Redis、系统吞吐明显提升。
问题:如果没有原子扣减,仍会超卖。

第 3 步:Lua 原子扣减 + 单人限购

收益:解决核心并发竞争、减少重复下单。
问题:同步落单仍然限制吞吐。

第 4 步:接入 MQ 异步削峰

收益:接口 RT 大幅下降、订单创建与抢购入口解耦。
问题:开始引入一致性和幂等复杂度。

第 5 步:分层限流 + 服务隔离 + 降级

收益:活动高峰不再拖垮主站、系统开始具备稳定性工程能力。

第 6 步:分库分表 + 容器弹性 + 可观测性

收益:支撑多活动并发、支撑更高订单吞吐、具备体系化治理能力。

第 7 步:单元化、多机房、平台化能力中心

收益:接近百万级甚至更高流量场景、支持多业务线复用、真正具备大促级平台能力。

这 7 步里,真正重要的不是技术名词本身,而是每一步都在回答:这一层的瓶颈是什么?应该把流量挡在哪里?应该把状态收敛到哪里?应该把失败恢复到哪里的问题。

九、常见误区总结

误区 1:用了 Redis 就不会超卖

错,只有原子扣减才有资格谈防超卖。

误区 2:用了 MQ 就天然高并发

错,如果消费端落库慢、无幂等、无重试治理,MQ 只会把同步故障改成延迟故障。

误区 3:秒杀系统的关键是机器多

错,没有前置拦截、没有削峰、没有隔离,再多机器也只是更大范围地一起崩。

误区 4:用户看到“抢购成功”就代表订单成功

错,必须区分资格成功、排队成功、订单成功、支付成功。

误区 5:活动结束后系统就没事了

错,真正考验工程能力的,往往是活动后的对账、回补、异常修复和复盘。

十、结语:秒杀系统不是一个功能点,而是一套稳定性工程

秒杀系统最容易被低估的地方在于,它表面看只是一个“库存很少、流量很大”的抢购场景,实际上却要求系统同时具备极强的瞬时承压能力、清晰的核心一致性边界以及分层限流与快速失败能力。同样不可忽视的,还包括完整的异步补偿、面向热点的扩展与隔离,以及覆盖活动前、中、后的流程化治理能力。

如果只记一句,那么这个结论依然成立:秒杀系统的本质,不是“让更多请求成功”,而是“让有限的成功请求以可控、可恢复、可验证的方式成功”。

当你能把下面这条链路真正打通——页面静态化、令牌控制、原子扣减、异步下单、幂等落库、支付回补、对账修复——那你的系统才算真正迈过了从 Demo 到生产的门槛。

附:一份可直接复用的秒杀系统上线检查表

架构层

  • 页面已静态化并完成 CDN 预热
  • 秒杀链路与普通交易链路已隔离
  • Redis / MQ / DB 已完成容量评估
  • 热点 SKU 有单独限流与监控

代码层

  • Redis 扣减使用 Lua 原子脚本
  • 接口、消息、数据库三层幂等均已实现
  • 订单创建、取消、回补链路已联调
  • 死信队列和重试机制已验证

治理层

  • 降级开关可动态生效
  • 告警阈值已按活动峰值调整
  • 活动中盯盘与应急负责人明确
  • 活动后对账、复盘流程已准备

体验层

  • 用户提示文案区分“抢购成功”和“订单成功”
  • 查询订单状态接口可快速反馈
  • 售罄、排队、失败三类结果语义清晰

关键词:秒杀系统、高并发架构、Redis Lua、异步削峰、库存预减、幂等设计、限流降级、订单补偿、分库分表、Kubernetes、生产级架构




上一篇:C++虚继承下的对象sizeof:Itanium与MSVC的分歧根源
下一篇:grill-me 技能:一份 AI 编程“拷问”指南,专治 Vibe Coding 返工重来
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-7-28 04:25 , Processed in 1.187926 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表