MySQL高并发崩溃案例解析 电商抢购系统稳定运行方案

一个让我头皮发麻的深夜报警

去年双十一,我接到一个电话,对方声音都在抖。某头部电商平台的活动页面崩了,库存显示混乱,订单超卖了三万单。技术负责人说,MySQL直接扛不住了,连接池被打爆,整个数据库卡死在查询阶段,连运维团队都连不上去。

那天晚上,他们团队三个人熬到凌晨四点,才把问题定位清楚。今天就把这个真实案例,掰开揉碎了讲给你听。如果你正在做抢购活动,或者打算做,这篇文章绝对能帮你省下一笔不小的事故处理费。


一、高并发场景下MySQL为什么会崩?

很多开发者对MySQL有天然的信任,觉得”MySQL这么成熟的数据库,怎么会撑不住呢”。但现实是,MySQL再强,也有它的物理上限

1.1 崩溃的几种典型表现

先看看那些让人崩溃的场景长什么样:

1. 连接数爆炸:Error - "Too many connections"
2. 查询卡死:InnoDB锁等待超时,事务堆积
3. 磁盘IO打满:buffer pool刷脏页跟不上写入速度
4. CPU 100%:大量热数据竞争,锁冲突严重
5. 主从延迟:Binlog来不及同步,读写分离失效

1.2 根本原因拆解

我们用那个电商案例来说明。他们的抢购流程是这样的:

用户点击抢购 → 应用层校验 → 扣减库存 → 生成订单 → 支付

听起来很简单,对吧?但当10万用户同时点击”抢购”,问题就来了。

问题一:行锁竞争

-- 伪代码,实际他们就是这么写的
UPDATE product_stock 
SET stock = stock - 1 
WHERE product_id = 10086 AND stock > 0;

这一行UPDATE,在高并发下会变成什么?

想象一下,10万人同时排队过一扇窄门,每秒钟只有几个人能通过,后面的人就得等。MySQL的InnoDB引擎对这一行数据加的是行级锁,所有人都在争这一个锁,队列越长,响应越慢,最终连接超时,数据库直接拒绝新连接。

问题二:事务膨胀

-- 一个抢购事务可能包含:
BEGIN;
    SELECT stock FROM product_stock WHERE product_id = 10086 FOR UPDATE;  -- 查库存
    UPDATE product_stock SET stock = stock - 1 WHERE product_id = 10086;   -- 扣库存
    INSERT INTO orders (...) VALUES (...);                                  -- 下订单
    INSERT INTO order_items (...) VALUES (...);                             -- 订单项
COMMIT;

每一个请求都是一个完整的事务,10万个事务同时运行,Undo Log疯狂增长,Buffer Pool被塞满,脏页刷新压力巨大。MySQL的缓冲池不是无限的,一旦被挤爆,性能断崖式下跌。

问题三:连接池耗尽

应用服务器的连接池通常配置的是200~500个连接,但10万并发请求来,每个请求都要持有一个数据库连接完成事务。连接池瞬间被打满,新来的请求排队等连接,等连接的过程中CPU还在工作,最终整条链路全部卡死。


二、真实案例复盘:XX电商抢购事故

2.1 事故时间线

时间 事件
10:00 抢购活动开始,QPS从平时的200飙升到15000
10:01 应用层开始出现超时,响应时间超过5秒
10:03 MySQL CPU使用率达到98%,出现大量锁等待
10:05 数据库连接池耗尽,开始报错”Too many connections”
10:07 业务方强制停机,活动结束
10:30 排查发现超卖12743单,需要紧急补货

2.2 根因分析

事故后,他们用pt-query-digest分析了慢查询日志,发现了一个致命问题:

-- 最慢的查询TOP3
# Query_time: 8.2s  Lock_time: 7.9s  Rows_sent: 0  Rows_examined: 1
SELECT * FROM product_stock WHERE product_id = ? FOR UPDATE;

# Query_time: 5.1s  Lock_time: 4.8s  Rows_sent: 0  Rows_examined: 1
UPDATE product_stock SET stock = stock - 1 WHERE product_id = ? AND stock > 0;

# Query_time: 3.7s  Lock_time: 3.2s  Rows_sent: 1  Rows_examined: 1
INSERT INTO orders (...) VALUES (...);

关键发现:Lock_time占据了Query_time的绝大部分!

这说明什么?说明数据库根本没有在干活,而是在排队等锁。每一个请求都在等前面那个事务释放锁,然后才能执行,形成了严重的串行化竞争。

2.3 数据损失评估

  • 超卖订单:12,743单
  • 涉及商品:38个SKU
  • 用户投诉:2,341条
  • 直接经济损失:约87万元(赔付+补货成本)
  • 品牌声誉损失:无法量化

这个案例告诉我们,高并发下的数据库设计不是”能用就行”,而是要从架构层面做好预判和防护


三、电商抢购系统的完整稳定方案

下面这套方案,是我在帮几家电商客户做架构改造时沉淀下来的,经过实战检验,能扛住每秒数万甚至十数万并发。

3.1 整体架构设计

┌─────────────────────────────────────────────────────────────┐
│                        客户端请求                            │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                    网关层(限流+熔断)                        │
│  • Nginx限流:单IP每分钟最多请求10次                           │
│  • 服务熔断:下游超时自动熔断,防止雪崩                         │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                    应用服务层                                 │
│  • 请求队列:消息队列削峰填谷                                 │
│  • 本地缓存:热点数据缓存到本地,减少DB查询                    │
│  • 异步处理:订单生成异步化,快速响应客户端                    │
└───────────────────────────┬─────────────────────────────────┘
                            │
        ┌───────────────────┼───────────────────┐
        ▼                   ▼                   ▼
┌───────────────┐  ┌───────────────┐  ┌───────────────┐
│   Redis集群    │  │   消息队列     │  │   MySQL集群    │
│  • 库存预扣减  │  │  • 订单异步   │  │  • 订单持久化  │
│  • 分布式锁   │  │  • 状态更新   │  │  • 对账补偿   │
└───────────────┘  └───────────────┘  └───────────────┘

核心思想就一句话:能不在数据库里做的事,就尽量不在数据库里做

3.2 第一道防线:应用层限流

限流是成本最低的防护手段,必须在最外层就把恶意流量和异常流量挡掉。

/**
 * 抢购限流器 - 基于令牌桶算法
 */
@Component
public class FlashSaleRateLimiter {

    // 每个用户每分钟的请求令牌数
    private final ConcurrentHashMap<String, RateLimiter> userLimiters = new ConcurrentHashMap<>();

    /**
     * 获取用户限流器
     */
    private RateLimiter getUserLimiter(String userId) {
        return userLimiters.computeIfAbsent(userId, 
            k -> RateLimiter.create(5.0)); // 每秒最多5个请求
    }

    /**
     * 尝试获取令牌
     * @return true表示允许通过,false表示被限流
     */
    public boolean tryAcquire(String userId) {
        return getUserLimiter(userId).tryAcquire();
    }

    /**
     * 全局限流 - 基于QPS
     */
    private final RateLimiter globalLimiter = RateLimiter.create(5000.0); // 全局5000 QPS

    public boolean tryGlobalAcquire() {
        return globalLimiter.tryAcquire();
    }
}

限流策略要分层次:

层次 策略 阈值
IP维度 同一IP每分钟请求次数 ≤10次
用户维度 同一用户每分钟请求次数 ≤5次
全局维度 整体QPS限制 ≤5000
接口维度 不同接口不同配额 按业务重要性分配

3.3 第二道防线:Redis预扣库存

这是整个方案的核心。把库存放在Redis里做扣减,利用Redis的单线程特性和原子操作,避免数据库锁竞争。

3.3.1 库存预热

活动开始前,把库存加载到Redis:

/**
 * 库存预热 - 活动开始前10分钟执行
 */
@Service
public class StockPreheatService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Autowired
    private ProductMapper productMapper;

    /**
     * 预热单个商品的库存
     */
    public void preheatStock(Long productId) {
        // 从数据库读取库存
        ProductStock stock = productMapper.selectByProductId(productId);
        if (stock == null) {
            throw new BusinessException("商品不存在");
        }

        // 设置到Redis,TTL设为活动结束时间+1小时
        String stockKey = "flash_sale:stock:" + productId;
        redisTemplate.opsForValue().set(
            stockKey, 
            String.valueOf(stock.getStock()),
            3600 * 24, 
            TimeUnit.SECONDS
        );

        // 设置库存标记,用于校验
        String flagKey = "flash_sale:stock_flag:" + productId;
        redisTemplate.opsForValue().set(flagKey, "1", 3600 * 24, TimeUnit.SECONDS);

        log.info("商品{}库存预热完成,库存量: {}", productId, stock.getStock());
    }

    /**
     * 批量预热
     */
    public void batchPreheat(List<Long> productIds) {
        productIds.forEach(this::preheatStock);
    }
}

3.3.2 Lua脚本原子扣减

-- stock_deduct.lua - Redis原子扣减库存
-- 参数:KEYS[1] = 库存key, ARGV[1] = 购买数量, ARGV[2] = 用户ID

local stockKey = KEYS[1]
local buyQty = tonumber(ARGV[1])
local userId = ARGV[2]

-- 1. 检查库存是否存在
local stock = redis.call('GET', stockKey)
if stock == false then
    return -1  -- 库存未初始化
end

-- 2. 检查库存是否充足
stock = tonumber(stock)
if stock < buyQty then
    return -2  -- 库存不足
end

-- 3. 扣减库存
local newStock = stock - buyQty
redis.call('SET', stockKey, newStock)

-- 4. 记录用户购买信息(防止重复购买)
local userKey = "flash_sale:user_buy:" .. stockKey
redis.call('SADD', userKey, userId)

return 1  -- 扣减成功
/**
 * Redis扣减库存服务
 */
@Service
public class RedisStockService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 扣减库存 - 使用Lua脚本保证原子性
     *
     * @param productId 商品ID
     * @param buyQty    购买数量
     * @param userId    用户ID
     * @return 扣减结果:1成功,-1库存未初始化,-2库存不足,-3已购买
     */
    public int deductStock(Long productId, int buyQty, String userId) {
        String stockKey = "flash_sale:stock:" + productId;
        String script = """
            local stockKey = KEYS[1]
            local buyQty = tonumber(ARGV[1])
            local userId = ARGV[2]
            
            local stock = redis.call('GET', stockKey)
            if stock == false then
                return -1
            end
            
            stock = tonumber(stock)
            if stock < buyQty then
                return -2
            end
            
            local newStock = stock - buyQty
            redis.call('SET', stockKey, newStock)
            
            local userKey = "flash_sale:user_buy:" .. stockKey
            redis.call('SADD', userKey, userId)
            
            return 1
        """;

        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(stockKey),
            String.valueOf(buyQty),
            userId
        );

        return result.intValue();
    }

    /**
     * 检查是否已购买
     */
    public boolean hasPurchased(Long productId, String userId) {
        String userKey = "flash_sale:user_buy:flash_sale:stock:" + productId;
        return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(userKey, userId));
    }
}

3.3.3 为什么用Lua脚本而不是多条命令?

很多新手会这么写:

// ❌ 错误做法 - 非原子操作
long stock = redisTemplate.opsForValue().getDecrement(stockKey);
if (stock < 0) {
    redisTemplate.opsForValue().increment(stockKey); // 回滚
    return false;
}
redisTemplate.opsForSet().add(userKey, userId);

这种做法在高并发下会有竞态条件:线程A检查库存充足,开始扣减;线程B也检查了库存,也扣减了;结果库存被扣成负数。

Lua脚本在Redis里是单线程原子执行的,从根本上杜绝了这个问题。

3.4 第三道防线:消息队列异步下单

库存扣减成功后,不需要立刻写数据库,而是把订单信息放入消息队列,由消费者异步处理。

/**
 * 抢购服务 - 核心流程
 */
@Service
@Slf4j
public class FlashSaleService {

    @Autowired
    private RedisStockService redisStockService;

    @Autowired
    private RocketMQProducer rocketMQProducer;

    @Autowired
    private RateLimiter rateLimiter;

    /**
     * 抢购入口
     */
    public FlashSaleResult flashSale(Long productId, Integer buyQty, String userId) {
        // 1. 限流检查
        if (!rateLimiter.tryAcquire(userId)) {
            return FlashSaleResult.rateLimited();
        }

        // 2. 检查是否已购买
        if (redisStockService.hasPurchased(productId, userId)) {
            return FlashSaleResult.alreadyPurchased();
        }

        // 3. Redis扣减库存
        int result = redisStockService.deductStock(productId, buyQty, userId);
        if (result != 1) {
            return FlashSaleResult.fromCode(result);
        }

        // 4. 库存扣减成功,发送异步消息到MQ
        FlashSaleMessage message = new FlashSaleMessage();
        message.setProductId(productId);
        message.setBuyQty(buyQty);
        message.setUserId(userId);
        message.setCreateTime(System.currentTimeMillis());

        rocketMQProducer.send("FLASH_SALE_ORDER_TOPIC", message);

        // 5. 立即返回成功,不等待数据库写入
        return FlashSaleResult.success();
    }
}
/**
 * 消息消费者 - 异步写数据库
 */
@Component
@RocketMQMessageListener(
    topic = "FLASH_SALE_ORDER_TOPIC",
    consumerGroup = "flash_sale_order_consumer"
)
public class FlashSaleOrderConsumer implements RocketMQListener<FlashSaleMessage> {

    @Autowired
    private OrderService orderService;

    @Autowired
    private ProductStockMapper productStockMapper;

    @Override
    public void onMessage(FlashSaleMessage message) {
        try {
            // 1. 创建订单
            Order order = orderService.createOrder(
                message.getUserId(),
                message.getProductId(),
                message.getBuyQty()
            );

            // 2. 更新数据库库存(最终一致性)
            productStockMapper.decreaseStock(
                message.getProductId(),
                message.getBuyQty()
            );

            log.info("订单创建成功, orderId: {}, userId: {}", 
                order.getOrderId(), message.getUserId());

        } catch (Exception e) {
            log.error("订单创建失败, message: {}", message, e);
            // 3. 失败补偿:回滚Redis库存
            compensateStock(message.getProductId(), message.getBuyQty());
        }
    }

    /**
     * 补偿逻辑 - 回滚Redis库存
     */
    private void compensateStock(Long productId, int buyQty) {
        String stockKey = "flash_sale:stock:" + productId;
        redisTemplate.opsForValue().increment(stockKey, buyQty);
        
        String userKey = "flash_sale:user_buy:" + stockKey;
        redisTemplate.opsForSet().remove(userKey, message.getUserId());
    }
}

3.5 数据库层优化

即便用了Redis和MQ,数据库仍然承受着一定压力,需要做针对性优化。

3.5.1 表结构设计

-- 商品库存表 - 热点表,需要特殊处理
CREATE TABLE `product_stock` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `product_id` BIGINT UNSIGNED NOT NULL COMMENT '商品ID',
  `stock` INT NOT NULL DEFAULT 0 COMMENT '剩余库存',
  `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
  `created_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';

-- 订单表 - 拆表存储
CREATE TABLE `orders_00` (
  `order_id` BIGINT UNSIGNED NOT NULL COMMENT '订单ID',
  `user_id` BIGINT UNSIGNED NOT NULL,
  `product_id` BIGINT UNSIGNED NOT NULL,
  `quantity` INT NOT NULL,
  `total_amount` DECIMAL(10,2) NOT NULL,
  `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已完成',
  `created_time` DATETIME NOT NULL,
  PRIMARY KEY (`order_id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_product_id` (`product_id`),
  KEY `idx_created_time` (`created_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 按时间分区,方便后续归档
ALTER TABLE `orders_00` PARTITION BY RANGE (YEAR(created_time) * 100 + MONTH(created_time)) (
  PARTITION p202401 VALUES LESS THAN (202402),
  PARTITION p202402 VALUES LESS THAN (202403),
  PARTITION p202403 VALUES LESS THAN (202404),
  -- ...
);

3.5.2 MySQL参数优化

# my.cnf - 抢购场景针对性调优

[mysqld]

# 连接数设置 - 根据实际业务调整
max_connections = 2000
max_user_connections = 1500

# InnoDB缓冲池 - 设置为物理内存的70%
innodb_buffer_pool_size = 12G
innodb_buffer_pool_instances = 8

# 日志配置 - 抢购期间降低日志刷盘频率
innodb_flush_log_at_trx_commit = 2  # 每秒刷盘,而非每次事务
innodb_log_file_size = 512M
innodb_log_buffer_size = 16M

# IO调度优化
innodb_flush_method = O_DIRECT
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000

# 锁等待超时 - 避免长事务占锁
innodb_lock_wait_timeout = 3
wait_timeout = 60
interactive_timeout = 60

# 禁用DNS解析 - 加快连接建立
skip_name_resolve = 1

# 关闭不需要的功能
performance_schema = OFF

3.5.3 批量写入优化

/**
 * 批量插入订单 - 减少数据库交互次数
 */
@Service
public class OrderBatchService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 批量插入订单
     */
    public int batchInsertOrders(List<Order> orders) {
        String sql = """
            INSERT INTO orders (order_id, user_id, product_id, quantity, 
                               total_amount, status, created_time)
            VALUES (?, ?, ?, ?, ?, 0, NOW())
        """;

        // 分批插入,每批500条
        int batchSize = 500;
        int totalInserted = 0;

        for (int i = 0; i < orders.size(); i += batchSize) {
            List<Order> batch = orders.subList(i, Math.min(i + batchSize, orders.size()));
            
            int[] result = jdbcTemplate.batchUpdate(sql, 
                new BatchPreparedStatementSetter() {
                    @Override
                    public void setValues(PreparedStatement ps, int j) throws SQLException {
                        Order order = batch.get(j);
                        ps.setLong(1, order.getOrderId());
                        ps.setLong(2, order.getUserId());
                        ps.setLong(3, order.getProductId());
                        ps.setInt(4, order.getQuantity());
                        ps.setBigDecimal(5, order.getTotalAmount());
                    }
                    
                    @Override
                    public int getBatchSize() {
                        return batch.size();
                    }
                }
            );
            
            totalInserted += Arrays.stream(result).sum();
        }

        return totalInserted;
    }
}

3.6 熔断降级方案

再好的设计也可能有意外,熔断降级是最后一道保险。

/**
 * 熔断器配置
 */
@Configuration
public class CircuitBreakerConfig {

    /**
     * 数据库操作熔断器
     */
    @Bean
    public CircuitBreaker dbCircuitBreaker() {
        return CircuitBreaker.of("db-operation", 
            CircuitBreakerConfigBuilder.custom()
                .failureRateThreshold(50)  // 失败率超过50%触发熔断
                .waitDurationInOpenState(Duration.ofSeconds(30))  // 熔断30秒
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(20)  // 最近20次调用
                .minimumNumberOfCalls(10)  // 至少10次调用才统计
                .build();
    }

    /**
     * Redis操作熔断器
     */
    @Bean
    public CircuitBreaker redisCircuitBreaker() {
        return CircuitBreaker.of("redis-operation",
            CircuitBreakerConfigBuilder.custom()
                .failureRateThreshold(30)
                .waitDurationInOpenState(Duration.ofSeconds(60))
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(10)
                .minimumNumberOfCalls(5)
                .build();
    }
}
/**
 * 熔断降级服务
 */
@Service
@Slf4j
public class DowngradeService {

    @Autowired
    private CircuitBreaker dbCircuitBreaker;

    @Autowired
    private CircuitBreaker redisCircuitBreaker;

    /**
     * 熔断降级:数据库不可用时,直接返回失败
     */
    public FlashSaleResult downgradeFlashSale(Long productId, Integer buyQty, String userId) {
        // 尝试Redis扣减
        try {
            Boolean result = redisCircuitBreaker.run(
                () -> redisStockService.deductStock(productId, buyQty, userId),
                e -> -1  // 异常时返回-1
            );
            
            if (result != null && result == 1) {
                return FlashSaleResult.success();
            }
        } catch (Exception e) {
            log.error("Redis扣减库存失败", e);
        }

        // 熔断器打开,直接返回库存不足
        return FlashSaleResult.outOfStock();
    }
}

四、监控与告警体系

没有监控的系统就像蒙眼开车,出了事都不知道死在哪。

4.1 关键监控指标

# Prometheus监控配置
groups:
  - name: flash_sale_metrics
    rules:
      # MySQL连接数
      - alert: MySQLConnectionsHigh
        expr: mysql_global_status_threads_connected > 1500
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "MySQL连接数过高"
          
      # MySQL慢查询
      - alert: MySQLSlowQueries
        expr: rate(mysql_global_status_slow_queries[5m]) > 10
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "MySQL慢查询激增"
          
      # Redis库存
      - alert: RedisStockLow
        expr: redis_flash_sale_stock < 100
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "商品库存低于100"
          
      # 订单创建成功率
      - alert: OrderCreateSuccessRate
        expr: order_create_success_rate < 0.95
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "订单创建成功率低于95%"

4.2 实时告警通知

/**
 * 告警服务
 */
@Component
public class AlertService {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 发送告警
     */
    public void sendAlert(String type, String message, Map<String, String> context) {
        AlertMessage alert = new AlertMessage();
        alert.setType(type);
        alert.setMessage(message);
        alert.setContext(context);
        alert.setTimestamp(System.currentTimeMillis());

        // 发送到告警队列
        rocketMQTemplate.syncSend("ALERT_TOPIC", 
            MessageBuilder.withPayload(alert).build());

        // 根据严重程度选择通知方式
        if ("critical".equals(context.get("severity"))) {
            sendDingTalkAlert(alert);
            sendSMSAlert(alert);
        }
    }

    /**
     * 钉钉告警
     */
    private void sendDingTalkAlert(AlertMessage alert) {
        String url = "https://oapi.dingtalk.com/robot/send?access_token=xxx";
        
        Map<String, Object> content = new HashMap<>();
        content.put("msgtype", "markdown");
        content.put("markdown", Map.of(
            "title", "🚨 抢购系统告警",
            "text", String.format("""
                **告警类型**: %s
                **告警内容**: %s
                **时间**: %s
                **上下文**: %s
                """, 
                alert.getType(),
                alert.getMessage(),
                new Date(alert.getTimestamp()),
                alert.getContext()
            )
        ));

        // 发送钉钉消息...
    }
}

五、压测验证方案

方案写好了,必须经过压测验证才能上线。

5.1 压测环境搭建

# docker-compose.yml - 压测环境
version: '3.8'

services:
  # JMeter压测机
  jmeter:
    image: jmeter:5.5
    volumes:
      - ./jmx:/opt/jmeter/testplans
      - ./results:/opt/jmeter/results
    command: >
      jmeter -n -t /opt/jmeter/testplans/flash_sale.jmx
              -l /opt/jmeter/results/result_$(date +%s).jtl
              -e -o /opt/jmeter/results/html/$(date +%s)
    deploy:
      replicas: 3  # 3台压测机并发

  # 被测服务
  flash-sale-api:
    image: flash-sale-api:latest
    environment:
      - SPRING_PROFILES_ACTIVE=stress
    depends_on:
      - mysql
      - redis

  # MySQL - 独立实例,避免相互影响
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: flash_sale
    volumes:
      - mysql_data:/var/lib/mysql
    command: --max-connections=2000

  # Redis - 独立实例
  redis:
    image: redis:7.0
    command: redis-server --maxmemory 2gb --maxmemory-policy allkeys-lru

volumes:
  mysql_data:

5.2 JMeter压测脚本

// 抢购接口压测脚本 - 模拟10万用户并发
public class FlashSaleStressTest {

    /**
     * 配置场景:5秒内并发10万请求
     */
    public static void main(String[] args) {
        // 目标:5秒内完成10万请求
        // 即 QPS = 100000 / 5 = 20000
        
        int totalUsers = 100000;
        int rampUpSeconds = 5;  // 5秒内全部启动
        int loopCount = 1;       // 每人只请求一次
        
        // 计算线程调度
        long pauseBetweenThreads = (rampUpSeconds * 1000L) / totalUsers;
        // pauseBetweenThreads ≈ 0ms,即尽可能同时启动
        
        System.out.println("=== 抢购压测配置 ===");
        System.out.println("总用户数: " + totalUsers);
        System.out.println(" Ramp-up时间: " + rampUpSeconds + "s");
        System.out.println(" 目标QPS: " + (totalUsers / rampUpSeconds));
        System.out.println("===================");
    }
}

5.3 压测指标与通过标准

指标 目标值 说明
吞吐量 ≥5000 QPS 单机目标,集群可线性扩展
平均响应时间 ≤200ms P99≤500ms
错误率 ≤0.01% 不含业务失败(库存不足等)
MySQL CPU使用率 ≤70% 留有余量应对峰值
Redis内存使用率 ≤80% 防止OOM
订单成功率 与Redis一致 最终一致性校验

六、常见坑点与避坑指南

经过这么多项目,踩过的坑够写一本书了,挑几个最关键的说说。

6.1 坑一:Redis和MySQL数据不一致

问题场景:Redis扣减成功,但MQ消息丢失,导致数据库库存没减,超卖。

解决方案

  1. 采用本地消息表保证最终一致性
  2. 定时任务对账,发现差异自动修复
-- 本地消息表
CREATE TABLE `local_message` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型',
  `biz_id` VARCHAR(64) NOT NULL COMMENT '业务ID',
  `content` TEXT NOT NULL COMMENT '消息内容',
  `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2发送失败',
  `retry_count` INT NOT NULL DEFAULT 0,
  `created_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_status_created` (`status`, `created_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

6.2 坑二:热点Key问题

问题场景:只有一个商品在抢购,所有请求都打在同一个Redis Key上,单Key成为瓶颈。

解决方案Key拆分,将库存分散到多个Key:

/**
 * 热点Key拆分 - 将1000库存拆分为10个Key,每个100
 */
public class HotKeySplitter {

    private static final int SPLIT_COUNT = 10;

    /**
     * 获取子Key
     */
    public String getSubKey(Long productId, int index) {
        return "flash_sale:stock:" + productId + ":sub:" + (index % SPLIT_COUNT);
    }

    /**
     * 扣减库存 - 随机选择一个子Key
     */
    public int deductStock(Long productId, int buyQty, String userId) {
        int subIndex = new Random().nextInt(SPLIT_COUNT);
        String subKey = getSubKey(productId, subIndex);
        
        // 尝试扣减
        int result = tryDeduct(subKey, buyQty, userId);
        
        // 如果不足,继续尝试其他子Key
        if (result != 1) {
            for (int i = 0; i < SPLIT_COUNT; i++) {
                if (i == subIndex) continue;
                result = tryDeduct(getSubKey(productId, i), buyQty, userId);
                if (result == 1) break;
            }
        }
        
        return result;
    }

    private int tryDeduct(String key, int buyQty, String userId) {
        // Lua脚本扣减...
    }
}

6.3 坑三:时钟回拨问题

问题场景:服务器时间回拨,导致订单时间戳异常,分布式锁失效。

解决方案:使用业务时间而非系统时间,配合时间轮盘检测:

/**
 * 时间守卫 - 检测时间回拨
 */
public class TimeGuard {

    private volatile long lastTime = System.currentTimeMillis();

    public long getSafeTime() {
        long currentTime = System.currentTimeMillis();
        
        // 时间回拨超过阈值,告警并返回上次安全时间
        if (currentTime < lastTime - 1000) {
            log.error("检测到时间回拨! current={}, last={}, diff={}", 
                currentTime, lastTime, lastTime - currentTime);
            alertService.sendAlert("time_step_back", 
                "时间回拨检测", Map.of("diff", String.valueOf(lastTime - currentTime)));
            return lastTime;
        }
        
        lastTime = currentTime;
        return currentTime;
    }
}

七、成本与收益评估

做这套方案,投入是什么,能换来什么?

7.1 投入成本

项目 说明 预估成本
Redis集群 3主3从,16G内存 约2万/年
MQ集群 RocketMQ,3节点 约1.5万/年
压测环境 3台压测机 约1万/年
人力成本 架构设计+开发+测试 约3人月
合计 约25万/年

7.2 收益评估

收益项 说明 预估价值
避免超卖损失 按每单200元,避免千单损失 20万+/次
避免投诉赔付 按每投诉20元 5万+/次
品牌声誉 无法量化但极其重要 无法估量
用户体验提升 抢购成功率从30%提升到95% 用户留存

结论:这套方案一次大促就能回本,而且保护的是品牌的长期价值。


八、总结与行动清单

写到这里,如果你准备做抢购活动,应该已经清楚该怎么做了。最后给你一个行动清单:

上线前必做Checklist

  • [ ] 限流策略:IP、用户、全局三层限流已配置
  • [ ] Redis预热:库存预热完成,TTL设置合理
  • [ ] Lua脚本:扣减逻辑使用原子操作
  • [ ] 消息队列:异步下单,失败重试机制就绪
  • [ ] 补偿对账:定时对账脚本已部署
  • [ ] 熔断降级:熔断器配置完成,降级逻辑测试通过
  • [ ] 监控告警:核心指标监控已配置,告警通知已测试
  • [ ] 压测验证:QPS达到目标的1.5倍以上
  • [ ] 回滚预案:问题发生时能快速回滚
  • [ ] 值班安排:活动期间技术团队7×24小时值班

一句话总结

把数据库当宝藏保护起来,能放Redis的事不放DB,能异步的事不等同步,能让用户快速失败的早点失败。

高并发不是MySQL的错,是设计没跟上。希望这篇文章能帮你在下一次大促中稳稳当当。如果你在实际落地中遇到问题,欢迎随时交流,我们一起解决。