高并发场景下,数据库不再是瓶颈,而是需要被“保护”的对象
秒杀系统,几乎是所有互联网大厂面试中的必考题。当面试官抛出“如何设计秒杀系统”时,很多人滔滔不绝地讲Redis预扣库存、MQ异步削峰、CDN静态化……
但当面试官追问一句:“如果让你在数据库层面防爆,你会怎么做?”很多人就卡壳了。
今天,我们就直击痛点,聊聊秒杀系统在数据库层面的防爆实战。
一、为什么数据库会“爆”?
秒杀系统的流量特点是:瞬时流量极高、热点数据集中、读写冲突剧烈。
假设一个商品库存100件,10万人同时抢。如果没有防护,数据库会面临:
- 连接数被打满:大量线程阻塞在数据库连接池上
- 行锁竞争激烈:对同一行库存记录的更新操作排队执行
- 死锁风险:多表操作或复杂事务容易触发死锁
- 主从延迟:高并发写入导致从库同步滞后
一句话:数据库不是用来扛高并发的,是用来保证数据一致性的。
二、数据库防爆的核心策略
1. 连接池隔离:别让秒杀拖垮整个系统
问题:秒杀流量把数据库连接池占满,导致正常业务无法访问数据库。
解决方案:为秒杀业务单独配置数据库连接池。
# HikariCP 配置示例# 正常业务连接池normal-pool: maximum-pool-size: 50 connection-timeout: 30000# 秒杀业务专用连接池(更小的连接数)seckill-pool: maximum-pool-size: 10 connection-timeout: 5000 # 超时更快,快速失败 connection-init-sql: "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED"关键点:
- 专用连接池连接数控制在 5-15 之间,防止打满整个数据库连接资源
- 超时时间设短,快速失败比长时间等待更优雅
2. 库存扣减:用对SQL,避免超卖
问题:高并发下,多个请求同时读到剩余库存>0,然后同时扣减,导致超卖。
错误写法:
-- 先查询,后更新(有并发风险)SELECT stock FROM product WHERE id = 1;if (stock > 0) { UPDATE product SET stock = stock - 1 WHERE id = 1;}正确写法:用带条件的原子更新
-- 一条SQL搞定,数据库保证原子性UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0;-- 返回受影响行数,为1表示成功,为0表示库存不足进阶方案:乐观锁+版本号(适用于非热点场景,秒杀慎用)
UPDATE product SET stock = stock - 1, version = version + 1WHERE id = 1 AND version = #{version} AND stock > 0;秒杀场景建议:直接用悲观锁(行锁),因为冲突概率极高,乐观锁重试反而浪费资源。
3. 事务控制:缩短锁持有时间
问题:事务中包含远程调用、复杂业务逻辑,导致行锁长时间不释放。
反例:
@Transactionalpublic void seckill(Long userId, Long productId) { // 扣减库存(持有行锁) productMapper.decreaseStock(productId); // 创建订单 orderMapper.insert(order); // 发送MQ通知(远程调用,可能耗时) mqClient.send(message);}正确做法:事务越小越好,扣库存与创建订单可以解耦
public void seckill(Long userId, Long productId) { // 1. 扣减库存(独立事务,立即释放锁) int rows = productMapper.decreaseStock(productId); if (rows == 0) { throw new StockException("库存不足"); } // 2. 异步创建订单(MQ解耦) orderMQ.send(new OrderMessage(userId, productId));}关键原则:
- 事务中只做必要的数据操作
- 把网络调用、缓存操作、外部请求移出事务
- 设置事务超时时间 @Transactional(timeout = 3)
4. 表结构优化:从源头降低冲突
问题:所有秒杀商品共用一张库存表,热点数据扎堆。
解决方案:分表+行设计优化
分表策略:
-- 按商品ID哈希分表,如拆成16张表product_stock_0product_stock_1...product_stock_15库存行设计优化:
CREATE TABLE `product_stock` ( `id` bigint NOT NULL, `stock` int NOT NULL COMMENT '总库存', `sold` int NOT NULL DEFAULT 0 COMMENT '已售数量', `version` int NOT NULL DEFAULT 0, PRIMARY KEY (`id`)) ENGINE=InnoDB;进阶技巧:将库存拆成多个槽位
-- 将100件库存拆成10行,每行10件-- 扣减时随机选一行操作,分散行锁压力UPDATE product_stock_slot SET stock = stock - 1 WHERE product_id = 1 AND slot_id = #{randomSlot} AND stock > 0;5. 限流熔断:在数据库前兜底
问题:再优化的SQL也扛不住无限流量。
解决方案:在数据库访问层做限流
// 使用RateLimiter或Semaphoreprivate final Semaphore semaphore = new Semaphore(10); // 数据库层并发上限public void seckill(Long productId) { if (!semaphore.tryAcquire(1, TimeUnit.MILLISECONDS)) { throw new BusyException("系统繁忙,请稍后再试"); } try { // 执行数据库操作 productMapper.decreaseStock(productId); } finally { semaphore.release(); }}数据库层面的熔断配置:

-- MySQL:设置最大连接数SET GLOBAL max_connections = 500;-- 设置线程池大小(MariaDB/Percona)SET GLOBAL thread_pool_size = 16;SET GLOBAL thread_pool_max_threads = 100;6. 最终一致性:异步削峰
问题:所有请求都实时落库,数据库扛不住。
解决方案:Redis预扣库存 + 异步消费落库
流程:用户请求 → Redis预扣库存(Lua脚本保证原子性)→ 返回“排队中”→ 发送MQ消息 → 消费者批量落库(每批100条)→ 更新最终库存Redis Lua脚本示例:
-- 库存扣减Lua脚本local key = KEYS[1]local stock = tonumber(redis.call('get', key))if stock and stock > 0 then redis.call('decr', key) return 1endreturn 0三、面试加分项:说出你的防爆三板斧
当面试官问到数据库防爆时,可以这样总结:
第一板斧:限流隔离
专用连接池 + 数据库层限流,防止流量打满连接。第二板斧:行锁优化
单行原子更新 + 短事务 + 多槽位拆分,最小化锁竞争。第三板斧:异步削峰
Redis预扣库存 + MQ异步落库,用最终一致性换取系统稳定性。
四、总结
数据库在秒杀系统中的角色,不是“扛流量”的战士,而是“保一致”的底线。
记住三个核心原则:
- 能不进库就不进库:用Redis、CDN、静态化挡掉99%的流量
- 进库就要快:短事务、原子操作、减少锁持有时间
- 扛不住就限流:在数据库前面加一层限流熔断,优雅降级
掌握了这些,再遇到“数据库防爆”的问题,你就能从容应对了。