订单超卖服务器宕机 电商大促MySQL高并发优化实战 索引设计分表读写分离缓存架构全攻略

先别急着往下看,咱先聊聊那天晚上发生了什么。

凌晨两点,大促活动正式开启后的第十一个小时。你们团队应该都记得那个瞬间——监控大屏上的QPS从平稳的几千瞬间飙到十几万,数据库连接数爆满,CPU打满,然后就是熟悉的画面:订单接口开始返回500,超卖现象出现,客服群炸了,老板的电话也来了。

我见过太多这样的场景,真的。有些团队大促前做了无数优化,结果一到关键时刻还是扛不住。今天我就把这个过程中踩过的坑、做过的事,全部摊开来跟你们聊聊。

一、先搞清楚你的系统到底在扛什么

很多人上来就想着加缓存、分库分表,其实第一步应该是明白你的系统在生产环境中到底承受了什么。

我接触过不少团队,他们的大促流量模型大概是这样:

  • 平时:QPS 500-1000,MySQL轻松应对
  • 预热期:QPS 3000-5000,开始有点压力
  • 爆发期:QPS 2万-10万,系统开始告警
  • 峰值:可能达到50万+,持续几秒到几分钟

问题就出在,你的系统要同时处理几种完全不同的请求:

读多写少的时候,比如用户浏览商品详情、查看订单列表,这种主要是读操作。

写多读少的时候,比如秒杀下单、支付回调,这种对数据库写入压力巨大。

读写混在一起的时候,最麻烦的情况,既有大量查询,又有高频写入,数据库连接池被占满,查询和写入互相阻塞。

我之前做过一个案例,某生鲜电商平台,大促期间用户既要秒杀下单,又要实时查看订单状态,还要频繁查询商品详情。三种请求混在一起,数据库压力呈指数级增长。最后我们是把读请求全部引流到缓存,写请求走独立的写入集群,才把系统的吞吐量提升了一个数量级。

二、索引设计——很多人低估了这件事

先说一个我亲身经历的教训。

某电商团队在大促前做了性能测试,压测结果还不错。但真实大促开始后,订单查询接口响应时间从200ms变成了20秒,直接超时。排查后发现,是因为订单表的索引设计有问题——查询用的是 ORDER BY create_time DESC,但数据库在执行这个查询时做了全表排序,而不是走索引。

这个案例告诉我们的第一件事就是:索引设计不是建了几个索引就完事了,要针对你的实际查询模式来设计。

2.1 索引设计的基本原则

索引设计有几个核心原则,我总结了一句话:最左前缀 + 区分度 + 覆盖。

最左前缀原则很好理解,比如你的联合索引是 (user_id, order_status, create_time),那查询条件必须是先过滤user_id,再过滤order_status,最后过滤create_time,才能充分利用索引。如果你只查order_status和create_time,这个索引就基本废了。

区分度是指索引列的重复值越少,索引效果越好。举个例子,order_status列可能只有几种值(待付款、已付款、已发货等),这种列作为索引区分度很低。而user_id是唯一的,区分度很高。所以我们在设计联合索引时,要把区分度高的列放在前面。

覆盖索引是个好东西。如果一个查询只需要从索引中获取数据,不需要回表,那查询速度会快很多。比如你的查询只需要 SELECT order_id, user_id FROM orders WHERE user_id = ? AND status = ?,而你的索引正好是 (user_id, status, order_id),那就完全可以用覆盖索引,不用回表查数据。

2.2 具体案例:订单表的索引设计

我来用一个真实的订单表结构来说明。

CREATE TABLE `orders` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `order_no` varchar(64) NOT NULL COMMENT '订单号',
  `user_id` bigint(20) unsigned NOT NULL COMMENT '用户ID',
  `product_id` bigint(20) unsigned NOT NULL COMMENT '商品ID',
  `sku_id` bigint(20) unsigned NOT NULL COMMENT 'SKU ID',
  `total_amount` decimal(10,2) NOT NULL COMMENT '订单金额',
  `pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额',
  `order_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '订单状态',
  `pay_time` int(11) NOT NULL DEFAULT 0 COMMENT '支付时间',
  `create_time` int(11) NOT NULL COMMENT '创建时间',
  `update_time` int(11) NOT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`),
  KEY `idx_user_id_status_time` (`user_id`, `order_status`, `create_time`),
  KEY `idx_product_status` (`product_id`, `order_status`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

这个索引设计有几个考虑:

第一个是 idx_user_id_status_time,这是用户查询自己订单最常用的场景。用户ID区分度高,放最前面;订单状态作为第二层过滤;创建时间作为排序字段,可以利用索引避免文件排序。这个查询在实际场景中非常多,所以这个索引很关键。

第二个是 idx_product_status,这个是运营查询某个商品的销售数据,以及系统查询某个商品关联的订单时用的。

第三个是 idx_create_time,这个是定时任务用的,比如每天凌晨清理过期未支付订单,或者统计每日订单数据。

2.3 索引设计要注意的坑

有一个坑特别常见,就是索引过多。

很多人觉得索引越多越好,查询越快越好。但实际上,每个索引都会增加写入的开销。数据库在执行INSERT、UPDATE、DELETE时,不仅要维护数据,还要维护所有索引。索引越多,写入越慢。

我见过一个案例,一个订单表建了十几个索引,结果大促期间写入性能急剧下降,订单创建接口超时率飙升。后来我们把一些冗余索引去掉,写入性能提升了40%。

另一个坑是索引失效的场景。比如模糊查询 LIKE '%abc',这种以通配符开头的查询,索引是无效的。再比如对索引列进行函数操作,或者隐式类型转换,都会导致索引失效。

三、分表——把压力分摊到多个表上

当单表数据量超过千万级别,查询和写入性能都会明显下降。这时候分表就派上用场了。

3.1 为什么要分表

我接触过的一个项目,订单表在半年内从几千万数据增长到了两亿多。查询订单列表时,即使用了索引,响应时间也达到了好几秒。更糟糕的是,大表的写入性能也变差了,因为InnoDB在进行INSERT时需要维护更大的B+树结构。

分表的核心思想很简单:把一个大数据量表拆分成多个小数据量表,每个表的数据量控制在合理范围内(一般建议单表在1000万行以内)。

3.2 分表策略怎么选

常见的分表策略有几种:

按时间分表:比如按月分表,orders_202401、orders_202402、orders_202403 这样。这种策略适合有明显时间特征的查询场景,比如统计某个月的销售数据。缺点是如果查询跨越多个时间范围,需要合并多个表的数据。

按用户ID分表:比如 orders_001、orders_002、orders_003 这样,通过用户ID取模来决定数据存放在哪个表。这种策略的好处是同一个用户的数据都在一个表里,查询用户订单列表时性能很好。缺点是如果某个用户的数据量特别大(比如大客户、B端用户),这个表可能会成为热点。

按订单号分表:跟按用户ID分表类似,通过订单号取模来决定。这种策略比较均匀,但如果需要查询某个用户的所有订单,就需要扫描所有分表。

我推荐的做法是复合分表,先用用户ID分表,再用时间分表。比如 orders_user_001_202401 表示用户ID模100余1,2024年1月的订单。这样既保证了用户订单查询的性能,又控制了单表数据量。

3.3 分表的具体实现

我们来看一下分表的实现代码。

public class OrderShardingStrategy {
    
    private static final int USER_SHARD_COUNT = 100;
    private static final int MONTH_SHARD_COUNT = 12;
    
    /**
     * 根据用户ID和订单创建时间确定分表名
     */
    public String getTableName(Long userId, Date createTime) {
        int userShard = (int) (userId % USER_SHARD_COUNT);
        int month = Calendar.getInstance().get(Calendar.MONTH) + 1;
        return String.format("orders_%03d_%04d%02d", 
            userShard, 
            createTime.getYear() + 1900, 
            month);
    }
    
    /**
     * 根据用户ID确定分表(适用于单用户查询场景)
     */
    public String getTableNameByUserId(Long userId) {
        int userShard = (int) (userId % USER_SHARD_COUNT);
        // 查询最近12个月的订单
        Calendar calendar = Calendar.getInstance();
        StringBuilder tableName = new StringBuilder();
        for (int i = 0; i < 12; i++) {
            int month = (calendar.get(Calendar.MONTH) - i + 12) % 12;
            int year = calendar.get(Calendar.YEAR);
            if (i == 0 && month > calendar.get(Calendar.MONTH)) {
                year--;
            }
            tableName.append(String.format("orders_%03d_%04d%02d,", 
                userShard, year, month + 1));
        }
        return tableName.deleteCharAt(tableName.length() - 1).toString();
    }
}

这个分表策略的核心就是用户ID取模,保证同一个用户的数据集中在几个表里。查询用户订单时,最多只需要查12个表(最近12个月),而不是全量扫描。

3.4 分表的注意事项

分表不是银弹,有几个问题需要注意:

跨表查询:如果查询条件不包含分表键,就需要扫描所有分表,性能会很差。所以设计查询时,一定要尽量带上分表键。

跨表聚合:比如统计全平台的销售总额,这种查询需要汇总所有分表的数据。可以在写入时维护一张汇总表,定时更新,避免实时聚合。

扩容问题:分表后如果要增加表的数量,需要重新分片数据,这个过程比较复杂。建议在系统初期就规划好足够大的分片数量,避免后续扩容。

四、读写分离——让查询和写入各走各路

即使做了分表,数据库还是可能扛不住。这时候读写分离就能派上用场了。

4.1 读写分离的基本原理

读写分离的核心思想是:主库负责写入,从库负责读取。写入操作只在主库执行,然后异步同步到从库。读取操作可以分发到多个从库,从而分摊读取压力。

这种架构在电商系统中非常常见,因为大多数场景下,读操作的量级远大于写操作。比如用户浏览商品详情,可能只有偶尔下单,但会反复查看商品信息和订单状态。

4.2 如何配置读写分离

我们来看一下MySQL读写分离的配置。

-- 主库配置
[mysqld]
server-id=1
log-bin=mysql-bin
binlog-format=ROW
relay-log=relay-bin
-- 从库配置
[mysqld]
server-id=2
log-bin=mysql-bin
binlog-format=ROW
relay-log=relay-bin

在主库上创建复制账号:

CREATE USER 'repl'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;

在从库上配置主从复制:

CHANGE MASTER TO
  MASTER_HOST='master_host',
  MASTER_USER='repl',
  MASTER_PASSWORD='password',
  MASTER_LOG_FILE='mysql-bin.000001',
  MASTER_LOG_POS=154;

START SLAVE;

4.3 读写分离在应用层的实现

在应用层实现读写分离,更灵活可控。以Spring Boot为例:

@Configuration
public class DataSourceConfig {
    
    @Bean
    @Primary
    @ConfigurationProperties("spring.datasource.master")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean
    @ConfigurationProperties("spring.datasource.slave")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean
    public DataSource routingDataSource() {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceType.MASTER, masterDataSource());
        targetDataSources.put(DataSourceType.SLAVE, slaveDataSource());
        
        RoutingDataSource routingDataSource = new RoutingDataSource();
        routingDataSource.setTargetDataSources(targetDataSources);
        routingDataSource.setDefaultTargetDataSource(masterDataSource());
        return routingDataSource;
    }
}

public enum DataSourceType {
    MASTER, SLAVE
}

public class DataSourceContextHolder {
    private static final ThreadLocal<DataSourceType> CONTEXT = new ThreadLocal<>();
    
    public static void set(DataSourceType type) {
        CONTEXT.set(type);
    }
    
    public static DataSourceType get() {
        return CONTEXT.get();
    }
    
    public static void clear() {
        CONTEXT.remove();
    }
}

public class RoutingDataSource extends AbstractRoutingDataSource {
    
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.get();
    }
}

在Service层控制读写分离:

@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 创建订单,走主库
     */
    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        DataSourceContextHolder.set(DataSourceType.MASTER);
        try {
            Order order = new Order();
            order.setOrderNo(generateOrderNo());
            order.setUserId(request.getUserId());
            order.setProductId(request.getProductId());
            order.setTotalAmount(request.getTotalAmount());
            order.setCreateTime(System.currentTimeMillis());
            orderMapper.insert(order);
            return order;
        } finally {
            DataSourceContextHolder.clear();
        }
    }
    
    /**
     * 查询订单,走从库
     */
    public Order getOrder(Long orderId) {
        DataSourceContextHolder.set(DataSourceType.SLAVE);
        try {
            return orderMapper.selectById(orderId);
        } finally {
            DataSourceContextHolder.clear();
        }
    }
}

4.4 读写分离需要注意的问题

主从延迟:这是读写分离最大的问题。主库写入后,数据需要时间同步到从库。如果在主库写入后立即从从库读取,可能会读到旧数据。在电商场景中,这会导致刚下单的用户查询订单时看不到最新状态。

解决主从延迟的方法有几个:

  • 强制读主库:对于写入后的即时查询,强制走主库。可以通过注解或者ThreadLocal标记来实现。
  • 缩短同步间隔:调整MySQL的主从同步参数,比如设置sync_binlog=1,让主库每次提交都同步binlog到从库。但这会影响写入性能。
  • 设置从库延迟阈值:监控从库的延迟,超过一定阈值就把这个从库从可用池中移除。

从库数量:从库不是越多越好。每个从库都需要维护主库的数据同步,从库越多,主库的写入压力越大。一般建议从库数量控制在3-5个,具体根据业务需求调整。

从库热点:如果某个查询特别频繁,所有从库都要处理这个查询,某个从库可能会成为热点。这时候可以考虑对这个查询做缓存,避免直接打到数据库。

五、缓存架构——扛住高并发的最后一道防线

缓存是解决高并发问题最有效的手段之一。但缓存设计不好,也会带来很多问题。

5.1 缓存的基本思路

缓存的核心思想很简单:把 frequently accessed 的数据放在内存里,避免每次都去查数据库。

在电商系统中,缓存可以放在几个层级:

  • 本地缓存:比如Caffeine、Guava Cache,数据存在应用服务器的内存里,读取速度最快,但数据一致性差,且受服务器内存限制。
  • 分布式缓存:比如Redis、Memcached,数据存在独立的缓存服务器上,可以被多个应用服务器共享,性能好,一致性也相对较好。

我推荐的做法是本地缓存 + 分布式缓存的两级缓存架构。本地缓存放最热的数据,分布式缓存放次热的数据,数据库放底层数据。

5.2 Redis缓存的典型场景

在电商系统中,Redis缓存有几个典型的使用场景:

商品详情缓存:商品详情数据变化不频繁,但读取量巨大。可以把商品详情缓存到Redis,设置合理的TTL。

@Service
public class ProductService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    public Product getProduct(Long productId) {
        String cacheKey = "product:" + productId;
        
        // 先查缓存
        String cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return JSON.parseObject(cached, Product.class);
        }
        
        // 缓存未命中,查数据库
        Product product = productMapper.selectById(productId);
        if (product != null) {
            // 写入缓存,TTL 30分钟
            redisTemplate.opsForValue().set(cacheKey, 
                JSON.toJSONString(product), 30, TimeUnit.MINUTES);
        }
        
        return product;
    }
}

库存缓存:库存数据变化频繁,但又需要快速读取。可以用Redis的incr/decr来维护库存,避免直接操作数据库。

@Service
public class InventoryService {
    
    @Autowired
    private RedisTemplate<String, Long> redisTemplate;
    
    @Autowired
    private InventoryMapper inventoryMapper;
    
    /**
     * 扣减库存,返回是否成功
     */
    public boolean deductInventory(Long skuId, int quantity) {
        String cacheKey = "inventory:" + skuId;
        
        // 使用Redis的decrement操作扣减库存
        Long remaining = redisTemplate.opsForValue().decrement(cacheKey, quantity);
        
        if (remaining >= 0) {
            // 扣减成功,异步同步到数据库
            syncInventoryToDb(skuId, quantity);
            return true;
        } else {
            // 扣减失败,回滚缓存
            redisTemplate.opsForValue().increment(cacheKey, quantity);
            return false;
        }
    }
    
    /**
     * 异步同步库存到数据库
     */
    private void syncInventoryToDb(Long skuId, int quantity) {
        CompletableFuture.runAsync(() -> {
            Inventory inventory = inventoryMapper.selectBySkuId(skuId);
            if (inventory != null) {
                // 使用乐观锁更新库存
                inventoryMapper.deductInventory(skuId, quantity, inventory.getVersion());
            }
        });
    }
}

订单缓存:订单数据变化也比较频繁,可以用Redis缓存订单状态,减少数据库查询压力。

5.3 缓存设计的核心问题

缓存设计有几个核心问题需要解决:

缓存穿透:查询一个不存在的数据,缓存和数据库都查不到,每次都打到数据库。解决办法是缓存空值,或者用布隆过滤器拦截非法查询。

public Product getProductSafe(Long productId) {
    String cacheKey = "product:" + productId;
    
    // 查询缓存
    String cached = redisTemplate.opsForValue().get(cacheKey);
    
    // 缓存命中,直接返回
    if (cached != null) {
        // 空值标记
        if ("NULL".equals(cached)) {
            return null;
        }
        return JSON.parseObject(cached, Product.class);
    }
    
    // 缓存未命中,查数据库
    Product product = productMapper.selectById(productId);
    
    // 数据库也没有,缓存空值,TTL 5分钟
    if (product == null) {
        redisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
        return null;
    }
    
    // 数据库有数据,写入缓存,TTL 30分钟
    redisTemplate.opsForValue().set(cacheKey, 
        JSON.toJSONString(product), 30, TimeUnit.MINUTES);
    
    return product;
}

缓存击穿:某个热点Key过期时,大量请求同时打到数据库。解决办法是使用互斥锁,只有一个线程去查数据库,其他线程等待。

public Product getProductWithMutex(Long productId) {
    String cacheKey = "product:" + productId;
    
    // 查询缓存
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return JSON.parseObject(cached, Product.class);
    }
    
    // 缓存未命中,获取互斥锁
    String lockKey = "lock:" + cacheKey;
    boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            // 双重检查,防止多个线程同时查询数据库
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                return JSON.parseObject(cached, Product.class);
            }
            
            // 查数据库
            Product product = productMapper.selectById(productId);
            
            // 写入缓存
            if (product != null) {
                redisTemplate.opsForValue().set(cacheKey, 
                    JSON.toJSONString(product), 30, TimeUnit.MINUTES);
            }
            
            return product;
        } finally {
            // 释放锁
            redisTemplate.delete(lockKey);
        }
    } else {
        // 其他线程在查数据库,等待后重试
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getProductWithMutex(productId);
    }
}

缓存雪崩:大量缓存Key同时过期,导致大量请求打到数据库。解决办法是给TTL加上随机值,避免同时过期;或者使用多级缓存,一级缓存过期时二级缓存还能撑住。

缓存一致性:缓存和数据库的数据不一致是缓存设计最大的难题。解决办法有几种:

  • 先更新数据库,再删除缓存:这是最常用的方式,删除缓存比更新缓存更安全,因为更新缓存可能会导致并发问题。
  • 使用延迟双删:先删除缓存,更新数据库,再延迟删除缓存。这样可以处理并发场景下的缓存不一致问题。
  • 订阅Binlog:通过订阅MySQL的Binlog,异步更新或删除缓存,保证最终一致性。
@Service
public class OrderCacheService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 更新订单后同步缓存
     * 使用延迟双删策略
     */
    public void updateOrder(Order order) {
        // 1. 先删除缓存
        redisTemplate.delete("order:" + order.getId());
        
        // 2. 更新数据库
        orderMapper.updateById(order);
        
        // 3. 延迟删除缓存,确保 Binlog 处理完成
        CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS).execute(() -> {
            redisTemplate.delete("order:" + order.getId());
        });
    }
    
    /**
     * 订阅 Binlog 更新缓存
     */
    @StreamListener("binlogInput")
    public void handleBinlog(BinlogEvent event) {
        if (event.getTable().equals("orders")) {
            if (event.getType() == BinlogType.UPDATE || event.getType() == BinlogType.DELETE) {
                Long orderId = event.getPrimaryKey();
                redisTemplate.delete("order:" + orderId);
            }
        }
    }
}

5.4 缓存架构的完整设计

在一个完整的电商系统中,缓存架构应该是这样的:

用户请求
    |
    v
[本地缓存] (Caffeine) ---- 命中直接返回 ----> 响应
    | 未命中
    v
[Redis缓存] ---- 命中返回 ----> 响应
    | 未命中
    v
[数据库] ---- 查询返回 ----> 写入缓存 ----> 响应

本地缓存设置一个较短的TTL(比如30秒),Redis缓存设置较长的TTL(比如30分钟)。当数据更新时,先更新数据库,再删除Redis缓存,本地缓存通过过期策略自然淘汰。

六、大促前的准备工作

优化做得再好,如果不提前测试,大促时还是可能出问题。我见过太多团队因为缺少压测,在大促时措手不及。

6.1 压测要做哪些事情

压测的目的不是证明你的系统能扛多少,而是找到系统的瓶颈在哪里,以及瓶颈出现时的表现。

全链路压测:模拟真实用户请求,从前端到后端到数据库,全部链路都要压到。很多团队只压接口,结果发现数据库才是瓶颈,但已经来不及了。

峰值测试:模拟大促时的峰值流量,看看系统能否扛住。这个峰值应该是预估峰值的1.5-2倍,留出安全余量。

稳定性测试:让系统在峰值负载下持续运行一段时间(比如4-8小时),看看是否有内存泄漏、连接池耗尽等问题。

6.2 压测工具推荐

常用的压测工具包括:

  • JMeter:开源免费,功能强大,适合接口压测
  • wrk:轻量级HTTP压测工具,适合简单的性能测试
  • Gatling:基于Scala的高性能压测工具,报告美观
  • Locust:Python编写的分布式压测工具,脚本编写简单

6.3 应急预案

压测后,一定要制定应急预案。常见的应急措施包括:

降级:当系统压力过大时,关闭一些非核心功能,比如推荐系统、评论系统,把资源留给核心业务。

限流:当流量超过系统承受能力时,对请求进行限流,保护系统不被打垮。可以用令牌桶算法或者漏桶算法实现。

@Component
public class RateLimiter {
    
    private final ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();
    
    public boolean tryAcquire(String key, int permitsPerSecond) {
        RateLimiter limiter = limiters.computeIfAbsent(key, 
            k -> RateLimiter.create(permitsPerSecond));
        return limiter.tryAcquire();
    }
}

// 使用示例
@RestController
public class OrderController {
    
    @Autowired
    private RateLimiter rateLimiter;
    
    @PostMapping("/order/create")
    public Result createOrder(@RequestBody CreateOrderRequest request) {
        String userId = String.valueOf(request.getUserId());
        
        if (!rateLimiter.tryAcquire(userId, 5)) {
            return Result.error("请求过于频繁,请稍后重试");
        }
        
        // 正常处理订单创建
        Order order = orderService.createOrder(request);
        return Result.success(order);
    }
}

熔断:当某个服务不可用时,快速失败,避免级联故障。可以用Hystrix或者Sentinel实现。

扩容:当系统负载过高时,自动扩容实例。这需要配合容器化部署和弹性伸缩策略。

6.4 监控和告警

大促期间,监控和告警系统必须正常运行。需要监控的指标包括:

  • 系统层面:CPU使用率、内存使用率、磁盘I/O、网络带宽
  • 应用层面:QPS、响应时间、错误率、线程池使用率、连接池使用率
  • 数据库层面:连接数、慢查询数量、锁等待时间、主从延迟

当这些指标超过阈值时,要及时告警,让相关人员能够快速响应。

七、实战案例:某电商平台的大促优化

让我分享一个真实的案例,这是我和一个生鲜电商团队合作的项目。

7.1 背景

这个电商平台在大促期间面临的主要问题:

  • 订单创建接口超时率高,峰值时达到30%
  • 商品详情页加载慢,平均响应时间超过2秒
  • 数据库CPU经常打满,连接数耗尽
  • 超卖现象频发

7.2 问题分析

经过排查,我们发现了几个关键问题:

数据库连接池配置不合理:连接池大小设置过小,导致请求排队等待连接。

缺少缓存层:商品详情每次都查数据库,数据库压力大。

订单表没有分表:订单表数据量超过5000万,查询性能差。

缺少限流和熔断:流量峰值时系统直接崩溃。

7.3 解决方案

我们采取了以下优化措施:

  1. 优化数据库连接池配置:

    • 连接池大小从50调整为200
    • 设置合理的最大等待时间,避免请求无限等待
    • 启用连接泄漏检测,及时发现和释放泄漏的连接
  2. 引入Redis缓存:

    • 商品详情缓存到Redis,TTL 10分钟
    • 库存使用Redis原子操作扣减
    • 订单状态缓存到Redis,减少数据库查询
  3. 订单表分表:

    • 按用户ID分100张表
    • 按时间分12个子表
    • 复合分表后,单表数据量控制在500万以内
  4. 读写分离:

    • 主库负责写入,3个从库负责读取
    • 查询订单详情走从库,创建订单走主库
  5. 限流和熔断:

    • 接口级别限流,单个用户每秒最多5次请求
    • 服务级别熔断,当错误率超过20%时自动熔断

7.4 优化效果

经过优化,系统在大促期间的表现大幅提升:

  • 订单创建接口成功率从70%提升到99.5%
  • 平均响应时间从2秒降到200毫秒
  • 数据库CPU使用率从95%降到60%
  • 超卖现象完全消除

八、总结

高并发优化是一个系统工程,不是单一技术能解决的。需要从索引设计、分表、读写分离、缓存架构、限流熔断等多个方面综合考虑。

关键的心得是:

不要等到出了问题再优化。应该在系统设计和开发阶段就考虑好性能问题,提前做容量规划和压测。

监控先行。没有监控就没有优化,不知道系统的瓶颈在哪里,优化就是盲人摸象。

持续迭代。优化不是一次性的工作,需要根据实际运行情况不断调整和改进。

希望这篇文章能帮到你们。如果有什么问题,欢迎交流。大促加油!