上周深夜两点,我的手机像炸弹一样震动。不是闹钟,是监控告警。

某电商平台大促预热,订单服务突然全线飘红。DBA群里一片死寂,因为所有人都知道这意味着什么——数据库扛不住了。等我们赶到会议室时,屏幕上只有两个触目惊心的数字:活跃连接数 3200/3200(已满),QPS 0,平均响应时间 >30s。

那一刻,我意识到这不仅是一个技术问题,更是一场关于架构底线、索引细节和连接管理的综合考验。今天,我把这五年来亲手处理过的五个最典型的“高并发卡死”案例毫无保留地分享给你。这些不是教科书里的理论,而是带着服务器余温和代码现场感的实战复盘。

案例一:那个被忽略的“隐式类型转换”,让百万QPS瞬间归零

故障现场

某内容社区APP,发布文章接口在高并发下突然超时。监控系统显示数据库CPU利用率从20%瞬间飙升至100%,但连接数并未满载。初步判断是慢查询拖垮了整体性能。

排查过程

我抓取了当时的慢查询日志,发现所有请求都卡在同一个SQL上:

SELECT * FROM articles WHERE phone_number = '13800138000' LIMIT 10;

phone_number字段明明有索引,为什么不走索引?我登录数据库执行EXPLAIN

EXPLAIN SELECT * FROM articles WHERE phone_number = '13800138000' LIMIT 10;

结果令人震惊:type: ALLkey: NULLrows: 5000000。全表扫描!

根本原因

业务代码里,phone_number字段在数据库中的类型是VARCHAR(20),但Java实体类中定义的是Long类型。当MyBatis进行参数绑定时,MySQL为了进行隐式类型转换,将索引列phone_number(VARCHAR)与传入的整型值进行了比较。根据MySQL的规则,当比较两端类型不同时,会对列进行函数转换,这直接导致索引失效。

更糟糕的是,这个查询在热点字段上并发极高,每次全表扫描500万行数据,CPU瞬间被打满。

优化方案

  1. 修改代码层:将Java实体类中的phone_number字段类型改为String,确保类型一致。
// 修复前
private Long phoneNumber;

// 修复后
private String phoneNumber;
  1. 添加SQL层校验:在关键SQL上加/*+ INDEX(articles idx_phone_number) */提示,强制使用索引,避免再次失效。

  2. 灰度发布:先在线上环境小流量验证,确认无问题后再全量发布。

效果

优化后,该接口的平均响应时间从800ms降至12ms,CPU利用率稳定在15%以下。

给开发者的忠告

永远不要信任隐式类型转换。在代码Review环节,务必检查数据库字段类型与Java实体类类型是否一致。一个简单的VARCHAR vs Long的 mismatch,就可能引发生产事故。

案例二:连接池的“雪崩效应”,3200个连接如何拖垮整个集群

故障现场

某金融交易系统,双十一当天交易量激增。下午3点,应用服务器开始出现大量“Connection pool exhausted”错误,用户提交交易失败率飙升至30%。

排查过程

我首先检查了数据库服务器的网络连接数:

mysql -h 10.0.0.1 -u root -p -e "SHOW PROCESSLIST;" | wc -l

结果显示有3200个活跃连接,而连接池最大配置也是3200。这意味着所有连接都被占用,新请求无法获取连接。

进一步分析,发现这些连接中有大量处于Sleep状态的连接,且持续时间超过30秒。典型的一个长连接占用场景如下:

Id: 12345
User: app_user
Host: 10.0.1.5:54321
db: order_db
Command: Sleep
Time: 45
State: 
Info: NULL

根本原因

业务代码中使用了HikariCP连接池,配置如下:

<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource">
    <property name="jdbcUrl" value="jdbc:mysql://10.0.0.1:3306/order_db"/>
    <property name="username" value="app_user"/>
    <property name="password" value="password"/>
    <property name="maximumPoolSize" value="3200"/>
    <property name="minimumIdle" value="100"/>
    <property name="idleTimeout" value="300000"/>
    <property name="maxLifetime" value="1800000"/>
</bean>

问题在于:

  1. 连接池配置过大:3200个连接对于单台MySQL服务器来说是巨大的负担。每个连接都会占用内存(约2MB/连接),3200个连接就是6.4GB内存,再加上网络开销,服务器资源被严重消耗。

  2. 缺少合理的超时机制idleTimeout为5分钟,maxLifetime为30分钟,但实际业务中很多连接因为异常未正确关闭,导致连接长期处于Sleep状态。

  3. 线程池与连接池不匹配:应用服务器有10个节点,每个节点3200个连接,总共32000个连接并发请求数据库,远超MySQL的承受能力。

优化方案

  1. 降低连接池大小:将maximumPoolSize从3200降至200。
<property name="maximumPoolSize" value="200"/>
  1. 添加合理的超时配置
<property name="connectionTimeout" value="30000"/>
<property name="idleTimeout" value="60000"/>
<property name="maxLifetime" value="600000"/>
  1. 增加数据库中间层:引入ProxySQL作为数据库代理,统一管理连接,实现读写分离和连接池复用。

  2. 优化SQL性能:将慢查询优化,减少单次查询耗时,从而降低连接占用时间。

效果

优化后,系统能够稳定支撑5万QPS,连接数控制在500以内,平均响应时间从200ms降至50ms。

给架构师的忠告

连接池不是越大越好。需要根据数据库服务器的性能、网络带宽和业务实际需求进行合理配置。一般来说,单台MySQL服务器建议连接池大小不超过500。同时,务必设置合理的超时机制,避免连接泄漏。

案例三:锁等待超时,一次“简单”UPDATE引发的全站瘫痪

故障现场

某电商平台,商品库存扣减接口在高并发下出现大量超时错误。用户反映下单时提示“系统繁忙,请稍后重试”。

排查过程

我登录数据库查看当前锁等待情况:

SELECT * FROM information_schema.INNODB_LOCK_WAITS;

结果显示有数百个事务在等待锁,阻塞链很长。进一步查看阻塞源头:

SELECT * FROM information_schema.INNODB_TRX;

发现一个ID为12345的事务长时间未提交,持有一行记录的排他锁,导致后续所有请求都在等待这把锁。

根本原因

业务代码中,库存扣减逻辑如下:

@Transactional
public void deductStock(Long productId, int quantity) {
    // 1. 查询库存
    Product product = productMapper.selectById(productId);
    
    // 2. 检查库存是否充足
    if (product.getStock() < quantity) {
        throw new BusinessException("库存不足");
    }
    
    // 3. 扣减库存
    int rows = productMapper.deductStock(productId, quantity);
    
    // 4. 记录订单
    orderMapper.insert(order);
    
    // 事务结束,释放锁
}

问题在于:

  1. 事务范围过大:事务从查询库存开始,到记录订单结束,中间包含了网络请求(调用其他服务)和数据库查询。在高并发下,一个慢事务会长时间持锁,阻塞其他事务。

  2. 行锁竞争:当多个请求同时扣减同一商品的库存时,会在同一行上加锁,导致锁等待。

  3. 缺少锁超时设置:MySQL默认锁等待超时时间为50秒,但在高并发下,50秒的等待时间是不可接受的。

优化方案

  1. 缩小事务范围:将库存扣减和订单记录分开,使用分布式事务或最终一致性方案。
// 第一步:扣减库存(小事务)
@Transactional
public int deductStock(Long productId, int quantity) {
    // 使用SELECT FOR UPDATE加锁
    Product product = productMapper.selectForUpdate(productId);
    
    if (product.getStock() < quantity) {
        throw new BusinessException("库存不足");
    }
    
    return productMapper.deductStock(productId, quantity);
}

// 第二步:记录订单(异步处理,不阻塞扣减)
public void createOrderAsync(Order order) {
    // 异步调用,使用MQ或线程池处理
    orderService.createOrder(order);
}
  1. 使用乐观锁:通过版本号机制避免行锁竞争。
UPDATE products 
SET stock = stock - #{quantity}, version = version + 1 
WHERE id = #{productId} AND version = #{version} AND stock >= #{quantity}
  1. 设置锁超时
SET innodb_lock_wait_timeout = 5;
  1. 引入Redis分布式锁:对于超热点商品,使用Redis进行预扣减,避免数据库压力。

效果

优化后,库存扣减接口的TP99从5秒降至200ms,锁等待超时错误归零。

给开发者的忠告

事务范围越小越好。尽量避免在事务中进行网络调用或复杂计算。对于热点数据,优先考虑乐观锁或Redis方案,避免行锁竞争。

案例四:参数化查询的“意外”,SQL注入背后的性能陷阱

故障现场

某在线教育平台,课程报名接口在高峰期响应缓慢。监控显示数据库CPU利用率高达90%,但业务流量并未达到预期峰值。

排查过程

我抓取了当时的SQL日志,发现大量重复的SQL语句,但参数不同:

SELECT * FROM courses WHERE id = 12345;
SELECT * FROM courses WHERE id = 12346;
SELECT * FROM courses WHERE id = 12347;
...

这些SQL语句完全相同,只是参数不同。按照常理,MySQL应该能够复用执行计划,但实际情况是每条SQL都进行了全表扫描。

根本原因

业务代码中使用了字符串拼接的方式构建SQL:

// 错误示例
String sql = "SELECT * FROM courses WHERE id = " + courseId;
ResultSet rs = statement.executeQuery(sql);

虽然这里看起来是动态SQL,但实际上每次执行都会重新解析和优化SQL。更严重的是,如果courseId来自用户输入且未进行严格校验,可能存在SQL注入风险。

优化方案

  1. 使用预编译语句
// 正确示例
String sql = "SELECT * FROM courses WHERE id = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setLong(1, courseId);
ResultSet rs = pstmt.executeQuery();

预编译语句的优势在于:

  • MySQL会缓存执行计划,后续相同结构的SQL可以直接复用,减少解析开销。
  • 防止SQL注入,因为参数会被正确转义。
  1. 开启查询缓存(MySQL 5.7及以下):
SET query_cache_type = ON;
SET query_cache_size = 64 * 1024 * 1024; -- 64MB
  1. 使用缓存层:对于热点数据,引入Redis缓存,减少数据库查询。
public Course getCourse(Long courseId) {
    String cacheKey = "course:" + courseId;
    
    // 先查缓存
    Course course = redis.get(cacheKey);
    if (course != null) {
        return course;
    }
    
    // 再查数据库
    course = courseMapper.selectById(courseId);
    if (course != null) {
        redis.set(cacheKey, course, 3600); // 缓存1小时
    }
    
    return course;
}

效果

优化后,数据库CPU利用率从90%降至30%,接口响应时间从500ms降至50ms。

给开发者的忠告

永远不要使用字符串拼接构建SQL。预编译语句不仅能提升性能,还能防止SQL注入。这是最基本的编程规范,也是线上稳定的基石。

案例五:大字段查询的“隐形杀手”,TEXT类型如何拖慢整个系统

故障现场

某社交平台,用户动态 feed流接口在高并发下响应缓慢。监控显示数据库I/O利用率高达80%,但业务逻辑并不复杂。

排查过程

我分析了feed流查询的SQL:

SELECT id, user_id, content, image_urls, tags, create_time 
FROM feeds 
WHERE user_id = 12345 
ORDER BY create_time DESC 
LIMIT 20;

content字段是TEXT类型,平均大小约2KB,最大可达64KB。image_urls是JSON数组,平均大小约1KB。

根本原因

  1. 大字段查询:每次查询都会返回大量数据,即使业务层只用到了idcreate_time。MySQL需要从磁盘读取这些大字段,导致I/O压力剧增。

  2. 回表开销:由于contentimage_urls不在索引中,MySQL需要先通过主键索引找到行,再回表读取大字段数据。

  3. 网络传输:每次查询返回的数据量约3-5KB,高并发下网络带宽成为瓶颈。

优化方案

  1. 字段分离:将大字段单独存一张表,主表只存元数据。
-- 主表:只存元数据
CREATE TABLE feeds (
    id BIGINT PRIMARY KEY,
    user_id INT NOT NULL,
    content_summary VARCHAR(500), -- 内容摘要
    image_count INT DEFAULT 0,
    create_time DATETIME NOT NULL,
    INDEX idx_user_time (user_id, create_time)
);

-- 详情表:存大字段
CREATE TABLE feeds_detail (
    feed_id BIGINT PRIMARY KEY,
    content TEXT,
    image_urls JSON,
    FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE
);
  1. 优化查询:先查询主表,再按需关联详情表。
// 第一步:查询元数据
List<FeedMeta> metas = feedMapper.selectMetaByUserId(userId, limit);

// 第二步:按需查询详情
if (needContent) {
    List<Long> feedIds = metas.stream().map(FeedMeta::getId).collect(Collectors.toList());
    List<FeedDetail> details = feedDetailMapper.selectByIds(feedIds);
    // 合并结果
}
  1. 使用覆盖索引:如果只需要少数字段,可以创建覆盖索引。
-- 覆盖索引:包含所有查询字段
CREATE INDEX idx_cover ON feeds(user_id, create_time, id, content_summary);

这样查询可以直接从索引中获取数据,无需回表。

  1. 压缩大字段:对于content字段,可以使用 gzip 压缩存储。
// 存储时压缩
byte[] compressed = gzipCompress(content.getBytes(StandardCharsets.UTF_8));
feedDetail.setContent(compressed);

// 查询时解压
byte[] decompressed = gzipDecompress(feedDetail.getContent());
String content = new String(decompressed, StandardCharsets.UTF_8);

效果

优化后,feed流接口的平均响应时间从800ms降至80ms,数据库I/O利用率从80%降至20%。

给架构师的忠告

大字段查询是性能杀手。永远遵循“按需加载”原则,将大字段分离存储,避免每次查询都传输大量无用数据。同时,考虑使用压缩技术减少存储空间和网络传输开销。

总结:高并发下的数据库稳定性五要素

回顾这五个案例,我发现高并发卡死问题往往不是单一原因导致的,而是多个因素叠加的结果。作为DBA,我们需要从以下五个维度建立防护体系:

1. 索引设计

  • 确保查询字段都有合适的索引
  • 避免索引失效(类型转换、函数运算、模糊查询前缀等)
  • 定期分析慢查询日志,优化缺失索引的SQL

2. 连接池管理

  • 合理配置连接池大小,避免过大或过小
  • 设置合理的超时机制,防止连接泄漏
  • 使用数据库代理层(如ProxySQL)统一管理连接

3. 事务控制

  • 缩小事务范围,避免长事务
  • 合理使用锁机制,避免行锁竞争
  • 对于热点数据,优先考虑乐观锁或分布式锁

4. SQL规范

  • 永远使用预编译语句,防止SQL注入
  • 避免SELECT *,只查询需要的字段
  • 分页查询使用游标或延迟关联优化

5. 架构设计

  • 大字段分离存储,按需加载
  • 引入缓存层,减少数据库压力
  • 读写分离,负载均衡

高并发下的数据库稳定性不是一蹴而就的,需要持续的监控、优化和迭代。希望这五个案例能帮助你避免类似的陷阱,让你的系统在高并发场景下依然稳如磐石。

记住,线上无小事。每一个看似微小的优化,都可能在关键时刻挽救整个系统。