上周二下午三点,我们的核心订单服务突然告警,QPS从平时的5000瞬间飙到28000,数据库CPU直接飙到98%,连接数爆满,错误日志里全是 Too many connections 和 Deadlock found。那一刻我才真正意识到,之前写的”简单Demo”代码在生产环境面前有多脆弱。今天就把这套血泪换来的实战方案掏出来,希望能帮你们少走弯路。
连接池不是越大越好,而是”刚好够用”
很多团队一遇到高并发,第一反应就是”加大连接池”,这是最大的误区。连接池太大反而会成为压垮MySQL的最后一根稻草。每个数据库连接都要占用内存、CPU和文件描述符,连接数过多会导致MySQL在上下文切换上消耗大量资源,性能反而下降。
我们先看一个典型的错误配置:
# 错误示例:连接池设置过大
spring:
datasource:
hikari:
maximum-pool-size: 200 # 太大!MySQL默认最大连接数才151
minimum-idle: 50 # 空闲连接也太多
idle-timeout: 30000 # 30秒就释放,太频繁
max-lifetime: 1800000 # 30分钟,合理
connection-timeout: 30000 # 30秒超时,太宽松
leak-detection-threshold: 0 # 关闭了泄漏检测
正确的做法是基于你的MySQL服务器性能和业务特点来计算。有一个经典公式可以参考:
推荐连接数 = (CPU核心数 × 2) + 磁盘数
假设你的MySQL服务器是16核、2块磁盘,那么推荐连接数大约是36个。当然这是上限,实际应用中需要根据业务特征调整。
// Spring Boot 正确的连接池配置
@Configuration
public class DataSourceConfig {
@Bean
public HikariDataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(System.getenv("DB_URL"));
config.setUsername(System.getenv("DB_USER"));
config.setPassword(System.getenv("DB_PASSWORD"));
// 核心配置:根据服务器资源合理设置
config.setMaximumPoolSize(40); // 略高于理论计算值
config.setMinimumIdle(10); // 保持一定空闲连接应对突发
config.setIdleTimeout(60000); // 1分钟空闲释放
config.setMaxLifetime(1800000); // 30分钟最大生命周期
config.setConnectionTimeout(5000); // 5秒超时,快速失败
config.setLeakDetectionThreshold(30000); // 30秒泄漏检测
// 关键:设置SQL超时,防止长查询占用连接
config.addDataSourceProperty("sessionVariables", "sql_mode='STRICT_ALL_TABLES'");
config.addDataSourceProperty("initialTimeout", "2");
return new HikariDataSource(config);
}
}
连接池优化还有个关键点:SQL语句的执行效率。一个慢查询占用连接10秒,比10个快查询占用连接1秒危害大得多。所以连接池优化必须和SQL优化同步进行。
-- 查看当前连接数和慢查询
SHOW PROCESSLIST;
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
-- 设置慢查询阈值(根据实际情况调整,建议1-2秒)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
索引设计:90%的性能问题都能靠索引解决
高并发场景下,索引设计比连接池配置更重要。一个错误的索引可能让查询从10毫秒变成10秒,直接拖垮整个连接池。
先说一个真实案例:我们有个订单查询接口,原SQL是:
-- 错误示例:没有利用索引的全表扫描
SELECT * FROM orders
WHERE user_id = 12345
AND status = 1
ORDER BY create_time DESC
LIMIT 20;
这张表有500万数据,每次查询都要扫描大量行。优化方案是设计复合索引:
-- 正确的索引设计:遵循最左前缀原则
-- user_id + status + create_time 的组合能覆盖WHERE和ORDER BY
CREATE INDEX idx_user_status_time ON orders (user_id, status, create_time);
-- 验证索引是否生效
EXPLAIN SELECT * FROM orders
WHERE user_id = 12345
AND status = 1
ORDER BY create_time DESC
LIMIT 20;
EXPLAIN结果会显示 type: ref 和 key: idx_user_status_time,这说明索引被正确使用了。
但索引不是越多越好。每个索引都会增加写入开销和存储空间。我们有个项目曾经给一张表加了15个索引,结果写入性能下降了40%。
// 使用MyBatis-Plus时,注意避免N+1查询问题
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 错误示例:循环查询,高并发下会放大N倍
*/
public List<OrderVO> getOrdersBad(List<Long> userIds) {
List<OrderVO> result = new ArrayList<>();
for (Long userId : userIds) {
// 每次循环都发一次SQL,100个用户就是100次查询
Order order = orderMapper.selectOne(
new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId)
.orderByDesc(Order::getCreateTime)
.last("LIMIT 1")
);
result.add(convertToVO(order));
}
return result;
}
/**
* 正确示例:批量查询,一次SQL搞定
*/
public List<OrderVO> getOrdersGood(List<Long> userIds) {
// IN查询,配合索引 idx_user_create_time
List<Order> orders = orderMapper.selectList(
new LambdaQueryWrapper<Order>()
.in(Order::getUserId, userIds)
.orderByDesc(Order::getCreateTime)
.last("LIMIT 100") // 业务上限制返回数量
);
return orders.stream().map(this::convertToVO).collect(Collectors.toList());
}
}
还有一个容易被忽视的点:覆盖索引。如果查询只需要索引中的字段,MySQL可以直接从索引返回结果,不需要回表查询,性能提升巨大。
-- 原始查询需要回表
SELECT id, user_id, amount, status, create_time
FROM orders
WHERE user_id = 12345 AND status = 1;
-- 优化后:如果这五个字段都在索引中,就不需要回表
CREATE INDEX idx_user_status_cover ON orders (user_id, status, id, amount, create_time);
读写分离:别让写压力拖垮读性能
我们的经验是:读多写少的业务,读写分离能解决60%的高并发问题。但读写分离不是简单地把读请求分发到从库就完事了。
首先要注意主从延迟问题。如果从库同步延迟超过1秒,用户刚下单就去查询,可能查不到自己的订单。
// 关键:强制读主库的场景
@Service
public class OrderQueryService {
@Autowired
private OrderMapper orderMapper;
/**
* 需要实时性的查询,强制走主库
*/
public Order getRecentOrder(Long userId) {
// 使用注解或路由规则,强制走主库
return orderMapper.selectOne(
new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId)
.orderByDesc(Order::getCreateTime)
.last("LIMIT 1")
);
}
/**
* 对实时性要求不高的查询,走从库
*/
public List<Order> getHistoryOrders(Long userId, int page) {
// 默认走从库,利用缓存和分库
return orderMapper.selectList(
new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId)
.ge(Order::getCreateTime, System.currentTimeMillis() - 30L * 24 * 60 * 60 * 1000)
.orderByDesc(Order::getCreateTime)
.page(new Page<>(page, 20))
);
}
}
读写分离的架构设计也很关键。我们采用的是ShardingSphere来做透明读写分离:
# ShardingSphere读写分离配置
spring:
shardingsphere:
datasource:
names: master,slave1,slave2
master:
url: jdbc:mysql://master-host:3306/order_db
username: root
password: xxx
slave1:
url: jdbc:mysql://slave1-host:3306/order_db
username: root
password: xxx
slave2:
url: jdbc:mysql://slave2-host:3306/order_db
username: root
password: xxx
rules:
readwrite-splitting:
data-sources:
ds:
write-data-source-name: master
read-data-sources: [slave1, slave2]
load-balancer-type: ROUND_ROBIN # 轮询负载均衡
props:
read-data-source-names: slave1,slave2
write-data-source-name: master
sql-show: true # 生产环境关闭,调试用
但读写分离也有局限:对于事务性要求高的操作,还是必须走主库。所以我们在架构上做了分层:
- 核心写操作(下单、支付):强制主库,保证数据一致性
- 非核心读操作(历史查询、报表):走从库,允许秒级延迟
- 热点数据:加本地缓存+Redis缓存,减少数据库访问
缓存策略:数据库的最后一道防线
高并发下,缓存是保护数据库最有效的手段。但缓存不是万能药,用错了反而增加复杂性。
我们的缓存策略分三层:
@Service
public class ProductCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper;
// 本地缓存,缓存5分钟,减少Redis压力
private final Cache<String, Product> localCache = CacheBuilder.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.maximumSize(1000)
.build();
/**
* 三级缓存查询策略
*/
public Product getProduct(Long productId) {
// 第一层:本地缓存
try {
Product local = localCache.getIfPresent(String.valueOf(productId));
if (local != null) {
return local;
}
} catch (Exception e) {
// 本地缓存异常不影响业务
}
// 第二层:Redis缓存
String redisKey = "product:" + productId;
Product redisProduct = (Product) redisTemplate.opsForValue().get(redisKey);
if (redisProduct != null) {
// 回源本地缓存
localCache.put(String.valueOf(productId), redisProduct);
return redisProduct;
}
// 第三层:数据库
Product dbProduct = productMapper.selectById(productId);
if (dbProduct != null) {
// 写入Redis,设置过期时间防止雪崩
redisTemplate.opsForValue().set(redisKey, dbProduct, 30, TimeUnit.MINUTES);
localCache.put(String.valueOf(productId), dbProduct);
}
return dbProduct;
}
/**
* 缓存更新策略:Cache-Aside Pattern
*/
@Transactional
public void updateProduct(Product product) {
// 1. 先更新数据库
productMapper.updateById(product);
// 2. 再删除缓存(不是更新缓存,避免并发问题)
redisTemplate.delete("product:" + product.getId());
localCache.invalidate(String.valueOf(product.getId()));
}
}
缓存设计有几个关键原则:
- 先更新数据库,再删除缓存,避免脏数据
- 设置合理的过期时间,防止缓存雪崩
- 热点key单独处理,防止单key瓶颈
- 缓存击穿防护,使用互斥锁或逻辑过期
/**
* 热点key防护:使用分布式锁防止缓存击穿
*/
public Product getProductWithHotKeyProtection(Long productId) {
String key = "product:" + productId;
// 先查缓存
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
// 缓存未命中,加锁防止并发穿透
String lockKey = "lock:product:" + productId;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查
cached = (Product) redisTemplate.opsForValue().get(key);
if (cached == null) {
// 查数据库
cached = productMapper.selectById(productId);
if (cached != null) {
redisTemplate.opsForValue().set(key, cached, 30, TimeUnit.MINUTES);
}
}
return cached;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 获取锁失败,休眠后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductWithHotKeyProtection(productId);
}
}
限流与降级:最后一道保障
即使做了以上所有优化,极端情况下还是可能扛不住。这时候限流和降级就是最后的救命稻草。
@RestController
public class OrderController {
@Autowired
private OrderService orderService;
// 使用Redis + Lua脚本实现令牌桶限流
@PostMapping("/order/create")
public Result createOrder(@RequestBody OrderRequest request) {
// 限流检查
if (!rateLimiter.tryAcquire("order:create:" + request.getUserId())) {
return Result.fail("系统繁忙,请稍后重试");
}
try {
return Result.success(orderService.createOrder(request));
} catch (Exception e) {
// 降级处理:返回兜底数据或友好提示
log.error("订单创建异常", e);
return Result.fail("订单创建失败,请联系客服");
}
}
}
@Component
public class RateLimiter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 令牌桶限流实现
* @param key 限流键
* @return 是否允许通过
*/
public boolean tryAcquire(String key) {
String luaScript =
"local key = KEYS[1] " +
"local capacity = tonumber(ARGV[1]) " +
"local tokens = tonumber(ARGV[2]) " +
"local now = tonumber(ARGV[3]) " +
"local ttl = tonumber(ARGV[4]) " +
"local current = redis.call('GET', key) " +
"if current == false then " +
" redis.call('SET', key, capacity) " +
" redis.call('EXPIRE', key, ttl) " +
" return 1 " +
"end " +
"if tonumber(current) >= tokens then " +
" redis.call('DECRBY', key, tokens) " +
" return 1 " +
"end " +
"return 0";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(key),
"100", // 令牌桶容量
"1", // 每次请求消耗令牌数
String.valueOf(System.currentTimeMillis() / 1000),
"60" // TTL 60秒
);
return result != null && result == 1;
}
}
限流策略要根据业务特点来定:
- 接口维度:每个接口独立限流
- 用户维度:防止单个用户恶意刷接口
- 全局维度:保护整个系统不崩盘
监控与告警:早发现早处理
最后但同样重要的是监控。没有监控的优化都是盲目的。
# Prometheus + Grafana 监控配置
spring:
boot:
metrics:
export:
prometheus:
enabled: true
# 关键监控指标
- 数据库连接数(当前/最大)
- QPS/TPS(每秒查询/事务数)
- 慢查询数量
- 锁等待时间
- 主从延迟
- 缓存命中率
我们设置了一套自动告警规则:
”`java @Component public class MonitorListener {
@Autowired
private PrometheusClient prometheusClient;
@Autowired
private AlertService alertService;
/**
* 每分钟检查一次关键指标
*/
@Scheduled(fixedRate = 60000)
public void checkMetrics() {
// 检查连接数
int currentConnections = prometheusClient.query(
"mysql_global_status_threads_connected"
);
int maxConnections = prometheusClient.query(
"mysql_global_variables_max_connections"
);
if (currentConnections > maxConnections * 0.8) {
alertService.sendAlert("数据库连接数接近
