秒杀是检验数据库架构能力的试金石:瞬时流量可能是平时的几百倍,同一件商品的库存行会被成千上万的请求同时争抢。只要设计稍有不慎,就会出现超卖、服务雪崩或数据库被打垮。本节从 MySQL 的角度,拆解一套真实可落地的防超卖与性能优化方案。
26.5.1 秒杀的三个核心挑战
- 超卖:库存只有 100 件,最终却卖出了 120 件。这是最致命的数据一致性问题。
- 性能压力:秒杀开始瞬间,大量请求涌入,数据库连接数被打满、锁等待堆积、CPU 飙升,最终整个服务不可用。
- 限流与公平性:如何拒绝多余的请求,同时尽量让真正抢到的用户有较好的体验。
26.5.2 库存超卖的根源与解决思路
超卖的根本原因在于读写竞态:多个请求同时读到一个“库存为 1”,然后都认为自己可以扣减,最终扣了多次。
❌ 错误做法:先查询再更新
-- 步骤一:查询库存
SELECT stock FROM product WHERE id = 100;
-- 步骤二:应用程序判断 stock > 0
-- 步骤三:更新库存
UPDATE product SET stock = stock - 1 WHERE id = 100;
这两个操作之间没有原子性保护,在并发下必然超卖。
✅ 正确方案一:利用 MySQL 行锁与原子更新
最简单的防超卖就是把判断和扣减合并为一条原子语句:
UPDATE product SET stock = stock - 1
WHERE id = 100 AND stock > 0;
然后检查受影响行数(affected rows)。如果返回 0,说明库存已耗尽,秒杀失败。这一条语句利用了 InnoDB 的行级锁:当多个事务同时执行这条 UPDATE 时,同一行会被串行化,一个事务修改后 stock 减 1,下一个事务看到的 stock 已经符合实情,不会超卖。
如果需要严格的幂等性和去重(防止同一用户重复秒杀),可以进一步加入用户扣减记录表,用事务包裹:
START TRANSACTION;
-- 乐观检查:是否已秒杀
SELECT 1 FROM seckill_order WHERE user_id = 123 AND product_id = 100 FOR UPDATE;
-- 如果没有,扣库存
UPDATE product SET stock = stock - 1
WHERE id = 100 AND stock > 0;
-- 插入秒杀记录
INSERT INTO seckill_order (user_id, product_id) VALUES (123, 100);
COMMIT;
事务中先对用户记录加 FOR UPDATE 锁,确保同一用户不会被并发重复处理,同时库存更新受行锁保护。
✅ 正确方案二:乐观锁(版本号或时间戳)
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = @old_version;
如果 @old_version 已经被其他事务改变,受影响行数为 0,重试即可。乐观锁避免了行锁等待,但需要处理重试逻辑,适合冲突不太极大的场景。
✅ 正确方案三:Redis 预减库存 + 数据库最终兜底
将库存同步到 Redis,在 Redis 中进行快速的原子递减(DECR),大幅降低打到数据库的流量。只有当 Redis 库存扣减成功(>0),才向数据库发送最终扣减请求。数据库主要负责持久化和最终一致性兜底。这个架构最常见,门槛低,性能极高。
26.5.3 限流策略:保护 MySQL 不被瞬间冲垮
即时只有少数请求真正进入“扣库存”事务,但海量的无效请求仍会占用连接、消耗资源。需要多级限流。
1. 网关/负载均衡层限流
通过 Nginx 的 limit_req 或云厂商的 API 网关,限制单 IP 的请求频率(比如每节点每秒允许 10 次请求),过滤掉大部分恶意和无效请求。
2. 应用服务器限流
在业务层使用令牌桶或漏桶算法(如 Guava RateLimiter、Sentinel),每个服务实例只允许少量请求进入“秒杀处理线程池”。示例:
RateLimiter limiter = RateLimiter.create(1000); // 每秒1000个令牌
if (!limiter.tryAcquire()) {
throw new BusyException("活动太火爆,请稍后再试");
}
3. 前端排队/MQ 削峰
用户点击秒杀后,不直接到后端,而是先进入消息队列(如 Kafka、RocketMQ),由消费者按固定速率消费扣库存。用户端显示“排队中”,成功后再通知。这种方式虽然牺牲了实时性,但彻底保护了数据库。
26.5.4 数据库层面的性能优化
在秒杀这样的极端场景下,数据库除了保证正确性,还需要在可承受的 QPS 下稳定运行。
① 连接数控制
将应用层连接池(如 HikariCP)的大小调低,例如 maximumPoolSize = 20,这样即使应用实例很多,到数据库的总连接数也是可控的。避免因连接过多导致 MySQL 线程调度开销激增。MySQL 端 max_connections 可根据压测结果设置一个安全上限,比如 500,超出直接拒绝而不是排队等待。
② 热点行与行锁优化
库存行是绝对的热点,所有扣减请求串行化在这一行上。优化方向:
- 减少锁持有时间:事务内只做必要的操作,任何外部调用、日志打印都放在事务外。
- 拆分库存桶:将总库存拆为多个子库存(如 100 个库存拆成 10 个桶,每个桶 10 件),请求随机选取一个桶扣减。这样可以将行锁竞争分散到多个行,吞吐量线性提升。当总库存不足时,再回退到合并判断。
示例表结构:
CREATE TABLE product_stock (
product_id INT,
bucket_no INT,
stock INT,
PRIMARY KEY (product_id, bucket_no)
);
-- 扣减时随机选桶
UPDATE product_stock SET stock = stock - 1
WHERE product_id = 100 AND bucket_no = FLOOR(RAND()*10) AND stock > 0;
③ 不依赖数据库的纯缓存扣减
如果对数据的绝对一致性要求不是金融级别,可以让 Redis 直接扣减库存并异步落库。数据库只在 Redis 失败或活动结束后做一次对账。
④ 关闭查询缓存和耗时监控
MySQL 8.0 已移除查询缓存,但如果用 5.7 注意关闭。同时,不要在这种业务中开启 general_log 或慢查询日志的全量记录,避免额外的 I/O 开销。
26.5.5 推荐架构方案
一个经过实战检验的中大型秒杀架构分层如下:
用户 → CDN/静态化 → 网关限流 → 应用服务器(轻量校验+限流)
→ Redis(预扣库存) → MQ(异步下单) → 消费者 → MySQL(最终扣库存)
- 网关层:承担大部分粗粒度限流。
- 应用层:只做简单的参数校验和用户登录态检查,并将请求转发至 Redis。
- Redis:执行原子 DECR,返回是否抢到,并记录已抢用户ID(防止重复抢)。成功后产生一条消息至 MQ。
- MQ:削峰填谷,消费者以稳定的速率(比如每秒 1000 个)消费消息,执行 MySQL 的事务扣库存+生成订单。即便 MySQL 短暂抖动,MQ 也能暂存消息,避免丢失。
如果团队技术栈偏简单,或者秒杀规模可控(如几千 QPS),可以在应用层直接使用 MySQL 的 UPDATE ... WHERE stock>0 方案,配合行级锁和极简事务,省去 Redis 与 MQ。实测单行热点大约支持 500~2000 QPS,视硬件和事务复杂度而定。
26.5.6 常见的坑与避坑指南
- 不要把商品详情页的读流量打到主库:务必用 Redis 缓存或读写分离,秒杀的业务读(如活动页)与写分离。
- 不要用
SELECT ... FOR UPDATE锁定不必要的行:比如先查商品再锁,锁范围变大。应直接用更新语句。 - 注意事务隔离级别:默认 REPEATABLE READ 在高并发下间隙锁可能导致死锁。可考虑在秒杀业务中使用 READ COMMITTED(读已提交),既减少锁范围,又能满足本次防超卖需求(更新语句本身有行锁保护)。
- 不要忽略幂等性:MQ 消费可能重复,数据库事务必须保证同一用户重复秒杀时不会多扣库存。
- 压测,压测,压测:至少在线下模拟真实并发,确认库存不超卖且延迟在接受范围内。
秒杀的本质不是“让所有人都买到”,而是“在极限压力下正确地服务有限的请求”。MySQL 提供了最底层的原子性和一致性保证,配合缓存和消息队列,足以支撑绝大多数业务场景的秒杀需求。