MySQL被高并发搞崩了怎么办

数据库管理员常用读写分离缓存优化分库分表缓解查询卡顿避免系统崩溃


你的数据库正在经历什么

想象一下,你家楼下的奶茶店,平时每分钟来5个人买奶茶,一切井然有序。突然有一天,来了100个人同时排队,收银员手忙脚乱,制作台排成一队,配送员跑来跑去……最后的结果就是:订单乱套,系统崩溃。

你的MySQL数据库,就是这家奶茶店。

当高并发来了——可能是秒杀活动、可能是大促、可能是某个API接口突然被大量调用——数据库的连接数会暴涨,查询请求堆积如山,线程池爆满,然后呢?慢查询、锁等待、连接超时,最终要么查询卡顿到让用户抓狂,要么直接宕机,服务彻底不可用。

作为数据库管理员,你需要的不是”祈祷高并发少一点”,而是有一套系统性的应对策略。今天,我们把这套策略掰开揉碎了讲清楚。


第一层防线:读写分离

为什么需要读写分离

大多数业务系统的读写比例,并不是1:1,而是像 10:1 甚至 100:1。也就是说,每收到100条查询请求,只有1条是写入请求,剩下99条都是读取请求。

如果你让所有的读写都挤在同一个数据库实例上,那就像让奶茶店的收银员同时负责点单、制作和配送——当然会忙不过来。

读写分离的核心思想很简单:把读操作和写操作分配到不同的服务器上。主库负责写入,从库负责读取,通过主从复制保持数据一致性。

如何搭建读写分离

我们以常见的MySQL主从复制为例,来看具体怎么做。

第一步:配置主库(Master)

在主库的配置文件 /etc/my.cnf 中加入:

[mysqld]
# 开启binlog,这是主从复制的基础
log-bin = mysql-bin
# 服务器唯一ID,主从库必须不同
server-id = 1
# 建议开启半同步复制,提高数据安全
plugin-load = "semisync_master.so"
rpl_semi_sync_master_enabled = 1
rpl_semi_sync_master_timeout = 1000

配置完成后重启MySQL:

systemctl restart mysqld

第二步:创建复制用户

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

-- 查看主库状态,记住File和Position
SHOW MASTER STATUS;

输出大概是这样的:

+------------------+----------+--------------+------------------+
| File             | Position | Binlog_Do_DB | Binlog_Ignore_DB |
+------------------+----------+--------------+------------------+
| mysql-bin.000003 |      567 |              |                  |
+------------------+----------+--------------+------------------+

第三步:配置从库(Slave)

在从库的配置文件 /etc/my.cnf 中加入:

[mysqld]
server-id = 2
plugin-load = "semisync_slave.so"
rpl_semi_sync_slave_enabled = 1

然后执行以下命令,告诉从库去连接主库:

CHANGE MASTER TO
  MASTER_HOST = '192.168.1.100',
  MASTER_USER = 'repl',
  MASTER_PASSWORD = 'StrongPassword123!',
  MASTER_LOG_FILE = 'mysql-bin.000003',
  MASTER_LOG_POS = 567;

START SLAVE;
SHOW SLAVE STATUS\G

第四步:验证复制是否正常

关键看这两个字段:

Slave_IO_Running: Yes
Slave_SQL_Running: Yes

如果两个都是 Yes,说明复制链路畅通。Seconds_Behind_Master 表示从库落后主库多少秒,理想情况下应该是0或者很小的值。

应用层如何实现读写分离

配置好了主从,接下来要让应用程序知道”写操作连主库,读操作连从库”。

以Java + Spring Boot为例,可以用动态数据源方案:

@Configuration
public class DataSourceConfig {

    @Bean("masterDataSource")
    @ConfigurationProperties("spring.datasource.master")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean("slaveDataSource")
    @ConfigurationProperties("spring.datasource.slave")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @Primary
    public DynamicDataSource dynamicDataSource(
            @Qualifier("masterDataSource") DataSource master,
            @Qualifier("slaveDataSource") DataSource slave) {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DBEnum.MASTER, master);
        targetDataSources.put(DBEnum.SLAVE, slave);

        DynamicDataSource dataSource = new DynamicDataSource();
        dataSource.setTargetDataSources(targetDataSources);
        dataSource.setDefaultTargetDataSource(master);
        return dataSource;
    }
}

public enum DBEnum {
    MASTER, SLAVE
}

配合AOP注解实现自动路由:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Slave {
}
@Aspect
@Component
public class DataSourceAspect {

    @Around("@annotation(slave)")
    public Object around(ProceedingJoinPoint point, Slave slave) throws Throwable {
        DataSourceContextHolder.setDB(DBEnum.SLAVE);
        try {
            return point.proceed();
        } finally {
            DataSourceContextHolder.clearDB();
        }
    }

    @Around("@annotation(master)")
    public Object aroundMaster(ProceedingJoinPoint point, Master master) throws Throwable {
        DataSourceContextHolder.setDB(DBEnum.MASTER);
        try {
            return point.proceed();
        } finally {
            DataSourceContextHolder.clearDB();
        }
    }
}

使用时就非常简单了:

@Service
public class OrderService {

    // 写操作走主库
    @Master
    public void createOrder(Order order) {
        orderMapper.insert(order);
    }

    // 读操作走从库
    @Slave
    public Order getOrder(Long id) {
        return orderMapper.selectById(id);
    }
}

读写分离的注意事项

读写分离不是银弹,有几个坑你必须知道:

1. 主从延迟问题

数据从主库同步到从库需要时间。如果用户刚写完数据,立刻去从库读取,可能读不到最新数据。这在订单详情、余额查询等场景下是致命的。

解决方案:

  • 降低延迟:使用半同步复制(Semi-Sync),保证至少一个从库接收完binlog后才返回成功
  • 关键查询强制走主库:通过注解或拦截器识别写后立即读的请求,强制路由到主库
  • 容忍弱一致性:对于日志类、统计类数据,接受几秒内的延迟

2. 从库数量不是越多越好

每增加一个从库,同步开销就大一分。太多从库会导致网络带宽和IO压力爆炸。一般建议2~3个从库就够了,实在扛不住就升级硬件或者考虑分库。

3. 从库的查询优化

从库往往承担了大量读流量,但很多DBA只关注主库的性能,忽略了从库。从库上要避免大事务、避免全表扫描、避免复杂的JOIN,否则慢查询会拖垮整个从库,进而拖垮所有依赖从库的业务。


第二层防线:缓存优化

缓存的本质:用空间换时间

缓存的核心思想特别朴素:把经常访问的数据放到更快的地方

内存的访问速度比磁盘快好几个数量级。MySQL的数据默认存在磁盘上(数据文件、索引文件),每次查询都要经历磁盘IO。如果把热点数据放在内存里,查询速度可以提升几十倍甚至上百倍。

常见的缓存方案有:

  • 本地缓存:EHCache、Caffeine,存在应用服务器内存里,最快但有内存限制
  • 分布式缓存:Redis、Memcached,独立部署,所有应用节点共享,扩展性好

生产环境一般用Redis,它既快又灵活。

Redis缓存的典型架构

以商品查询为例,来看看缓存是怎么工作的:

用户请求
    ↓
应用服务器
    ↓
【先查Redis缓存】
    ├── 命中 → 直接返回数据(耗时约1ms)
    └── 未命中 → 查MySQL数据库
                    ↓
                把结果写入Redis(设置过期时间)
                    ↓
                返回数据给用户

完整代码示例

@Service
public class ProductService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Autowired
    private ProductMapper productMapper;

    private static final String CACHE_KEY_PREFIX = "product:";
    private static final long CACHE_EXPIRE_SECONDS = 300; // 5分钟过期

    /**
     * 查询商品,带缓存
     */
    public ProductDTO getProduct(Long productId) {
        String cacheKey = CACHE_KEY_PREFIX + productId;

        // 1. 先查Redis
        String cachedJson = redisTemplate.opsForValue().get(cacheKey);
        if (cachedJson != null && !cachedJson.isEmpty()) {
            // 命中缓存,直接返回
            return JSON.parseObject(cachedJson, ProductDTO.class);
        }

        // 2. 缓存未命中,查数据库
        Product product = productMapper.selectById(productId);
        if (product == null) {
            // 防止缓存穿透:查询空对象,缓存一个较短的过期时间
            redisTemplate.opsForValue().set(cacheKey, "", 60, TimeUnit.SECONDS);
            return null;
        }

        // 3. 写入缓存
        ProductDTO dto = convertToDTO(product);
        redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(dto),
                CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS);

        return dto;
    }

    /**
     * 更新商品,同步删除缓存
     */
    @Transactional
    public void updateProduct(ProductUpdateRequest request) {
        // 1. 更新数据库
        Product product = new Product();
        product.setId(request.getId());
        product.setName(request.getName());
        product.setPrice(request.getPrice());
        productMapper.updateById(product);

        // 2. 删除缓存(先删缓存,再更新DB 或者 先更新DB再删缓存)
        String cacheKey = CACHE_KEY_PREFIX + request.getId();
        redisTemplate.delete(cacheKey);
    }
}

缓存的三大杀手

缓存用起来简单,但一旦出问题就是大问题。你需要警惕三个经典问题:

1. 缓存穿透

用户查询一个根本不存在的数据,缓存里查不到,每次请求都直接打到数据库上。如果攻击者故意大量查询不存在的ID,数据库直接被打挂。

解决方法:

  • 缓存空值:查询结果为空时,也缓存一个空对象,设置较短过期时间
  • 布隆过滤器:在缓存层前面加一道门槛,快速判断key是否存在
// 缓存空值示例
if (product == null) {
    // 缓存空字符串,60秒过期
    redisTemplate.opsForValue().set(cacheKey, "", 60, TimeUnit.SECONDS);
    return null;
}

2. 缓存击穿

某个热点key在过期的那一瞬间,大量请求同时涌入,全部打到数据库上。

解决方法:

  • 热点key永不过期:不设置TTL,由后台线程定期刷新
  • 分布式锁:只有一个请求去查数据库并回填缓存,其他请求等待
public ProductDTO getProductWithLock(Long productId) {
    String cacheKey = CACHE_KEY_PREFIX + productId;

    // 先查缓存
    String cachedJson = redisTemplate.opsForValue().get(cacheKey);
    if (cachedJson != null) {
        return JSON.parseObject(cachedJson, ProductDTO.class);
    }

    // 缓存未命中,加分布式锁
    String lockKey = "lock:" + cacheKey;
    Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

    if (Boolean.TRUE.equals(locked)) {
        try {
            // 双重检查:加锁成功后再查一次缓存(可能其他线程已经回填了)
            cachedJson = redisTemplate.opsForValue().get(cacheKey);
            if (cachedJson != null) {
                return JSON.parseObject(cachedJson, ProductDTO.class);
            }

            // 查数据库
            Product product = productMapper.selectById(productId);
            if (product != null) {
                ProductDTO dto = convertToDTO(product);
                redisTemplate.opsForValue().set(cacheKey,
                        JSON.toJSONString(dto), CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS);
                return dto;
            }
        } finally {
            // 释放锁
            redisTemplate.delete(lockKey);
        }
    } else {
        // 没有拿到锁,稍等后重试
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getProductWithLock(productId);
    }
    return null;
}

3. 缓存雪崩

大量key在同一时刻过期,或者Redis服务本身宕机,导致所有请求全部打到数据库。

解决方法:

  • 过期时间加随机抖动:不要所有key都设置5分钟过期,而是5分钟±随机数
  • Redis高可用:使用集群模式或主从+哨兵
  • 熔断降级:Redis挂了,直接返回默认值或错误信息,不让请求全部堆积到数据库
// 过期时间加随机抖动,防止大量key同时过期
long expireSeconds = CACHE_EXPIRE_SECONDS + ThreadLocalRandom.current().nextLong(0, 120);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(dto),
        expireSeconds, TimeUnit.SECONDS);

缓存与数据库的一致性

这是一个老生常谈但非常重要的问题。缓存和数据库数据不一致是迟早会发生的,你只能尽量缩短不一致的时间窗口,无法完全消除。

业界常见的策略有两种:

先更新数据库,再删除缓存(推荐)

// 先写DB
productMapper.updateById(product);
// 再删缓存
redisTemplate.delete(cacheKey);

为什么不直接更新缓存?因为可能有并发写,先删缓存再让下一个读请求回填,是更安全的做法。至于”删缓存失败”的情况,可以通过MQ延迟重试或者定时校准来补偿。

不要这样做:先删缓存再更新数据库

// 不推荐的顺序
redisTemplate.delete(cacheKey);       // 先删缓存
productMapper.updateById(product);    // 再更新DB
// 如果更新失败,缓存已删,数据库是旧数据,产生不一致

第三层防线:分库分表

什么时候该考虑分库分表

读写分离和缓存能解决大部分问题,但终究有天花板。当你的数据量达到以下几个量级时,就需要认真考虑分库分表了:

  • 单表数据超过 500万~1000万行,索引效率开始明显下降
  • 单库QPS超过 5000~10000,CPU和IO开始吃不消
  • 单库存储超过 2TB,备份恢复时间变得难以接受

分库分表的本质是:把一个巨大的数据库拆成多个小数据库/小表,分散压力和存储

分表:水平拆分

假设你有一个订单表,现在有1000万条数据,查询越来越慢。最简单的做法是把这一张表拆成多张:

order_0
order_1
order_2
...
order_9

拆分策略通常用 取模法

/**
 * 根据订单ID确定分表索引
 * 10张表,ID对10取模
 */
public int getTableIndex(Long orderId) {
    return (int) (orderId % 10);
}

/**
 * 获取实际表名
 */
public String getTableName(Long orderId) {
    return "order_" + getTableIndex(orderId);
}

写入时:

String tableName = getTableName(order.getId());
String sql = "INSERT INTO " + tableName + " (id, user_id, amount, create_time) VALUES (?, ?, ?, ?)";
jdbcTemplate.update(sql, order.getId(), order.getUserId(), order.getAmount(), order.getCreateTime());

查询时:

String tableName = getTableName(orderId);
String sql = "SELECT * FROM " + tableName + " WHERE id = ?";
return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper<>(Order.class), orderId);

取模分表的优缺点:

  • 优点:数据分布均匀,扩容时只需要按倍数增加表
  • 缺点:跨表查询困难,分页查询需要全表扫描后归并

分库:水平拆分+垂直拆分

水平分库:和分表类似,把数据按规则分散到多个数据库实例上。比如按用户ID取模,不同用户的订单落到不同的库。

垂直分库:把大表按列拆分,把不常用的列拆分出去。比如订单表有50个字段,其中20个是订单详情(频繁访问),30个是商品快照(偶尔访问)。可以把商品快照拆到另一张表,需要时再JOIN。

-- 主表:只放高频字段
CREATE TABLE order_main (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    amount DECIMAL(10,2),
    status TINYINT,
    create_time DATETIME,
    INDEX idx_user_id (user_id)
) ENGINE=InnoDB;

-- 扩展表:放低频大字段
CREATE TABLE order_detail (
    order_id BIGINT PRIMARY KEY,
    goods_snapshot TEXT,
    remark VARCHAR(500),
    INDEX idx_order_id (order_id)
) ENGINE=InnoDB;

分库分表框架选型

自己实现分库分表逻辑很繁琐,生产环境一般用成熟框架:

框架 语言 特点
ShardingSphere Java/Go 功能最全,支持分库分表、读写分离、数据库密码加密等,社区活跃
MyCat Java 老牌中间件,基于JDBC代理,性能稳定
Vitess Go Google出品,适合超大规模场景

以ShardingSphere为例,配置非常简洁:

# application.yml
spring:
  shardingsphere:
    datasource:
      names: ds0,ds1
      ds0:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.1.100:3306/db0
        username: root
        password: StrongPassword123!
      ds1:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.1.100:3307/db1
        username: root
        password: StrongPassword123!
    rules:
      sharding:
        tables:
          order:
            actual-data-nodes: ds$->{0..1}.order_$->{0..9}
            table-strategy:
              standard:
                sharding-column: order_id
                sharding-algorithm-name: order-table-inline
            key-generate-strategy:
              column: order_id
              key-generator-name: snowflake
        sharding-algorithms:
          order-table-inline:
            type: INLINE
            props:
              algorithm-expression: order_$->{order_id % 10}
        key-generators:
          snowflake:
            type: SNOWFLAKE
    props:
      sql-show: true

代码层几乎不用改动,ShardingSphere会自动路由:

// 插入时自动路由到对应的分表
orderMapper.insert(order);

// 查询时自动路由
Order order = orderMapper.selectById(orderId);

分库分表的关键挑战

1. 跨库查询

分库分表后,跨分片查询成了最大的痛点。比如你想查”所有用户ID在1000~2000之间的订单”,这个范围跨越了多个分片,必须在每个分片上执行查询然后归并结果。

解决方案:

  • 避免跨分片查询:优化业务设计,让查询都能落在单一分片
  • 使用ES等搜索引擎:把数据同步到Elasticsearch,复杂查询走ES
  • 逻辑表:对于确实需要跨库的场景,维护一张逻辑表,定时同步

2. 全局唯一ID

分库分表后,不能用数据库自增ID了,因为多个库同时生成会冲突。业界常用的是 雪花算法(Snowflake)

public class SnowflakeIdGenerator {
    private long workerId;
    private long datacenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

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

    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();

        if (timestamp < lastTimestamp) {
            throw new RuntimeException("Clock moved backwards. Refusing to generate id.");
        }

        if (lastTimestamp == timestamp) {
            // 同一毫秒内,序号递增
            sequence = (sequence + 1) & 4095;
            if (sequence == 0) {
                // 序号溢出,等下一毫秒
                timestamp = waitNextMillis(lastTimestamp);
            }
        } else {
            // 新毫秒,序号归零
            sequence = 0L;
        }

        lastTimestamp = timestamp;

        // 生成64位ID:符号位(1) + 时间戳(41) + 数据中心(5) + 工作机(5) + 序号(12)
        return ((timestamp - 1288834974657L) << 22)
                | (datacenterId << 17)
                | (workerId << 12)
                | sequence;
    }

    private long waitNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}

雪花算法生成的ID是全局唯一、趋势递增的,非常适合作为分片键。

3. 扩容问题

分库分表不是一劳永逸的。随着业务增长,你可能需要扩容。扩容最怕的是 数据重新分布——把所有数据打散重新分片,这个操作在大表上几乎不可能完成(需要停机或者大量数据迁移)。

所以,设计阶段就要预留足够的扩展空间。比如一开始分16张表,而不是8张。宁可多预留,不要到时候后悔。


第四层防线:查询优化

慢查询是万恶之源

在高并发场景下,最可怕的不是数据量大,而是 查询慢。一个没走索引的慢查询,可能阻塞整个表,影响所有其他请求。

根据MySQL官方的建议,单条查询的耗时最好控制在 毫秒级,最慢也不要超过1秒。

索引优化

索引是MySQL性能的基石。但很多开发者对索引的理解停留在”加索引就能快”的层面,这是不够的。

最左前缀原则

复合索引 (a, b, c),查询条件必须从左到右匹配:

-- 走索引 ✅
SELECT * FROM users WHERE a = 1 AND b = 2 AND c = 3;
SELECT * FROM users WHERE a = 1 AND b = 2;
SELECT * FROM users WHERE a = 1;

-- 不走索引(b、c没有a作为前缀) ❌
SELECT * FROM users WHERE b = 2 AND c = 3;

避免在索引列上做计算

-- 不走索引 ❌
SELECT * FROM orders WHERE YEAR(create_time) = 2024;

-- 走索引 ✅
SELECT * FROM orders WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';

使用EXPLAIN分析查询计划

EXPLAIN SELECT * FROM orders WHERE user_id = 10001 ORDER BY create_time DESC LIMIT 10;

关注这几个关键字段:

  • type:连接类型,从好到差是 system > const > eq_ref > ref > range > index > ALL,最差是ALL(全表扫描)
  • key:实际使用的索引
  • rows:预估扫描的行数
  • Extra:额外信息,出现 Using filesortUsing temporary 需要警惕

分页优化

传统分页在大表上是个灾难:

-- 传统分页,offset很大时性能极差 ❌
SELECT * FROM orders LIMIT 100000, 10;
-- MySQL需要扫描100010行,然后丢弃前100000行

优化方案:延迟关联

-- 先通过索引查出ID,再回表查完整数据 ✅
SELECT o.* FROM orders o
INNER JOIN (
    SELECT id FROM orders ORDER BY create_time DESC LIMIT 100000, 10
) AS t ON o.id = t.id;

或者用 游标分页

-- 记录上一页最大ID,下一页从这个ID之后开始查 ✅
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;

大事务优化

高并发下,大事务是性能杀手。一个持有锁几秒甚至几十秒的事务,会阻塞其他所有请求。

原则:事务越小越好

// ❌ 坏例子:大事务,做了很多无关操作
@Transactional
public void processOrder(Order order) {
    // 1. 插入订单
    orderMapper.insert(order);

    // 2. 扣减库存(网络调用!)
    inventoryService.deductStock(order.getGoodsId(), order.getQuantity());

    // 3. 发送通知(RPC调用!)
    notificationService.send(order.getUserId(), "订单已创建");

    // 4. 记录日志(写磁盘!)
    logService.record(order);
}

// ✅ 好例子:事务只包含数据库操作
@Transactional
public void processOrder(Order order) {
    orderMapper.insert(order);
}

// 事务外做其他事情
inventoryService.deductStock(order.getGoodsId(), order.getQuantity());
notificationService.send(order.getUserId(), "订单已创建");
logService.record(order);

连接池调优

连接是数据库最宝贵的资源之一。高并发时如果连接池配置不当,要么连接不够用导致排队,要么连接太多把数据库打满。

# Spring Boot HikariCP连接池配置
spring:
  datasource:
    hikari:
      minimum-idle: 10           # 最小空闲连接数
      maximum-pool-size: 50       # 最大连接数(不要超过数据库能承受的连接上限)
      idle-timeout: 600000        # 空闲连接超时(10分钟)
      max-lifetime: 1800000       # 连接最大存活时间(30分钟)
      connection-timeout: 30000   # 获取连接超时(30秒)
      leak-detection-threshold: 60000  # 连接泄漏检测(60秒)

连接数的经验公式:连接数 ≈ CPU核数 × 2 + 磁盘数。比如8核CPU、2块磁盘,连接数设置在18~20左右比较合理。MySQL的 max_connections 建议设置为连接池最大连接数 × 应用实例数 + 10。


第五层防线:架构兜底

限流:拒绝服务

当并发量超过系统承受能力时,最好的策略不是硬扛,而是 主动拒绝一部分请求。这就是限流。

常见的限流算法:

  • 令牌桶:以固定速率产生令牌,请求需要消耗令牌才能通过
  • 漏桶:请求进入队列,以固定速率处理
  • 滑动窗口:统计最近N秒内的请求数,超过阈值就拒绝

以Sentinel为例(阿里巴巴开源的限流框架):

// 定义资源
@PostConstruct
public void init() {
    FlowRule rule = new FlowRule("createOrder");
    rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
    rule.setCount(100); // 每秒最多100个请求
    FlowRuleManager.loadRules(Collections.singletonList(rule));
}

// 业务代码
public Result createOrder(OrderRequest request) {
    Entry entry = null;
    try {
        entry = SphU.entry("createOrder");
        // 正常业务逻辑
        Order order = orderService.create(request);
        return Result.success(order);
    } catch (BlockException e) {
        // 被限流了
        return Result.fail("系统繁忙,请稍后再试");
    } finally {
        if (entry != null) {
            entry.exit();
        }
    }
}

降级:保留核心功能

限流是拒绝部分请求,降级是 在系统负载过高时,关闭非核心功能,保证核心业务可用

比如大促时:

  • 核心功能:下单、支付(必须可用)
  • 非核心功能:推荐算法、评论、积分(可以暂时关闭)
@SentinelResource(value = "getRecommendations", blockHandler = "getRecommendationsFallback")
public List<Recommendation> getRecommendations(Long userId) {
    return recommendationService.getRecommendations(userId);
}

// 降级逻辑:返回空列表或默认推荐
public List<Recommendation> getRecommendationsFallback(Long userId, BlockException e) {
    log.warn("推荐服务被限流/降级,userId: {}", userId);
    return getDefaultRecommendations();
}

监控告警:早发现早处理

没有监控的系统就是在裸奔。你需要知道:

  • 当前QPS是多少
  • 慢查询有多少
  • 连接数是否接近上限
  • 从库延迟多少
  • 缓存命中率如何
  • 错误率是否飙升
-- 监控慢查询
SHOW STATUS LIKE 'Slow_queries';
SHOW VARIABLES LIKE 'long_query_time';

-- 查看当前连接数和状态
SHOW PROCESSLIST;
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Threads_running';

-- 查看InnoDB状态
SHOW ENGINE INNODB STATUS;

-- 查看表锁等待
SELECT * FROM information_schema.INNODB_TRX;
SELECT * FROM performance_schema.data_locks;

结合Prometheus + Grafana,可以搭建完整的监控面板,设置阈值告警,在问题爆发前就感知到异常。


实战案例:一次真实的高并发救火

让我们回顾一个真实的场景:

背景:某电商平台的订单服务,平时日活10万,QPS约50。某天做了限时秒杀活动,瞬间QPS飙升到5000+。

问题爆发

  • 订单查询接口平均响应时间从50ms飙升至3秒
  • 数据库连接数从50飙升到500,达到上限
  • 部分从库延迟超过10秒
  • 用户反馈:下单成功但订单查不到

根因分析

  1. 秒杀期间,大量用户刷新订单详情,读压力暴增
  2. 缓存全部失效(秒杀商品ID集中,同时过期)
  3. 从库同步延迟导致读到的数据不是最新的
  4. 慢查询阻塞了连接池

解决方案

  1. 紧急扩容:临时增加从库节点,分担读压力
  2. 缓存预热:秒杀开始前预热商品数据和库存数据到Redis
  3. 限流:对订单详情接口做限流,每秒最多2000次
  4. 异步化:下单接口改为异步处理,立即返回”提交成功”,后台异步创建订单
  5. 索引优化:发现一条慢查询 SELECT * FROM orders WHERE user_id = ? ORDER BY create_time,给 (user_id, create_time) 加了联合索引

结果:活动结束时,系统平稳运行,0故障。事后复盘,在配置文件中加上了以下优化:

# 最终优化配置
spring:
  shardingsphere:
    rules:
      sharding:
        tables:
          order:
            actual-data-nodes: ds$->{0..3}.order_$->{0..15}
            table-strategy:
              standard:
                sharding-column: order_id
                sharding-algorithm-name: order-table-inline
        sharding-algorithms:
          order-table-inline:
            type: INLINE
            props:
              algorithm-expression: order_$->{order_id % 16}
  datasource:
    hikari:
      maximum-pool-size: 100
      minimum-idle: 20

总结:高并发下数据库保命的完整路线图

面对高并发,不要指望一招鲜吃遍天,而是一套组合拳:

第一层:读写分离 — 解决读多写少的问题,把读压力分散到从库

第二层:缓存优化 — 用Redis挡住大部分读请求,保护数据库

第三层:分库分表 — 数据量大了就把数据拆开,每个库/表只处理一部分数据

第四层:查询优化 — 索引、SQL、事务、连接池,每一个细节都要做到位

第五层:架构兜底 — 限流、降级、监控,做好最坏情况的准备

这五层不是孤立的,而是一个整体。读写分离解决了流量分配,缓存挡住了大部分请求,分库分表解决了存储和单点性能瓶颈,查询优化保证了每条请求都尽可能快,限流降级则在系统快扛不住的时候保住底线。

记住,数据库不会因为一次优化就高枕无忧。高并发的压力会一直增长,你需要持续监控、持续优化。最好的防御,是在问题爆发之前就发现问题

你的数据库,准备好了吗?