摘要:从一次双十一秒杀翻车事故出发,揭秘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挂了,秒杀系统怎么办?
答案:
多层保护:
- Redis哨兵/集群:主节点挂了,自动切换到从节点(秒级)
- 熔断降级:Redis不可用时,直接返回"系统繁忙",保护MySQL
- 限流兜底:降低限流阈值,部分请求直接打到MySQL(风险可控)
- 应急预案:提前准备好"全部走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秒内点击100次)
- 答题验证:秒杀前必须答对题目
- 人机验证:滑动验证码、点选验证码
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万人抢,如何让用户体验最好?
答案:
策略组合:
- 前端排队动画:显示"前面还有XX人",而不是一直转圈
- 分批放量:不是一次性放100个,而是每秒放10个,持续10秒
- 幸运值机制:失败的用户下次抢购提升优先级
- 安慰奖:没抢到的用户发优惠券
- 候补名单:用户取消订单后,自动补给候补用户
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优化!记住:高并发不是靠单机硬扛,而是靠架构设计!
收藏+点赞,下次双十一不翻车!💪