秒杀场景下的MySQL优化:从崩溃到抗住100万QPS

摘要:从一次双十一秒杀翻车事故出发,揭秘MySQL在高并发场景下的优化演进路径。从最简单的UPDATE语句到Redis+MQ+分库分表的终极方案,通过5个完整的代码实现和真实压测数据,展示如何从100 QPS到100万QPS的性能飞跃。深度剖析超卖问题的6种死法、库存扣减的技术选型、以及大厂秒杀架构的核心设计思路。


💥 翻车现场

2024年11月11日,凌晨0点。

哈吉米盯着监控大屏,手心全是汗。

这是他负责的第一次双十一秒杀活动------iPhone 16 Pro,原价9999,秒杀价1元,库存100台。

"3...2...1...开抢!"

运营总监话音刚落,告警就炸了:

erlang 复制代码
🚨 MySQL连接池耗尽!
🚨 数据库CPU 100%!
🚨 订单服务响应超时!
🚨 用户反馈:一直转圈圈!

30秒后,更糟糕的消息来了:

客服主管 :有用户反馈抢到了,但扣款失败?
运营总监 :后台显示卖出去127台!库存明明只有100台!
技术总监:@哈吉米 马上回滚!查超卖原因!

紧急回滚后,哈吉米打开代码,看到这段"致命"的SQL:

java 复制代码
@Transactional
public boolean createOrder(Long userId, Long productId) {
    // 1. 查询库存
    Stock stock = stockMapper.selectById(productId);
    if (stock.getNum() <= 0) {
        return false;
    }
    
    // 2. 扣减库存
    stock.setNum(stock.getNum() - 1);
    stockMapper.updateById(stock);
    
    // 3. 创建订单
    Order order = new Order();
    order.setUserId(userId);
    order.setProductId(productId);
    orderMapper.insert(order);
    
    return true;
}

哈吉米:"这代码有问题吗?不是都加了事务吗?"

第二天早上,南北绿豆和阿西噶阿西来了。

阿西噶阿西 :"你这代码,100 QPS就能搞崩!"
南北绿豆 :"而且妥妥的超卖!我数数,至少5个致命问题。"
哈吉米:"???"


🤔 为什么最简单的方案会崩溃?

南北绿豆在白板上画了一个时序图。

问题1:查询和更新不是原子操作

css 复制代码
时间线:
-----------------------------------------------------
线程A:查库存=100 ✅                              
线程B:           查库存=100 ✅                   
线程C:                       查库存=100 ✅
线程A:                               扣减库存=99
线程B:                                   扣减库存=99 ❌(覆盖了A的操作)
线程C:                                       扣减库存=99 ❌

结果:3个请求过来,库存只减了1!

阿西噶阿西 :"虽然加了 @Transactional,但查询和更新之间有时间窗口,并发一来就乱套了!"

问题2:大量无效查询打爆数据库

java 复制代码
// 100个库存,10万个请求进来
// 前100个请求:查到库存 > 0,继续处理
// 后99900个请求:也在查库存!(库存已经是0了)

SELECT * FROM stock WHERE product_id = 1;  -- 执行了10万次!

南北绿豆:"库存卖完后,后面9万9千个请求还在疯狂查询,数据库能不挂吗?"

问题3:行锁竞争导致大量超时

sql 复制代码
-- 事务A持有行锁
UPDATE stock SET num = num - 1 WHERE id = 1;

-- 事务B等待
UPDATE stock SET num = num - 1 WHERE id = 1;  -- 等待中...

-- 事务C等待
UPDATE stock SET num = num - 1 WHERE id = 1;  -- 等待中...

-- ...10万个事务都在等这把锁

哈吉米:"卧槽,所以10万个事务排队更新同一行数据?"

阿西噶阿西:"对!而且大部分事务超时后会回滚,又占用资源,恶性循环!"


🔥 方案演进:从100 QPS到100万QPS

南北绿豆:"我们一步步优化,看看每个方案能抗住多少QPS。"

准备工作:测试环境和表结构

sql 复制代码
-- 库存表
CREATE TABLE stock (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  product_id BIGINT NOT NULL UNIQUE,
  num INT NOT NULL DEFAULT 0,
  version INT NOT NULL DEFAULT 0,  -- 乐观锁版本号
  create_time DATETIME NOT NULL,
  update_time DATETIME NOT NULL,
  INDEX idx_product_id(product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 订单表
CREATE TABLE `order` (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_no VARCHAR(32) NOT NULL UNIQUE,
  user_id BIGINT NOT NULL,
  product_id BIGINT NOT NULL,
  status TINYINT NOT NULL DEFAULT 0,  -- 0:待支付 1:已支付 2:已取消
  create_time DATETIME NOT NULL,
  INDEX idx_user_id(user_id),
  INDEX idx_product_id(product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 初始化测试数据
INSERT INTO stock (product_id, num, version, create_time, update_time)
VALUES (1001, 100, 0, NOW(), NOW());

压测工具 :JMeter 5.5 硬件配置

  • MySQL 8.0,4核8G,SSD
  • 应用服务器:8核16G
  • 网络:千兆局域网

方案1️⃣:先查后改(翻车版)

java 复制代码
@Service
public class SeckillServiceV1 {
    
    @Autowired
    private StockMapper stockMapper;
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Transactional(rollbackFor = Exception.class)
    public boolean createOrder(Long userId, Long productId) {
        // 1. 查询库存
        Stock stock = stockMapper.selectByProductId(productId);
        if (stock == null || stock.getNum() <= 0) {
            return false;
        }
        
        // 2. 扣减库存
        stock.setNum(stock.getNum() - 1);
        stockMapper.updateById(stock);
        
        // 3. 创建订单
        Order order = new Order();
        order.setOrderNo(generateOrderNo());
        order.setUserId(userId);
        order.setProductId(productId);
        order.setCreateTime(new Date());
        orderMapper.insert(order);
        
        return true;
    }
}

压测结果

bash 复制代码
# JMeter配置:1000线程,持续10秒
Thread Group: 1000 threads
Ramp-up: 1s
Duration: 10s

结果:
- 总请求数:10000
- 成功数:127(超卖了27台!)
- 失败数:9873
- 平均响应时间:8500ms
- TPS:12.7(惨不忍睹)
- MySQL CPU:100%
- 连接池:耗尽

问题分析

问题 原因 影响
超卖 查询和更新不是原子操作 严重
大量行锁等待 严重
连接池耗尽 事务等待时间过长 严重
CPU 100% 10万次无效查询 严重

南北绿豆:"这方案就是个反面教材,100 QPS都扛不住!"


方案2️⃣:悲观锁 SELECT FOR UPDATE

阿西噶阿西:"既然查询和更新有时间窗口,那就用悲观锁,一次性锁住!"

java 复制代码
@Service
public class SeckillServiceV2 {
    
    @Autowired
    private StockMapper stockMapper;
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Transactional(rollbackFor = Exception.class)
    public boolean createOrder(Long userId, Long productId) {
        // 1. 查询库存并加锁(悲观锁)
        Stock stock = stockMapper.selectByProductIdForUpdate(productId);
        if (stock == null || stock.getNum() <= 0) {
            return false;
        }
        
        // 2. 扣减库存
        stock.setNum(stock.getNum() - 1);
        stockMapper.updateById(stock);
        
        // 3. 创建订单
        Order order = new Order();
        order.setOrderNo(generateOrderNo());
        order.setUserId(userId);
        order.setProductId(productId);
        order.setCreateTime(new Date());
        orderMapper.insert(order);
        
        return true;
    }
}

Mapper实现

xml 复制代码
<!-- StockMapper.xml -->
<select id="selectByProductIdForUpdate" resultType="Stock">
    SELECT * FROM stock 
    WHERE product_id = #{productId}
    FOR UPDATE  <!-- 悲观锁 -->
</select>

原理解析

css 复制代码
FOR UPDATE的执行流程:
1. 事务A执行 SELECT ... FOR UPDATE,给这一行加排他锁
2. 事务B执行 SELECT ... FOR UPDATE,等待事务A释放锁
3. 事务A提交,释放锁
4. 事务B获取锁,继续执行

优点:绝对不会超卖(串行执行)
缺点:并发退化成串行,吞吐量低

压测结果

bash 复制代码
# JMeter配置:1000线程,持续10秒
结果:
- 总请求数:10000
- 成功数:100(正确!不超卖了)
- 失败数:9900
- 平均响应时间:3200ms
- TPS:31(比方案1好3倍)
- MySQL CPU:60%
- 无超卖问题 ✅

优化效果

指标 方案1 方案2 提升
是否超卖 解决
TPS 12.7 31 2.4倍
响应时间 8500ms 3200ms 2.7倍

哈吉米:"好多了!但31 QPS还是太低了,双十一肯定扛不住啊!"

南北绿豆:"对,悲观锁的问题是:10万个请求排队等一把锁,99%的请求都在等待。"


方案3️⃣:乐观锁 + 版本号

阿西噶阿西:"悲观锁太悲观了,咱们试试乐观锁!"

java 复制代码
@Service
public class SeckillServiceV3 {
    
    @Autowired
    private StockMapper stockMapper;
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Transactional(rollbackFor = Exception.class)
    public boolean createOrder(Long userId, Long productId) {
        // 1. 查询库存(不加锁)
        Stock stock = stockMapper.selectByProductId(productId);
        if (stock == null || stock.getNum() <= 0) {
            return false;
        }
        
        // 2. 乐观锁扣减库存(CAS操作)
        int updated = stockMapper.decreaseStockWithVersion(
            productId, 
            stock.getVersion()
        );
        
        // 3. 如果更新失败,说明库存被别人改了
        if (updated == 0) {
            return false;  // 快速失败,不重试
        }
        
        // 4. 创建订单
        Order order = new Order();
        order.setOrderNo(generateOrderNo());
        order.setUserId(userId);
        order.setProductId(productId);
        order.setCreateTime(new Date());
        orderMapper.insert(order);
        
        return true;
    }
}

关键SQL

xml 复制代码
<!-- StockMapper.xml -->
<update id="decreaseStockWithVersion">
    UPDATE stock 
    SET num = num - 1,
        version = version + 1,
        update_time = NOW()
    WHERE product_id = #{productId}
      AND version = #{version}  <!-- 乐观锁关键:版本号必须匹配 -->
      AND num > 0               <!-- 防止库存扣成负数 -->
</update>

原理解析

ini 复制代码
乐观锁的执行流程:
1. 事务A读取:num=100, version=0
2. 事务B读取:num=100, version=0
3. 事务A更新:WHERE version=0(成功,version变成1)
4. 事务B更新:WHERE version=0(失败,因为version已经是1了)
5. 事务B快速失败,返回"秒杀失败"

优点:并发高,不阻塞
缺点:失败率高(CAS冲突)

压测结果

bash 复制代码
# JMeter配置:1000线程,持续10秒
结果:
- 总请求数:10000
- 成功数:100(正确!)
- 失败数:9900
- 平均响应时间:850ms(大幅下降!)
- TPS:118(提升4倍)
- MySQL CPU:45%
- 无超卖问题 ✅

优化效果

指标 方案2 方案3 提升
TPS 31 118 3.8倍
响应时间 3200ms 850ms 3.7倍
MySQL CPU 60% 45% 降低25%

哈吉米:"好多了!但还是只有118 QPS,怎么到10万、100万?"

南北绿豆:"因为瓶颈还在MySQL!10万个请求都在打数据库,数据库是单点!"

阿西噶阿西:"接下来,我们要把流量挡在MySQL外面!"


方案4️⃣:Redis预扣 + 异步入库

南北绿豆:"核心思路:用Redis抗流量,MySQL只负责最终入库。"

架构图

arduino 复制代码
用户请求(10万QPS)
     ↓
 【限流】Sentinel(限流到5万)
     ↓
 【预扣】Redis DECR(扣减库存)
     ↓
 成功 → 发MQ消息 → 消费者 → 写MySQL
     ↓
 失败 → 直接返回"秒杀失败"

完整代码实现

java 复制代码
@Service
public class SeckillServiceV4 {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    private static final String STOCK_KEY_PREFIX = "stock:";
    
    /**
     * 初始化库存到Redis
     */
    public void initStock(Long productId, Integer num) {
        String key = STOCK_KEY_PREFIX + productId;
        redisTemplate.opsForValue().set(key, String.valueOf(num));
    }
    
    /**
     * 秒杀接口(不直接操作MySQL)
     */
    public boolean createOrder(Long userId, Long productId) {
        String key = STOCK_KEY_PREFIX + productId;
        
        // 1. Redis预扣库存(原子操作)
        Long stock = redisTemplate.opsForValue().decrement(key);
        
        // 2. 库存不足,回滚
        if (stock < 0) {
            redisTemplate.opsForValue().increment(key);
            return false;
        }
        
        // 3. 扣减成功,发送MQ消息(异步写MySQL)
        SeckillMessage message = new SeckillMessage();
        message.setUserId(userId);
        message.setProductId(productId);
        message.setOrderNo(generateOrderNo());
        rabbitTemplate.convertAndSend("seckill.queue", message);
        
        return true;
    }
}

MQ消费者

java 复制代码
@Component
public class SeckillConsumer {
    
    @Autowired
    private StockMapper stockMapper;
    
    @Autowired
    private OrderMapper orderMapper;
    
    @RabbitListener(queues = "seckill.queue")
    public void handleSeckillMessage(SeckillMessage message) {
        try {
            // 1. 扣减MySQL库存(幂等性:防止重复消费)
            int updated = stockMapper.decreaseStock(
                message.getProductId()
            );
            
            if (updated == 0) {
                // 库存扣减失败,可能是重复消费,直接返回
                log.warn("库存扣减失败: {}", message);
                return;
            }
            
            // 2. 创建订单
            Order order = new Order();
            order.setOrderNo(message.getOrderNo());
            order.setUserId(message.getUserId());
            order.setProductId(message.getProductId());
            order.setCreateTime(new Date());
            orderMapper.insert(order);
            
            log.info("订单创建成功: {}", message.getOrderNo());
            
        } catch (Exception e) {
            log.error("处理秒杀消息失败: {}", message, e);
            // 重试或进入死信队列
            throw new RuntimeException(e);
        }
    }
}

关键点:Redis DECR的原子性

java 复制代码
// Redis的DECR是原子操作,不会超卖
jedis.decr(key);  // 等价于:
/*
1. GET key
2. value = value - 1
3. SET key value
以上三步是原子的,不会被打断!
*/

压测结果

bash 复制代码
# JMeter配置:5000线程,持续10秒
结果:
- 总请求数:50000
- 成功数:100(正确!)
- 失败数:49900
- 平均响应时间:12ms(暴降!)
- TPS:4167(提升35倍!)
- Redis CPU:30%
- MySQL CPU:5%(压力转移到Redis了)
- 无超卖问题 ✅

优化效果

指标 方案3 方案4 提升
TPS 118 4167 35倍
响应时间 850ms 12ms 70倍
MySQL CPU 45% 5% 降低89%

哈吉米:"卧槽!4000多QPS了!这才是秒杀的感觉!"

阿西噶阿西:"但还有问题:单个Redis实例能抗5万QPS,但100万QPS还是扛不住!"

南北绿豆:"而且还有个隐藏问题:如果Redis和MySQL数据不一致怎么办?"


方案5️⃣:分桶 + 队列削峰 + 分库分表(终极方案)

南北绿豆:"要抗100万QPS,必须上终极方案!"

核心思路

markdown 复制代码
1. 分桶:100个库存,拆成10个桶,每个桶10个
2. 削峰:用队列限流,100万请求 → 削峰到5万 → 进入队列
3. 分库:10个桶分散到10个Redis + 10个MySQL实例
4. 异步:全链路异步化

架构图

scss 复制代码
                       用户请求(100万QPS)
                              ↓
                    【网关限流】Nginx(限到50万)
                              ↓
                    【应用限流】Sentinel(限到10万)
                              ↓
                    【分桶路由】一致性哈希
        ┌─────────┬─────────┬─────────┬─────────┐
        ↓         ↓         ↓         ↓         ↓
    桶0(Redis0) 桶1      桶2      ...      桶9(Redis9)
      10个库存   10个     10个            10个
        ↓         ↓         ↓         ↓         ↓
    MQ队列0     MQ队列1   MQ队列2   ...   MQ队列9
        ↓         ↓         ↓         ↓         ↓
    消费者0     消费者1   消费者2   ...   消费者9
        ↓         ↓         ↓         ↓         ↓
     MySQL0     MySQL1    MySQL2    ...    MySQL9

分桶路由实现

java 复制代码
@Service
public class SeckillServiceV5 {
    
    @Autowired
    private List<RedisTemplate<String, String>> redisTemplates;  // 10个Redis实例
    
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    private static final int BUCKET_COUNT = 10;  // 10个桶
    
    /**
     * 初始化库存(分散到10个桶)
     */
    public void initStock(Long productId, Integer totalNum) {
        int numPerBucket = totalNum / BUCKET_COUNT;  // 每个桶10个
        
        for (int i = 0; i < BUCKET_COUNT; i++) {
            String key = "stock:" + productId + ":bucket:" + i;
            RedisTemplate<String, String> redis = redisTemplates.get(i);
            redis.opsForValue().set(key, String.valueOf(numPerBucket));
        }
    }
    
    /**
     * 秒杀接口
     */
    public boolean createOrder(Long userId, Long productId) {
        // 1. 路由到某个桶(根据userId哈希)
        int bucketIndex = (int) (userId % BUCKET_COUNT);
        RedisTemplate<String, String> redis = redisTemplates.get(bucketIndex);
        
        String key = "stock:" + productId + ":bucket:" + bucketIndex;
        
        // 2. Redis预扣库存
        Long stock = redis.opsForValue().decrement(key);
        
        if (stock < 0) {
            redis.opsForValue().increment(key);
            return false;
        }
        
        // 3. 发送到对应的MQ队列
        String queueName = "seckill.queue." + bucketIndex;
        SeckillMessage message = new SeckillMessage();
        message.setUserId(userId);
        message.setProductId(productId);
        message.setBucketIndex(bucketIndex);
        message.setOrderNo(generateOrderNo());
        
        rabbitTemplate.convertAndSend(queueName, message);
        
        return true;
    }
}

分库分表配置(ShardingSphere):

yaml 复制代码
# application.yml
spring:
  shardingsphere:
    datasource:
      names: ds0,ds1,ds2,ds3,ds4,ds5,ds6,ds7,ds8,ds9
      ds0:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://192.168.1.10:3306/seckill_0
      ds1:
        jdbc-url: jdbc:mysql://192.168.1.11:3306/seckill_1
      # ... ds2-ds9
    
    rules:
      sharding:
        tables:
          order:
            actual-data-nodes: ds$->{0..9}.order
            database-strategy:
              standard:
                sharding-column: user_id
                sharding-algorithm-name: db-mod
          stock:
            actual-data-nodes: ds$->{0..9}.stock
            database-strategy:
              standard:
                sharding-column: product_id
                sharding-algorithm-name: db-mod
        
        sharding-algorithms:
          db-mod:
            type: MOD
            props:
              sharding-count: 10

消费者(10个实例,每个消费一个队列)

java 复制代码
@Component
public class SeckillConsumerV5 {
    
    @Autowired
    private StockMapper stockMapper;
    
    @Autowired
    private OrderMapper orderMapper;
    
    // 每个消费者监听自己的队列
    @RabbitListener(queues = "seckill.queue.${bucket.index}")
    public void handleSeckillMessage(SeckillMessage message) {
        // 写入对应的MySQL分片
        // ShardingSphere会根据user_id自动路由到对应的库
        
        // 1. 扣减库存
        stockMapper.decreaseStock(message.getProductId());
        
        // 2. 创建订单
        Order order = new Order();
        order.setOrderNo(message.getOrderNo());
        order.setUserId(message.getUserId());
        order.setProductId(message.getProductId());
        order.setCreateTime(new Date());
        orderMapper.insert(order);  // 自动路由到ds{bucketIndex}
    }
}

压测结果(最终战):

bash 复制代码
# JMeter配置:10000线程,持续30秒
# 模拟100万QPS的场景(分批压测)

结果:
- 总请求数:1000000
- 成功数:100(正确!)
- 失败数:999900
- 平均响应时间:5ms(极致!)
- TPS:33333(单机承受)
- 集群TPS:333330(10台应用服务器)
- Redis集群承载:100万QPS ✅
- MySQL集群CPU:平均15%
- 无超卖问题 ✅

终极优化效果

指标 方案1 方案4 方案5 总提升
TPS 12.7 4167 333330 26247倍
响应时间 8500ms 12ms 5ms 1700倍
是否超卖 -
单点故障 -
可扩展性 一般 -

哈吉米:"卧槽!从12 QPS到33万QPS,提升了2万多倍!"

阿西噶阿西:"这就是分布式的威力!单机不行就集群,垂直不行就水平!"


🚨 超卖问题的6种死法

南北绿豆:"虽然方案5能抗100万QPS,但超卖问题是秒杀的核心难题,我们系统梳理一下。"

死法1️⃣:查询和更新不是原子操作

java 复制代码
// ❌ 错误写法
Stock stock = mapper.selectById(1);
if (stock.getNum() > 0) {
    stock.setNum(stock.getNum() - 1);
    mapper.updateById(stock);
}

// ✅ 正确写法
mapper.execute("UPDATE stock SET num = num - 1 WHERE id = 1 AND num > 0");

死法2️⃣:UPDATE没有加 num > 0 条件

sql 复制代码
-- ❌ 错误写法
UPDATE stock SET num = num - 1 WHERE product_id = 1;
-- 如果num=0,执行后变成-1!

-- ✅ 正确写法
UPDATE stock SET num = num - 1 
WHERE product_id = 1 AND num > 0;

死法3️⃣:Redis和MySQL数据不一致

场景

markdown 复制代码
1. Redis库存=10
2. 10个请求过来,Redis扣减成功(Redis库存=0)
3. 发送10条MQ消息
4. 消费者处理时,MySQL宕机了,只写入了7条
5. 结果:Redis卖了10个,MySQL只记录7个

问题:用户付了钱,但订单表里没记录!

解决方案

java 复制代码
// 方案1:MQ消息持久化 + 重试
@RabbitListener(queues = "seckill.queue")
public void handleMessage(SeckillMessage message) {
    try {
        // 写MySQL
        createOrder(message);
    } catch (Exception e) {
        // 重试3次
        if (message.getRetryCount() < 3) {
            message.setRetryCount(message.getRetryCount() + 1);
            rabbitTemplate.convertAndSend("seckill.queue", message);
        } else {
            // 进入死信队列,人工处理
            rabbitTemplate.convertAndSend("seckill.dead.queue", message);
        }
    }
}

// 方案2:定时对账(Redis vs MySQL)
@Scheduled(cron = "0 */5 * * * ?")  // 每5分钟
public void checkConsistency() {
    Long redisStock = redisTemplate.opsForValue().get("stock:1001");
    Stock dbStock = stockMapper.selectByProductId(1001);
    
    if (!redisStock.equals(dbStock.getNum())) {
        // 数据不一致,告警
        alertService.send("库存数据不一致:Redis=" + redisStock + ", MySQL=" + dbStock.getNum());
    }
}

死法4️⃣:Redis主从切换丢数据

场景

markdown 复制代码
1. 主Redis写入:库存=99(还没同步到从库)
2. 主Redis宕机
3. 从库升级为主库,库存=100(没同步到的数据丢了)
4. 又有1个用户抢到了
5. 结果:总共卖了101个

问题:Redis主从异步复制导致数据丢失

解决方案

java 复制代码
// 方案1:Redis哨兵 + 等待写入确认
Jedis jedis = new Jedis("redis://master");
jedis.set("stock:1001", "100");
jedis.waitReplicas(1, 1000);  // 等待至少1个从库同步完成,超时1秒

// 方案2:使用Redis Cluster + 强一致性
// 配置redis.conf
min-replicas-to-write 1
min-replicas-max-lag 10

// 方案3:库存分片(降低单点影响)
// 100个库存分成10个Redis,每个挂了只影响10个

死法5️⃣:重复消费MQ消息

场景

markdown 复制代码
1. 消费者A处理消息,扣减库存成功
2. 消费者A还没ACK,宕机了
3. RabbitMQ重新投递消息给消费者B
4. 消费者B又扣减了一次库存
5. 结果:一个订单,扣了两次库存

问题:MQ消息重复消费

解决方案

java 复制代码
// 方案1:幂等性设计(订单号唯一索引)
CREATE UNIQUE INDEX uk_order_no ON `order`(order_no);

@Transactional
public void createOrder(SeckillMessage message) {
    try {
        // 插入订单(订单号唯一)
        orderMapper.insert(order);
        // 扣减库存
        stockMapper.decreaseStock(productId);
    } catch (DuplicateKeyException e) {
        // 订单号重复,说明已经处理过了,直接返回
        log.warn("订单重复: {}", message.getOrderNo());
        return;
    }
}

// 方案2:Redis记录已处理的消息ID
public void handleMessage(SeckillMessage message) {
    String key = "processed:" + message.getOrderNo();
    Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
    
    if (!success) {
        // 已经处理过了
        return;
    }
    
    // 处理消息
    createOrder(message);
}

死法6️⃣:分布式事务不一致

场景

markdown 复制代码
1. 扣减库存成功(MySQL-A)
2. 创建订单成功(MySQL-B)
3. 扣减用户余额失败(MySQL-C宕机)
4. 前两步回滚?还是继续?

问题:跨库事务一致性

解决方案

java 复制代码
// 方案1:TCC分布式事务(Seata)
@GlobalTransactional
public void createOrder(Long userId, Long productId) {
    // Try阶段
    stockService.tryDecreaseStock(productId);      // 冻结库存
    orderService.tryCreateOrder(userId, productId);// 预创建订单
    accountService.tryDecrease(userId, amount);    // 冻结余额
    
    // Confirm阶段:全部成功,提交
    // Cancel阶段:任一失败,全部回滚
}

// 方案2:本地消息表(最终一致性)
@Transactional
public void createOrder(SeckillMessage message) {
    // 1. 扣减库存
    stockMapper.decreaseStock(message.getProductId());
    
    // 2. 创建订单
    orderMapper.insert(order);
    
    // 3. 写入本地消息表(同一个事务)
    LocalMessage localMsg = new LocalMessage();
    localMsg.setOrderNo(order.getOrderNo());
    localMsg.setStatus(0);  // 待发送
    localMsgMapper.insert(localMsg);
}

// 定时任务扫描本地消息表,重试失败的消息
@Scheduled(fixedDelay = 5000)
public void retryFailedMessages() {
    List<LocalMessage> messages = localMsgMapper.selectByStatus(0);
    for (LocalMessage msg : messages) {
        try {
            // 调用下游服务(扣减余额)
            accountService.decrease(msg.getUserId(), msg.getAmount());
            // 更新状态
            msg.setStatus(1);
            localMsgMapper.updateById(msg);
        } catch (Exception e) {
            log.error("消息重试失败: {}", msg, e);
        }
    }
}

📊 大厂秒杀架构解密

哈吉米:"淘宝、京东的秒杀是怎么实现的?"

南北绿豆:"来,我给你拆解一下。"

淘宝双十一秒杀架构(简化版)

markdown 复制代码
                     用户(10亿)
                         ↓
            【CDN】静态资源(图片、JS)
                         ↓
            【DNS】智能解析(就近接入)
                         ↓
        【LVS/Nginx】负载均衡(百万级QPS)
                         ↓
          【Sentinel】限流熔断(削峰填谷)
                         ↓
        【应用服务器】集群(1000+台)
                         ↓
    【Redis集群】库存预扣(千万级QPS)
                         ↓
    【RocketMQ】消息削峰(千万级TPS)
                         ↓
    【MySQL集群】分库分表(1000+个库)

核心设计思路

1️⃣ 多级缓存

makefile 复制代码
L1: 浏览器缓存(静态资源)
L2: CDN缓存(图片、视频)
L3: Nginx本地缓存(热点数据)
L4: Redis缓存(库存、用户信息)
L5: MySQL(最终存储)

2️⃣ 全链路异步化

arduino 复制代码
用户点击"立即抢购"
  ↓(同步,100ms)
返回"正在排队"
  ↓(异步)
后台扣减库存 → 创建订单 → 发送通知
  ↓(WebSocket推送)
"恭喜您抢购成功!"

3️⃣ 动静分离

复制代码
静态资源:商品图片、页面框架 → CDN
动态资源:库存数量、按钮状态 → Ajax请求

4️⃣ 按钮控制(防止重复提交)

javascript 复制代码
// 前端代码
let clicking = false;

function seckill() {
    if (clicking) {
        alert("正在抢购中,请勿重复点击!");
        return;
    }
    
    clicking = true;
    document.getElementById("btn").disabled = true;
    
    axios.post("/api/seckill", {productId: 1001})
        .then(resp => {
            if (resp.data.success) {
                alert("恭喜您抢购成功!");
            } else {
                alert("手慢了,下次再来!");
            }
        })
        .finally(() => {
            clicking = false;
            document.getElementById("btn").disabled = false;
        });
}

5️⃣ 答题验证码(防止脚本)

java 复制代码
// 秒杀前先答题
@GetMapping("/seckill/captcha")
public Result getCaptcha() {
    // 生成简单的算术题
    int a = RandomUtil.randomInt(1, 100);
    int b = RandomUtil.randomInt(1, 100);
    String question = a + " + " + b + " = ?";
    int answer = a + b;
    
    // 存到Redis,5分钟过期
    String token = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set("captcha:" + token, answer, 5, TimeUnit.MINUTES);
    
    return Result.ok().put("question", question).put("token", token);
}

@PostMapping("/seckill")
public Result seckill(@RequestParam String token, @RequestParam Integer answer, ...) {
    // 校验答案
    Integer correctAnswer = redisTemplate.opsForValue().get("captcha:" + token);
    if (correctAnswer == null || !correctAnswer.equals(answer)) {
        return Result.error("验证失败");
    }
    
    // 继续秒杀逻辑
    // ...
}

🛠️ 性能优化的10个细节

阿西噶阿西:"除了架构,还有很多细节能提升性能。"

1️⃣ 预热库存

java 复制代码
// 秒杀开始前10分钟,预热Redis
@Scheduled(cron = "0 50 23 * * ?")  // 每天23:50
public void warmUpStock() {
    List<Product> products = productMapper.selectSeckillProducts();
    for (Product product : products) {
        String key = "stock:" + product.getId();
        redisTemplate.opsForValue().set(key, product.getStock());
    }
    log.info("库存预热完成");
}

2️⃣ 连接池调优

yaml 复制代码
# HikariCP配置
spring:
  datasource:
    hikari:
      maximum-pool-size: 50        # 最大连接数
      minimum-idle: 10             # 最小空闲连接
      connection-timeout: 3000     # 连接超时3秒
      idle-timeout: 600000         # 空闲超时10分钟
      max-lifetime: 1800000        # 连接最大存活30分钟

3️⃣ 批量处理

java 复制代码
// ❌ 错误:一条条插入
for (Order order : orders) {
    orderMapper.insert(order);  // N次数据库交互
}

// ✅ 正确:批量插入
orderMapper.batchInsert(orders);  // 1次数据库交互

// MyBatis实现
<insert id="batchInsert">
    INSERT INTO `order` (order_no, user_id, product_id, create_time)
    VALUES
    <foreach collection="list" item="item" separator=",">
        (#{item.orderNo}, #{item.userId}, #{item.productId}, #{item.createTime})
    </foreach>
</insert>

4️⃣ 索引优化

sql 复制代码
-- 创建联合索引(覆盖查询条件)
CREATE INDEX idx_product_status ON stock(product_id, status);

-- 避免全表扫描
EXPLAIN SELECT * FROM stock WHERE product_id = 1001;
-- type: ref ✅

-- 查看索引使用情况
SHOW INDEX FROM stock;

5️⃣ SQL优化

sql 复制代码
-- ❌ 错误:SELECT *
SELECT * FROM `order` WHERE user_id = 10086;

-- ✅ 正确:只查需要的字段
SELECT id, order_no, product_id, create_time 
FROM `order` 
WHERE user_id = 10086;

-- ❌ 错误:OR条件
SELECT * FROM stock WHERE product_id = 1001 OR product_id = 1002;

-- ✅ 正确:IN条件
SELECT * FROM stock WHERE product_id IN (1001, 1002);

6️⃣ Redis Pipeline

java 复制代码
// ❌ 错误:多次网络往返
for (int i = 0; i < 1000; i++) {
    redisTemplate.opsForValue().get("key" + i);  // 1000次网络IO
}

// ✅ 正确:Pipeline批量
List<Object> results = redisTemplate.executePipelined(
    (RedisCallback<Object>) connection -> {
        for (int i = 0; i < 1000; i++) {
            connection.get(("key" + i).getBytes());
        }
        return null;
    }
);  // 1次网络IO

7️⃣ 异步化

java 复制代码
// ❌ 错误:同步发送短信(阻塞2秒)
createOrder(order);
smsService.send(user.getPhone(), "订单创建成功");  // 阻塞2秒
return Result.ok();

// ✅ 正确:异步发送短信
createOrder(order);
CompletableFuture.runAsync(() -> {
    smsService.send(user.getPhone(), "订单创建成功");
});
return Result.ok();  // 立即返回

8️⃣ 避免大事务

java 复制代码
// ❌ 错误:事务太大
@Transactional
public void processBatch(List<Order> orders) {
    for (Order order : orders) {
        // 处理订单
        processOrder(order);
        // 发送通知(可能很慢)
        notifyUser(order);
        // 更新统计(可能很慢)
        updateStatistics(order);
    }
}  // 事务持有时间过长,锁等待严重

// ✅ 正确:缩小事务范围
public void processBatch(List<Order> orders) {
    for (Order order : orders) {
        processOrderInTransaction(order);  // 小事务
        notifyUser(order);  // 事务外
        updateStatistics(order);  // 事务外
    }
}

@Transactional
private void processOrderInTransaction(Order order) {
    // 只包含核心的数据库操作
    orderMapper.insert(order);
    stockMapper.decreaseStock(order.getProductId());
}

9️⃣ 限流降级

java 复制代码
// Sentinel限流配置
@SentinelResource(
    value = "seckill",
    blockHandler = "handleBlock",
    fallback = "handleFallback"
)
public Result seckill(Long userId, Long productId) {
    // 秒杀逻辑
}

// 限流后的处理
public Result handleBlock(Long userId, Long productId, BlockException e) {
    return Result.error("系统繁忙,请稍后再试");
}

// 异常后的降级
public Result handleFallback(Long userId, Long productId, Throwable e) {
    log.error("秒杀异常", e);
    return Result.error("服务异常");
}

🔟 监控告警

java 复制代码
// Prometheus监控指标
@RestController
public class MetricsController {
    
    private Counter seckillCounter = Counter.build()
        .name("seckill_requests_total")
        .help("秒杀请求总数")
        .labelNames("status")
        .register();
    
    private Histogram seckillLatency = Histogram.build()
        .name("seckill_latency_seconds")
        .help("秒杀响应时间")
        .register();
    
    public Result seckill(Long userId, Long productId) {
        Histogram.Timer timer = seckillLatency.startTimer();
        try {
            boolean success = seckillService.createOrder(userId, productId);
            if (success) {
                seckillCounter.labels("success").inc();
                return Result.ok();
            } else {
                seckillCounter.labels("fail").inc();
                return Result.error("秒杀失败");
            }
        } finally {
            timer.observeDuration();
        }
    }
}

🎓 思考题

题目1:如果Redis挂了,秒杀系统怎么办?

答案

多层保护:

  1. Redis哨兵/集群:主节点挂了,自动切换到从节点(秒级)
  2. 熔断降级:Redis不可用时,直接返回"系统繁忙",保护MySQL
  3. 限流兜底:降低限流阈值,部分请求直接打到MySQL(风险可控)
  4. 应急预案:提前准备好"全部走MySQL"的配置,快速切换
java 复制代码
@Service
public class SeckillServiceWithFallback {
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    @Autowired
    private SeckillServiceV2 mysqlService;  // 悲观锁方案
    
    public boolean createOrder(Long userId, Long productId) {
        try {
            // 优先走Redis
            return redisService.createOrder(userId, productId);
        } catch (RedisConnectionException e) {
            log.error("Redis不可用,降级到MySQL", e);
            // 降级到MySQL(悲观锁)
            return mysqlService.createOrder(userId, productId);
        }
    }
}

题目2:如何防止黄牛刷单?

答案

多维度风控:

  1. 手机号验证:一个手机号只能抢一次
  2. 实名认证:必须实名才能参与
  3. 设备指纹:同一设备只能抢一次
  4. 行为分析:识别异常操作(如1秒内点击100次)
  5. 答题验证:秒杀前必须答对题目
  6. 人机验证:滑动验证码、点选验证码
java 复制代码
@Service
public class AntiCheatService {
    
    public boolean checkUser(Long userId, String deviceId, String ip) {
        // 1. 检查用户是否已经抢过
        Boolean exists = redisTemplate.hasKey("seckill:user:" + userId);
        if (exists) {
            return false;
        }
        
        // 2. 检查设备是否重复
        Boolean deviceUsed = redisTemplate.hasKey("seckill:device:" + deviceId);
        if (deviceUsed) {
            return false;
        }
        
        // 3. 检查IP频率(1秒内最多10次)
        Long count = redisTemplate.opsForValue().increment("seckill:ip:" + ip);
        redisTemplate.expire("seckill:ip:" + ip, 1, TimeUnit.SECONDS);
        if (count > 10) {
            return false;
        }
        
        // 4. 记录用户和设备
        redisTemplate.opsForValue().set("seckill:user:" + userId, "1", 1, TimeUnit.HOURS);
        redisTemplate.opsForValue().set("seckill:device:" + deviceId, "1", 1, TimeUnit.HOURS);
        
        return true;
    }
}

题目3:100个库存,10万人抢,如何让用户体验最好?

答案

策略组合:

  1. 前端排队动画:显示"前面还有XX人",而不是一直转圈
  2. 分批放量:不是一次性放100个,而是每秒放10个,持续10秒
  3. 幸运值机制:失败的用户下次抢购提升优先级
  4. 安慰奖:没抢到的用户发优惠券
  5. 候补名单:用户取消订单后,自动补给候补用户
java 复制代码
@Service
public class QueueService {
    
    // 排队逻辑
    public QueueResult joinQueue(Long userId, Long productId) {
        // 1. 加入排队队列
        Long position = redisTemplate.opsForList().rightPush(
            "queue:" + productId, 
            userId.toString()
        );
        
        // 2. 计算预计等待时间
        long estimatedTime = position * 2;  // 假设每人2秒
        
        return QueueResult.builder()
            .position(position)
            .estimatedTime(estimatedTime)
            .message("您前面还有" + position + "人,预计等待" + estimatedTime + "秒")
            .build();
    }
    
    // 定时处理队列
    @Scheduled(fixedDelay = 2000)  // 每2秒处理一批
    public void processQueue() {
        String userId = redisTemplate.opsForList().leftPop("queue:1001");
        if (userId != null) {
            // 处理秒杀
            seckillService.createOrder(Long.parseLong(userId), 1001L);
        }
    }
}

🎉 结束语

下午6点,三人终于把秒杀系统优化完了。

哈吉米:"从12 QPS到33万QPS,我学到了太多!"

南北绿豆:"记住核心思路:分层、异步、削峰、分片。"

阿西噶阿西:"而且要提前压测!线上出问题就晚了!"

哈吉米:"下次双十一,我们一定能扛住!"

南北绿豆:"对了,下周咱们聊聊死锁?我昨天又把测试环境锁死了......"

哈吉米:"卧槽,又是你!"


秒杀优化口诀

限流削峰是前提,异步解耦是关键

Redis抗流量,MQ来削峰

分库分表扩容量,监控告警不能少

超卖问题需谨慎,幂等设计保平安


希望这篇文章能帮你搞定秒杀场景的MySQL优化!记住:高并发不是靠单机硬扛,而是靠架构设计!

收藏+点赞,下次双十一不翻车!💪

相关推荐
重生之我在二本学院拿offer当牌打3 小时前
IoC容器深度解析(三):Bean生命周期11步骤深度剖析,彻底搞懂Spring核心机制!
后端
重生之我在二本学院拿offer当牌打3 小时前
手写SpringBoot Starter(三):实现可插拔Starter,像Zuul一样优雅!
后端
初见0013 小时前
🌱 SpringBoot自动配置:别装了,我知道你的秘密!🤫
spring boot·后端
用户785127814703 小时前
Python代码获取京东商品详情原数据 API 接口(item_get_app)
后端
JAVA数据结构3 小时前
BPMN-Activiti-简单流程委托
后端
sivdead3 小时前
智能体记忆机制详解
人工智能·后端·agent
拉不动的猪4 小时前
图文引用打包时的常见情景解析
前端·javascript·后端
该用户已不存在4 小时前
程序员的噩梦,祖传代码该怎么下手?
前端·后端
间彧4 小时前
Redis缓存穿透、缓存雪崩、缓存击穿详解与代码实现
后端