说到数据库的高并发瓶颈,很多刚入行的同学第一反应就是:“加索引”或者“换更快的硬件”。这没错,但这就像是给一辆正在高速公路上抛锚的赛车换个更好的轮胎——治标不治本。当QPS(每秒查询率)从几千飙升到几万甚至几十万时,单机的MySQL就像是一个在早高峰地铁里试图背起所有乘客的壮汉,迟早会累趴下。

今天我们要聊的,不是枯燥的理论,而是一场真实的“救火行动”。我会带你走进一个典型的电商大促场景,看看我们是如何通过多级缓存架构分库分表策略,把原本濒临崩溃的系统拉回正轨的。这里没有晦涩难懂的公式,只有实打实的代码和血泪教训。

第一阶段:危机降临——为什么单机扛不住了?

想象一下,你的电商平台正在搞“双11”预热活动。流量突然激增,订单表(orders)的数据量瞬间爆炸。

现象描述

  1. CPU飙升:数据库服务器的CPU使用率长期维持在95%以上。
  2. 连接数打满:MySQL的最大连接数(max_connections)被占满,新请求直接报错 Too many connections
  3. 响应延迟:普通查询从5ms变成了500ms,甚至超时。
  4. 锁竞争严重:热点商品库存扣减时,行锁冲突导致大量事务等待。

核心问题分析

经过排查,我们发现主要瓶颈在于:

  • 读多写少:80%的请求是查询商品详情和库存,但每次都要去磁盘IO读取数据。
  • 热点数据集中:几个爆款商品的访问频率极高,导致单行记录锁竞争剧烈。
  • 数据量过大:订单表突破5000万行,全表扫描和索引失效的风险增加。

这时候,我们需要两把利器:缓存(Cache)来挡掉大部分读请求,分库分表(Sharding)来分散写入压力和存储压力。

第二阶段:缓存优化——构建多级防护盾

缓存的核心思想是:用空间换时间。既然大部分用户查的是同一批数据,那就把这些数据存到内存里,下次直接取内存,根本不用碰数据库。

1. 为什么只用Redis不够?

很多人喜欢一上来就部署Redis集群,觉得万事大吉。但在高并发场景下,单一Redis也可能成为瓶颈,或者出现缓存穿透、击穿、雪崩等问题。因此,我们采用本地缓存 + 分布式缓存的多级架构。

  • L1:Caffeine/Guava Cache(JVM堆内):速度最快(纳秒级),但受限于单机内存,适合存极热点、小体积的数据(如配置信息、极少变动的字典)。
  • L2:Redis(分布式):速度次之(毫秒级),容量大,支持集群,适合存主要的业务数据(如商品详情、库存)。

2. 实战代码:带过期时间和互斥锁的缓存更新

假设我们要优化“获取商品详情”这个接口。如果直接用get-if-miss-then-db-set模式,在高并发下会出现“缓存击穿”问题:当某个热点Key过期瞬间,成千上万个请求同时打到数据库,数据库瞬间宕机。

错误示范(伪代码):

// 危险!高并发下会导致数据库被打死
if (cache.get(key) == null) {
    data = db.query(key);
    cache.put(key, data);
}

正确姿势:互斥锁 + 逻辑过期

我们使用Redis的SETNX命令实现分布式锁,确保同一时刻只有一个线程去查数据库并回填缓存,其他线程等待或返回旧值。

以下是基于Spring Boot + Redisson的完整实现示例:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import redisson.api.RLock;
import redisson.api.RedissonClient;

import java.util.concurrent.TimeUnit;

@Service
public class ProductService {

    @Autowired
    private RedissonClient redissonClient;

    @Autowired
    private ProductMapper productMapper; // MyBatis Mapper

    /**
     * 获取商品详情,防止缓存击穿
     */
    public ProductDTO getProductDetail(Long productId) {
        String key = "product:" + productId;
        
        // 1. 尝试从Redis获取
        Object cachedData = redissonClient.getMap("product_cache").get(key);
        if (cachedData != null) {
            return (ProductDTO) cachedData;
        }

        // 2. 如果缓存为空,尝试获取分布式锁
        RLock lock = redissonClient.getLock("lock:product:" + productId);
        try {
            // 尝试加锁,最多等10秒,锁自动释放时间30秒
            if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                // 双重检查:因为等待锁的过程中,可能其他线程已经写入缓存了
                cachedData = redissonClient.getMap("product_cache").get(key);
                if (cachedData != null) {
                    return (ProductDTO) cachedData;
                }

                // 3. 真正查询数据库
                System.out.println("Cache miss, querying DB for product: " + productId);
                ProductDTO product = queryFromDB(productId);
                
                // 4. 写入缓存,设置过期时间(比如2小时),防止内存溢出
                redissonClient.getMap("product_cache").put(key, product, 2, TimeUnit.HOURS);
                
                return product;
            } else {
                // 获取锁失败,休眠后重试,或者返回兜底数据
                Thread.sleep(50);
                return getProductDetail(productId); // 简单重试
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Interrupted while waiting for lock", e);
        } finally {
            // 5. 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
                System.out.println("Released lock for product: " + productId);
            }
        }
    }

    private ProductDTO queryFromDB(Long productId) {
        // 模拟数据库查询
        return productMapper.selectById(productId);
    }
}

3. 缓存一致性怎么保证?

这是面试必问,也是实战中最头疼的问题。用户修改了商品名称,数据库更新了,但Redis里的旧数据还没删,怎么办?

方案A:先删缓存,再更新数据库(推荐)

  • 优点:实现简单。
  • 缺点:极端情况下(更新DB慢于删除缓存),可能会短暂读到旧数据。

方案B:先更新数据库,再删缓存(更稳妥)

  • 优点:保证最终一致性。
  • 缺点:需要处理删除失败的情况。

方案C:延时双删(终极方案)

  1. 先删缓存。
  2. 更新数据库。
  3. 休眠一小段时间(比如500ms)。
  4. 再次删缓存。 这样可以确保在第2步更新完成后,如果有其他线程在读取并重建缓存,第4步能将其清除。

在实际生产中,我们通常结合Canal监听MySQL Binlog,异步发送消息到MQ,由消费者统一删除或更新Redis缓存。这样解耦了业务代码和缓存操作,提高了系统的稳定性。

第三阶段:分库分表——拆解单体巨兽

当数据量达到千万级,即使加了缓存,写入性能依然会成为瓶颈。此时,我们需要对数据库进行垂直拆分(按业务模块)和水平拆分(按数据量)。

1. 分片键(Sharding Key)的选择

分库分表最难的不是技术选型,而是选哪个字段做分片键

  • 原则:分片键必须是高频查询条件,且数据分布均匀。
  • 案例:对于订单表,user_id 是一个很好的分片键,因为查询用户历史订单很频繁,且每个用户的订单量相对均匀。但如果用 order_status(如“已支付”)做分片键,会导致数据倾斜,某些分片负载极高。

2. 中间件选型:ShardingSphere vs MyCat

目前业界主流是 Apache ShardingSphere。它轻量级、无侵入(基于JDBC协议),社区活跃。MyCat虽然老牌,但维护力度不如前者。我们这次选用 ShardingSphere-JDBC。

3. 实战配置:订单表分库分表

假设我们将订单表 orders 按照 user_id 进行分片。规则如下:

  • 分库策略:根据 user_id % 4 路由到4个数据库节点。
  • 分表策略:在每个数据库中,根据 order_id % 16 路由到16张表(orders_0orders_15)。
  • 全局ID生成:避免ID冲突,使用雪花算法(Snowflake)生成唯一ID。

application.yml 配置示例

spring:
  shardingsphere:
    datasource:
      names: ds0,ds1,ds2,ds3
      ds0:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/db_order_0?useSSL=false&serverTimezone=UTC
        username: root
        password: 123456
      ds1:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/db_order_1?useSSL=false&serverTimezone=UTC
        username: root
        password: 123456
      ds2:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/db_order_2?useSSL=false&serverTimezone=UTC
        username: root
        password: 123456
      ds3:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/db_order_3?useSSL=false&serverTimezone=UTC
        username: root
        password: 123456
    
    rules:
      sharding:
        tables:
          orders:
            actual-data-nodes: ds$->{0..3}.orders_$->{0..15}
            table-strategy:
              standard:
                sharding-column: user_id
                sharding-algorithm-name: inline-user-id
        sharding-algorithms:
          inline-user-id:
            type: INLINE
            props:
              algorithm-expression: ds$->{user_id % 4}

注意:上面的配置简化了分表逻辑,实际生产中分表算法也需要配置。ShardingSphere支持复杂的表达式,例如 orders_$->{user_id % 16}

4. 跨分片查询难题:分页与排序

分库分表后,最大的痛点是非分片键的查询以及跨分片的分页排序

场景:后台管理员要查看所有状态为“待付款”的订单,并按创建时间倒序排列,每页10条。

问题分析

  1. “待付款”不是分片键,数据分散在所有16x4=64个表中。
  2. 如果要分页,ShardingSphere需要在内存中合并所有分片的结果,排序后再截取。如果总数据量很大(比如几百万条待付款订单),内存会爆,性能极差。

解决方案

  1. ES同步:将订单数据实时同步到 Elasticsearch。利用ES强大的聚合和搜索能力处理复杂查询。后台管理查询走ES,核心交易查询走MySQL。
  2. 宽表设计:在订单主表中冗余一些常用查询字段(如 status, create_time),建立普通索引。虽然增加了写入开销,但能解决大部分单表内的范围查询。
  3. 游标分页:避免使用 LIMIT offset, size,改用 WHERE id > last_max_id LIMIT size,减少内存排序压力。

第四阶段:儿童视角的通俗解释——图书馆的故事

为了让家里的小朋友也能听懂,我们可以把这套系统比作一个巨大的图书馆

  1. MySQL数据库:就是图书馆的书库,书都放在那里,很安全,但是找书很慢,因为要跑很远去书架拿。
  2. 高并发:就是开学第一天,全校几千人同时冲进图书馆借书,书库门口挤爆了,管理员(CPU)忙得晕头转向,根本来不及找书。
  3. 缓存(Redis):就是在图书馆大厅设了一个快速借阅台。大家都想借《哈利波特》,管理员提前把这本书复印好放在大厅桌上。学生来了直接拿复印本,不用进书库。这样书库的压力就小多了。
    • 注意:如果《哈利波特》被借走了,管理员要及时从桌上撤下来,不然别人拿到的就是“空壳”,这就是缓存一致性。
  4. 分库分表:如果图书馆的书实在太多了,一个书库放不下,或者找书太慢,我们就把图书馆拆分成好几个分馆
    • 按学生年级分:一年级去A馆,二年级去B馆。这样每个馆的人少了,找书就快了。
    • 这就叫“分库分表”。但是,如果我想找“所有喜欢看科幻书的学生”,我就得跑遍所有分馆去查,这就很麻烦,所以我们要想办法让喜欢科幻书的人都集中在一个馆,或者建一个总索引目录(ES)。

第五阶段:避坑指南与最佳实践

在实际落地过程中,有几个坑是必须绕开的:

1. 不要过度分片

分片越多,运维复杂度呈指数级上升。初期建议先垂直拆分(订单库、用户库分离),数据量大了再水平拆分。一般单表控制在500万-1000万行为宜。

2. 预加载热点数据

对于秒杀活动,不要等到用户点进来才查数据库。在活动开始前,就将秒杀商品信息、库存预加载到Redis中,并将库存扣减逻辑放在Redis中执行(Lua脚本保证原子性),成功后再异步发送消息到MQ,最终更新MySQL。

3. 监控与告警

没有监控的分库分表就是盲人摸象。务必接入Prometheus + Grafana,监控:

  • 各分片节点的QPS、TPS、慢查询。
  • 缓存命中率。
  • 连接池使用情况。 一旦某个分片异常,能迅速定位。

4. 数据迁移方案

如果线上已经有数据,如何平滑迁移到分库分表?

  • 双写阶段:新老系统同时写,以新为主。
  • 历史数据搬迁:使用工具(如DataX、ShardingSphere-Proxy的迁移功能)将老数据导入新库。
  • 校验阶段:比对新旧数据一致性。
  • 切换流量:逐步将读流量切到新库,最后切断老库写入。

结语

优化MySQL高并发瓶颈,从来不是一蹴而就的魔法,而是一套组合拳。缓存解决了读的瓶颈,分库分表解决了写的瓶颈和存储瓶颈。它们相辅相成,缺一不可。

记住,没有最好的架构,只有最合适的架构。在业务初期,简单的单库单表加上合理的索引和少量缓存,往往就能支撑数百万用户。只有当真正的流量洪峰到来时,才需要考虑复杂的分片策略。

希望这篇实战案例能为你提供一些思路。如果你在具体的代码实现或架构设计中遇到难题,欢迎随时交流。毕竟,技术是在不断的折腾和复盘中成长起来的。