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消息丢失,导致数据库库存没减,超卖。
解决方案:
- 采用本地消息表保证最终一致性
- 定时任务对账,发现差异自动修复
-- 本地消息表
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的错,是设计没跟上。希望这篇文章能帮你在下一次大促中稳稳当当。如果你在实际落地中遇到问题,欢迎随时交流,我们一起解决。
