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字段:应该是refrange,避免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 // 令牌桶