前两天,某头部电商平台的数据库突然报警,那场面简直像是在早高峰的地铁站里硬塞进了一万辆共享单车。监控大屏上,QPS(每秒查询率)从平时的几千瞬间飙升至几十万,CPU占用率直接飙到100%,磁盘I/O打满,整个订单服务开始大量超时。作为这个系统的资深DBA,我当时就在现场,那种紧张感现在回想起来还心有余悸。
很多人问我:“老张,你们怎么扛住双11这种级别的流量的?”其实,MySQL本身并没有“高并发”这几个字的魔法属性。它的默认配置是给一般业务用的,一旦并发上来,连接数爆满、锁争用激烈、索引失效,数据库就像一台过热的小汽车,不仅跑不动,还可能直接抛锚。
今天,我不讲那些晦涩的理论,就结合这次实战经历,把分库分表、读写分离和连接池优化这三个“救命稻草”给你掰扯清楚。我会尽量用大白话,哪怕是刚入门的小朋友,也能听懂这背后的逻辑。
一、 先别急着动手,先看懂“敌人”是谁
在优化之前,我们必须先搞清楚,高并发下数据库到底是在哪里“累死”的。
1. 连接数爆炸:大门被挤爆了
MySQL是一个基于连接处理的数据库。每个客户端连上来,都会占用一个连接(Connection)。想象一下,你的数据库是一个只有100个窗口的银行大厅。平时只有10个人来办理业务,大家都很舒服。突然,来了10000个人,这100个窗口瞬间被堵死。后面的人进不来,前面的人办完也走不了,因为服务器忙着处理连接握手、权限验证这些“前台工作”,根本没力气去查数据。
现象:
- 报错
Too many connections - 响应时间急剧上升
2. 锁争用激烈:过道里堵死了
当多个请求同时修改同一行数据时,MySQL会使用行锁或表锁。在高并发下,大家挤在同一条过道上,你推我搡,谁也动不了。这就是锁争用。
现象:
- 锁等待超时(Lock wait timeout exceeded)
- 死锁频繁发生
3. 磁盘I/O瓶颈:路太窄了
即使CPU和内存够用,如果数据文件太大,查询时需要从磁盘读取大量数据,I/O就成了瓶颈。尤其是在没有合适索引的情况下,全表扫描会让磁盘忙得不可开交。
现象:
iowait高- 慢查询日志(Slow Query Log)激增
二、 读写分离:把“借书”和“还书”的人分开
解决高并发的第一步,往往是把读请求和写请求分开。这就像图书馆,借书(写)和还书(读)是两种不同的行为,如果混在一起,效率极低。
1. 原理是什么?
MySQL的主从复制(Master-Slave Replication)是实现读写分离的基础。主库(Master)负责处理所有的写操作(INSERT, UPDATE, DELETE),并通过二进制日志(binlog)将数据变化同步到从库(Slave)。从库则负责处理读操作(SELECT)。
为什么这样能提升性能?
- 主库只专注于写,压力较小。
- 从库可以部署多个,分摊读压力。
- 读写分离后,整体吞吐量大幅提升。
2. 怎么实现?(代码示例)
假设我们有一个Java应用,使用Spring Boot框架。我们需要配置数据源,让读请求走从库,写请求走主库。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource() {
// 创建主库数据源
DataSource masterDataSource = createDataSource("jdbc:mysql://master:3306/mydb", "user", "password");
// 创建从库数据源
DataSource slaveDataSource = createDataSource("jdbc:mysql://slave1:3306/mydb", "user", "password");
// 配置路由
DynamicDataSource dynamicDataSource = new DynamicDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DBTypeEnum.MASTER, masterDataSource);
targetDataSources.put(DBTypeEnum.SLAVE, slaveDataSource);
dynamicDataSource.setTargetDataSources(targetDataSources);
dynamicDataSource.setDefaultTargetDataSource(masterDataSource); // 默认走主库
return dynamicDataSource;
}
private DataSource createDataSource(String url, String user, String password) {
// 这里简化处理,实际项目中建议使用HikariCP等连接池
return null; // 具体实现见下文连接池部分
}
}
// 动态数据源路由类
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDBType();
}
}
// 线程上下文持有者
public class DataSourceContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
public static void setDBType(DBTypeEnum dbTypeEnum) {
contextHolder.set(dbTypeEnum.name());
}
public static String getDBType() {
return contextHolder.get();
}
public static void clearDBType() {
contextHolder.remove();
}
}
public enum DBTypeEnum {
MASTER, SLAVE
}
在Service层,我们可以通过AOP(面向切面编程)来自动切换数据源:
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class DataSourceAspect {
@Before("@annotation(com.example.annotation.ReadOnly)")
public void setReadDataSource() {
DataSourceContextHolder.setDBType(DBTypeEnum.SLAVE);
}
@Before("@annotation(com.example.annotation.ReadOnly)")
public void setMasterDataSource() {
DataSourceContextHolder.setDBType(DBTypeEnum.MASTER);
}
}
注意: 读写分离有一个经典问题——主从延迟。如果主库刚写完数据,从库还没同步完,此时查询从库可能会拿到旧数据。对于强一致性要求的场景(如余额查询),需要特殊处理,比如强制读主库。
三、 分库分表:把“大仓库”拆成“小货架”
当单库的单表数据量达到千万级甚至亿级时,即使读写分离也难以承受。这时,分库分表就派上用场了。
1. 垂直拆分 vs 水平拆分
- 垂直拆分(Vertical Sharding): 把一个大表按列拆分成多个小表。比如,把用户表中的“用户基本信息”和“用户详细资料”拆成两张表,分别放在不同的库中。这适合解决单表中某些列占用空间大、查询频率低的问题。
- 水平拆分(Horizontal Sharding): 把一个大表按行拆分成多个小表。比如,按用户ID取模,将用户数据分散到10个库中,每个库再分100张表。这是解决高并发最常用的手段。
2. 分片策略:怎么分?
常见的分片策略有:
- 取模分片(Modulo Sharding): 根据主键ID取模。简单高效,但扩容困难。
- 范围分片(Range Sharding): 根据ID范围分片。适合时间序列数据,但容易产生热点数据。
- 哈希分片(Hash Sharding): 使用一致性哈希算法。扩容相对容易,数据分布更均匀。
3. 中间件选型:ShardingSphere vs MyCAT
目前,Apache ShardingSphere和MyCAT是两大主流分库分表中间件。ShardingSphere功能更强大,支持多种数据库,社区活跃度高,推荐使用。
ShardingSphere配置示例:
spring:
shardingjdbc:
datasource:
names: ds0,ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/ds0
username: root
password: root
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/ds1
username: root
password: root
sharding:
tables:
t_order:
actual-data-nodes: ds$->{0..1}.t_order_$->{0..9}
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: order-table-inline
key-generate-strategy:
column: order_id
key-generator-name: snowflake
algorithms:
order-table-inline:
type: INLINE
props:
algorithm-expression: t_order_$->{order_id % 10}
key-generators:
snowflake:
type: SNOWFLAKE
关键点:
actual-data-nodes:指定数据分布在哪些物理库表上。sharding-algorithm-name:指定分片算法。key-generate-strategy:指定主键生成策略,推荐使用分布式ID生成算法(如雪花算法)。
四、 连接池优化:给数据库装上“缓冲带”
连接池是数据库和高并发应用之间的缓冲带。合理的连接池配置可以极大提升性能。
1. 为什么需要连接池?
每次创建数据库连接都是一个耗时的过程,涉及网络握手、身份验证等。连接池将已创建的连接缓存起来,复用这些连接,避免了频繁创建和销毁连接的开销。
2. HikariCP:最快的连接池
HikariCP是目前性能最好的连接池之一,默认配置已经非常优秀。但如果遇到高并发,还需要微调。
关键参数解释:
- maximumPoolSize: 最大连接数。一般建议设置为
CPU核数 * 2 + 有效磁盘数。过高会导致上下文切换开销增加,过低则会阻塞请求。 - minimumIdle: 最小空闲连接数。建议与
maximumPoolSize相同,避免连接数动态变化带来的开销。 - connectionTimeout: 连接超时时间。默认30秒,可根据业务需求调整。
- idleTimeout: 空闲连接超时时间。默认10分钟,建议设置得比
maxLifetime短。 - maxLifetime: 连接最大生命周期。默认30分钟,建议设置为小于数据库的
wait_timeout。
HikariCP配置示例:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
keepalive-time: 30000
3. 连接池监控
使用HikariCP时,可以通过其提供的Metrics接口监控连接池状态,如活跃连接数、空闲连接数、等待线程数等。
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.metrics.Metrics;
import com.zaxxer.hikari.metrics.MeterFactory;
public class HikariMonitor {
public static void main(String[] args) {
HikariDataSource ds = new HikariDataSource();
// 配置数据源...
// 获取MeterFactory
MeterFactory meterFactory = Metrics.getRegistry();
// 监控连接池状态
System.out.println("Active Connections: " + ds.getHikariPoolMXBean().getActiveConnections());
System.out.println("Idle Connections: " + ds.getHikariPoolMXBean().getIdleConnections());
System.out.println("Total Connections: " + ds.getHikariPoolMXBean().getTotalConnections());
System.out.println("Threads Waiting for Connection: " + ds.getHikariPoolMXBean().getThreadsAwaitingConnection());
}
}
五、 其他优化建议:锦上添花
除了上述三大策略,还有一些细节优化可以提升数据库性能。
1. 索引优化
- 选择合适的索引: 根据查询频率和WHERE条件创建索引。
- 避免冗余索引: 过多索引会增加写操作开销。
- 使用覆盖索引: 查询只需要访问索引,不需要回表。
2. SQL优化
- 避免SELECT *: 只查询需要的字段。
- 优化分页查询: 深分页时使用延迟关联。
- 避免在索引列上进行函数操作: 会导致索引失效。
3. 参数调优
- innodb_buffer_pool_size: InnoDB缓冲池大小,建议设置为物理内存的50%-70%。
- innodb_log_file_size: InnoDB重做日志文件大小,适当增大可以提升写性能。
- max_connections: 最大连接数,根据连接池配置和系统资源调整。
六、 总结:没有银弹,只有组合拳
面对高并发,没有任何单一技术是万能的。分库分表、读写分离、连接池优化,这三者需要结合使用,形成一个完整的解决方案。
实战经验:
- 先监控,后优化: 使用监控工具(如Prometheus+Grafana)找出性能瓶颈。
- 小步快跑,迭代优化: 不要一次性做所有优化,逐步验证效果。
- 关注业务场景: 不同业务场景对一致性和性能的要求不同,选择合适策略。
最后,我想说,数据库优化是一场持久战。今天扛住了,明天可能又会出现新问题。保持学习,关注新技术,才能在技术的海洋中游刃有余。希望这篇文章能对你有所帮助,如果有任何问题,欢迎在评论区留言讨论。
记住,最好的优化,是让数据库“不累”;最高的境界,是让用户“无感”。