引言
那天凌晨3点,我被钉钉的报警电话惊醒。线上交易系统崩溃,大量订单数据丢失,用户投诉如潮水般涌来。那一刻,我深刻意识到:在高并发场景下,一个小小的MySQL配置错误,就可能引发灾难性的后果。
经过整夜的分析和修复,我终于恢复了服务,并总结出了两条救命指南。今天,我想把这些宝贵的实战经验分享给大家,希望你的系统永远用不上这些”救命招数”。
第一部分:为什么高并发下MySQL容易崩溃?
1.1 并发压力的本质
在理解崩溃原因之前,我们先来聊聊”并发”这个概念。想象一下,如果你是一家餐厅的老板,同时来了1000个客人点餐,你的厨房能应付吗?
MySQL也是一样。当每秒有数千甚至数万次查询请求涌入时,数据库面临的压力主要体现在以下几个方面:
- 连接数爆炸:每个并发请求都需要建立数据库连接,连接数激增会耗尽系统资源
- 锁竞争加剧:多事务同时修改同一行数据,导致锁等待和死锁
- 内存溢出:Buffer Pool无法容纳所有热点数据,频繁的磁盘IO成为瓶颈
- 磁盘IO瓶颈:写操作频繁,日志文件来不及刷盘
1.2 真实案例分析
记得有一次,我们系统的QPS从平时的1000飙升至15000,主要原因是一个营销活动导致大量用户同时下单。当时的情况是:
-- 监控到的慢查询
SELECT * FROM orders WHERE user_id = ? AND status = 0 ORDER BY create_time DESC;
这个查询看似简单,但在高并发下,每次查询都需要扫描大量数据,导致:
- 全表扫描时间从几毫秒变成几秒
- 临时表创建频繁,占用大量磁盘空间
- Buffer Pool命中率从98%暴跌到65%
更糟糕的是,由于没有合理的索引和分库策略,单表数据量已经超过2亿行,查询性能呈指数级下降。
第二部分:第一招——读写分离的正确姿势
2.1 什么是读写分离?
读写分离的基本思想很简单:把读操作和写操作分开处理。主库负责写,从库负责读,这样可以:
- 分摊主库的读取压力
- 提高系统的整体吞吐量
- 为主库提供一定的容灾能力
2.2 常见的读写分离方案
方案一:应用层手动路由
这是最基础的方案,在代码层面判断是读操作还是写操作,然后路由到不同的数据库实例。
// 伪代码示例
public class DataSourceRouter {
private DataSource masterDataSource;
private List<DataSource> slaveDataSources;
public Object executeQuery(String sql, Object... params) {
// 读操作路由到从库
DataSource slave = selectSlave();
return executeOnDataSource(slave, sql, params);
}
public void executeUpdate(String sql, Object... params) {
// 写操作路由到主库
executeOnDataSource(masterDataSource, sql, params);
}
private DataSource selectSlave() {
// 简单的轮询策略
int index = ThreadLocalRandom.current().nextInt(slaveDataSources.size());
return slaveDataSources.get(index);
}
}
这个方案的优点是简单可控,但缺点也很明显:需要修改大量业务代码,而且负载均衡策略需要自己实现。
方案二:中间件代理层
使用像MyCat、ShardingSphere这样的中间件,对应用透明地实现读写分离。
# ShardingSphere配置示例
dataSources:
master_ds:
url: jdbc:mysql://master:3306/db
username: root
password: 123456
slave_ds:
url: jdbc:mysql://slave:3306/db
username: root
password: 123456
rules:
- !READWRITE_SPLITTING
dataSources:
ds:
writeDataSourceName: master_ds
readDataSources:
slave_ds_0:
slave_ds_1:
loadBalancerName: RANDOM
loadBalancers:
RANDOM:
type: RANDOM
这个方案的优势是应用层无需改动,中间件自动处理路由。但需要注意的是,中间件本身可能成为新的性能瓶颈。
2.3 读写分离的核心坑点
坑点一:主从延迟
这是读写分离最常见的坑。当主库写完数据后,需要一定时间同步到从库。如果用户刚写完数据就立即读取,可能会读到旧数据。
-- 解决方案:在关键读操作后强制路由到主库
@DataSource("master")
public Order queryLatestOrder(Long userId) {
// 这个查询必须走主库,确保读到最新数据
return orderMapper.selectByUserId(userId);
}
坑点二:从库选路不均
如果采用简单的轮询策略,可能导致某些从库压力过大,而其他从库闲得发慌。
// 改进的负载均衡策略:基于连接数的最小连接数算法
public DataSource selectSlaveByLeastConnection() {
DataSource selected = null;
int minConnections = Integer.MAX_VALUE;
for (DataSource slave : slaveDataSources) {
int activeConnections = getActiveConnections(slave);
if (activeConnections < minConnections) {
minConnections = activeConnections;
selected = slave;
}
}
return selected;
}
坑点三:事务内的读操作
在事务中,如果混用了主库和从库,可能导致数据不一致。
// 错误的做法:事务内读从库
@Transactional
public void processOrder(Order order) {
// 写操作走主库
orderMapper.insert(order);
// 读操作如果走从库,可能读到旧数据
OrderDTO dto = orderMapper.selectById(order.getId());
// 此时dto可能不包含刚插入的数据
}
// 正确的做法:整个事务都走主库
@Transactional
public void processOrder(Order order) {
// 强制使用主库
@DataSource("master")
orderMapper.insert(order);
@DataSource("master")
OrderDTO dto = orderMapper.selectById(order.getId());
// 确保读到最新数据
}
第三部分:第二招——分库分表的正确姿势
3.1 为什么要分库分表?
当单表数据量超过一定阈值(通常是千万级),查询性能会显著下降。分库分表的核心思想是”分而治之”:
- 垂直分库:按业务模块拆分到不同数据库
- 水平分表:将单表数据分散到多个表中
- 水平分库:将数据分散到多个数据库中
3.2 分库分表的核心挑战
挑战一:跨库查询
当数据分散在多个库中时,简单的查询可能无法执行。
-- 假设订单表按user_id分库,每个库10个分表
-- 查询某个用户的订单
SELECT * FROM orders WHERE user_id = 123456;
-- 这个查询可以轻松定位到具体的分片
-- 但如果是以下查询呢?
SELECT * FROM orders WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31';
-- 这个查询需要扫描所有分片,性能会很差
挑战二:分布式事务
当数据分布在多个数据库中时,保证数据一致性变得复杂。
// 使用Seata实现分布式事务
@GlobalTransactional
public void createOrder(OrderDTO orderDTO) {
// 1. 创建订单
orderMapper.insert(orderDTO);
// 2. 扣减库存(可能在不同数据库)
inventoryMapper.decreaseStock(orderDTO.getProductId(), orderDTO.getQuantity());
// 3. 记录日志(可能在另一个数据库)
logMapper.insert(orderDTO.getOrderId());
}
3.3 分库分表的正确姿势
策略一:合理选择分片键
分片键的选择直接影响查询性能。理想的分片键应该满足:
- 查询频率高
- 数据分布均匀
- 避免跨片查询
// 订单表的分片策略
public class OrderShardingAlgorithm implements ShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> availableTargetNames,
ShardingValue<Long> shardingValue) {
Long userId = shardingValue.getValue();
// 使用取模运算,确保数据均匀分布
int shardCount = availableTargetNames.size();
int shardIndex = Math.abs(userId.intValue() % shardCount);
String targetName = availableTargetNames.toArray()[shardIndex].toString();
return Collections.singleton(targetName);
}
}
策略二:处理跨片查询
对于必须跨片查询的场景,可以考虑以下方案:
- 广播查询:在所有分片执行查询,然后合并结果
- 建立全局索引表:将需要频繁查询的字段冗余到全局表中
- ES倒排索引:使用Elasticsearch处理复杂查询
# ShardingSphere配置:广播表
rules:
- !SHARDING
tables:
orders:
actualDataNodes: ds_${0..9}.orders_${0..9}
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: user_hash
keyGenerateStrategy:
column: id
keyGeneratorName: snowflake
# 商品表作为广播表,所有分片都有完整数据
products:
actualDataNodes: ds_${0..9}.products
type: BROADCAST
策略三:数据迁移方案
从单体数据库迁移到分库分表,需要谨慎处理数据迁移。
-- 1. 创建新的分片表结构
CREATE TABLE orders_0 (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
INDEX idx_user_id (user_id),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. 使用 Canal 监听主库binlog,实时同步数据
-- 3. 双写阶段:新旧系统同时写入
-- 4. 数据校验:对比新旧系统数据一致性
-- 5. 切流:将流量切换到新系统
-- 6. 下线旧系统
第四部分:实战中的完整解决方案
4.1 系统架构设计
在我们的案例中,最终采用了以下架构:
+---------------------+
| 负载均衡 |
| (Nginx/LVS) |
+----------+----------+
|
+--------------+--------------+
| |
+-------+-------+ +-------+-------+
| Web集群 | | 移动端服务 |
| (Spring Boot)| | (Spring Boot) |
+-------+-------+ +-------+-------+
| |
+--------------+--------------+
|
+--------------+--------------+
| ShardingSphere |
| (读写分离+分片) |
+--------------+--------------+
|
+------------------+------------------+
| | |
+-------+-------+ +-------+-------+ +-------+-------+
| 主库 | | 从库1 | | 从库2 |
| (写入+强读) | | (读负载均衡) | | (读负载均衡) |
+---------------+ +---------------+ +---------------+
4.2 关键配置参数
# application.yml 核心配置
spring:
shardingsphere:
datasource:
names: master,slave0,slave1
master:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://master:3306/order_db?useSSL=false&serverTimezone=UTC
username: root
password: ${DB_PASSWORD}
max-active: 200
min-idle: 20
slave0:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://slave0:3306/order_db?useSSL=false&serverTimezone=UTC
username: root
password: ${DB_PASSWORD}
max-active: 300
min-idle: 30
slave1:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://slave1:3306/order_db?useSSL=false&serverTimezone=UTC
username: root
password: ${DB_PASSWORD}
max-active: 300
min-idle: 30
rules:
readwrite-splitting:
data-sources:
ds:
write-data-source-name: master
read-data-sources: slave0,slave1
load-balancer-name: round_robin
load-balancers:
round_robin:
type: ROUND_ROBIN
props:
weight: 1,1
sharding:
tables:
orders:
actual-data-nodes: ds.orders_$->{0..9}
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: user_hash
key-generate-strategy:
column: id
key-generator-name: snowflake
sharding-algorithms:
user_hash:
type: HASH_MOD
props:
sharding-count: 10
key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 1
props:
sql-show: true
max-connections-size-per-query: 100
keep-alive: true
4.3 监控与告警
// 自定义监控指标
@Component
public class DatabaseMonitor {
@Autowired
private MeterRegistry meterRegistry;
public void recordQueryTime(String dataSource, long duration, boolean success) {
Timer.builder("db.query.time")
.tag("datasource", dataSource)
.tag("success", String.valueOf(success))
.register(meterRegistry)
.record(duration, TimeUnit.MILLISECONDS);
}
public void recordConnectionUsage(String dataSource, int activeConnections, int maxConnections) {
Gauge.builder("db.connections.active",
() -> activeConnections)
.tag("datasource", dataSource)
.register(meterRegistry);
Gauge.builder("db.connections.max",
() -> maxConnections)
.tag("datasource", dataSource)
.register(meterRegistry);
}
}
// 告警规则配置
# prometheus_alert_rules.yml
groups:
- name: database_alerts
rules:
- alert: HighQueryLatency
expr: histogram_quantile(0.99, rate(db_query_time_bucket[5m])) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "数据库查询延迟过高"
description: "P99查询延迟超过1秒,当前值: {{ $value }}ms"
- alert: ConnectionPoolExhausted
expr: db_connections_active / db_connections_max > 0.9
for: 2m
labels:
severity: critical
annotations:
summary: "数据库连接池即将耗尽"
description: "连接使用率超过90%,当前值: {{ $value | humanizePercentage }}"
第五部分:避坑指南与最佳实践
5.1 常见的坑与解决方案
坑点一:分片键选择不当
错误示例:使用order_id作为分片键
-- 问题:order_id是顺序增长的,导致数据倾斜
-- 解决方案:使用user_id或product_id等业务键
正确做法:选择业务查询常用的字段作为分片键
// 订单表分片策略
ShardingRuleConfiguration shardingRuleConfig = new ShardingRuleConfiguration();
// 使用user_id作为分片键,因为订单查询大多按用户维度
TableRuleConfiguration orderTableRule = new TableRuleConfiguration("orders", "ds.orders_$->{0..9}");
orderTableRule.setTableShardingStrategy(new StandardShardingStrategyConfiguration(
"user_id",
new ModuloShardingAlgorithm()
));
坑点二:忽略主从延迟
错误做法:写后立即读
// 错误:可能导致读到旧数据
orderMapper.insert(order);
OrderDTO result = orderMapper.selectById(order.getId()); // 可能为空或旧数据
正确做法:强制读主库或使用缓存 “`java // 正确:写后立即读主库 @DataSource(“master”) public OrderDTO createOrder(OrderDTO orderDTO) {
orderMapper.insert(orderDTO);
// 强制从主库读取
@DataSource("master")
OrderDTO result = orderMapper.selectById(orderDTO.getId());
// 同时将数据写入缓存,后续查询走缓存
redisTemplate.opsForValue().set("order:" + orderDTO.getId(), result
