Posted on ::

秒杀系统,10万人抢10件商品,背后是一整套复杂的系统设计问题:

  • 如何保证不超卖
  • 如何防止重复下单
  • 如何在高并发下保持高吞吐
  • 如何避免系统被瞬间流量击垮
  • 如何从单机平滑演进到分布式架构

这篇文章我会从一段简单的秒杀系统问题代码出发,一步步拆解问题,完成三次关键演进:

单机错误实现 → 单机高并发优化 → 分布式 + Redis → 限流 & 削峰体系

原始实现(问题代码)

public class SeckillService {

    // 库存
    private int stock = 10;

    // 已购买用户记录
    private List<String> userList = new ArrayList<>();

    // 秒杀方法
    public void doSeckill(String userId) {

        // 1. 判断用户是否已经买过
        for (int i = 0; i < userList.size(); i++) {
            if (userList.get(i).equals(userId)) {
                System.out.println("用户已购买,拒绝重复下单");
                return;
            }
        }

        // 2. 判断库存
        if (stock > 0) {
            // 3. 扣减库存
            stock--;

            // 4. 记录购买用户
            userList.add(userId);

            System.out.println("秒杀成功,剩余库存:" + stock);
        } else {
            System.out.println("库存不足,秒杀失败");
        }
    }
}

一个标准秒杀流程,本质是三个操作的组合:

查重(是否买过) + 扣库存 + 记录用户

技术的核心难点在于:如何在高并发下保证这三个操作的原子性 + 一致性 + 高性能

分析这段代码,存在 4 个致命缺陷:

  1. Check-then-Act 竞态条件导致超卖

    • 代码中的 if (stock > 0) { stock--; } 在底层分多步执行,不满足原子性。高并发下,假设库存仅剩 1 个,上千个线程同时查到 stock=1 并发执行扣减,会把库存扣成负数
  2. 并发修改异常与数据丢失(线程安全)

    • 使用了非线程安全的 ArrayList 记录用户。多线程并发执行 add() 时,极易引发底层数组越界、抛出 ConcurrentModificationException 异常;而且会发生数据相互覆盖,导致大量已购用户记录丢失,无法防住重复购买
  3. 接口性能问题(O(n) 的时间复杂度)

    • for 循环遍历 List 查重。随着购买的用户增加,每次请求的遍历耗时将逐步递增。会瞬间吃满服务器 CPU,导致系统吞吐量(QPS)下跌
  4. 单机架构局限

    • stockuserList 被硬编码在单台机器的 JVM 堆内存中。在分布式集群下,节点间状态无法共享。

V1.0 改造(单机高并发防超卖)

  1. 使用 volatile 保值 stock 变量的可见性,避免重排序
  2. 使用 ConcurrentHashMap.newKeySet() 替换 ArrayList,保证线程安全,并将查询时间复杂度降为 O(1)
  3. 引入 ReentrantLock 进行双重检查锁定(DCL),在保证原子性的同时最大化性能
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SeckillServiceV1 {
    // volatile 保证多线程下的内存可见性
    private volatile int stock = 10;
    // 线程安全的 Set,查询复杂度 O(1)
    private Set<String> userSet = ConcurrentHashMap.newKeySet();
    private final Lock lock = new ReentrantLock();

    public void doSeckill(String userId) {
        // 第一重拦截:无锁判断,快速失败 (极其重要,挡掉绝大部分无效请求)
        if (stock <= 0 || userSet.contains(userId)) {
            System.out.println("商品已售罄或您已购买过");
            return;
        }

        lock.lock();
        try {
            // 第二重拦截:防止排队等待的线程在获取锁后重复购买或超卖
            if (stock <= 0 || !userSet.add(userId)) {
                System.out.println("商品已售罄或您已购买过");
                return;
            }
            
            // 真正安全的扣减库存
            stock--;
            System.out.println("秒杀成功,剩余库存: " + stock);
        } finally {
            lock.unlock();
        }
    }
}
  1. 为什么不使用 Synchronized 或 Redis 分布式锁

    1. Synchronized 能用,但在高并发场景下性能和可控性不够好(不能中断、不能设置超时、不能公平锁、无法感知锁状态),系统容易被锁等待阻塞拖垮
    2. 分布式锁的网络 + IO 太重,并且复杂性高。本质上不需要“锁”,而是需要原子性,因此使用 Lua 脚本 原子执行 是更优解
  2. 为什么使用 volatile 而不是 AtomicInteger?

    在并发编程中,像 stock-- 这样的一行代码,在 CPU 指令层面其实分为三个独立的步骤:

    1. Read(读):从主内存读取 stock 的值到 CPU 本地缓存。
    2. Modify(改):在 CPU 寄存器内将值减 1。
    3. Write(写):将减去 1 的新值写回主内存。

    给变量加上 volatile 后,相当于加上了硬件级别的内存屏障。它强制要求线程每次读取都必须去主内存拿最新值,每次修改也必须立刻刷新回主内存。它打破了多核 CPU 的本地缓存黑盒,让所有线程的数据保持同步。但是 volatile 无法保证原子性

    AtomicInteger 是原子类,内部也维护了一个 volatile int value ,他的累加或递减操作(如 decrementAndGet())基于 CAS 指令

    真实的秒杀逻辑是 【查重防重 + 扣减库存 + 记录用户】 这 3 个动作的组合。AtomicInteger 只能保证库存扣减是安全的。但它无法保证多个独立变量(库存 + 用户集合)组合在一起依然是原子的,如果不加锁,同一个用户同时发起两个请求,依然有可能导致超卖,示例代码如下:

    // 错误示范
    private AtomicInteger stock = new AtomicInteger(10);
    private Set<String> userSet = ConcurrentHashMap.newKeySet();
    
    public void doSeckill(String userId) {
        if (userSet.contains(userId)) return;     // 动作1:判断是否重复下单
        
        // 假设还剩最后 1 个库存,同一用户利用脚本发送并发请求 A 和 B,同时来到这里,都发现自己还没买过。
        
        if (stock.decrementAndGet() >= 0) {       // 动作2:A 和 B 都在极其安全地扣库存
            userSet.add(userId);                  // 动作3:记录用户
            System.out.println("秒杀成功");
        }
    }
    

    因此,既然业务要求必须保护多个变量的组合状态一致性,就必须在外部加互斥锁(ReentrantLock 或 Redis 分布式锁)

    • 有了互斥锁,代码块内部天然就串行化了,再用 AtomicInteger 去扣库存就多余了,白白增加 CAS 自旋的性能开销。普通的 int 就足够了。
    • 而把 stock 声明为 volatile,是为了在锁的最外层,配合 if (stock <= 0) 提供极速的可见性判断。一旦库存归零,锁外排队的几万个线程瞬间就能“看见”,直接 return 拦截掉,连抢锁的资格都不用给。这就是大名鼎鼎的 Double-Checked Locking(DCL,双重检查锁定)

    AtomicInteger 的使用场景:

    1. 纯粹的高并发计数:统计某个 API 的 QPS 请求总数、记录网站的 UV、生成自增的单据号。

    2. 无限制的库存扣减:如果业务仅仅只有扣减库存这一件事(不需要判断单个用户是否重复购买,不查重),就是纯粹的卖 10 万个商品谁抢到算谁的,单机环境下直接用 AtomicInteger 性能最高,完全不用加任何锁。

      其实在极高并发的单纯计数场景下(比如统计双十一当天的订单总数),连 AtomicInteger 都不推荐使用。 因为当上千个线程同时 CAS 争抢同一个 AtomicInteger 时,会导致大量线程失败并陷入死循环(自旋),白白打满 CPU。 在 JDK 8 之后,我会首选 LongAdder。它底层采用了分段锁的思想,将单一的热点数据分散到了一个 Cell 数组中,让不同的线程去修改不同的槽位,最后再把各个槽位的值求和。这在极端高并发下,性能比 AtomicInteger 更高

V2.0 分布式高并发(引入 Redis + Lua)

当应用部署多台机器时,状态(库存、购买记录)必须从 JVM 外移到分布式缓存 Redis 中。必须使用 Lua 脚本,利用 Redis 单线程执行脚本的特性,保证多条指令的绝对原子性。

  1. 编写 Lua 脚本 (seckill.lua):

    local stockKey = KEYS[1]
    local userSetKey = KEYS[2]
    local userId = ARGV[1]
    
    -- 1. 判断是否重复购买 (SISMEMBER, O(1))
    if redis.call('sismember', userSetKey, userId) == 1 then
        return -1 -- 返回-1代表重复下单
    end
    
    -- 2. 判断库存是否大于0
    local stock = tonumber(redis.call('get', stockKey))
    if stock ~= nil and stock > 0 then
        -- 3. 扣减库存 & 记录用户
        redis.call('decr', stockKey)
        redis.call('sadd', userSetKey, userId)
        return 1 -- 返回1代表秒杀成功
    end
    
    return 0 -- 返回0代表库存不足
    
  2. Java 核心调用

    @Service
    public class SeckillServiceV2 {
        @Autowired
        private StringRedisTemplate redisTemplate;
    
        public String doSeckill(String userId, String goodsId) {
            // 执行 Lua 脚本,天生解决分布式超卖问题
            Long result = redisTemplate.execute(seckillScript, 
                    Arrays.asList("stock:" + goodsId, "users:" + goodsId), userId);
    
            if (result == -1L) return "您已参与过该活动";
            if (result == 0L) return "手慢了,商品已被抢光";
            
            // 【注意】拿到资格后,千万不能在这里同步写 MySQL 创建订单!
            return "秒杀成功,正在排队生成订单..."; 
        }
    }
    

Redis 单点能抗住大几万的 QPS,解决分布式超卖。但如果面对几十上百万的瞬时流量,Redis 网卡也会被打满,我们还需要最后一层终极演进。

V3.0 限流、削峰和数据库兜底

我们要在请求到达 DB 之前,设立多道防线

  1. 前端与网关层(阻挡恶意和多余流量)

    • 前端防护:点击按钮立刻置灰(防手抖连点);秒杀链接动态下发(防止黑客用脚本提前刷接口)。

    • 网关限流:使用 Sentinel 或 Nginx,按 IP 和 UserID 进行严格限流(如单用户1秒最多放行1次请求),被限流的请求直接触发降级,返回“活动太火爆”。

  2. 防线二:JVM 本地内存标记

    10 万人抢 10 部手机,当库存为 0 后,剩下的 99,990 个请求再去查 Redis 是毫无意义的,白白浪费网络带宽。

    • 优化方案:在 Java 服务中维护一个 volatile boolean isSoldOut = false; 或本地 Cache。一旦 Redis Lua 返回库存不足,立刻将本地标记设为 true。后续的海量请求直接在 Java 内存层被拦截,以 0 成本保护下游的 Redis
  3. MQ 异步解耦削峰(保护数据库)

    Redis 扣减成功只代表用户获得了购买资格。如果此时去同步操作 MySQL 甚至加事务,MySQL 会瞬间宕机。

    • 优化方案:将获得资格的 userIdgoodsId 封装成消息投递到 MQ,给前端快速响应“排队中”。

    • 后台的订单服务(消费者)按照 MySQL 能够平稳承受的速率(例如 1000 TPS),慢慢消费消息并创建订单。

  4. 数据库兜底(防击穿)

    • 防重复下单:订单表添加唯一索引 UNIQUE KEY(user_id, goods_id)

    • 防超卖(乐观锁):真正扣减物理库存时:UPDATE goods SET stock = stock - 1 WHERE id = #{goodsId} AND stock > 0;

Table of Contents