2023年双11前夜,某头部电商的架构群里炸开了锅。
凌晨两点,订单系统的监控曲线像心电图停跳一样拉成一条直线。DBA老张盯着屏幕上的”Too many connections”报错,手都在抖——秒杀活动开场才17分钟,MySQL主库的连接数就已经飙到3000+,而配置上限是2000。
那一刻,老张脑海里闪过一个念头:如果当初锁机制和读写分离做得更彻底一些,会不会不一样?
这个故事不是虚构的,而是中国电商技术圈每年都在重演的真实战役。今天我们就来聊聊,如何从这套真实的”崩溃-反思-重构”循环中,提炼出能落地的MySQL高并发优化策略。
一、先搞清楚:高并发到底在”高”什么?
很多团队一听到”高并发”三个字,第一反应就是垂直扩容——加内存、加CPU、换更好的云数据库。但这个方法有个致命的盲区:它解决的是”容量”问题,而不是”结构”问题。
想象一下,一个单向通行的隧道,无论把隧道拓宽多少倍,如果所有车都挤在同一个入口排队,拥堵只是时间问题。
在MySQL的语境里,”所有的车”就是那些并发请求,”隧道”就是你的数据库连接池,”入口”就是那些没有优化的锁和SQL。
并发压力的三个维度
第一个维度:连接数爆炸
MySQL默认的配置对于高并发场景几乎是灾难性的。每个连接都会占用内存(per-thread buffer),在5.7版本中,一个空闲连接就要消耗大约256KB的内存。当连接数达到1000时,光是连接本身的内存开销就是256MB——这还不算每个连接正在执行的SQL所消耗的临时表内存。
-- 查看当前连接配置
SHOW VARIABLES LIKE '%max_connections%';
SHOW VARIABLES LIKE '%thread_cache%';
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Threads_created';
如果你的Threads_created数值很高,说明MySQL在频繁创建和销毁线程,这是配置不当的典型信号。
第二个维度:锁竞争加剧
高并发下最隐蔽的杀手不是慢SQL,而是锁。InnoDB的行锁(Record Lock)、间隙锁(Gap Lock)、next-key lock,这些机制在单用户场景下相安无事,但一旦并发量上来,排队等锁的请求会像滚雪球一样越滚越大。
第三个维度:IO瓶颈显现
即使是SSD,随机IO的能力也是有限的。MySQL的redo log和binlog在并发写入时会形成IO尖峰,而buffer pool的命中率一旦下降,磁盘IO就会成为拦路虎。
二、秒杀场景的”死亡螺旋”
回到那个凌晨,让我们拆解一下秒杀场景为什么这么致命。
秒杀请求的”三重打击”
第一重:瞬时流量尖峰
正常订单系统可能每秒处理100单,但秒杀活动开场的那一秒,请求量可能飙到10万QPS。这意味着什么?意味着数据库在毫秒级别内要承受100倍的负载。
第二重:库存扣减的锁冲突
这是最核心的问题。想象一下这个场景:
用户A和B同时购买同一件商品(库存=1)
请求A:SELECT stock FROM products WHERE id=123 → 读到stock=1
请求B:SELECT stock FROM products WHERE id=123 → 读到stock=1
请求A:UPDATE products SET stock=stock-1 WHERE id=123 → 成功,stock=0
请求B:UPDATE products SET stock=stock-1 WHERE id=123 → 成功,stock=-1(超卖!)
为了防止超卖,你必须加锁。但加锁的方式决定了系统是优雅降级还是直接崩溃。
第三重:事务的连锁反应
一个完整的秒杀事务包含:查询库存 → 扣减库存 → 创建订单 → 扣减优惠券 → 记录日志。每一个步骤都涉及锁,每一个锁都在排队。当并发量上来时,事务排队等锁的时间可能比执行时间还长,形成”锁等待链”。
三、锁机制优化:从”严防死守”到”聪明放行”
3.1 行锁的精确化
很多开发者习惯用SELECT ... FOR UPDATE来保护库存查询,但这会带来严重问题:锁的粒度过粗。
-- 错误示范:锁住了整行,其他请求全部阻塞
BEGIN;
SELECT stock FROM products WHERE id=123 FOR UPDATE;
-- 其他事务在等待这把锁
UPDATE products SET stock=stock-1 WHERE id=123;
COMMIT;
更好的做法是使用条件更新,让数据库自己判断是否满足条件,而不是先在应用层查询再更新:
-- 正确示范:一条SQL完成,原子操作,锁范围最小
UPDATE products
SET stock=stock-1, version=version+1
WHERE id=123 AND stock>0;
-- 检查affected_rows,如果为0说明库存不足
这种方式的好处是:锁只在UPDATE执行的那一瞬间持有,而条件更新语句的锁等待时间极短。
3.2 乐观锁的妙用
对于秒杀这种场景,乐观锁往往比悲观锁更合适。原理很简单:假设冲突不会发生,只有在提交时才检查。
-- 利用version字段实现乐观锁
UPDATE products
SET stock=stock-1, version=version+1
WHERE id=123 AND version=#{current_version} AND stock>0;
如果affected_rows=0,说明发生了并发冲突,应用层可以:
- 返回”已售罄”
- 或者重试(有限次)
- 或者降级到排队模式
3.3 间隙锁的陷阱
InnoDB的默认隔离级别是REPEATABLE READ,这会导致间隙锁的出现。当你执行SELECT * FROM products WHERE id BETWEEN 100 AND 200 FOR UPDATE时,不仅锁住了100-200之间的记录,还锁住了这个范围内的”间隙”。
这意味着,即使其他事务访问的是101、102等没有被锁住的记录,也会被阻塞——因为间隙锁阻止了插队。
解决方案:
- 将隔离级别降低到READ COMMITTED(如果可以接受)
- 或者使用唯一索引进行等值查询,避免范围扫描
- 或者在应用层做预处理,减少锁的持有时间
3.4 锁等待的监控与告警
-- 查看当前锁等待情况
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM information_schema.innodb_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
如果你发现某个事务长时间阻塞其他事务,立即kill掉它。在秒杀场景下,宁可放弃一个请求,也不能让一个慢事务拖垮整个系统。
四、读写分离:从”一主多从”到”多级缓存”
4.1 读写分离的基础架构
┌─────────────┐
│ 应用层 │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐
│ 写主库 │ │ 读从库1 │ │ 读从库2 │
│ (MySQL A) │ │(MySQL B) │ │(MySQL C) │
└──────────┘ └──────────┘ └──────────┘
这是最基础的读写分离架构。但问题在于:从库的延迟
4.2 主从延迟的残酷现实
MySQL的主从复制是异步的。在高并发写入场景下,从库可能落后主库几百毫秒甚至几秒。对于订单系统来说,这意味着什么?
用户A:下单成功!(写入主库)
用户B:查询订单状态(读取从库)
从库:数据还没同步过来
结果:用户B看到"订单不存在"
解决方案一:强制路由
// 伪代码:写入操作后立即读主库
public Order createOrder(OrderRequest request) {
orderMapper.insert(request);
// 写入后强制读主库,避免读到过期数据
return orderMapper.selectById(request.getOrderId());
}
解决方案二:半同步复制
-- 开启半同步复制,确保至少一个从库实时同步
SET GLOBAL rpl_semi_sync_master_enabled = ON;
SET GLOBAL rpl_semi_sync_master_timeout = 1000;
半同步复制会阻塞主库的commit,直到至少一个从库确认收到binlog。这会增加写入延迟,但能保证数据一致性。对于订单系统,这是值得的。
解决方案三:binlog实时监控
-- 在从库上监控复制延迟
SHOW SLAVE STATUS\G
-- 关注Seconds_Behind_Master字段
如果延迟超过阈值,应用层应该自动降级到主库读,或者返回缓存数据并标记”可能过期”。
4.3 多级缓存架构
纯靠数据库读写分离,在千万级流量下是不够的。你需要引入多级缓存:
┌─────────────┐
│ 应用层 │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐
│ 本地缓存 │ │ Redis集群 │ │ 数据库 │
│ (Caffeine)│ │ (库存/订单)│ │ (MySQL) │
└──────────┘ └──────────┘ └──────────┘
第一级:本地缓存(Caffeine/Guava)
// 使用Caffeine构建本地缓存
Cache<String, Product> productCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.SECONDS)
.build();
// 查询逻辑
public Product getProduct(long id) {
String key = "product:" + id;
return productCache.get(key, () -> {
// 缓存未命中,查询Redis
return redisTemplate.opsForValue().get(key);
});
}
本地缓存的好处是零网络延迟,但代价是多节点数据不一致。对于秒杀场景,5秒的过期时间是可以接受的——最多让用户看到5秒前的库存状态。
第二级:Redis缓存
Redis不仅是缓存,还可以承担部分业务逻辑:
# 库存预扣减:使用Lua脚本保证原子性
redis-cli EVAL "
local stock = redis.call('GET', KEYS[1])
if stock and tonumber(stock) > 0 then
return redis.call('DECR', KEYS[1])
else
return -1
end
" 1 product:123:stock
第三级:数据库
数据库只处理Redis未命中的请求,或者处理写入操作。
4.4 缓存穿透与雪崩的预防
穿透问题: 攻击者反复查询不存在的商品ID,绕过缓存直接打数据库。
// 解决方案:缓存空值
public Product getProduct(long id) {
String key = "product:" + id;
Object value = redisTemplate.opsForValue().get(key);
if (value == null) {
// 缓存空值,设置较短过期时间
redisTemplate.opsForValue().set(key, "", 30, TimeUnit.SECONDS);
return null;
}
if ("".equals(value)) {
return null; // 商品不存在
}
return JSON.parseObject(value.toString(), Product.class);
}
雪崩问题: 大量缓存同时过期,请求瞬间涌向数据库。
// 解决方案:过期时间加随机偏移
long expireTime = 5 * 60 + new Random().nextInt(60); // 5-6分钟随机
redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);
五、分库分表:从”单点极限”到”分布式扩展”
当单表数据量超过1000万行,或者单库QPS超过10万时,读写分离和缓存都不够了。你需要分库分表。
5.1 垂直拆分 vs 水平拆分
垂直拆分: 按业务模块拆分数据库
订单系统数据库
├── 订单主库(订单表、支付表)
├── 商品库(商品表、库存表)
└── 用户库(用户表、地址表)
水平拆分: 按数据量拆分表
订单表(1000万行)→ 拆分
├── order_0(0-999万行)
├── order_1(1000-1999万行)
└── order_2(2000-2999万行)
5.2 分片策略
// 按用户ID分片
int shardId = userId % 16;
String tableName = "order_" + shardId;
分片键的选择至关重要:热点数据要避免跨分片。如果某个商品被所有人抢购,那无论怎么分片,这个商品的库存记录都会成为瓶颈。
解决方案:热点数据单独处理
// 热点商品库存单独存储
if (product.isHot()) {
// 走Redis预扣减 + 异步同步数据库
redisDecrStock(product.getId());
} else {
// 普通商品走分片数据库
orderMapper.insert(shardId, order);
}
5.3 跨分片查询的困境
分库分表后,跨分片的查询会变得非常复杂。比如”查询某用户的所有订单”,如果用户ID是 shard key,那就没问题;但如果要”查询某时间段内所有订单”,就需要遍历所有分片。
解决方案:建立全局索引表
-- 创建一个全局订单索引表
CREATE TABLE order_index (
order_id BIGINT PRIMARY KEY,
user_id BIGINT,
create_time DATETIME,
shard_id INT,
INDEX idx_user (user_id),
INDEX idx_time (create_time)
);
查询时先查索引表,再路由到具体分片。
六、数据库层面的终极优化
6.1 连接池的正确配置
# HikariCP配置
spring:
datasource:
hikari:
maximum-pool-size: 50 # 不要设太大
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000 # 开启连接泄漏检测
连接池的最大值不是越大越好。计算公式参考:
最大连接数 = CPU核心数 × 2 + 有效磁盘数
对于8核16线程的服务器,连接数设置在18-20左右比较合理。
6.2 SQL优化:从”能跑”到”跑得飞起”
*避免SELECT **
-- 错误
SELECT * FROM orders WHERE user_id=123;
-- 正确
SELECT order_id, amount, status FROM orders WHERE user_id=123;
索引优化
-- 查看慢查询
SHOW PROFILE FOR QUERY 1;
-- 分析索引使用情况
EXPLAIN SELECT * FROM orders WHERE user_id=123 AND status=1;
重点关注:
type字段:应该是ref或range,避免ALL(全表扫描)key字段:确认使用了预期索引rows字段:估计扫描行数
批量操作
-- 错误:循环单条插入
FOR order IN orders:
INSERT INTO orders VALUES (...);
-- 正确:批量插入
INSERT INTO orders (user_id, amount, status, create_time) VALUES
(1, 100, 0, NOW()),
(2, 200, 0, NOW()),
(3, 150, 0, NOW());
6.3 事务优化
// 缩短事务时间
@Transactional
public void createOrder(OrderRequest request) {
// 1. 查询库存(事务外)
int stock = redisDecrStock(request.getProductId());
if (stock < 0) {
throw new BusinessException("库存不足");
}
// 2. 创建订单(事务内,尽量短)
Order order = buildOrder(request);
orderMapper.insert(order);
// 3. 异步更新库存(不在这个事务内)
asyncUpdateStock(order.getProductId(), order.getAmount());
}
事务内的SQL越少、越快越好。把非核心逻辑(如日志记录、库存同步)移到事务外或异步处理。
七、架构层面的防御:从”硬抗”到”优雅降级”
7.1 限流:保护数据库的最后防线
当流量超过数据库承载能力时,必须限流。
”`java // 令牌桶
