哎,说到MySQL高并发,这话题我可太有话说了好吧。上周我还在帮一个做电商的大佬调优,他们的订单系统在秒杀活动时直接崩了,老板差点没把我给”祭天”。今天我就跟大伙儿唠唠这个既让人头大又让人着迷的话题——怎么让MySQL在高并发下依然稳如老狗。
先说说背景哈,现在大家谁没个APP没个网站啊?用户一多,尤其是那种搞秒杀、抢票、直播带货的场景,QPS(每秒查询率)动不动就上千上万。这时候MySQL要是扛不住,那页面打不开、订单丢单,损失可就大了。我见过太多人一来就搞读写分离、分库分表,但连连接池都没配好就想上天,这不扯淡嘛。
咱们今天不整那些虚的,从最基础的连接池优化开始,一步步讲到读写分离方案,保证让你看完能直接上手干活。
连接池:高并发的第一道防线
很多新手犯的第一个错误就是:每次查询都新建一个数据库连接。你想想,TCP三次握手、身份验证、权限检查…这一套下来,连接建立的成本可能比执行SQL还高。在高并发场景下,这种做法简直就是自杀。
连接池的原理其实挺简单的,就像餐厅里的座位管理。顾客(请求)来了,不是每次重新盖一个新餐厅,而是从现有座位(连接)里找一个空闲的。如果都满了,就等一等或者加几张桌子。用完饭后,座位清理一下再给下一批人用。
常见连接池方案对比
目前主流的Java连接池有DBCP、C3P0、HikariCP这几个。我来给你扒一扒它们的优缺点:
DBCP:老牌选手,Apache出品,稳定性不错。但它的性能在连接池内部使用同步机制,高并发下性能会下降。适合那些对性能要求不是特别极端的场景。
C3P0:也是经典款,支持多线程,配置相对简单。不过它的默认参数比较保守,在高并发下容易出现连接泄漏的问题。我有个朋友用它做金融系统,结果连接池慢慢被耗尽,服务直接挂掉,排查了三天才发现是配置问题。
HikariCP:现在的当红炸子鸡!我强烈推荐。它速度快、轻量级,核心代码就十几个类,但性能吊打前面两位。根据各种基准测试,HikariCP的连接获取性能比DBCP快2-3倍,比C3P0快4-5倍。它的优化点很多,比如用数组代替链表管理连接、减少锁竞争等。
说个真实案例,我之前帮一家物流公司优化系统,他们用的是C3P0,连接池配置是最大50个连接。但实际高峰期QPS有2000+,结果连接频繁耗尽,系统响应时间从200ms飙升到3秒以上。换成HikariCP后,同样的配置,响应时间稳定在150ms以内。
HikariCP配置详解
来,咱们直接上代码,看看怎么配:
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class HikariCPConfig {
public static DataSource getDataSource() {
HikariConfig config = new HikariConfig();
// 数据库连接URL
config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC&characterEncoding=utf8");
// 数据库用户名和密码
config.setUsername("root");
config.setPassword("password123");
// 数据源名称
config.setDataSourceName("HikariCP-DataSource");
// 连接池最小空闲连接数
config.setMinimumIdle(10);
// 连接池最大连接数,根据实际并发调整
config.setMaximumPoolSize(50);
// 连接最大存活时间(毫秒),建议设置为数据库的wait_timeout的一半
config.setMaxLifetime(1800000); // 30分钟
// 连接超时时间(毫秒),默认30秒
config.setConnectionTimeout(30000);
// 空闲连接超时时间(毫秒),超过此时间的空闲连接会被回收
config.setIdleTimeout(600000); // 10分钟
// 连接测试查询
config.setConnectionTestQuery("SELECT 1");
// 是否启用自动提交
config.setAutoCommit(true);
// 禁用statement缓存,提高性能
config.setPoolName("HikariPool-1");
// 添加其他有用的属性
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLength", "2048");
config.addDataSourceProperty("useServerPrepStmts", "true");
return new HikariDataSource(config);
}
public static void main(String[] args) {
DataSource dataSource = getDataSource();
// 模拟并发查询
for (int i = 0; i < 100; i++) {
final int id = i;
new Thread(() -> {
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?")) {
stmt.setInt(1, id);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
System.out.println("User: " + rs.getString("name"));
}
}
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
// 保持主线程运行
try {
Thread.sleep(60000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 关闭连接池
((HikariDataSource) dataSource).close();
}
}
配置里的几个关键参数我解释一下:
maximumPoolSize:最大连接数。这个值怎么定?经验公式是:(CPU核心数 * 2) + 有效磁盘数。但也要考虑业务场景,如果是IO密集型(比如大量查询),可以适当调大;如果是计算密集型,就不要设太大。一般建议20-100之间。
minimumIdle:最小空闲连接数。这个值应该小于或等于maximumPoolSize。设置合理的值可以避免在连接池冷启动时频繁创建连接。
maxLifetime:连接最大生命周期。一定要小于数据库的wait_timeout设置,否则数据库会主动断开连接,但连接池还不知道,还在用这个”僵尸”连接,就会报错。
connectionTimeout:获取连接的超时时间。如果超过这个时间还获取不到连接,就会抛出异常。这个值要合理设置,太短会影响用户体验,太长会占用资源。
还有个常见的坑:不要在使用完连接后忘记关闭。很多人写了这样的代码:
Connection conn = dataSource.getConnection();
// 各种操作...
// 忘记conn.close()了!
这会导致连接泄漏,连接池里的连接被慢慢耗尽。正确做法是使用try-with-resources,就像我上面代码里展示的那样。
连接池监控
光配好还不够,还得监控。HikariCP提供了很好的监控接口:
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.metrics.MetricsTrackerFactory;
import com.zaxxer.hikari.metrics.dropwizard.CodahaleMetricsTrackerFactory;
import io.dropwizard.metrics5.MetricRegistry;
public class ConnectionPoolMonitor {
public static void monitorPool(HikariDataSource dataSource) {
// 获取连接池统计信息
System.out.println("Active connections: " + dataSource.getHikariPoolMXBean().getActiveConnections());
System.out.println("Idle connections: " + dataSource.getHikariPoolMXBean().getIdleConnections());
System.out.println("Total connections: " + dataSource.getHikariPoolMXBean().getTotalConnections());
System.out.println("Threads waiting for connection: " + dataSource.getHikariPoolMXBean().getThreadsAwaitingConnection());
// 可以定期打印这些信息,或者集成到监控系统中
}
}
把这些数据接入到Prometheus、Grafana这样的监控平台,你就能实时看到连接池的健康状况。要是发现active connections一直很高,或者threadsAwaitingConnection不为0,那就说明连接池不够用,需要调整配置或者优化SQL。
SQL优化:高并发的根基
连接池只是外功,SQL优化才是内功。很多系统高并发下扛不住,根因就是SQL写得烂。
慢查询分析
首先要找出慢查询。MySQL自带了慢查询日志功能:
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 超过1秒的查询记录为慢查询
-- 查看慢查询日志位置
SHOW VARIABLES LIKE 'slow_query_log_file';
然后用mysqldumpslow工具分析:
# 按查询次数排序
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
# 按平均查询时间排序
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# 按锁定时间排序
mysqldumpslow -s l -t 10 /var/log/mysql/slow.log
索引优化
索引是SQL优化的核心。但很多人有个误区:索引越多越好。其实不然,索引虽然能加速查询,但会降低插入、更新、删除的速度,因为每次修改数据都要更新索引。
覆盖索引:如果查询的列都在索引中,就不需要回表查询了。比如:
-- 假设user表有索引idx_name_age(name, age)
-- 这个查询会使用覆盖索引,不需要回表
SELECT name, age FROM users WHERE name = '张三' AND age > 20;
-- 这个查询需要回表,因为select *需要所有列
SELECT * FROM users WHERE name = '张三' AND age > 20;
最左前缀原则:对于联合索引,查询条件必须从最左边的列开始。比如索引idx_name_age_email(name, age, email),查询条件如果跳过name直接查age和email,索引就失效了。
避免在索引列上做函数操作:
-- 这会失效索引
SELECT * FROM users WHERE YEAR(create_time) = 2024;
-- 应该改成范围查询
SELECT * FROM users WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';
分页优化
大分页是性能杀手。很多人写分页查询是这样的:
SELECT * FROM orders LIMIT 100000, 10;
这会让MySQL扫描100010行记录,然后丢弃前100000行。随着偏移量增大,性能急剧下降。
优化方案有两种:
方案一:子查询优化
SELECT * FROM orders
WHERE id >= (SELECT id FROM orders LIMIT 100000, 1)
LIMIT 10;
方案二:延迟关联
SELECT o.* FROM orders o
INNER JOIN (SELECT id FROM orders LIMIT 100000, 10) temp
ON o.id = temp.id;
这样MySQL只需要扫描索引,然后回表查询实际数据,效率大幅提升。
缓存策略:减轻数据库压力
高并发下,数据库扛不住怎么办?缓存啊!这是最常用的手段。
缓存架构选型
本地缓存:比如Caffeine、Guava Cache。优点是速度快(内存级),缺点是分布式环境下数据不一致。适合数据量小、变更不频繁的场景。
分布式缓存:Redis、Memcached。优点是数据共享、一致性较好,缺点是需要额外维护一个缓存层。适合大多数互联网场景。
我推荐Redis,功能强大、生态成熟。
Redis缓存实战
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.stream.Collectors;
public class RedisCacheService {
private static final JedisPool jedisPool;
private static final ObjectMapper objectMapper = new ObjectMapper();
static {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(100);
config.setMaxIdle(20);
config.setMinIdle(10);
config.setTestOnBorrow(true);
jedisPool = new JedisPool(config, "localhost", 6379, 3000, "password");
}
// 获取用户信息
public User getUser(Long userId) {
String cacheKey = "user:" + userId;
try (Jedis jedis = jedisPool.getResource()) {
// 先查缓存
String json = jedis.get(cacheKey);
if (json != null) {
return objectMapper.readValue(json, User.class);
}
// 缓存未命中,查数据库
User user = queryFromDB(userId);
if (user != null) {
// 写入缓存,设置过期时间避免雪崩
jedis.setex(cacheKey, 300, objectMapper.writeValueAsString(user));
}
return user;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
// 批量获取用户
public List<User> batchGetUsers(List<Long> userIds) {
try (Jedis jedis = jedisPool.getResource()) {
List<String> keys = userIds.stream()
.map(id -> "user:" + id)
.collect(Collectors.toList());
List<String> values = jedis.mget(keys.toArray(new String[0]));
return values.stream()
.map(json -> {
if (json == null) {
return null;
}
try {
return objectMapper.readValue(json, User.class);
} catch (Exception e) {
return null;
}
})
.collect(Collectors.toList());
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
private User queryFromDB(Long userId) {
// 实际项目中这里应该用MyBatis或其他ORM框架
// 这里简化处理
User user = new User();
user.setId(userId);
user.setName("用户" + userId);
return user;
}
public static class User {
private Long id;
private String name;
// getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
}
缓存穿透、击穿、雪崩问题
这三个问题是缓存的经典问题,必须搞清楚:
缓存穿透:查询不存在的数据,缓存和数据库都查不到。解决方案:缓存空值、使用布隆过滤器。
// 缓存空值
if (user == null) {
jedis.setex(cacheKey, 60, ""); // 缓存空值,过期时间设短一点
return null;
}
缓存击穿:热点key过期,大量请求瞬间打到数据库。解决方案:设置热点数据永不过期、使用互斥锁。
// 使用分布式锁
public User getUserWithLock(Long userId) {
String cacheKey = "user:" + userId;
// 先查缓存
String json = jedis.get(cacheKey);
if (json != null && !json.isEmpty()) {
try {
return objectMapper.readValue(json, User.class);
} catch (Exception e) {
// 缓存数据异常,删除缓存
jedis.del(cacheKey);
}
}
// 缓存未命中,加锁
String lockKey = "lock:" + userId;
boolean locked = jedis.set(lockKey, "1", "NX", "EX", 10) != null;
try {
if (locked) {
// 双重检查
json = jedis.get(cacheKey);
if (json != null && !json.isEmpty()) {
return objectMapper.readValue(json, User.class);
}
// 查数据库
User user = queryFromDB(userId);
if (user != null) {
jedis.setex(cacheKey, 300, objectMapper.writeValueAsString(user));
}
return user;
} else {
// 没抢到锁,稍等后重试
Thread.sleep(50);
return getUserWithLock(userId);
}
} finally {
if (locked) {
jedis.del(lockKey);
}
}
}
缓存雪崩:大量key同时过期。解决方案:过期时间加随机值。
// 过期时间加随机值,避免集中过期
int baseTTL = 300;
int randomTTL = (int) (Math.random() * 60); // 0-60秒随机
jedis.setex(cacheKey, baseTTL + randomTTL, json);
读写分离:架构升级的必经之路
当单库压力大到一定程度,读写分离就是必然选择。它的核心思想是:读操作走从库,写操作走主库。
读写分离原理
主从复制是读写分离的基础。MySQL支持多种复制方式:
基于语句的复制(SBR):记录执行的SQL语句。优点是日志小,缺点是某些函数(如UUID())可能导致主从数据不一致
