电商秒杀与外卖高峰期数据库崩溃案例分库分表加读写分离优化MySQL百万级并发连接数爆满慢查询解决方案
一场突如其来的”黑色星期五”
先给你讲个真事儿。
2023年双11,某头部电商平台搞了一场”限时秒杀”活动,一款原价1999元的旗舰手机,秒杀价只要999元。直播间里主播喊到”3、2、1,上链接”的时候,预计会有10万人同时点击“立即抢购”。
结果,后台数据库在0.5秒内直接崩了。
你能想象那个画面吗?用户点击支付,页面上转圈转了10秒,然后蹦出一行冷冰冰的字:“系统繁忙,请稍后重试。” 客服微信被打爆,技术群里有人发了一个跪着的表情包。
技术负责人老张第二天早上被叫到公司,一进门就看到监控大屏上一片血红——QPS从正常的2000飙到85万,MySQL的连接数直接拉满,CPU占用率100%,慢查询日志刷得磁盘写不动。
老张说,那是他职业生涯里最绝望的8小时。
外卖午高峰的”血色订单”
同一个城市,同一天的中午11:30,另一场灾难也在上演。
某外卖平台接入了5000家餐厅,午高峰时段每单平均3分钟完成一次下单-接单-配送的全流程。当天有活动,全场免配送费,结果平台在11:45收到了12万单同时涌入。
数据库连接池直接爆了。用户端显示”订单提交中”,点了半天没反应,实际后台连接数已经达到上限,新的请求根本进不来。更糟糕的是,数据库主库写入压力过大,从库同步延迟高达30秒,订单状态查询返回的是30秒前的数据——用户看到”已下单”,实际上订单根本还没写进去。
这两个案例看起来不同,但根子上是同一个问题:MySQL扛不住高并发。
MySQL高并发的”死亡三件套”
咱们先把问题拆开来看。电商平台秒杀和外卖午高峰,击垮MySQL的都是这三个家伙:
第一个:连接数爆满
MySQL默认的最大连接数是151。生产环境通常会调到1000-2000,但遇到秒杀场景,每个用户请求都要建立一次连接,10万人同时点,连接数瞬间打满。
-- 查看当前连接数配置
SHOW VARIABLES LIKE 'max_connections';
-- 输出:max_connections = 2000
-- 查看当前活跃连接数
SHOW STATUS LIKE 'Threads_connected';
-- 输出:Threads_connected = 2000 (已满)
-- 查看拒绝的连接数
SHOW STATUS LIKE 'Threads_created';
SHOW STATUS LIKE 'Connection_errors_max_connections';
当连接数满的时候,MySQL会直接拒绝新连接。这时候用户看到的不是”查询慢”,而是直接连接失败——这就是为什么很多系统在高峰期不是卡顿,而是直接崩。
第二个:慢查询堆积
连接数爆满只是表象,真正致命的是慢查询。高并发下,如果SQL写得有问题,一条慢查询能hold住一个连接好几秒,连接数瞬间就被撑满。
-- 查看慢查询配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
-- long_query_time默认10秒,生产环境建议调到0.5秒甚至0.1秒
-- 查看慢查询日志是否开启
SHOW VARIABLES LIKE 'slow_query_log';
-- 查看当前正在执行的查询(揪出慢查询元凶)
SELECT * FROM information_schema.processlist
WHERE COMMAND != 'Sleep'
ORDER BY TIME DESC
LIMIT 20;
第三个:磁盘I/O打满
慢查询多 → 连接不释放 → 更多请求堆积 → MySQL开始从磁盘读数据而不是从内存读 → 磁盘I/O飙升 → 响应时间进一步拉长 → 恶性循环。
分库分表:把一座山拆成多座小山丘
为什么需要分库分表
假设你的订单表有5亿条数据,每条查询都要扫这5亿行,哪怕有索引,InnoDB的B+树深度也已经到4-5层了。更可怕的是,所有写操作都集中在一张表上,单点的写入性能是瓶颈。
分库分表的核心思想就一句话:把一个大表拆成多个小表,把压力分散到多个节点上。
水平分表:把大表切成小表
水平分表是按行拆分,同一个表结构,拆到多个物理表中。
-- 原表结构(单表5亿数据)
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
product_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 拆分成16张表(按user_id取模)
CREATE TABLE orders_00 (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
product_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE orders_01 LIKE orders_00;
-- ... 重复到 orders_15
分片规则怎么定?
常见方案有:
- 取模分片:
table_index = user_id % 16,均匀但扩容麻烦 - 范围分片:按ID范围,比如1-1000万放表0,1000万-2000万放表1,扩容容易但容易数据倾斜
- 一致性哈希:扩容时只影响少量数据,但实现复杂
分库:把压力分散到多台机器
光分表还不够,因为如果所有表都在同一台MySQL实例上,连接数和CPU压力还是集中在一个点。分库就是把不同的表放到不同的MySQL实例上。
┌─────────────────────────────────────────────┐
│ 应用层(ShardingSphere) │
│ │
│ 请求 → 路由 → 订单库0(16表) 订单库1(16表) │
│ → 用户库0(8表) 用户库1(8表) │
└─────────────────────────────────────────────┘
用ShardingSphere做分库分表
手写了分片规则容易出错,生产环境一般用成熟的分库分表中间件。ShardingSphere是目前国内用得最多的方案。
# sharding-jdbc 核心配置
dataSources:
ds_0:
url: jdbc:mysql://10.0.1.10:3306/order_db_0?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: xxx
driverClassName: com.mysql.cj.jdbc.Driver
ds_1:
url: jdbc:mysql://10.0.1.11:3306/order_db_1?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: xxx
driverClassName: com.mysql.cj.jdbc.Driver
shardingRule:
tables:
orders:
actualDataNodes: ds_$->{0..1}.orders_$->{00..15}
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: orders-table-mod
keyGenerator:
type: SNOWFLAKE
prop:
worker-id: 1
shardingAlgorithms:
orders-table-mod:
type: MOD
props:
sharding-count: 16
这样配置之后,应用层自动根据user_id路由到正确的库和表,代码完全无感知。
分库分表后的经典问题
问题1:跨分片查询怎么办?
比如要查”某个用户在所有分片中的订单”,这其实问题不大,因为user_id就是分片键,路由一次就能定位到具体哪张表。
但如果是按order_id查,或者按时间范围查,就麻烦了:
// 跨分片查询 - 广播查询(性能差,能避免就避免)
SELECT * FROM orders_00 WHERE create_time BETWEEN '2023-11-11' AND '2023-11-12'
UNION ALL
SELECT * FROM orders_01 WHERE create_time BETWEEN '2023-11-11' AND '2023-11-12'
-- ... 重复16次
// 正确做法:在业务层面拆分查询,先通过ES/Mongo做检索,再回MySQL拿详情
最佳实践:尽量避免跨分片查询。如果必须查,把查询压力转移到搜索引擎(Elasticsearch)或数据仓库上。
问题2:ID怎么生成?
分库分表后,数据库自增ID会重复。必须用分布式ID生成方案:
// 雪花算法(Snowflake)实现
public class SnowflakeIdGenerator {
private long workerId;
private long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095; // 12位序列号,4096个
if (sequence == 0) {
timestamp = waitUntilNextMillis(timestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
// 41位时间戳 + 10位机器ID + 12位序列号
return ((timestamp - EPOCH) << 22) | (datacenterId << 17) | (workerId << 12) | sequence;
}
}
41位时间戳可以用到2084年,10位机器ID支持1024个节点,12位序列号每毫秒每节点生成4096个ID。这个方案比数据库自增ID稳定多了。
读写分离:让主库专心写,从库专心读
为什么需要读写分离
秒杀场景有一个特点:读多写少。10万人在看商品详情、看库存、看评价,但真正下单的只有几百人。如果所有请求都打到主库,主库的连接数和CPU全被读请求占满了,写入请求反而进不来。
读写分离的做法很简单:主库负责写,从库负责读,通过中间件或代码层面做路由。
// 简单的读写分离路由(动态数据源方案)
@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(DataSourceType.MASTER, master);
targetDataSources.put(DataSourceType.SLAVE, slave);
dynamicDataSource.setTargetDataSources(targetDataSources);
dynamicDataSource.setDefaultTargetDataSource(master);
return dynamicDataSource;
}
}
// 路由策略:通过AOP判断是读操作还是写操作
@Aspect
@Component
public class DataSourceAspect {
@Before("@annotation(readOnly)")
public void setReadOnly(JoinPoint point, ReadOnly readOnly) {
DataSourceType.set(DataSourceType.SLAVE);
}
@Before("@annotation(writeOnly)")
public void setWriteOnly(JoinPoint point, WriteOnly writeOnly) {
DataSourceType.set(DataSourceType.MASTER);
}
}
// 使用示例
@ReadOnly // 走从库
public OrderVO getOrder(Long orderId) { ... }
@WriteOnly // 走主库
public void createOrder(OrderDTO dto) { ... }
主从同步延迟问题
读写分离最大的坑是主从同步延迟。主库写入后,从库可能需要几百毫秒甚至几秒才能同步到。这时候如果用户刚下单,立刻去查订单状态,可能查不到。
主库写入 → binlog → 从库 replay → 查询从库
↑
延迟发生在这里
解决方案有几个:
方案1:写完后强制读主库
@WriteOnly
public OrderVO createOrderAndQuery(OrderDTO dto) {
// 写入走主库
Long orderId = orderMapper.insert(dto);
// 刚写完,强制读主库避免读到旧数据
DataSourceType.set(DataSourceType.MASTER);
try {
return orderMapper.selectById(orderId);
} finally {
DataSourceType.clear();
}
}
方案2:设置读主库的白名单
对于核心业务(订单查询、库存扣减),直接走主库,其他业务走从库。
方案3:缩短同步延迟
用MHA或Orchestrator做主从监控,选延迟最小的从库来读:
-- 检查从库延迟
SHOW SLAVE STATUS\G
-- Slave_SQL_Running: Yes
-- Seconds_Behind_Master: 0 (理想状态)
MySQL连接数优化:从百万并发中活下来
回到老张的那个案例。连接数爆满是最直接的死因,那怎么优化?
第一步:调大max_connections(治标)
-- 临时调整(重启后失效)
SET GLOBAL max_connections = 5000;
-- 永久调整,修改my.cnf
[mysqld]
max_connections = 5000
但这条路走不通——连接数开太大,每个连接都要占用内存(大约256KB-1MB),5000个连接就是好几个GB的内存,而且MySQL处理过多空闲连接的开销也很大。
第二步:连接池(治本的关键)
应用层必须用连接池,不能让每个请求都新建连接。
// HikariCP 连接池配置(性能最好的连接池之一)
spring:
datasource:
hikari:
maximum-pool-size: 50 # 每个数据源最大连接数
minimum-idle: 10 # 最小空闲连接
idle-timeout: 30000 # 空闲连接存活时间
max-lifetime: 1800000 # 连接最大存活时间(30分钟)
connection-timeout: 3000 # 获取连接超时时间
leak-detection-threshold: 60000 # 检测连接泄漏
连接池的核心思路:预先建立好连接,复用它们,而不是每次请求都新建。50个连接池可以支撑远高于50的并发,因为大部分连接大部分时间是空闲等待的。
第三步:降低单个连接的持有时间
很多慢查询的问题是:查询本身很慢,连接被占用了很久才释放。这时候即使连接池再大也没用。
-- 开启 performance_schema 分析慢查询
SET GLOBAL performance_schema = ON;
-- 查看最耗时的SQL
SELECT DIGEST_TEXT,
SUM_TIMER_WAIT/1000000000000 AS total_sec,
COUNT_STAR,
AVG_TIMER_WAIT/1000000000000 AS avg_sec
FROM performance_schema.events_statements_summary_by_digest
ORDER BY total_sec DESC
LIMIT 20;
第四步:MySQL 8.0 的改进
MySQL 8.0引入了很多连接优化的新特性:
# my.cnf 优化配置
[mysqld]
# 连接数
max_connections = 3000
# 线程缓存,避免频繁创建销毁线程
thread_cache_size = 100
# 禁用DNS解析,加快连接建立
skip-name-resolve
# 连接超时,快速释放空闲连接
wait_timeout = 60
interactive_timeout = 60
# 减少每个连接的内存开销
max_allowed_packet = 64M
第五步:用ProxySQL做连接复用
对于超高并发场景,可以在MySQL前面加一层ProxySQL,它能把很多小请求复用成少数量对MySQL的连接:
10000个客户端请求
↓
ProxySQL(连接池管理,最多维持200个到MySQL的连接)
↓
MySQL主库/从库(连接数可控)
# ProxySQL基本配置
INSERT INTO mysql-users (username, password, default_hostgroup)
VALUES ('app_user', 'password123', 10);
-- hostgroup 10 = 写主库,20 = 读从库
INSERT INTO mysql_server (hostname, port, hostgroup, weight)
VALUES ('10.0.1.10', 3306, 10, 1);
INSERT INTO mysql_server (hostname, port, hostgroup, weight)
VALUES ('10.0.1.11', 3306, 20, 1);
慢查询优化:从毫秒到微秒的优化之路
秒杀场景的典型慢查询
-- 问题SQL:秒杀时扣减库存(没有索引,全表扫描)
UPDATE products SET stock = stock - 1
WHERE product_id = 10086 AND stock > 0;
-- 全表扫描 + 行锁,高峰期直接卡死
-- 问题SQL:查询订单(没有联合索引,回表严重)
SELECT * FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 20;
-- 单索引效率低,回表多次
-- 问题SQL:跨表关联查询
SELECT o.*, p.name, p.image
FROM orders o
LEFT JOIN products p ON o.product_id = p.id
WHERE o.user_id = 12345;
-- 大表JOIN,没有覆盖索引
优化方案一:加对的正确索引
-- 优化扣减库存:确保product_id有索引
ALTER TABLE products ADD INDEX idx_product_id (product_id);
-- 优化订单查询:用联合索引避免回表
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, create_time);
-- 覆盖索引优化:只查需要的字段,减少回表
SELECT id, amount, status, create_time
FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 20;
-- 这个查询可以直接从索引树上拿到所有数据,不需要回表
优化方案二:用EXPLAIN分析执行计划
EXPLAIN FORMAT=JSON
SELECT o.id, o.amount, o.status
FROM orders o
WHERE o.user_id = 12345 AND o.status = 1
ORDER BY o.create_time DESC
LIMIT 20;
输出结果里重点关注:
{
"query_block": {
"select_id": 1,
"ordering_operation": {
"using_filesort": false, -- 必须为false,否则说明用了文件排序
"sorting_environment": {
"sort_buffer_size": "262144",
"sort_mode": "<sort_buffer, additional_rows>"
}
},
"table": {
"table_name": "orders",
"access_type": "ref", -- 必须是ref或range,不能是ALL
"possible_keys": ["idx_user_status_time"],
"key": "idx_user_status_time",
"used_key_parts": ["user_id", "status"],
"rows": 152, -- 扫描的行数,越少越好
"filtered": 100.00
}
}
}
常见EXPLAIN问题诊断:
| access_type | 含义 | 是否OK |
|---|---|---|
| system | 表只有一行 | ✅ 完美 |
| const | 按主键/唯一索引查 | ✅ 完美 |
| ref | 按普通索引查 | ✅ 可以 |
| range | 索引范围扫描 | ✅ 可以接受 |
| index | 全索引扫描 | ⚠️ 有点慢 |
| ALL | 全表扫描 | ❌ 必须优化 |
优化方案三:秒杀场景的特殊优化
秒杀和正常业务不一样,不能用常规的”查数据库→扣库存→写订单”流程,否则数据库根本扛不住。
经典秒杀架构:
用户请求
↓
┌────────────────────────────────────────────┐
│ 第1层:Nginx限流(每秒最多1000请求) │
│ limit_req zone=seckill burst=500 nodelay; │
└────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────┐
│ 第2层:Redis预扣库存 │
│ 1. Lua脚本原子扣减: │
│ redis.call('DECRBY', key, count) │
│ 2. 库存不足 → 直接返回"已售罄" │
│ 3. 库存充足 → 进入消息队列 │
└────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────┐
│ 第3层:消息队列削峰(Kafka/RocketMQ) │
│ 把突发请求变成匀速消费 │
└────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────┐
│ 第4层:MySQL异步落库 │
│ 消费者匀速写入数据库 │
└────────────────────────────────────────────┘
// Redis Lua脚本扣减库存(原子操作)
String script =
"local key = KEYS[1] " +
"local count = tonumber(ARGV[1]) " +
"local stock = tonumber(redis.call('get', key)) " +
"if stock < count then " +
" return 0 " +
"end " +
"redis.call('decrby', key, count) " +
"return 1";
// 调用
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList("seckill:stock:" + productId),
String.valueOf(quantity)
);
if (result == 0) {
return Result.fail("库存不足");
}
// 扣减成功,发送到消息队列
kafkaTemplate.send("seckill-order", orderDTO);
这样99%的请求在Redis层面就被拦截了,只有1%真正到MySQL。数据库的连接数和QPS直接下降两个数量级。
优化方案四:分页查询优化
外卖平台查订单列表,如果用的是传统的LIMIT 100000, 20,MySQL需要扫描10万行再扔掉99980行,非常慢。
-- 慢查询:深分页
SELECT * FROM orders
WHERE user_id = 12345
ORDER BY create_time DESC
LIMIT 100000, 20;
-- 扫描10万行,性能极差
-- 优化方案1:延迟关联
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders
WHERE user_id = 12345
ORDER BY create_time DESC
LIMIT 100000, 20
) AS tmp ON o.id = tmp.id;
-- 子查询只扫描索引,主查询只回表20行
-- 优化方案2:游标分页(推荐)
SELECT * FROM orders
WHERE user_id = 12345
AND create_time < '2023-11-10 12:30:00' -- 上一页最后一条的时间
ORDER BY create_time DESC
LIMIT 20;
-- 用where条件替代offset,性能稳定
完整架构升级方案
把上面所有方案拼在一起,就是一个完整的秒杀/高并发数据库优化架构:
┌──────────────┐
│ Nginx │
│ 限流+缓存 │
└──────┬───────┘
│
┌──────▼───────┐
│ Redis │
│ 缓存+预扣库存 │
└──────┬───────┘
│
┌──────▼───────┐
│ MQ │
│ 削峰+异步 │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ Sharding │ │ 读从库 │ │ 写主库 │
│ Sphere │ │(3台) │ │ (1台) │
└─────┬─────┘ └───┬────┘ └────┬─────┘
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ 16库×16 │ │ 同步 │ │ binlog │
│ 表=256表 │ │ 延迟<1s│ │ 复制 │
└───────────┘ └────────┘ └──────────┘
关键配置清单
# Spring Boot + MySQL 生产环境配置
spring:
datasource:
hikari:
maximum-pool-size: 50 # 每个分片50个连接
minimum-idle: 10
connection-timeout: 3000
idle-timeout: 30000
max-lifetime: 1800000
type: com.zaxxer.hikari.HikariDataSource
# ShardingSphere配置
sharding:
sharding-algorithms:
database-mod:
type: MOD
props:
sharding-count: 16
table-mod:
type: MOD
props:
sharding-count: 16
binding-tables:
- orders,order_detail # 绑定表避免跨库JOIN
broadcast-tables:
- sys_config # 广播表,所有库都有一份
-- MySQL生产环境my.cnf关键参数
[mysqld]
max_connections = 3000
thread_cache_size = 200
table_open_cache = 16384
innodb_buffer_pool_size = 8G # 尽量占物理内存的70%
innodb_log_file_size = 512M
innodb_flush_log_at_trx_commit = 2 # 性能优先时设为2
innodb_io_capacity = 2000
innodb_flush_method = O_DIRECT
sync_binlog = 1
binlog_format = ROW
binlog_cache_size = 4M
max_binlog_cache_size = 512M
skip-name-resolve
从老张的故事说起
说回开头的老张。双11那天之后,他们团队用了三周时间做了全面的数据库架构升级:
- 分库分表:订单表按user_id分16库×16表,共256张表
- 读写分离:1主3从,查询走从库,写入走主库
- 连接池优化:HikariCP配置调优,单实例连接数控制在50
- Redis预扣库存:秒杀场景99%的请求在Redis层就被拦住了
- MQ削峰:Kafka把突发流量变成匀速写入
- 慢查询治理:上线前用
pt-query-digest分析了全量慢查询日志,优化了TOP20慢SQL
第二次双11,QPS飙到120万的时候,老张看着监控大屏,连接数稳定在800(上限3000),MySQL CPU占用65%,慢查询每秒不到10条。
他没发跪着的表情包,发了个”稳了”。
总结一下
高并发下MySQL崩溃,从来不是单一原因造成的。它是一个链条:
坏SQL → 慢查询 → 连接占用时间长 → 连接数打满 → 新请求被拒 → 系统崩溃
要打破这个链条,从任何一个环节入手都可以:
- 治标:调大连接数、优化慢查询、加索引
- 治本:分库分表分散压力、读写分离减轻主库负担、Redis+MQ在数据库前面挡掉大部分请求
分库分表+读写分离是架构层面的硬解法,但前提是每一层的SQL都要写得规范。再大的集群,也救不了满篇SELECT *和没有索引的表。
优化数据库,本质上是优化对数据的访问方式。理解数据的流向,找到瓶颈在哪一环,然后在那一环上分流、减速、或者干脆不让它到达——这就是解决高并发数据库问题的核心思路。
如果你正在面临类似的数据库崩溃问题,不用慌。先从慢查询日志入手,找到最耗资源的那几条SQL,一条一条优化,往往就能把系统的承载力提升一个数量级。剩下的,才是架构层面的活儿。
