记得那是去年“618”大促的前夜,我们的核心交易服务突然报警,手机震得桌角都在抖。监控大屏上,MySQL的CPU使用率瞬间飙升至95%,紧接着是QPS(每秒查询率)曲线垂直下落,那是数据库在“窒息”。
那时候我才深刻意识到,教科书里讲的“读写分离+连接池+缓存”三大件,听起来像是一套完美的黄金三角,但在高并发的实战修罗场里,每一个环节如果没调教好,都不是保护伞,而是催命符。
今天,我不跟你们讲那些虚头巴脑的定义,咱们就复盘那次事故,聊聊怎么在MySQL高并发环境下,真正地把这三样东西用活、用稳。
一、 超卖的惊魂时刻:并发控制不是靠锁,是靠“乐观”
事故的根源,其实是一个经典的“超卖”问题。
当时的业务场景是:用户下单扣减库存。我们的代码逻辑很简单:
- 查询库存
SELECT stock FROM product WHERE id = 1001 - 判断库存是否大于0
- 执行扣减
UPDATE product SET stock = stock - 1 WHERE id = 1001
看似天衣无缝,对吧?但在高并发下,这两个步骤之间存在一个微小的时间窗口。假设库存只剩1件,100个用户同时发起请求:
- 用户A查询,看到库存=1,准备下单。
- 用户B查询,也看到库存=1,也准备下单。
- 用户A执行UPDATE,库存变为0,成功。
- 用户B执行UPDATE,因为条件里没限制库存必须大于0(或者即使限制了,在乐观锁version场景下),结果导致库存变成负数,或者因为版本冲突失败。
在极端压力下,这种CAS(Compare And Swap)的失效会引发连锁反应,大量的请求失败重试,进一步加重数据库负担。
避坑指南:别只用 SELECT 做前置判断
很多新手(包括当时的我)喜欢先查再改。但在高并发下,查询本身就是一种负担,而且是不可靠的信任基础。
正确的做法是用 UPDATE 自带原子性:
-- 这才是正确的姿势:利用数据库的行锁机制
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1001 AND stock > 0;
看这行代码,stock > 0 的条件写在 WHERE 里,MySQL在执行UPDATE时会持有行锁。如果并发请求同时到达,它们会在队列里排队。第一个拿到锁的更新成功,后续的发现 stock 已经不大于0,直接更新0行,返回受影响行数为0。
我们在Java层只需要检查 affectedRows:
int affected = productMapper.deductStock(productId);
if (affected > 0) {
// 扣减成功,继续下单
} else {
// 库存不足,返回前端提示
}
这样做的好处是:把判断逻辑下沉到数据库层,利用MVCC(多版本并发控制)和行锁解决竞态条件,彻底消灭超卖,同时也减少了无效查询带来的IO压力。
二、 连接池的迷思:越大多越好?错了
数据库雪崩的第二个推手,是我们对连接池的盲目自信。
为了应对高并发,我们最初将HikariCP(一款高性能连接池)的配置调得非常高:
maximum-pool-size: 500minimum-idle: 100connection-timeout: 30000ms
结果呢?连接数上去了,但数据库没撑住,反而操作系统层面的文件描述符(file descriptors)和TCP连接数被打满,导致网络栈阻塞,数据库线程因为上下文切换(Context Switch)过于频繁而崩溃。
真相:连接池不是越宽越好,而是“够用且流转快”
MySQL对每个连接的处理是有成本的(内存分配、权限检查、线程创建)。连接数过多,数据库CPU会被大量用于维护连接状态,而不是处理SQL。
实战调优公式:
计算合适的大小: 通常建议
maximum-pool-size=(CPU核心数 * 2) + 有效磁盘数。对于大多数高负载的OLTP场景,200-300个连接往往已经足够支撑极高的吞吐。如果数据库性能瓶颈不在CPU而在IO,可以适当增加,但绝对不要超过500。调整空闲策略: HikariCP默认的行为很激进,它会尽量保持最小连接数。建议设置:
maximum-pool-size: 根据压测结果确定,建议从100开始测试。minimum-idle: 设置为与maximum-pool-size相同,或者略低(如50)。HikariCP有一个特性叫“Keepalive”,它会定期检测连接是否存活,所以不需要维持太多空闲连接浪费资源。max-lifetime: 建议设置为1800000ms(30分钟),并设置为数据库wait_timeout的约80%,防止连接被数据库强制断开后应用层还在使用。idle-timeout: 设置为600000ms(10分钟)。
监控关键指标: 不要只看CPU,要看 Active Connections 和 Pending Requests。如果Pending Requests持续为0,说明连接池够用;如果Active Connections长期打满且Pending Requests很高,才需要考虑扩容或优化SQL。
// HikariConfig 配置示例(Spring Boot application.yml)
spring:
datasource:
hikari:
maximum-pool-size: 100 # 切勿盲目设置500+
minimum-idle: 20
idle-timeout: 600000
max-lifetime: 1800000
connection-timeout: 30000
keepalive-time: 30000 # 开启保活,防止防火墙切断空闲连接
三、 缓存的坑:一致性比性能更重要
如果只做前两步,数据库可能不会立刻雪崩,但QPS上来后,缓存会成为新的战场。
我们当时引入了Redis做缓存,思路是:下单前先查Redis库存,扣减Redis,再异步同步MySQL。
这就是典型的“缓存与数据库双写不一致”陷阱。
场景还原:缓存穿透与击穿
- 缓存穿透:黑客疯狂查询一个不存在的商品ID(比如-1)。请求直达MySQL,查到没数据,也不缓存。几次之后,MySQL被打死。
- 缓存击穿:某个热点商品(如iPhone首发)的缓存恰好过期,瞬间涌入大量请求,全部打到MySQL。
- 双写不一致:用户下单成功,MySQL库存扣减成功,但Redis更新失败,或者顺序反了,导致用户看到还有库存,再次下单,再次超卖。
避坑实战:缓存架构的正确姿势
1. 解决穿透:布隆过滤器或缓存空对象
对于不存在的数据,可以缓存一个空对象(TTL设短一点,如5分钟),或者使用Guava BloomFilter。
// 伪代码:缓存空对象策略
String cacheKey = "stock:" + productId;
String stock = redis.get(cacheKey);
if (stock == null) {
stock = mysql.queryStock(productId);
if (stock == null || stock < 0) {
// 缓存空值,防止穿透
redis.setex(cacheKey, 5 * 60, "0");
return 0;
}
// 缓存真实值
redis.setex(cacheKey, 2 * 60, stock);
}
return Integer.parseInt(stock);
2. 解决击穿:逻辑过期(互斥锁)
对于热点数据,不要用短TTL,而是设置逻辑过期。当发现数据过期时,不直接查数据库,而是获取一把分布式锁,只有拿到锁的线程去查库并重建缓存,其他线程等待或返回旧数据。
public String getStockWithLogicalExpire(Long productId) {
String key = "stock_" + productId;
String value = redis.get(key);
// 解析逻辑过期时间(存在value里,或者单独一个hash)
StockData stockData = JSON.parseObject(value, StockData.class);
if (stockData.getExpireTime() < System.currentTimeMillis()) {
// 数据过期,尝试获取重建锁
String lockKey = "lock_" + productId;
boolean isLock = redis.setnx(lockKey, "1", 10); // 10秒锁
if (isLock) {
try {
// 双重检查,防止并发重复查询
value = redis.get(key);
stockData = JSON.parseObject(value, StockData.class);
if (stockData.getExpireTime() < System.currentTimeMillis()) {
// 查询数据库
int realStock = mysql.queryStock(productId);
// 重建缓存,延长过期时间
StockData newData = new StockData(realStock, System.currentTimeMillis() + 1000 * 60);
redis.set(key, JSON.toJSONString(newData), 100);
return String.valueOf(realStock);
}
} finally {
redis.del(lockKey);
}
} else {
// 没拿到锁,返回旧数据(或者短暂等待后重试)
return String.valueOf(stockData.getStock());
}
}
return String.valueOf(stockData.getStock());
}
3. 解决一致性:Canal监听Binlog
不要在业务代码里同时写Redis和MySQL,那样太脆弱。推荐使用 Canal 或 RocketMQ 订阅MySQL的Binlog,异步更新Redis。
流程:
- 业务只操作MySQL。
- Canal监听MySQL Binlog变化。
- 发送消息到MQ。
- 消费者更新Redis。
这样,MySQL是权威数据源,Redis是缓存副本,即使缓存更新失败,下次请求超时后也会重新加载(或者通过延迟双删兜底)。
四、 读写分离的隐患:主从延迟导致的“读了个寂寞”
最后,聊聊读写分离。我们当时把所有写操作指向Master,读操作指向Slave。
结果出现了一个诡异的现象:用户刚下单成功,立即刷新订单列表,竟然查不到刚才下的单。
这就是著名的主从延迟(Master-Slave Lag)问题。数据写入Master后,异步同步到Slave需要时间(毫秒到秒级不等),在这个窗口期内,Slave上的数据还是旧的。
解决方案:强制读主 + 业务补偿
1. 关键路径强制读主
对于订单、支付、库存等强一致性的场景,永远不要读Slave,必须读Master。
@DS("master") // 使用多数据源注解,强制路由到主库
public Order queryOrder(Long orderId) {
return orderMapper.selectById(orderId);
}
2. 非关键路径容忍延迟
对于商品详情页、新闻列表等可以容忍几秒延迟的场景,可以读Slave,并设置较短的缓存TTL,通过缓存屏蔽延迟。
3. 监控主从延迟
必须接入监控,实时查看 Seconds_Behind_Master。如果延迟超过阈值(如2秒),自动熔断,将所有读请求强制路由到Master,或者返回“数据加载中”的提示,避免脏数据误导用户。
五、 总结:高并发的本质是“分层防御”
回头看那次事故,我们并不是被某一行代码搞垮的,而是被系统设计中的侥幸心理搞垮的。
- 超卖:不要信任应用层的逻辑判断,让数据库的原子操作来兜底。
- 连接池:不要盲目追求大连接数,要根据数据库承载能力和OS资源上限,追求“最小够用”。
- 缓存:一致性是红线,放弃双写竞争,转向Binlog异步更新;用逻辑过期解决热点击穿。
- 读写分离:区分强一致和最终一致场景,核心业务强制读主,非核心业务容忍延迟。
高并发下的MySQL调优,不是某一个参数的玄学调整,而是一套从SQL优化、连接管理、缓存架构到事务隔离级别的系统工程。
真正的专家,不是在故障发生时救火的人,而是在设计阶段就把“最坏情况”都考虑进去,并用代码将其屏蔽的人。
希望这次的复盘,能帮你避开那些我曾踩过的坑。记住,数据一致性永远排在性能优化之前,这是底线。
