哎,昨晚凌晨三点,我的手机又震了。
不是闹钟,是生产环境告警。MySQL的CPU直接飙到100%,连接数瞬间爆满,用户打开页面全是504超时。那一刻,我在屏幕前坐了好久,点了一根烟,深吸一口气,开始复盘。
这种事,做后端开发的谁没经历过?今天咱们不聊虚的,就聊聊MySQL在高并发场景下“崩盘”的真实案例,以及怎么一步步把它救回来。这篇文章,是我用血泪换来的经验,希望能帮到你。
一、 危机时刻:那台“崩”掉的MySQL
先说说背景。我们是一个电商平台,用户量不大,日活几万,但每到大促或者整点秒杀,数据库就扛不住。
那天晚上,监控显示:
- QPS(每秒查询率)从平时的500,瞬间飙到8000+
- 活跃连接数:150/200(快爆了)
- 慢查询日志刷屏:全是
SELECT * FROM orders WHERE user_id = ?这种简单查询,但并发太高 - InnoDB buffer pool命中率掉到60%以下
然后,用户反馈:下单失败、页面空白、购物车丢东西。
我登录服务器,top看一下,MySQL进程吃掉了90%的CPU。show processlist;一看,几十条查询卡在那儿不动。
那一刻,我知道:单库单表,已经到极限了。
二、 第一步:救火——连接池 + 读写分离
在彻底改造架构之前,我们先要做两件事:止血和减压。
2.1 连接池:别让每个请求都建新连接
很多新手踩的坑是:每次数据库操作都new一个Connection,用完就close。这在低并发下还行,高并发下,建连、断连的开销直接拖垮MySQL。
我们用的是HikariCP(Java生态中最流行的连接池,速度最快)。
@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://primary-db:3306/shop?useSSL=false&serverTimezone=UTC");
config.setUsername("root");
config.setPassword("your_password");
// 关键配置
config.setMaximumPoolSize(50); // 最大连接数,根据MySQL max_connections调整
config.setMinimumIdle(10); // 最小空闲连接
config.setIdleTimeout(30000); // 空闲连接超时30秒
config.setMaxLifetime(600000); // 连接最大存活时间10分钟
config.setConnectionTimeout(30000); // 获取连接超时30秒
// 性能优化参数
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
config.addDataSourceProperty("useServerPrepStmts", "true");
return new HikariDataSource(config);
}
}
为什么这么配?
maximumPoolSize=50:MySQL默认的max_connections是151,我们留点余量,防止连接数打满。cachePrepStmts:开启预编译语句缓存,减少SQL解析开销,高并发下效果明显。
2.2 读写分离:把查询压力分摊出去
单库既写又读,压力太大。我们引入从库,主库写,从库读。
架构图大概是这样的:
[App] --> [Proxy/MyCat] --> [Master DB] (写)
--> [Slave DB1] (读)
--> [Slave DB2] (读)
用MyCat做中间件,配置很简单:
<!-- mycat_schema.xml -->
<schema name="shop_db" checkSQLschema="false" sqlMaxLimit="100">
<table name="orders" dataNode="dn_master" rule="rule1" />
<table name="products" dataNode="dn_slave1,dn_slave2" rule="rule1" />
</schema>
<dataNode name="dn_master" dataHost="host_master" database="shop" />
<dataNode name="dn_slave1" dataHost="host_slave1" database="shop" />
<dataNode name="dn_slave2" dataHost="host_slave2" database="shop" />
<dataHost name="host_master" maxCon="1000" minCon="10" balance="0" writeType="0" dbType="mysql" dbDriver="jdbc">
<writeHost host="hostM1" url="jdbc:mysql://master:3306" user="root" password="pwd"/>
</dataHost>
<dataHost name="host_slave1" maxCon="1000" minCon="10" balance="1" writeType="0" dbType="mysql" dbDriver="jdbc">
<writeHost host="hostS1" url="jdbc:mysql://slave1:3306" user="root" password="pwd"/>
</dataHost>
关键点:
balance="1":开启读写分离,读请求会分摊到从库。writeType="0":所有写操作都发往主库。sqlMaxLimit="100":防止大数据量查询拖垮从库。
做完这一步,CPU从100%降到了60%左右,活下来了。但我知道,这只是暂时的。
三、 第二步:治本——分库分表
读写分离解决的是单点压力问题,但数据量大了,单库的存储和性能瓶颈依然还在。
我们的订单表orders,一年就积累了5000万数据,查询越来越慢。必须分库分表。
3.1 为什么分库分表?
假设你有一张1亿行的表,每次查询都要全表扫描,即使有索引,I/O开销也大得惊人。分库分表的核心思想是:把大表拆成小表,分散到多个数据库实例上,每个实例只承担一部分数据。
3.2 分片策略:按什么分?
常见的分片键有:
user_id:适合用户视角的查询(如查询某个用户的所有订单)order_id:适合订单视角create_time:适合按时间范围查询
我们选择user_id,因为大部分查询都是“查某用户的订单”。
分片算法:shard_id = user_id % 16,分成16个库。
3.3 用ShardingSphere实现分库分表
Spring Boot + ShardingSphere是目前最流行的方案。
@Configuration
public class ShardingSphereConfig {
@Bean
public DataSource dataSource() throws SQLException {
// 定义数据源
Map<String, DataSource> dataSourceMap = new HashMap<>();
// 16个库,每个库一张表
for (int i = 0; i < 16; i++) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:mysql://db-node-" + i + ":3306/shop?" +
"useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true");
ds.setUsername("root");
ds.setPassword("pwd");
dataSourceMap.put("ds_" + i, ds);
}
// 分片规则配置
ShardingRuleConfiguration ruleConfig = new ShardingRuleConfiguration();
// 订单表分库分表
TableRuleConfiguration orderTableRule = new TableRuleConfiguration("orders",
"ds_0.orders,ds_1.orders,...,ds_15.orders"); // 简化写法
orderTableRule.setDatabaseShardingStrategyConfig(new InlineShardingStrategyConfiguration(
"user_id", "ds_${user_id % 16}"));
orderTableRule.setTableShardingStrategyConfig(new InlineShardingStrategyConfiguration(
"user_id", "orders_${user_id % 64}")); // 每个库64张表,总共1024张表
ruleConfig.getTableRuleConfigs().add(orderTableRule);
// 绑定表:避免跨库join
ruleConfig.getBindingTableGroups().add("orders,order_items");
// 广播表:配置信息这类小表
ruleConfig.getBroadcastTables().add("config_info");
// 分片算法配置
Properties props = new Properties();
props.setProperty("strategy", "inline");
props.setProperty("algorithmExpression", "ds_${user_id % 16}");
ShardingSphereDataSource dataSource = new ShardingSphereDataSource(
dataSourceMap, ruleConfig, new Properties());
return dataSource;
}
}
关键点解析:
- 分库分表比例:我们用了16库×64表=1024张表。为什么这么多?因为单表超过500万行,性能就会明显下降。
- 绑定表:
orders和order_items是绑定表,join查询会在同一个分片内执行,避免跨库join的性能灾难。 - 广播表:配置类小表,所有库都有副本,避免频繁跨库查询。
3.4 分库分表后的痛点:跨库查询怎么办?
这是分库分表最大的坑。比如你要查“所有订单金额大于1000的用户”,这种查询在分库分表后几乎没法做。
解决方案:
- 避免跨库查询:业务设计上,尽量让查询落在同一个分片内。
- ES同步:把订单数据同步到Elasticsearch,复杂查询走ES。
- 离线计算:报表类需求,走Hadoop/Spark离线处理,不要实时查MySQL。
举个例子,我们做了一个用户画像服务,把订单数据实时同步到ES,前端展示“用户购买偏好”这种复杂查询,全部走ES,MySQL只负责在线交易。
四、 第三步:缓存——减轻数据库压力
就算分库分表了,数据库依然扛不住超高并发。这时候,缓存就派上用场了。
4.1 为什么需要缓存?
MySQL是磁盘存储,缓存是内存存储。内存读写速度比磁盘快几个数量级。把热点数据放进缓存,能大幅减少数据库访问量。
我们用的是Redis,分布式缓存。
4.2 缓存策略:Cache-Aside Pattern
最常见的缓存模式是Cache-Aside(旁路缓存)。
逻辑如下:
- 读请求:先查缓存,命中则返回;未命中则查数据库,写入缓存,再返回。
- 写请求:先写数据库,再删除缓存(不是更新缓存,避免并发问题)。
@Service
public class OrderService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private OrderMapper orderMapper;
// 查订单
public Order getOrder(long orderId) {
String cacheKey = "order:" + orderId;
// 1. 先查缓存
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return (Order) cached;
}
// 2. 缓存未命中,查数据库
Order order = orderMapper.selectById(orderId);
if (order != null) {
// 3. 写入缓存,过期时间30分钟
redisTemplate.opsForValue().set(cacheKey, order, 30, TimeUnit.MINUTES);
}
return order;
}
// 更新订单
public void updateOrder(Order order) {
// 1. 先写数据库
orderMapper.updateById(order);
// 2. 再删除缓存(不是更新!)
String cacheKey = "order:" + order.getId();
redisTemplate.delete(cacheKey);
}
}
为什么删除缓存而不是更新? 假设两个并发请求:
- 请求A:读到旧值,更新数据库,然后更新缓存为新值。
- 请求B:也读到旧值,更新数据库为新值,然后更新缓存为旧值(因为A的旧值先写入)。
结果:缓存里是旧值,数据库是新值,数据不一致。
删除缓存的话,下次读请求会重新从数据库加载,保证一致性。虽然多了一次数据库查询,但比数据错误强。
4.3 缓存穿透、击穿、雪崩
这三个问题,是高并发场景下的经典坑。
缓存穿透:查询不存在的数据,缓存和数据库都查不到,每次请求都打到数据库。
- 解决:缓存空值,设置短过期时间(如5分钟)。
缓存击穿:热点key过期,瞬间大量请求打到数据库。
- 解决:热点key永不过期,或用分布式锁,只让一个请求查数据库,其他等待。
缓存雪崩:大量key同时过期,或Redis宕机。
- 解决:过期时间加随机值,Redis集群部署。
我们用的是布隆过滤器+空值缓存来解决穿透,用分布式锁解决击穿,用Redis Sentinel集群解决雪崩。
// 缓存空值,防止穿透
if (order == null) {
redisTemplate.opsForValue().set(cacheKey, new Object(), 5, TimeUnit.MINUTES);
return null;
}
五、 第四步:架构升级——消息队列削峰
即使有了分库分表和缓存,秒杀这种瞬时高并发场景,依然可能冲垮数据库。
这时候,需要引入消息队列(Kafka/RocketMQ),做异步削峰。
5.1 削峰原理
用户下单请求,不直接写数据库,而是先发到MQ,后台服务从MQ拉取消息,慢慢处理。这样,数据库的压力被平摊到一段时间内,而不是瞬间爆发。
5.2 实现方案
@Service
public class OrderMqService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Autowired
private OrderService orderService;
// 下单接口
public String placeOrder(OrderRequest request) {
// 1. 参数校验
if (!validate(request)) {
return "参数错误";
}
// 2. 发送消息到MQ
Message<OrderRequest> message = new Message<>("order-topic", request);
rocketMQTemplate.send("order-topic", message);
return "下单成功,请等待处理";
}
}
@Component
@RocketMQMessageListener(topic = "order-topic", consumerGroup = "order-consumer")
public class OrderConsumer implements RocketMQListener<OrderRequest> {
@Autowired
private OrderService orderService;
@Override
public void onMessage(OrderRequest request) {
// 异步处理订单,慢慢写入数据库
orderService.createOrder(request);
}
}
关键点:
- 用户感知上,下单是成功的(其实只是入了队),后台异步处理。
- 如果订单处理失败,需要重试机制(MQ一般自带重试)。
- 需要保证消息不丢失(持久化、ACK确认)。
六、 实战经验:那些踩过的坑
6.1 分库分表后,ID生成问题
原来单库用自增ID,分库后每个库都从1开始,ID冲突了。
解决方案:用分布式ID生成器,如雪花算法(Snowflake)。
”`java @Component public class SnowflakeIdWorker {
private long workerId;
private long datacenterId;
private long sequence = 0L;
private long twepoch = 1288834974657L;
private long workerIdBits = 5L;
private long datacenterIdBits = 5L;
private long maxWorkerId = -1L ^ (-1L << workerIdBits);
private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
private long sequenceBits = 12L;
private long workerIdShift = sequenceBits;
private long datacenterIdShift = sequenceBits + workerIdBits;
private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private long sequenceMask = -1L ^ (-1L << sequenceBits);
private long lastTimestamp = -1L;
public SnowflakeIdWorker(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
|
