电商大促秒单MySQL崩了怎么办 索引优化读写分离缓存分库分表实战指南

去年双11,我凌晨三点被电话叫醒。线上告警群疯了似的刷屏:”QPS 飙到 12万,MySQL 连接池满了!”“主从延迟超过 30秒!”“订单创建接口 503!”

那年的惨状到现在我还记得清清楚楚。我们的MySQL主库从平时扛 2000 QPS,突然在零点那一刻冲到了 8万+,然后就是连锁崩溃——连接数打满、慢查询堆积、主从延迟爆炸、接口全面超时。技术团队全员待命,整整熬了4个小时才缓过来。

后来我们花了大半年时间,把整套架构彻底重构了一遍。今天这篇文章,就是把那些年踩过的坑、流过的泪,全部整理成一本实战手册。如果你也面临大促高并发的压力,或者正在做架构升级,这篇文章应该能帮你少熬几个通宵。


一、先别急着改架构,检查一下你的索引

很多团队一遇到性能问题,第一反应就是”上缓存”、”分库分表”。但在我们经历过的那次事故之后,回过头看,大部分MySQL崩盘,根因其实都是索引问题

1.1 典型的场景

以我们的订单表为例,大促前这张表的数据量已经涨到了 2亿条。查询接口主要是:

  • 按用户ID查订单:SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC
  • 按订单号查:SELECT * FROM orders WHERE order_no = ?
  • 按状态+时间范围查:SELECT * FROM orders WHERE status = ? AND create_time BETWEEN ? AND ?

看起来挺正常的表对吧?但当时我们的索引情况是这样的:

  • 主键索引:id(自增)
  • 唯一索引:order_no
  • 只有一个普通索引idx_user_id(user_id)

你以为这就够了?我们当时也是这么想的。直到大促那天,”按状态+时间范围查”的查询直接扫了 8000万行数据。

1.2 索引失效的常见陷阱

让我给你列举几个大促场景下最容易踩的坑:

陷阱一:模糊查询左边放通配符

-- 这种写法索引直接失效
SELECT * FROM orders WHERE order_no LIKE '%ORD2023%'

正确的做法是尽量用等值查询,或者把可过滤的字段单独拎出来:

-- 先等值过滤,再在应用层做模糊匹配
SELECT * FROM orders WHERE order_no = 'ORD2023121100001'

陷阱二:复合索引的顺序搞反了

假设我们有一个复合索引 idx_status_time(status, create_time),查询是:

-- 没问题,符合最左前缀原则
SELECT * FROM orders WHERE status = 1 AND create_time > '2023-11-11 00:00:00'

-- 有问题!status 放在后面,索引只能用一部分
SELECT * FROM orders WHERE create_time > '2023-11-11 00:00:00' AND status = 1

等等,上面这个其实 MySQL 5.6+ 有优化器会做索引reorder,但为了保险起见,写SQL的时候还是按照索引顺序来最稳妥

陷阱三:隐式类型转换

-- order_no 是 VARCHAR,但查询时传了数字
SELECT * FROM orders WHERE order_no = 123456789

MySQL 会做隐式类型转换,导致索引失效。这个bug特别隐蔽,平时用测试数据测不出问题,数据量一大就炸。

1.3 我们的优化方案

针对上面的订单表,我们重新设计了索引结构:

-- 主键
PRIMARY KEY (id),

-- 用户订单查询:按用户查最新订单
INDEX idx_user_time (user_id, create_time),

-- 状态+时间范围查询(用于运营后台统计)
INDEX idx_status_time (status, create_time),

-- 订单号唯一索引
UNIQUE KEY uk_order_no (order_no),

-- 支付状态+时间(用于超时未支付订单的定时任务)
INDEX idx_pay_status_time (pay_status, pay_time),

-- 商家维度查询
INDEX idx_seller_time (seller_id, create_time)

加了这几个复合索引之后,我们把慢查询日志拉出来,发现原来需要全表扫描的查询,现在全部走索引,响应时间从几百毫秒降到几毫秒

1.4 如何系统地排查索引问题

不要凭感觉加索引,要用数据说话:

-- 查看表的索引使用情况
SHOW INDEX FROM orders;

-- 查看查询的执行计划(关键!)
EXPLAIN SELECT * FROM orders WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 20;

-- 开启慢查询日志,定位真正的慢查询
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- 超过1秒的查询记录为慢查询

EXPLAIN 的结果里,重点关注这几个字段:

字段 说明 理想值
type 连接类型 const > eq_ref > ref > range > index > ALL
key 实际使用的索引 不应该为 NULL
rows 预估扫描行数 越少越好
Extra 额外信息 避免出现 Using filesort、Using temporary

如果看到 type=ALL(全表扫描)或者 Using filesort,那基本可以确定索引有问题,需要重新设计。


二、读写分离:把读的压力分出去

索引优化解决了”查询慢”的问题,但大促的真正杀手是”查询太多”。我们的 MySQL 主库在那年双11,读请求占了 90% 以上,而写请求其实只有不到 10%。

这意味着什么?意味着我们的主库在白白承受巨大的读压力,而真正需要写能力的资源却被挤占了。

2.1 读写分离的基本原理

读写分离的思路很简单:主库只负责写,从库负责读。通过 MySQL 的原生复制机制,主库的数据会异步同步到从库。

                    ┌──────────────┐
                    │   应用层      │
                    └──────┬───────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
        ┌─────▼────┐  ┌───▼────┐  ┌───▼────┐
        │  主库(写) │  │ 从库1  │  │ 从库2  │
        │  Master  │  │ Slave  │  │ Slave  │
        └──────────┘  └────────┘  └────────┘

2.2 我们的实现方案

我们用的是 ShardingSphere 来做读写分离,配合 Spring Boot:

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    public DataSource masterDataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:mysql://master-db:3306/ecommerce?useSSL=false&serverTimezone=Asia/Shanghai");
        ds.setUsername("root");
        ds.setPassword("your_password");
        ds.setMaximumPoolSize(20);   // 主库连接池小一点
        ds.setMinimumIdle(5);
        return ds;
    }

    @Bean
    public DataSource slaveDataSource1() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:mysql://slave-db1:3306/ecommerce?useSSL=false&serverTimezone=Asia/Shanghai");
        ds.setUsername("root");
        ds.setPassword("your_password");
        ds.setMaximumPoolSize(50);  // 从库连接池可以大一些
        ds.setMinimumIdle(10);
        return ds;
    }

    @Bean
    public DataSource slaveDataSource2() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:mysql://slave-db2:3306/ecommerce?useSSL=false&serverTimezone=Asia/Shanghai");
        ds.setUsername("root");
        ds.setPassword("your_password");
        ds.setMaximumPoolSize(50);
        ds.setMinimumIdle(10);
        return ds;
    }

    @Bean
    public AbstractDataSource routingDataSource(
            @Qualifier("masterDataSource") DataSource master,
            @Qualifier("slaveDataSource1") DataSource slave1,
            @Qualifier("slaveDataSource2") DataSource slave2) {
        
        Map<DataSource, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(master, "master");
        targetDataSources.put(slave1, "slave1");
        targetDataSources.put(slave2, "slave2");

        MultipleDataSource routingDataSource = new MultipleDataSource();
        routingDataSource.setDefaultTargetDataSource(master);
        routingDataSource.setTargetDataSources(targetDataSources);
        return routingDataSource;
    }
}
public class MultipleDataSource extends AbstractRoutingDataSource {

    private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();

    public static void setReadMode() {
        CONTEXT.set("slave");
    }

    public static void setWriteMode() {
        CONTEXT.set("master");
    }

    public static void clear() {
        CONTEXT.remove();
    }

    @Override
    protected Object determineCurrentLookupKey() {
        return CONTEXT.get();
    }
}

然后在我们 Service 层加个切面,自动区分读写:

@Aspect
@Component
public class DataSourceAspect {

    @Before("@annotation(readOnly)")
    public void setDataSourceType(JoinPoint point, ReadOnly readOnly) {
        MultipleDataSource.setReadMode();
    }

    @Before("@annotation(write)")
    public void setWriteDataSourceType(JoinPoint point, Write write) {
        MultipleDataSource.setWriteMode();
    }

    @AfterReturning
    public void clearDataSourceType(JoinPoint point) {
        MultipleDataSource.clear();
    }
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Write {}

这样,标注了 @ReadOnly 的方法就走从库,标注了 @Write 的就走主库,其他默认走主库。

2.3 读写分离的坑:数据延迟

这是读写分离最经典的问题——主从延迟

用户刚下完单,立刻去查订单详情,结果发现查不到。这种体验在大促期间是致命的。

我们的应对方案是分层次处理:

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 查询订单详情
     * 策略:如果是刚创建的订单(5秒内),强制走主库
     */
    @ReadOnly
    public OrderDTO getOrderDetail(Long orderId) {
        Order order = orderMapper.selectById(orderId);
        
        // 如果开启了强一致性模式,或者订单创建时间很近,走主库
        if (order == null && shouldForceMaster(orderId)) {
            MultipleDataSource.setWriteMode();
            order = orderMapper.selectById(orderId);
            MultipleDataSource.clear();
        }
        
        return convertToDTO(order);
    }

    /**
     * 判断是否需要强制走主库
     * 逻辑:订单创建时间在5秒内的,强制读主库
     */
    private boolean shouldForceMaster(Long orderId) {
        Order order = orderMapper.selectById(orderId);
        if (order == null) return false;
        
        long createTime = order.getCreateTime().getTime();
        long now = System.currentTimeMillis();
        
        // 5秒内的订单,可能存在主从延迟
        return (now - createTime) < 5000;
    }

    /**
     * 创建订单(写操作)
     */
    @Write
    @Transactional
    public OrderDTO createOrder(CreateOrderRequest request) {
        Order order = new Order();
        order.setOrderNo(generateOrderNo());
        order.setUserId(request.getUserId());
        order.setSellerId(request.getSellerId());
        order.setStatus(OrderStatus.PENDING_PAYMENT);
        order.setCreateTime(new Date());
        
        orderMapper.insert(order);
        
        // 插入订单明细
        for (OrderItem item : request.getItems()) {
            OrderItem orderItem = new OrderItem();
            orderItem.setOrderId(order.getId());
            orderItem.setProductId(item.getProductId());
            orderItem.setQuantity(item.getQuantity());
            orderItem.setPrice(item.getPrice());
            orderItemMapper.insert(orderItem);
        }
        
        return convertToDTO(order);
    }
}

对于特别敏感的业务(比如支付结果查询),我们还有一个兜底方案:主动刷新从库。当用户完成支付后,通过消息队列发送一个同步指令,强制主从数据同步,然后再让从库处理后续的读请求。

2.4 从库的负载均衡

多个从库的时候,还需要做负载均衡。我们用的是加权轮询策略:

public class SlaveLoadBalancer {

    private final List<SlaveNode> slaves;
    private final AtomicInteger position = new AtomicInteger(0);

    public SlaveLoadBalancer(List<SlaveNode> slaves) {
        this.slaves = slaves;
    }

    public SlaveNode next() {
        if (slaves.isEmpty()) {
            throw new RuntimeException("No slave available");
        }
        
        // 加权轮询
        int totalWeight = slaves.stream()
                .mapToInt(SlaveNode::getWeight)
                .sum();
        
        int random = new Random().nextInt(totalWeight);
        int cumulative = 0;
        
        for (SlaveNode slave : slaves) {
            cumulative += slave.getWeight();
            if (random < cumulative) {
                return slave;
            }
        }
        
        return slaves.get(slaves.size() - 1);
    }
}

每台从库的权重根据配置动态调整,性能好的从库权重高,分担更多流量。


三、缓存:挡住80%的读请求

读写分离解决了读写分离的问题,但大促的真正挑战是QPS太高。哪怕你把读请求全部分发到从库,从库也扛不住啊。

这时候就需要缓存了。我们的经验是:好的缓存架构,可以让数据库的读请求减少 80%-90%

3.1 多级缓存架构

我们采用的是两级缓存:本地缓存(Caffeine)+ 分布式缓存(Redis)

用户请求
    │
    ▼
┌──────────────┐
│  本地缓存     │  ← 速度最快,微秒级,但数据不一致风险高
│  (Caffeine)  │     适合放不频繁变化、数据量小的热点数据
└──────┬───────┘
       │ miss
       ▼
┌──────────────┐
│  分布式缓存   │  ← 速度快,毫秒级,数据一致性较好
│   (Redis)    │     适合放中等热度、数据量中等的热点数据
└──────┬───────┘
       │ miss
       ▼
┌──────────────┐
│   MySQL      │  ← 最终数据源
└──────────────┘

3.2 本地缓存(Caffeine)的配置

@Configuration
public class CacheConfig {

    @Bean
    public Cache<String, OrderDTO> orderLocalCache() {
        return Caffeine.newBuilder()
                .maximumSize(5000)                          // 最多缓存5000条
                .expireAfterWrite(30, TimeUnit.SECONDS)     // 写后30秒过期
                .expireAfterAccess(60, TimeUnit.SECONDS)    // 60秒未访问则过期
                .recordStats()                              // 开启统计
                .build();
    }

    @Bean
    public Cache<String, ProductDTO> productLocalCache() {
        return Caffeine.newBuilder()
                .maximumSize(10000)
                .expireAfterWrite(60, TimeUnit.SECONDS)
                .expireAfterAccess(120, TimeUnit.SECONDS)
                .recordStats()
                .build();
    }

    @Bean
    public Cache<String, UserDTO> userLocalCache() {
        return Caffeine.newBuilder()
                .maximumSize(20000)
                .expireAfterWrite(120, TimeUnit.SECONDS)
                .expireAfterAccess(300, TimeUnit.SECONDS)
                .recordStats()
                .build();
    }
}

本地缓存的核心优势是速度极快,因为数据在进程内存里,不需要网络IO。但缺点是多实例之间数据不一致,所以需要配合分布式缓存使用。

3.3 分布式缓存(Redis)的实战

@Service
public class OrderCacheService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Autowired
    private Cache<String, OrderDTO> orderLocalCache;

    /**
     * 获取订单详情(多级缓存)
     */
    public OrderDTO getOrderDetail(Long orderId) {
        String cacheKey = "order:detail:" + orderId;

        // 第一层:本地缓存
        OrderDTO localCache = orderLocalCache.getIfPresent(cacheKey);
        if (localCache != null) {
            Metrics.counter("cache.hit.local.order").increment();
            return localCache;
        }

        // 第二层:Redis缓存
        String redisValue = redisTemplate.opsForValue().get(cacheKey);
        if (redisValue != null) {
            Metrics.counter("cache.hit.remote.order").increment();
            OrderDTO dto = JSON.parseObject(redisValue, OrderDTO.class);
            // 回填本地缓存
            orderLocalCache.put(cacheKey, dto);
            return dto;
        }

        // 缓存未命中,查数据库
        Metrics.counter("cache.miss").increment();
        OrderDTO order = queryOrderFromDB(orderId);
        
        if (order != null) {
            // 写入Redis,设置30秒过期
            redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(order), 30, TimeUnit.SECONDS);
            // 写入本地缓存
            orderLocalCache.put(cacheKey, order);
        } else {
            // 缓存穿透:数据不存在,也缓存一个空值,防止恶意查询
            redisTemplate.opsForValue().set(cacheKey, "NULL", 60, TimeUnit.SECONDS);
        }

        return order;
    }

    /**
     * 更新订单时,主动失效缓存
     */
    public void invalidateOrderCache(Long orderId) {
        String cacheKey = "order:detail:" + orderId;
        // 删除Redis缓存
        redisTemplate.delete(cacheKey);
        // 删除本地缓存
        orderLocalCache.invalidate(cacheKey);
    }

    /**
     * 批量查询(用于列表页)
     */
    public List<OrderDTO> batchGetOrders(List<Long> orderIds) {
        return orderIds.parallelStream()
                .map(this::getOrderDetail)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
    }
}

3.4 缓存的经典问题及解决方案

问题一:缓存穿透

恶意用户或者 Bug 导致查询不存在的数据,每次都打到数据库。

解决方案:缓存空值。上面代码里已经体现了,查询不到数据时,缓存一个 “NULL” 标记,设置较短的过期时间(60秒)。

问题二:缓存击穿

热点数据突然过期,大量请求同时打到数据库。

解决方案:

  • 互斥锁:只有一个线程去查数据库,其他线程等待
  • 逻辑过期:不过期,而是设置一个逻辑过期时间,后台异步刷新
/**
 * 互斥锁方案
 */
public OrderDTO getOrderWithMutex(Long orderId) {
    String cacheKey = "order:detail:" + orderId;
    
    // 先查缓存
    OrderDTO dto = getFromCache(cacheKey);
    if (dto != null) {
        return dto;
    }
    
    // 缓存未命中,用分布式锁防止并发穿透
    String lockKey = "lock:order:" + orderId;
    boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            // 双重检查
            dto = getFromCache(cacheKey);
            if (dto != null) {
                return dto;
            }
            
            // 查数据库
            dto = queryOrderFromDB(orderId);
            if (dto != null) {
                redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(dto), 30, TimeUnit.SECONDS);
                orderLocalCache.put(cacheKey, dto);
            }
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 没获取到锁,短暂等待后重试
        Thread.sleep(50);
        return getOrderWithMutex(orderId);
    }
    
    return dto;
}

问题三:缓存雪崩

大量缓存同时过期,请求瞬间打到数据库。

解决方案:

  • 过期时间加随机抖动(比如 30秒 ± 5秒)
  • 热点数据永不过期(逻辑过期方案)
// 过期时间加随机抖动
int baseTTL = 30;
int randomJitter = new Random().nextInt(10); // 0-10秒的随机抖动
redisTemplate.opsForValue().set(cacheKey, value, baseTTL + randomJitter, TimeUnit.SECONDS);

问题四:缓存一致性

数据更新了,缓存还没失效,用户看到的是旧数据。

解决方案:

  • 先删缓存,再更新数据库(但有并发问题)
  • 先更新数据库,再删缓存(相对可靠)
  • 延迟双删(最稳妥但复杂)
/**
 * 延迟双删策略
 * 1. 先删缓存
 * 2. 更新数据库
 * 3.  sleep一小段时间
 * 4.  再删一次缓存(确保从库同步后的数据也被清除)
 */
public void updateOrder(OrderUpdateRequest request) {
    String cacheKey = "order:detail:" + request.getOrderId();
    
    // 1. 先删缓存
    redisTemplate.delete(cacheKey);
    orderLocalCache.invalidate(cacheKey);
    
    // 2. 更新数据库
    orderMapper.updateById(request);
    
    // 3. 延迟再删一次缓存
    // 使用延迟队列或者线程池延迟执行
    CompletableFuture.runAsync(() -> {
        try {
            Thread.sleep(500); // 等待主从同步
            redisTemplate.delete(cacheKey);
            orderLocalCache.invalidate(cacheKey);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}

3.5 热点 Key 问题

大促期间,秒杀商品的详情可能会被几百万人同时查询,这就产生了热点 Key 问题。即使有缓存,Redis 单点也扛不住。

解决方案:

  1. 本地缓存+分布式缓存:每个服务实例都有自己的本地缓存,大量请求在本地就解决了
  2. Key 打散:把热点 Key 拆分成多个子 Key,分散到不同的 Redis 节点
  3. Redis Cluster:使用 Redis 集群,水平扩展
/**
 * 热点 Key 打散方案
 * 把一个热点 Key 拆分成 N 个子 Key,轮流使用
 */
@Service
public class HotKeyService {

    private static final int SHARD_COUNT = 10;

    public String getHotProductCache(Long productId) {
        // 根据请求ID或时间打散
        long shardIndex = (System.nanoTime() / 1000000) % SHARD_COUNT;
        String shardKey = "product:hot:" + productId + ":shard:" + shardIndex;
        
        String value = redisTemplate.opsForValue().get(shardKey);
        if (value != null) {
            return value;
        }
        
        // 缓存未命中,从数据库加载并写入所有分片
        ProductDTO product = queryProductFromDB(productId);
        if (product != null) {
            String json = JSON.toJSONString(product);
            for (int i = 0; i < SHARD_COUNT; i++) {
                String key = "product:hot:" + productId + ":shard:" + i;
                redisTemplate.opsForValue().set(key, json, 30, TimeUnit.SECONDS);
            }
            return json;
        }
        
        return null;
    }
}

四、分库分表:当单库也扛不住的时候

缓存和读写分离解决了大部分问题,但到了大促的最高峰,我们的订单表数据量已经超过了 5亿,单表查询性能开始恶化。这时候就必须上分库分表了。

4.1 分库分表的思路

我们的方案是:按 user_id 分库,按 order_id 分表

  • 分4个库:ecommerce_0, ecommerce_1, ecommerce_2, ecommerce_3
  • 每个库分16张表:orders_0 ~ orders_15
  • 总共 4 × 16 = 64 张表
user_id % 4 → 哪个库
order_id % 16 → 哪张表

4.2 ShardingSphere 配置

# application-sharding.yml
shardingjdbc:
  dataSource:
    names: ds0,ds1,ds2,ds3
    
    ds0:
      type: com.zaxxer.hikari.HikariDataSource
      jdbc-url: jdbc:mysql://db0:3306/ecommerce_0?useSSL=false&serverTimezone=Asia/Shanghai
      username: root
      password: your_password
      driver-class-name: com.mysql.cj.jdbc.Driver
      
    ds1:
      type: com.zaxxer.hikari.HikariDataSource
      jdbc-url: jdbc:mysql://db1:3306/ecommerce_1?useSSL=false&serverTimezone=Asia/Shanghai
      username: root
      password: your_password
      driver-class-name: com.mysql.cj.jdbc.Driver
      
    ds2:
      type: com.zaxxer.hikari.HikariDataSource
      jdbc-url: jdbc:mysql://db2:3306/ecommerce_2?useSSL=false&serverTimezone=Asia/Shanghai
      username: root
      password: your_password
      driver-class-name: com.mysql.cj.jdbc.Driver
      
    ds3:
      type: com.zaxxer.hikari.HikariDataSource
      jdbc-url: jdbc:mysql://db3:3306/ecommerce_3?useSSL=false&serverTimezone=Asia/Shanghai
      username: root
      password: your_password
      driver-class-name: com.mysql.cj.jdbc.Driver

  config:
    sharding:
      tables:
        orders:
          actual-data-nodes: ds$->{0..3}.orders_$->{0..15}
          table-strategy:
            standard:
              sharding-column: order_id
              sharding-algorithm-name: orders-table-inline
          key-generate-strategy:
            column: id
            key-generator-name: snowflake
      
      # 分库策略
      binding-tables: orders,order_items
      default-data-source-name: ds0
      
      sharding-algorithms:
        orders-db-inline:
          type: INLINE
          props:
            algorithm-expression: ds$->{user_id % 4}
        orders-table-inline:
          type: INLINE
          props:
            algorithm-expression: orders_$->{order_id % 16}
      
      key-generators:
        snowflake:
          type: SNOWFLAKE
          props:
            worker-id: 1

4.3 分库分表后的查询挑战

分库分表之后,很多查询会变得复杂。最典型的问题就是跨库查询

场景一:按 user_id 查询

这个最简单,因为 user_id 就是分库键:

@ReadOnly
public List<OrderDTO> getOrdersByUserId(Long userId, int page, int size) {
    // 直接根据 user_id 路由到对应的库
    String sql = "SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC LIMIT ? OFFSET ?";
    // ShardingSphere 会自动路由到 ds_{userId % 4}
    return orderMapper.selectList(sql, userId, size, (page - 1) * size);
}

场景二:按 order_no 查询

order_no 不是分片键,所以需要全局索引或者通过 order_id 反查:

@ReadOnly
public OrderDTO getOrderByOrderNo(String orderNo) {
    // 方案一:通过 order_no 索引在分片键列上查询(需要order_no包含分片信息)
    // 方案二:使用全局索引表
    // 方案三:ES查询
    
    // 这里用 ES 查询
    SearchRequest request = new SearchRequest("orders_index");
    SearchSourceBuilder builder = new SearchSourceBuilder();
    builder.query(QueryBuilders.termQuery("order_no", orderNo));
    request.source(builder);
    
    SearchResponse response = elasticsearchClient.search(request, RequestOptions.DEFAULT);
    
    if (response.getHits().getHits().length > 0) {
        String orderIdStr = response.getHits().getHits()[0].getSourceAsMap().get("order_id").toString();
        Long orderId = Long.parseLong(orderIdStr);
        return getOrderById(orderId);
    }
    
    return null;
}

场景三:统计类查询(跨库聚合)

@ReadOnly
public OrderStatistics getDailyOrderStatistics(LocalDate date) {
    // 需要跨所有库查询,使用 ShardingSphere 的跨库聚合
    String sql = "SELECT seller_id, COUNT(*) as order_count, SUM(amount) as total_amount " +
                 "FROM orders " +
                 "WHERE create_time BETWEEN ? AND ? " +
                 "GROUP BY seller_id";
    
    // ShardingSphere 会自动合并所有分片的结果
    List<Map<String, Object>> results = orderMapper.selectMaps(sql, 
        date.atStartOfDay(), date.plusDays(1).atStartOfDay());
    
    OrderStatistics stats = new OrderStatistics();
    stats.setTotalOrders(results.stream().mapToLong(m -> ((Number)m.get("order_count")).longValue()).sum());
    stats.setTotalAmount(results.stream().mapToDouble(m -> ((Number)m.get("total_amount")).doubleValue()).sum());
    
    return stats;
}

4.4 分库分表的最佳实践

  1. 尽量用分片键查询:避免跨库查询,性能差距可能达到 10倍以上
  2. 分片键的选择:选择查询频率高、数据分布均匀的字段
  3. 预留扩容空间:一开始就考虑好未来可能的扩容,比如分库数用 2的幂次方
  4. 全局 ID 生成:使用雪花算法(Snowflake)生成全局唯一的 ID,避免分片后 ID 冲突
@Component
public class SnowflakeIdGenerator {

    private final long workerId;
    private final long datacenterId;
    private final long sequence = 0L;
    
    private long lastTimestamp = -1L;

    public SnowflakeIdGenerator(long workerId, long datacenterId) {
        if (workerId > 31 || workerId < 0) {
            throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", 31));
        }
        if (datacenterId > 31 || datacenterId < 0) {
            throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", 31));
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    public synchronized long nextId() {
        long timestamp = timeGen();
        
        if (timestamp < lastTimestamp) {
            throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
        }
        
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & 4095;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        
        lastTimestamp = timestamp;
        
        // 时间戳(41位) + 数据中心(5位) + 工作节点(5位) + 序列号(12位)
        return ((timestamp - EPOCH) << 22) | (datacenterId << 17) | (workerId << 12) | sequence;
    }
    
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }
    
    private long timeGen() {
        return System.currentTimeMillis();
    }
}

五、大促前的完整实战 checklist

经历了多次大促之后,我们总结了一套完整的备战 checklist。每次大促前,都会按这个清单逐项检查:

5.1 数据库层面

  • [ ] 索引审查:用 EXPLAIN 检查所有核心查询的执行计划,确保没有全表扫描
  • [ ] 慢查询治理:导出过去一个月的慢查询日志,逐条优化
  • [ ] 连接池配置:根据预估流量调整连接池大小,预留 30% 的缓冲
  • [ ] 主从延迟监控:配置主从延迟告警,延迟超过 5秒就报警
  • [ ] 备份验证:确认备份可用,有恢复演练记录

5.2 缓存层面

  • [ ] 缓存预热:大促前把热点数据加载到缓存,避免冷启动冲击
  • [ ] 缓存监控:配置缓存命中率、内存使用量、过期 Key 数量的监控
  • [ ] 降级预案:准备缓存不可用时的降级方案(直接查数据库)
  • [ ] 热点检测:配置热点 Key 自动检测,超过阈值的 Key 自动打散

5.3 分库分表层面

  • [ ] 扩容演练:提前进行分库分表的扩容演练
  • [ ] 数据迁移方案:准备好数据迁移脚本和回滚方案
  • [ ] 跨库查询梳理:梳理所有跨库查询,评估性能影响
  • [ ] 全局ID验证:验证雪花ID生成器的稳定性和唯一性

5.4 监控告警

# 核心监控指标
monitoring:
  mysql:
    - connection_pool_active: 连接池活跃数告警阈值 80%
    - slow_query_count: 慢查询数量告警阈值 100/分钟
    - replicas_lag: 主从延迟告警阈值 10秒
    - qps: QPS 告警阈值 基于历史峰值的 150%
    
  redis:
    - memory_usage: 内存使用率告警阈值 85%
    - hit_rate: 缓存命中率告警阈值 90%
    - connected_clients: 连接数告警阈值 90%
    
  application:
    - response_time_p99: P99响应时间告警阈值 500ms
    - error_rate: 错误率告警阈值 1%
    - thread_pool_active: 线程池活跃数告警阈值 80%

六、一个真实的故障复盘

去年双11之后,我们做了一个详细的故障复盘。以下是当时的时间线:

00:00:00  大促开始,流量瞬间飙升
00:00:15  QPS 从 2000 飙升到 50000
00:00:30  主库连接数达到上限(200/200)
00:00:45  开始有请求超时,503 错误出现
00:01:00  主从延迟达到 30秒
00:01:30  从库也开始扛不住,QPS 达到 80000
00:02:00  运维手动重启从库,暂时恢复
00:02:30  主库CPU 100%,慢查询堆积
00:03:00  开启熔断降级,非核心功能下线
00:04:00  流量高峰过去,系统逐渐恢复
00:08:00  全部恢复,历时4分钟

根因分析

  1. 订单查询接口没有走缓存,每次直接查 MySQL
  2. 几个核心查询缺少复合索引,走了全表扫描
  3. 从库没有足够的水位,读写分离后从库依然被打满
  4. 没有热点 Key 的防护机制,秒杀商品详情直接打穿 Redis

改进措施

问题 解决方案 效果
无缓存 本地缓存+Redis缓存,缓存命中率 95%+ 数据库读请求减少 90%
索引缺失 补充复合索引,慢查询减少 99% 单查询响应从 500ms 降到 5ms
从库不足 增加从库数量,读写分离+负载均衡 从库可扛 5万 QPS
热点Key 本地缓存+Key打散+预热 热点Key不再打穿缓存层

第二年双11,同样的流量规模,我们的 MySQL 主库 QPS 峰值只有 5000,远低于前一年的 80000。系统稳稳当当,零故障。


七、总结与思考

回看这几年的架构演进,有一个深刻的体会:没有银弹,只有层层防御

索引优化是基础,成本低见效快,一定要优先做好。读写分离是标配,把读压力分散出去。缓存是核心武器,能挡住绝大部分流量。分库分表是最后的手段,用来应对数据量增长带来的极限压力。

但最重要的是:提前演练,提前准备。我们每年大促前都会做压力测试,模拟峰值流量,提前发现瓶颈。那些在生产环境炸开的坑,最好在测试环境先踩一遍。

最后送你一句话:架构设计不是猜出来的,是测出来的。不要相信自己的直觉,要相信监控数据。

祝你下次大促,安稳入睡。