电商大促数据库崩盘怎么办MySQL高并发处理策略从订单超卖到连接池优化完整解决方案
做电商的谁没经历过那种”黑五”时刻?
凌晨零点,订单量瞬间翻几十倍,数据库连接数直接拉满,服务器报警声此起彼伏,产品经理在群里疯狂@技术负责人。这时候你要是还能冷静地喝着咖啡看监控面板,那绝对是修炼到位了。
今天就把这些年踩过的坑、调过的系统,毫无保留地讲一遍。从订单超卖到连接池优化,再到完整的高并发处理策略,咱们一点一点拆开看。
一、超卖问题:大促最致命的伤
超卖是什么?简单说就是库存只有100件,结果卖出去了200件。 customer付款成功,后台一查库存为负,发货发不出来,退款+投诉+差评一条龙,平台信誉直接跌入谷底。
1.1 超卖的根源分析
大多数系统超卖的根源,都逃不出这几个问题:
并发读取库存时没有做隔离。
两个订单同时读取库存为10,各自判断”库存充足”,然后各自减1,最后库存变成8。实际应该卖出去2件,但库存只显示减少了2件(从10到8),而实际上卖出了更多。
事务粒度不够小或者完全没加锁。
有些系统为了性能,查询库存不加锁,更新库存时也不加锁,完全依赖应用层逻辑判断。这种设计在大促流量面前,等于裸奔。
缓存和数据库不一致。
库存放在Redis缓存里,并发更新缓存,但数据库同步有延迟。用户看到缓存里还有库存,下单后数据库层面库存其实已经不够了。
1.2 解决超卖的核心思路
第一层:数据库悲观锁
最直接的方式,在查询库存时加排他锁。
-- 查询并锁定库存行
SELECT stock FROM product_stock WHERE product_id = 12345 FOR UPDATE;
-- 检查库存是否充足
-- 如果充足,扣减库存
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 12345 AND stock >= 1;
这里FOR UPDATE是关键,它会在事务期间对这一行加上排他锁,其他事务必须等待这个事务提交或回滚后才能操作这行数据。
但悲观锁有个问题:高并发下所有请求都在排队等锁,吞吐量直接下降。大促场景下,如果每秒几万的下单请求都堵在这里,数据库连接会被锁等待直接撑爆。
第二层:乐观锁 + CAS更新
更常用的方式是乐观锁,利用数据库的CAS(Compare And Swap)机制。
-- 不锁行,直接尝试更新
UPDATE product_stock
SET stock = stock - 1, version = version + 1
WHERE product_id = 12345
AND stock >= 1
AND version = 5; -- 版本号匹配才更新
更新成功后,检查影响行数。如果影响行数为0,说明库存不足或者版本冲突,需要重试或者返回给用户”售罄”。
// Java伪代码示例
public boolean deductStock(Long productId) {
int retryCount = 0;
int maxRetry = 3;
while (retryCount < maxRetry) {
// 1. 先查询当前版本号(可选,用于乐观锁)
ProductStock stock = stockMapper.selectByVersion(productId);
// 2. 尝试CAS更新
int rows = stockMapper.deductStockCAS(productId, stock.getVersion());
if (rows > 0) {
return true; // 扣减成功
}
retryCount++;
if (retryCount >= maxRetry) {
return false; // 重试耗尽,返回失败
}
// 短暂等待后重试
Thread.sleep(10);
}
return false;
}
deductStockCAS对应的SQL就是上面那条带version条件的UPDATE。这种方式不需要显式加锁,性能更好,但在极端高并发下,重试率可能会比较高。
第三层:Redis原子扣减 + 异步同步数据库
对于超大规模的大促场景,仅靠MySQL可能扛不住,这时候Redis的原子操作就派上用场了。
// 使用Redis的DECR命令原子扣减库存
public boolean deductStockWithRedis(Long productId, int quantity) {
String stockKey = "product:stock:" + productId;
// 获取当前库存
Long currentStock = redisTemplate.opsForValue().get(stockKey);
if (currentStock == null || currentStock < quantity) {
return false; // 库存不足
}
// 原子扣减
Long newStock = redisTemplate.opsForValue().decrement(stockKey, quantity);
if (newStock < 0) {
// 扣减成功但库存变成负数,说明超卖了,需要回滚
redisTemplate.opsForValue().increment(stockKey, quantity);
return false;
}
return true;
}
Redis的DECR、DECRBY都是原子操作,天然支持高并发。但这里有个问题:Redis是缓存,数据最终要落到数据库。如果Redis扣减成功但数据库同步失败,就会出现数据不一致。
第四层:消息队列削峰 + 异步落库
完整的解决方案应该是:Redis预扣库存 + 消息队列削峰 + 数据库最终落库。
// 伪代码示意完整流程
public void createOrder(OrderRequest request) {
// 1. Redis原子扣减库存(快速响应)
boolean deducted = redisDeductStock(request.getProductId(), request.getQuantity());
if (!deducted) {
throw new BusinessException("库存不足");
}
// 2. 扣减成功后,发送消息到MQ(异步处理)
OrderMessage message = new OrderMessage();
message.setProductId(request.getProductId());
message.setQuantity(request.getQuantity());
message.setOrderId(generateOrderId());
message.setTimestamp(System.currentTimeMillis());
mqProducer.send("order-create-topic", message);
// 3. 立即返回给用户"下单成功"(实际上订单还在创建中)
return "下单成功,请等待确认";
}
// 消费者端:异步创建订单并落库
@RabbitListener(queues = "order-create-queue")
public void handleOrderMessage(OrderMessage message) {
try {
// 落库创建订单
Order order = new Order();
order.setProductId(message.getProductId());
order.setQuantity(message.getQuantity());
order.setOrderId(message.getOrderId());
order.setStatus("CREATED");
orderMapper.insert(order);
// 同步扣减数据库库存(作为最终一致性保障)
stockMapper.deductStockCAS(message.getProductId(), 1);
} catch (Exception e) {
// 记录日志,发送告警,进入重试队列
log.error("订单创建失败", e);
mqProducer.sendRetry("order-create-retry-queue", message);
}
}
这个方案的核心思想是:快速响应 + 异步落库 + 最终一致性。用户感知到的是秒级响应,而数据库层面的压力被消息队列均匀分散了。
二、连接池优化:被忽视的性能黑洞
数据库连接是昂贵的资源。每次新建连接都需要TCP三次握手、SSL协商(如果用了加密)、MySQL身份验证、权限检查等流程。在大促场景下,如果连接池配置不当,要么连接不够用导致请求排队,要么连接太多把数据库压垮。
2.1 连接池的核心参数
以HikariCP为例(Spring Boot默认的连接池),几个关键参数:
spring:
datasource:
hikari:
# 连接池最小空闲连接数
minimum-idle: 10
# 连接池最大连接数(核心参数)
maximum-pool-size: 50
# 连接最大生命周期(防止连接老化)
max-lifetime: 1800000 # 30分钟
# 连接超时时间(等待获取连接的超时)
connection-timeout: 30000 # 30秒
# 空闲连接超时时间
idle-timeout: 600000 # 10分钟
# 连接测试查询
connection-test-query: SELECT 1
maximum-pool-size是最容易出问题的参数。很多团队直接设置为一个固定值,比如50或者100。但这个值需要根据实际情况计算:
推荐的最大连接数 = CPU核心数 × 2 + 磁盘数
比如你的服务器是8核CPU,2块磁盘,那么推荐连接数是 8×2 + 2 = 18。为什么不能设太大?因为连接数太多,数据库侧的并发处理压力会指数级增长,上下文切换开销也会很大。
2.2 连接泄漏的检测与预防
连接泄漏是最头疼的问题之一。代码里获取了连接但没有正确关闭,或者异常时忘记释放,连接池里的连接就会被慢慢耗尽。
// 错误的写法 - 容易泄漏连接
Connection conn = dataSource.getConnection();
try {
// 业务逻辑
conn.prepareStatement("UPDATE ...").execute();
} catch (Exception e) {
// 异常时没有关闭连接,直接return了
return false;
} finally {
// 这里可能永远执行不到
conn.close();
}
正确的写法应该用try-with-resources:
// 正确的写法 - 自动关闭连接
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement("UPDATE product_stock SET stock = stock - ? WHERE product_id = ? AND stock >= ?")) {
stmt.setInt(1, quantity);
stmt.setLong(2, productId);
stmt.setInt(3, quantity);
int rows = stmt.executeUpdate();
return rows > 0;
} catch (SQLException e) {
log.error("库存扣减失败", e);
return false;
}
HikariCP本身有连接泄漏检测机制:
spring:
datasource:
hikari:
# 连接泄漏检测超时时间(0表示禁用)
leak-detection-threshold: 60000 # 60秒未归还则记录警告
当检测到连接泄漏时,HikariCP会在日志里打出警告信息,方便定位问题代码。
2.3 读多写少场景的读写分离
电商系统通常是读多写少的场景。浏览商品、查看订单列表这些操作占绝大多数,真正下单写库的操作只占一小部分。读写分离可以大幅降低主库压力。
spring:
datasource:
# 主库配置
master:
jdbc-url: jdbc:mysql://master-db:3306/ecommerce?useSSL=false
username: root
password: xxxxx
# 从库配置
slave:
jdbc-url: jdbc:mysql://slave-db:3306/ecommerce?useSSL=false
username: root
password: xxxxx
# 使用动态数据源切换
@Configuration
public class DataSourceConfig {
@Bean
@Primary
public DataSource dynamicDataSource(
@Qualifier("masterDataSource") DataSource master,
@Qualifier("slaveDataSource") DataSource slave) {
DynamicDataSource dynamicDataSource = new DynamicDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DBTypeEnum.MASTER, master);
targetDataSources.put(DBTypeEnum.SLAVE, slave);
dynamicDataSource.setTargetDataSources(targetDataSources);
dynamicDataSource.setDefaultTargetDataSource(master);
return dynamicDataSource;
}
// 通过AOP或注解切换数据源
@DS(DBTypeEnum.SLAVE)
public List<Product> queryProducts(Long categoryId) {
// 查询操作走从库
return productMapper.selectByCategoryId(categoryId);
}
@DS(DBTypeEnum.MASTER)
public boolean createOrder(Order order) {
// 写操作走主库
return orderMapper.insert(order) > 0;
}
}
读写分离要注意数据延迟问题。从库同步主库数据有毫秒到秒级的延迟,如果用户刚下单就立刻查询订单状态,可能从库还没同步到最新数据。解决方案是在关键业务路径上强制走主库,或者接受短暂的最终一致性。
三、慢查询优化:大促时的隐形杀手
数据库崩了,很多时候不是因为连接数不够,而是因为几个慢查询把整个数据库拖死了。一条没有索引的查询,在大促流量下可能会扫描几十万甚至上百万行数据,锁住大量资源。
3.1 排查慢查询
MySQL自带慢查询日志功能:
-- 查看慢查询配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL long_query_time = 1; -- 超过1秒的查询记录为慢查询
生产环境建议将long_query_time设置为0.5秒甚至更低,这样能捕获更多潜在问题。
3.2 索引优化实战
大促期间最容易出问题的查询,通常是缺少索引的复杂查询。
场景一:订单列表查询
-- 问题SQL:没有复合索引,全表扫描
SELECT * FROM orders
WHERE user_id = 12345
AND create_time > '2024-11-10 00:00:00'
AND status IN (1, 2, 3)
ORDER BY create_time DESC
LIMIT 20;
如果orders表没有合适的索引,这个查询会扫描整个表。正确的做法是建立复合索引:
-- 添加复合索引
CREATE INDEX idx_user_time_status ON orders (user_id, create_time, status);
建立索引后,执行计划会从全表扫描变成索引范围扫描,查询时间从几秒降到几毫秒。
场景二:商品搜索
-- 问题SQL:对商品名称做模糊查询,无法走索引
SELECT * FROM products
WHERE name LIKE '% iPhone %'
AND category_id = 100
ORDER BY sales DESC
LIMIT 20;
LIKE %xxx%开头的模糊查询无法使用B树索引。解决方案:
-- 方案一:建立复合索引,利用最左前缀原则
CREATE INDEX idx_category_name_sales ON products (category_id, name, sales);
-- 注意:LIKE '%xxx%'仍然无法使用索引,需要换方案
-- 方案二:使用全文索引(MySQL 5.6+)
ALTER TABLE products ADD FULLTEXT INDEX ft_idx_name (name);
-- 使用全文索引查询
SELECT * FROM products
WHERE MATCH(name) AGAINST('iPhone' IN BOOLEAN MODE)
AND category_id = 100
ORDER BY sales DESC
LIMIT 20;
-- 方案三:引入ES/Elasticsearch做搜索
-- 大数据量下,ES是更好的选择
场景三:统计类查询
-- 问题SQL:对大表做COUNT统计
SELECT COUNT(*) FROM orders WHERE create_time > '2024-11-01';
在订单表有几千万行的情况下,这种查询会非常慢。优化方案:
-- 方案一:使用覆盖索引(只查索引列,不查数据行)
SELECT COUNT(*) FROM orders
WHERE create_time > '2024-11-01'
AND create_time < '2024-12-01';
-- 如果索引是 (create_time),则可以走覆盖索引,避免回表
-- 方案二:建立单独的统计表,定时更新
-- 用触发器或定时任务,每小时/每天统计一次,查询时直接读统计表
-- 方案三:使用近似计数(可接受一定误差时)
-- 在Redis中维护计数,每次插入订单时INCR
3.3 EXPLAIN分析执行计划
每优化一条慢查询之前,先用EXPLAIN看看执行计划:
EXPLAIN SELECT * FROM orders
WHERE user_id = 12345
AND create_time > '2024-11-10 00:00:00'
ORDER BY create_time DESC
LIMIT 20;
关注几个关键字段:
- type:访问类型,从好到差依次是:
system > const > eq_ref > ref > range > index > ALL。ALL是最差的,表示全表扫描。 - key:实际使用的索引。如果为NULL,说明没用上索引。
- rows:预估扫描的行数。行数越大,查询越慢。
- Extra:额外信息。
Using filesort表示需要额外排序,Using temporary表示使用了临时表,都是需要优化的信号。
四、分库分表:架构层面的终极方案
当单表数据量超过千万级,或者单库QPS超过承受极限,分库分表就是必须考虑的方案了。
4.1 分库策略
按用户ID分库是常见的做法,保证同一个用户的数据在同一个库里,避免跨库查询。
-- 逻辑库名
ecommerce_db
-- 实际物理库
ecommerce_db_0
ecommerce_db_1
ecommerce_db_2
ecommerce_db_3
-- 分库规则:user_id % 4
-- user_id为奇数的用户数据在 ecommerce_db_1 和 ecommerce_db_3
-- user_id为偶数的用户数据在 ecommerce_db_0 和 ecommerce_db_2
4.2 分表策略
订单表按时间分表是常见做法,大促期间按天或按周分表:
-- 按月分表
orders_202411
orders_202412
orders_202501
-- ...
-- 按月分表的创建脚本
CREATE TABLE orders_202411 (
id BIGINT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
INDEX idx_user_id (user_id),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4.3 使用ShardingSphere简化分库分表
手写分库分表逻辑太麻烦了,推荐使用Apache ShardingSphere:
# sharding.yml 配置示例
dataSources:
ds_0:
url: jdbc:mysql://localhost:3306/ecommerce_ds_0
username: root
password: xxxxx
ds_1:
url: jdbc:mysql://localhost:3306/ecommerce_ds_1
username: root
password: xxxxx
rules:
- !SHARDING
tables:
orders:
actualDataNodes: ds_$->{0..1}.orders_$->{202411..202501}
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: orders-table-inline
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
shardingAlgorithms:
orders-table-inline:
type: INLINE
props:
algorithm-expression: orders_$->{order_id % 13}
keyGenerators:
snowflake:
type: SNOWFLAKE
配置完成后,应用层代码完全不需要关心分片逻辑,ShardingSphere会自动路由到正确的库和表。
五、缓存策略:数据库的缓冲层
即使做了分库分表,数据库仍然可能扛不住大促流量。缓存是最后一道防线,但缓存用不好,问题比不用缓存更多。
5.1 缓存穿透、击穿、雪崩
缓存穿透:查询不存在的数据,缓存和数据库都没有,每次请求都直接打到数据库。
// 解决方案:缓存空值
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 1. 先查缓存
Product product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 2. 缓存没有,查数据库
product = productMapper.selectById(productId);
// 3. 如果数据库也没有,缓存一个空值(避免每次都穿透)
if (product == null) {
redisTemplate.opsForValue().set(key, null, 5, TimeUnit.MINUTES);
return null;
}
// 4. 缓存结果
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
return product;
}
缓存击穿:某个热点key过期,大量请求同时打到数据库。
// 解决方案:互斥锁
public Product getProductWithLock(Long productId) {
String key = "product:" + productId;
Product product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 获取分布式锁
String lockKey = "lock:product:" + productId;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查
product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 查数据库
product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
}
return product;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 没获取到锁,短暂等待后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductWithLock(productId);
}
}
缓存雪崩:大量key同时过期,或者Redis整体宕机。
// 解决方案:过期时间加随机值
public void setWithRandomTTL(String key, Object value) {
// 基础过期时间30分钟 + 随机0-10分钟
int baseTTL = 30;
int randomTTL = new Random().nextInt(10);
redisTemplate.opsForValue().set(key, value, baseTTL + randomTTL, TimeUnit.MINUTES);
}
5.2 缓存与数据库一致性
双写不一致是大促场景最常见的问题之一。正确的做法是:先更新数据库,再删除缓存(不是更新缓存)。
// 错误的做法:先更新缓存,再更新数据库
// 原因:并发场景下,A线程更新缓存后B线程更新数据库,可能导致脏数据
// 正确的做法:先更新数据库,再删除缓存
@Transactional
public boolean updateProduct(Long productId, ProductUpdateRequest request) {
// 1. 先更新数据库
Product product = new Product();
product.setId(productId);
product.setPrice(request.getPrice());
product.setStock(request.getStock());
productMapper.updateById(product);
// 2. 再删除缓存(不是更新缓存)
String key = "product:" + productId;
redisTemplate.delete(key);
// 3. 如果删除失败,可以通过消息队列重试
return true;
}
为什么是”删除缓存”而不是”更新缓存”?因为缓存的key和value映射关系可能很复杂,直接更新容易出错。删除后,下次查询时会重新加载最新数据到缓存,保证一致性。
六、限流与熔断:保护系统的最后一道闸门
当流量超过系统承受能力时,限流和熔断是保护系统不崩盘的最终手段。
6.1 接口限流
// 使用Guava RateLimiter做单机限流
private final RateLimiter rateLimiter = RateLimiter.create(1000.0); // 每秒1000个请求
public Result createOrder(OrderRequest request) {
// 尝试获取令牌,获取不到则限流
if (!rateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
return Result.fail("系统繁忙,请稍后重试");
}
// 正常业务逻辑
return orderService.createOrder(request);
}
分布式限流可以用Redis + Lua脚本实现:
-- rate_limit.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
if current > limit then
return 0
end
return 1
// Java调用
public boolean tryAcquire(String userId) {
String key = "rate_limit:" + userId;
List<String> keys = Collections.singletonList(key);
List<String> args = Arrays.asList("100", "60"); // 每秒100次,窗口60秒
Long result = (Long) redisTemplate.execute(
new DefaultRedisScript<>(limitScript, Long.class),
keys, args.toArray()
);
return result != null && result == 1;
}
6.2 熔断降级
当某个下游服务(比如库存服务)响应变慢或者失败率上升时,熔断器会快速失败,避免雪崩效应。
// 使用Spring Cloud CircuitBreaker
@CircuitBreaker(name = "stockService", fallbackMethod = "getStockFallback")
public StockInfo getStock(Long productId) {
return stockFeignClient.getStock(productId);
}
// 降级逻辑
public StockInfo getStockFallback(Long productId, Throwable cause) {
log.warn("库存服务降级,productId: {}", productId, cause);
// 从Redis缓存获取库存(缓存可能过期,但总比没有好)
String cacheKey = "product:stock:" + productId;
String cachedStock = redisTemplate.opsForValue().get(cacheKey);
if (cachedStock != null) {
return new StockInfo(productId, Integer.parseInt(cachedStock), true);
}
return new StockInfo(productId, 0, true);
}
熔断器的状态机:
- 关闭状态:正常请求
- 开启状态:所有请求直接走降级逻辑
- 半开状态:周期性尝试少量请求,如果成功则恢复关闭状态
七、大促前的 checklist
最后,分享一个我们团队在大促前必做的检查清单,亲测有效:
性能层面:
- [ ] 数据库慢查询日志已开启,过去一周的慢查询已优化
- [ ] 核心接口加了索引,EXPLAIN执行计划最优
- [ ] 连接池参数根据压测结果调整过
- [ ] Redis缓存命中率监控已接入
- [ ] 消息队列消费速度已压测,不会堆积
容量层面:
- [ ] 数据库CPU、内存、IO容量评估过,留有30%以上余量
- [ ] 应用服务器并发连接数评估过
- [ ] 带宽峰值预估过,CDN和限流策略已配置
- [ ] 备份数据库和从库已就绪
监控告警层面:
- [ ] 核心指标监控已配置(QPS、响应时间、错误率、连接数)
- [ ] 告警阈值设置合理(不要太敏感也不要太迟钝)
- [ ] 告警通知渠道已测试(短信、电话、钉钉/飞书)
- [ ] 值班表已排好,关键人员联系方式已确认
应急预案层面:
- [ ] 降级开关已准备好(可以一键关闭非核心功能)
- [ ] 限流策略已测试(可以一键提高限流阈值或关闭限流)
- [ ] 数据库读写分离故障切换流程已验证
- [ ] 回滚方案已准备好(如果新代码有问题可以快速回滚)
写在最后
大促数据库崩盘,说到底是对并发场景下的系统行为预估不足。很多问题的根源在于:开发时只考虑了正常流量,没有考虑峰值流量;只考虑了单个接口的性能,没有考虑多个接口并发时的资源竞争。
解决高并发问题,没有银弹。它需要架构设计、代码实现、数据库优化、缓存策略、监控告警等多个层面的配合。但核心思想就一句话:在最有可能成为瓶颈的地方,提前做好准备。
希望这篇文章能帮到你。如果还有具体问题,欢迎在评论区交流。
