上周二下午三点,我们的核心订单服务突然告警,QPS从平时的5000瞬间飙到28000,数据库CPU直接飙到98%,连接数爆满,错误日志里全是 Too many connectionsDeadlock found。那一刻我才真正意识到,之前写的”简单Demo”代码在生产环境面前有多脆弱。今天就把这套血泪换来的实战方案掏出来,希望能帮你们少走弯路。

连接池不是越大越好,而是”刚好够用”

很多团队一遇到高并发,第一反应就是”加大连接池”,这是最大的误区。连接池太大反而会成为压垮MySQL的最后一根稻草。每个数据库连接都要占用内存、CPU和文件描述符,连接数过多会导致MySQL在上下文切换上消耗大量资源,性能反而下降。

我们先看一个典型的错误配置:

# 错误示例:连接池设置过大
spring:
  datasource:
    hikari:
      maximum-pool-size: 200      # 太大!MySQL默认最大连接数才151
      minimum-idle: 50            # 空闲连接也太多
      idle-timeout: 30000         # 30秒就释放,太频繁
      max-lifetime: 1800000       # 30分钟,合理
      connection-timeout: 30000   # 30秒超时,太宽松
      leak-detection-threshold: 0 # 关闭了泄漏检测

正确的做法是基于你的MySQL服务器性能和业务特点来计算。有一个经典公式可以参考:

推荐连接数 = (CPU核心数 × 2) + 磁盘数

假设你的MySQL服务器是16核、2块磁盘,那么推荐连接数大约是36个。当然这是上限,实际应用中需要根据业务特征调整。

// Spring Boot 正确的连接池配置
@Configuration
public class DataSourceConfig {
    
    @Bean
    public HikariDataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(System.getenv("DB_URL"));
        config.setUsername(System.getenv("DB_USER"));
        config.setPassword(System.getenv("DB_PASSWORD"));
        
        // 核心配置:根据服务器资源合理设置
        config.setMaximumPoolSize(40);           // 略高于理论计算值
        config.setMinimumIdle(10);               // 保持一定空闲连接应对突发
        config.setIdleTimeout(60000);            // 1分钟空闲释放
        config.setMaxLifetime(1800000);          // 30分钟最大生命周期
        config.setConnectionTimeout(5000);       // 5秒超时,快速失败
        config.setLeakDetectionThreshold(30000); // 30秒泄漏检测
        
        // 关键:设置SQL超时,防止长查询占用连接
        config.addDataSourceProperty("sessionVariables", "sql_mode='STRICT_ALL_TABLES'");
        config.addDataSourceProperty("initialTimeout", "2");
        
        return new HikariDataSource(config);
    }
}

连接池优化还有个关键点:SQL语句的执行效率。一个慢查询占用连接10秒,比10个快查询占用连接1秒危害大得多。所以连接池优化必须和SQL优化同步进行。

-- 查看当前连接数和慢查询
SHOW PROCESSLIST;
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';

-- 设置慢查询阈值(根据实际情况调整,建议1-2秒)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

索引设计:90%的性能问题都能靠索引解决

高并发场景下,索引设计比连接池配置更重要。一个错误的索引可能让查询从10毫秒变成10秒,直接拖垮整个连接池。

先说一个真实案例:我们有个订单查询接口,原SQL是:

-- 错误示例:没有利用索引的全表扫描
SELECT * FROM orders 
WHERE user_id = 12345 
AND status = 1 
ORDER BY create_time DESC 
LIMIT 20;

这张表有500万数据,每次查询都要扫描大量行。优化方案是设计复合索引:

-- 正确的索引设计:遵循最左前缀原则
-- user_id + status + create_time 的组合能覆盖WHERE和ORDER BY
CREATE INDEX idx_user_status_time ON orders (user_id, status, create_time);

-- 验证索引是否生效
EXPLAIN SELECT * FROM orders 
WHERE user_id = 12345 
AND status = 1 
ORDER BY create_time DESC 
LIMIT 20;

EXPLAIN结果会显示 type: refkey: idx_user_status_time,这说明索引被正确使用了。

但索引不是越多越好。每个索引都会增加写入开销和存储空间。我们有个项目曾经给一张表加了15个索引,结果写入性能下降了40%。

// 使用MyBatis-Plus时,注意避免N+1查询问题
@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 错误示例:循环查询,高并发下会放大N倍
     */
    public List<OrderVO> getOrdersBad(List<Long> userIds) {
        List<OrderVO> result = new ArrayList<>();
        for (Long userId : userIds) {
            // 每次循环都发一次SQL,100个用户就是100次查询
            Order order = orderMapper.selectOne(
                new LambdaQueryWrapper<Order>()
                    .eq(Order::getUserId, userId)
                    .orderByDesc(Order::getCreateTime)
                    .last("LIMIT 1")
            );
            result.add(convertToVO(order));
        }
        return result;
    }
    
    /**
     * 正确示例:批量查询,一次SQL搞定
     */
    public List<OrderVO> getOrdersGood(List<Long> userIds) {
        // IN查询,配合索引 idx_user_create_time
        List<Order> orders = orderMapper.selectList(
            new LambdaQueryWrapper<Order>()
                .in(Order::getUserId, userIds)
                .orderByDesc(Order::getCreateTime)
                .last("LIMIT 100")  // 业务上限制返回数量
        );
        return orders.stream().map(this::convertToVO).collect(Collectors.toList());
    }
}

还有一个容易被忽视的点:覆盖索引。如果查询只需要索引中的字段,MySQL可以直接从索引返回结果,不需要回表查询,性能提升巨大。

-- 原始查询需要回表
SELECT id, user_id, amount, status, create_time 
FROM orders 
WHERE user_id = 12345 AND status = 1;

-- 优化后:如果这五个字段都在索引中,就不需要回表
CREATE INDEX idx_user_status_cover ON orders (user_id, status, id, amount, create_time);

读写分离:别让写压力拖垮读性能

我们的经验是:读多写少的业务,读写分离能解决60%的高并发问题。但读写分离不是简单地把读请求分发到从库就完事了。

首先要注意主从延迟问题。如果从库同步延迟超过1秒,用户刚下单就去查询,可能查不到自己的订单。

// 关键:强制读主库的场景
@Service
public class OrderQueryService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 需要实时性的查询,强制走主库
     */
    public Order getRecentOrder(Long userId) {
        // 使用注解或路由规则,强制走主库
        return orderMapper.selectOne(
            new LambdaQueryWrapper<Order>()
                .eq(Order::getUserId, userId)
                .orderByDesc(Order::getCreateTime)
                .last("LIMIT 1")
        );
    }
    
    /**
     * 对实时性要求不高的查询,走从库
     */
    public List<Order> getHistoryOrders(Long userId, int page) {
        // 默认走从库,利用缓存和分库
        return orderMapper.selectList(
            new LambdaQueryWrapper<Order>()
                .eq(Order::getUserId, userId)
                .ge(Order::getCreateTime, System.currentTimeMillis() - 30L * 24 * 60 * 60 * 1000)
                .orderByDesc(Order::getCreateTime)
                .page(new Page<>(page, 20))
        );
    }
}

读写分离的架构设计也很关键。我们采用的是ShardingSphere来做透明读写分离:

# ShardingSphere读写分离配置
spring:
  shardingsphere:
    datasource:
      names: master,slave1,slave2
      master:
        url: jdbc:mysql://master-host:3306/order_db
        username: root
        password: xxx
      slave1:
        url: jdbc:mysql://slave1-host:3306/order_db
        username: root
        password: xxx
      slave2:
        url: jdbc:mysql://slave2-host:3306/order_db
        username: root
        password: xxx
    rules:
      readwrite-splitting:
        data-sources:
          ds:
            write-data-source-name: master
            read-data-sources: [slave1, slave2]
            load-balancer-type: ROUND_ROBIN  # 轮询负载均衡
            props:
              read-data-source-names: slave1,slave2
              write-data-source-name: master
              sql-show: true  # 生产环境关闭,调试用

但读写分离也有局限:对于事务性要求高的操作,还是必须走主库。所以我们在架构上做了分层:

  1. 核心写操作(下单、支付):强制主库,保证数据一致性
  2. 非核心读操作(历史查询、报表):走从库,允许秒级延迟
  3. 热点数据:加本地缓存+Redis缓存,减少数据库访问

缓存策略:数据库的最后一道防线

高并发下,缓存是保护数据库最有效的手段。但缓存不是万能药,用错了反而增加复杂性。

我们的缓存策略分三层:

@Service
public class ProductCacheService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    // 本地缓存,缓存5分钟,减少Redis压力
    private final Cache<String, Product> localCache = CacheBuilder.newBuilder()
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .maximumSize(1000)
            .build();
    
    /**
     * 三级缓存查询策略
     */
    public Product getProduct(Long productId) {
        // 第一层:本地缓存
        try {
            Product local = localCache.getIfPresent(String.valueOf(productId));
            if (local != null) {
                return local;
            }
        } catch (Exception e) {
            // 本地缓存异常不影响业务
        }
        
        // 第二层:Redis缓存
        String redisKey = "product:" + productId;
        Product redisProduct = (Product) redisTemplate.opsForValue().get(redisKey);
        if (redisProduct != null) {
            // 回源本地缓存
            localCache.put(String.valueOf(productId), redisProduct);
            return redisProduct;
        }
        
        // 第三层:数据库
        Product dbProduct = productMapper.selectById(productId);
        if (dbProduct != null) {
            // 写入Redis,设置过期时间防止雪崩
            redisTemplate.opsForValue().set(redisKey, dbProduct, 30, TimeUnit.MINUTES);
            localCache.put(String.valueOf(productId), dbProduct);
        }
        
        return dbProduct;
    }
    
    /**
     * 缓存更新策略:Cache-Aside Pattern
     */
    @Transactional
    public void updateProduct(Product product) {
        // 1. 先更新数据库
        productMapper.updateById(product);
        
        // 2. 再删除缓存(不是更新缓存,避免并发问题)
        redisTemplate.delete("product:" + product.getId());
        localCache.invalidate(String.valueOf(product.getId()));
    }
}

缓存设计有几个关键原则:

  1. 先更新数据库,再删除缓存,避免脏数据
  2. 设置合理的过期时间,防止缓存雪崩
  3. 热点key单独处理,防止单key瓶颈
  4. 缓存击穿防护,使用互斥锁或逻辑过期
/**
 * 热点key防护:使用分布式锁防止缓存击穿
 */
public Product getProductWithHotKeyProtection(Long productId) {
    String key = "product:" + productId;
    
    // 先查缓存
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }
    
    // 缓存未命中,加锁防止并发穿透
    String lockKey = "lock:product:" + productId;
    boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            // 双重检查
            cached = (Product) redisTemplate.opsForValue().get(key);
            if (cached == null) {
                // 查数据库
                cached = productMapper.selectById(productId);
                if (cached != null) {
                    redisTemplate.opsForValue().set(key, cached, 30, TimeUnit.MINUTES);
                }
            }
            return cached;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 获取锁失败,休眠后重试
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getProductWithHotKeyProtection(productId);
    }
}

限流与降级:最后一道保障

即使做了以上所有优化,极端情况下还是可能扛不住。这时候限流和降级就是最后的救命稻草。

@RestController
public class OrderController {
    
    @Autowired
    private OrderService orderService;
    
    // 使用Redis + Lua脚本实现令牌桶限流
    @PostMapping("/order/create")
    public Result createOrder(@RequestBody OrderRequest request) {
        // 限流检查
        if (!rateLimiter.tryAcquire("order:create:" + request.getUserId())) {
            return Result.fail("系统繁忙,请稍后重试");
        }
        
        try {
            return Result.success(orderService.createOrder(request));
        } catch (Exception e) {
            // 降级处理:返回兜底数据或友好提示
            log.error("订单创建异常", e);
            return Result.fail("订单创建失败,请联系客服");
        }
    }
}

@Component
public class RateLimiter {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 令牌桶限流实现
     * @param key 限流键
     * @return 是否允许通过
     */
    public boolean tryAcquire(String key) {
        String luaScript = 
            "local key = KEYS[1] " +
            "local capacity = tonumber(ARGV[1]) " +
            "local tokens = tonumber(ARGV[2]) " +
            "local now = tonumber(ARGV[3]) " +
            "local ttl = tonumber(ARGV[4]) " +
            "local current = redis.call('GET', key) " +
            "if current == false then " +
            "    redis.call('SET', key, capacity) " +
            "    redis.call('EXPIRE', key, ttl) " +
            "    return 1 " +
            "end " +
            "if tonumber(current) >= tokens then " +
            "    redis.call('DECRBY', key, tokens) " +
            "    return 1 " +
            "end " +
            "return 0";
        
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(key),
            "100",    // 令牌桶容量
            "1",      // 每次请求消耗令牌数
            String.valueOf(System.currentTimeMillis() / 1000),
            "60"      // TTL 60秒
        );
        
        return result != null && result == 1;
    }
}

限流策略要根据业务特点来定:

  • 接口维度:每个接口独立限流
  • 用户维度:防止单个用户恶意刷接口
  • 全局维度:保护整个系统不崩盘

监控与告警:早发现早处理

最后但同样重要的是监控。没有监控的优化都是盲目的。

# Prometheus + Grafana 监控配置
spring:
  boot:
    metrics:
      export:
        prometheus:
          enabled: true

# 关键监控指标
- 数据库连接数(当前/最大)
- QPS/TPS(每秒查询/事务数)
- 慢查询数量
- 锁等待时间
- 主从延迟
- 缓存命中率

我们设置了一套自动告警规则:

”`java @Component public class MonitorListener {

@Autowired
private PrometheusClient prometheusClient;

@Autowired
private AlertService alertService;

/**
 * 每分钟检查一次关键指标
 */
@Scheduled(fixedRate = 60000)
public void checkMetrics() {
    // 检查连接数
    int currentConnections = prometheusClient.query(
        "mysql_global_status_threads_connected"
    );
    int maxConnections = prometheusClient.query(
        "mysql_global_variables_max_connections"
    );

    if (currentConnections > maxConnections * 0.8) {
        alertService.sendAlert("数据库连接数接近