前两天帮朋友处理了一个线上事故,那场面至今想起来还后背发凉。他们的电商系统在秒杀活动刚开始的十分钟内,数据库CPU直接飙到100%,用户点什么都转圈,客服群炸了锅。我连SSH上去看日志,发现全是Locked等待状态,那一刻真的能感受到那种窒息感——不是技术难题,而是业务和架构之间巨大的鸿沟。
今天就把我压箱底的高并发优化经验全盘托出,不整那些虚头巴脑的理论,全是能落地的干货。
先别急着调优,你得知道”病”在哪儿
很多开发者一遇到慢查询就拼命加索引,结果越加越慢。这就像你去医院看病,连体温都没量就直接开药,肯定不对。
高并发下的性能瓶颈通常来自这几个维度:
- 连接风暴:瞬间涌入的并发连接数超过MySQL能承受的阈值
- 锁竞争:多事务同时修改同一行数据,导致锁等待排队
- 磁盘IO瓶颈:缓冲池命中率低,频繁从磁盘读取数据
- CPU密集型操作:排序、临时表、复杂JOIN消耗大量CPU
- 网络延迟:应用服务器和数据库之间的网络成为瓶颈
我的朋友那个案例,问题根源就是第2点——秒杀扣库存时,所有请求都在争抢同一行记录的行锁,后面堆积的请求全部进入Locked状态,最终拖垮了整个数据库。
第一层防线:架构层面的”分流减压”
真正厉害的系统,从来不让MySQL独自扛下所有压力。我见过太多架构设计,请求直接打到数据库,这种设计在日活几万的时候还行,一旦上到几十万,必崩无疑。
1.1 缓存层:Redis + 多级缓存
为什么要用缓存?
想象一下,一个热门商品每秒被查询10万次,如果每次查询都去读MySQL,那MySQL就得处理10万次读请求。但如果我们用Redis缓存,9万次请求直接命中缓存,MySQL只需要处理1万次,压力直接下降90%。
核心策略:Cache-Aside Pattern(旁路缓存)
/**
* 商品查询的标准缓存模式
* 注意:这里用的是Redis,不是Memcached,因为Redis数据结构更丰富
*/
public class ProductCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private final ProductMapper productMapper;
/**
* 查询商品,带缓存
* 流程:先查缓存 -> 缓存未命中查数据库 -> 写入缓存
*/
public Product queryProduct(Long productId) {
String cacheKey = "product:" + productId;
// 第一步:尝试从缓存获取
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
// 注意:实际项目中要用JSON序列化/反序列化
return (Product) cached;
}
// 第二步:缓存未命中,查数据库
Product product = productMapper.selectById(productId);
if (product == null) {
// 缓存穿透:数据不存在,也要缓存一个空值(设置短过期时间)
redisTemplate.opsForValue().set(cacheKey, null, 60, TimeUnit.SECONDS);
return null;
}
// 第三步:写入缓存,设置过期时间防止数据不一致
// 热点数据可以设置较长的TTL,非热点数据设置较短的TTL
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
return product;
}
/**
* 更新商品,同步删除缓存
* 关键点:先更新数据库,再删除缓存(不是更新缓存!)
*/
public void updateProduct(Product product) {
// 1. 先更新数据库
productMapper.updateById(product);
// 2. 再删除缓存(让下次查询重新加载最新数据)
String cacheKey = "product:" + product.getId();
redisTemplate.delete(cacheKey);
}
}
为什么是”删除缓存”而不是”更新缓存”?
这是很多新手会踩的坑。想象这个场景:
- 事务A读取数据,开始更新
- 事务B也读取了同一份数据
- 事务A更新数据库成功,然后更新缓存
- 事务B更新数据库,如果这时候去更新缓存,会用B的数据覆盖A的数据,导致数据不一致
所以先更新数据库,再删除缓存,让下一次查询自然重新加载最新数据,这才是正确姿势。
1.2 读写分离:让从库分担压力
单节点MySQL的瓶颈很快会出现,读写分离是最常见的解决方案。
应用服务器
|
+---写入请求---> 主库 (Master)
|
+---读取请求---> 从库1 (Slave1)
+---读取请求---> 从库2 (Slave2)
+---读取请求---> 从库3 (Slave3)
关键配置示例:
# Spring Boot 配置读写分离
spring:
datasource:
master:
jdbc-url: jdbc:mysql://master-host:3306/mydb
username: root
password: your_password
slave1:
jdbc-url: jdbc:mysql://slave1-host:3306/mydb
username: root
password: your_password
slave2:
jdbc-url: jdbc:mysql://slave2-host:3306/mydb
username: root
password: your_password
注意点:
- 读写分离会有主从延迟问题,从库数据可能不是最新的
- 对于强一致性要求高的场景(如支付、库存),必须走主库
- 可以用延迟监控,当从库延迟超过阈值时,自动切回主库查询
1.3 分库分表:解决单表数据量瓶颈
当单表数据超过1000万行,查询性能会明显下降。分库分表是终极解决方案。
垂直拆分 vs 水平拆分:
| 拆分方式 | 适用场景 | 例子 |
|---|---|---|
| 垂直拆分 | 表字段多,冷热数据分离 | 用户表拆分为基本信息表+扩展信息表 |
| 水平拆分 | 数据量巨大,按ID取模分布 | 订单表按user_id % 10 分成10张表 |
ShardingSphere 分表配置示例:
# sharding-sphere 配置示例
spring:
shardingsphere:
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: password
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/ds1
username: root
password: password
rules:
sharding:
tables:
orders:
actual-data-nodes: ds$->{0..1}.orders_$->{0..1}
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: order_id_inline
sharding-algorithms:
order_id_inline:
type: INLINE
props:
algorithm-expression: orders_$->{order_id % 2}
第二层防线:MySQL自身的调优
架构层面的优化做了,MySQL本身的参数调优也不能少。很多开发者安装完MySQL就用默认配置,这在生产环境是灾难性的。
2.1 关键参数调优
innodb_buffer_pool_size(最重要)
这个参数控制InnoDB缓冲池的大小,建议设置为物理内存的50%-70%。缓冲池越大,MySQL能从内存读取数据的比例越高,磁盘IO就越少。
[mysqld]
# 假设服务器有16GB内存
innodb_buffer_pool_size = 10G
# 如果有多块盘,可以分成多个缓冲池
innodb_buffer_pool_instances = 4
innodb_flush_log_at_trx_commit(性能 vs 安全权衡)
这个参数控制日志刷新策略,直接影响性能和数据安全性:
| 值 | 行为 | 性能 | 安全性 |
|---|---|---|---|
| 1 | 每次事务提交都刷新日志到磁盘 | 最慢 | 最安全 |
| 2 | 每次提交只写日志,每秒刷新一次 | 较快 | 可接受(断电可能丢1秒数据) |
| 0 | 每秒刷新一次,不保证每次提交都写 | 最快 | 不安全 |
# 电商系统建议用2,平衡性能和安全性
innodb_flush_log_at_trx_commit = 2
max_connections(连接数限制)
不要设置太大!每个连接都会占用内存。建议根据实际并发需求设置,一般500-1000足够。
# 根据业务需求调整
max_connections = 800
# 设置连接超时,避免空闲连接占用资源
wait_timeout = 60
interactive_timeout = 60
2.2 查询优化:索引的艺术
索引不是越多越好
索引能提高查询速度,但会降低写入速度(每次INSERT/UPDATE/DELETE都要更新索引),还会占用磁盘空间。
什么情况下建立索引?
- WHERE子句中频繁使用的列
- JOIN关联的列
- ORDER BY排序的列
- GROUP BY分组的列
索引失效的常见陷阱:
-- ❌ 错误示例:对索引列进行函数操作,导致索引失效
SELECT * FROM orders WHERE YEAR(create_time) = 2024;
-- ✅ 正确做法:使用范围查询,让索引生效
SELECT * FROM orders WHERE create_time >= '2024-01-01'
AND create_time < '2025-01-01';
-- ❌ 错误示例:LIKE以通配符开头
SELECT * FROM users WHERE name LIKE '%张三';
-- ✅ 正确做法:如果能确定前缀,就用前缀匹配
SELECT * FROM users WHERE name LIKE '张三%';
-- ❌ 错误示例:隐式类型转换
-- 假设age列有索引,但传了字符串
SELECT * FROM users WHERE age = '18';
-- ✅ 正确做法:保持类型一致
SELECT * FROM users WHERE age = 18;
-- ❌ 错误示例:OR条件中有一列无索引
SELECT * FROM users WHERE name = '张三' OR age = 18;
-- 如果age没有索引,整个查询可能不走索引
-- ✅ 正确做法:确保OR所有列都有索引,或者拆成两个查询
SELECT * FROM users WHERE name = '张三'
UNION ALL
SELECT * FROM users WHERE age = 18;
覆盖索引:减少回表
-- 假设我们有这个表
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
amount DECIMAL(10,2),
status TINYINT,
create_time DATETIME,
INDEX idx_user_id (user_id)
);
-- ❌ 查询所有列,需要回表查询
SELECT * FROM orders WHERE user_id = 1001;
-- ✅ 只查询需要的列,使用覆盖索引,避免回表
SELECT id, amount, status FROM orders WHERE user_id = 1001;
-- 因为(id, user_id)是索引,id在索引里就有,不需要回表
联合索引的最左前缀原则
-- 联合索引 (a, b, c)
-- 以下查询能用到索引:
SELECT * FROM t WHERE a = 1; -- 用到索引
SELECT * FROM t WHERE a = 1 AND b = 2; -- 用到索引
SELECT * FROM t WHERE a = 1 AND b = 2 AND c = 3; -- 用到索引
SELECT * FROM t WHERE a = 1 AND c = 3; -- a部分用到索引,c用不到
-- 以下查询用不到索引:
SELECT * FROM t WHERE b = 2; -- 用不到
SELECT * FROM t WHERE c = 3; -- 用不到
2.3 慢查询日志:找到性能瓶颈
[mysqld]
# 开启慢查询日志
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2 # 超过2秒的查询记录为慢查询
定期分析慢查询:
# 使用mysqldumpslow分析慢查询日志
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# -s t: 按查询时间排序
# -t 10: 只显示前10条
第三层防线:锁优化与事务管理
回到我朋友那个秒杀案例,锁竞争是核心问题。
3.1 乐观锁 vs 悲观锁
悲观锁:假设最坏情况,每次查询都加锁
-- 悲观锁示例:查询时直接加锁
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 这会锁住这一行,其他事务必须等待
-- UPDATE products SET stock = stock - 1 WHERE id = 1;
乐观锁:假设最好情况,更新时检查版本号
-- 表结构增加version字段
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- 查询
SELECT * FROM products WHERE id = 1;
-- 拿到version = 5
-- 更新时检查version
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 如果affected rows = 0,说明有人改过,需要重试
乐观锁的优势:
- 不加锁,并发性能更高
- 适合读多写少的场景
- 冲突时可以用重试机制处理
乐观锁的劣势:
- 冲突时需要重试,可能增加复杂性
- 不适合写多读少的场景
3.2 秒杀场景的优化实例
这是最关键的部分,也是我最想分享的实战经验。
问题场景:
- 1000个用户同时秒杀1个商品
- 所有请求都要更新同一行库存
- 行锁导致999个请求排队等待
优化方案:内存预减 + 异步扣减
”`java /**
秒杀服务优化版
核心思路:用Redis预减库存,避免直接操作数据库 */ @Service public class SeckillService {
@Autowired private RedisTemplate
redisTemplate; @Autowired private RabbitTemplate rabbitTemplate; // 消息队列
@Autowired private SeckillOrderMapper orderMapper;
private static final String STOCK_KEY_PREFIX = “seckill:stock:”;
/**
秒杀入口 */ public SeckillResult seckill(Long productId, Long userId) { String stockKey = STOCK_KEY_PREFIX + productId;
// 1. 从Redis预减库存 Long stock = redisTemplate.opsForValue().decrement(stockKey);
// 2. 库存不足 if (stock < 0) {
// 恢复库存 redisTemplate.opsForValue().increment(stockKey); return SeckillResult.fail("秒杀结束");}
// 3. 库存足够,检查是否已下单(防止重复购买) String orderKey = “seckill:order:” + productId + “:” + userId; Boolean exists = redisTemplate.opsForValue().setIfAbsent(orderKey, “1”, 10, TimeUnit.MINUTES); if (!exists) {
// 恢复库存 redisTemplate.opsForValue().increment(stockKey); return SeckillResult.fail("请勿重复购买");}
// 4. 下单成功,发送消息到队列,异步处理数据库操作 SeckillMessage message = new SeckillMessage(productId, userId); rabbitTemplate.convertAndSend(“seckill_exchange”, “”, message);
// 5. 立即返回成功,让用户先看到结果 return SeckillResult.success(“秒杀成功,请等待订单处理”); }
/**
消息消费者:异步扣减数据库库存 */ @RabbitListener(queues = “seckill_queue”) public void handleSeckillMessage(SeckillMessage message) { try {
// 这里可以加分布式锁,防止同一用户重复下单 DistributedLock lock = new DistributedLock("seckill:" + message.getUserId()); if (!lock.tryLock(3, TimeUnit.SECONDS)) { log.warn("获取锁失败,userId: {}", message.getUserId()); return; } try { // 查询商品库存 Product product = productMapper.selectById(message.getProductId()); if (product == null || product.getStock() <= 0) { return; } // 扣减库存 int rows = seckillMapper.seckillStock(message.getProductId(), message.getUserId()); if (rows > 0) { // 插入订单 SeckillOrder order = new SeckillOrder(); order.setProductId(message.getProductId()); order.setUserId(message.getUserId()); order.setCreateTime(new Date()); seckillOrderMapper.insert(order); log.info("秒杀成功,productId: {}, userId: {}", message.getProductId(), message.getUserId()); } else { log.warn("库存不足,productId: {}, userId: {}", message.getProductId(), message.getUserId()); } } finally { lock.unlock
