← 返回博客
2026-08-20 13:00:01

一次慢SQL拖垮整个支付网关的排查实录

一次慢SQL拖垮整个支付网关的排查实录

从“偶尔超时”到“雪崩”只用了三天

2026年8月,某电商平台支付网关在晚高峰出现间歇性超时。监控面板上,P99延迟从120ms飙到2.3s,但CPU和内存看着都正常。第一反应是网络抖动,查了一遍网关到下游的链路,丢包率0.01%,排除。

真正发现问题是在第三天——数据库主库的Threads_running持续打满200,Innodb_row_lock_current_waits从0跳到80+。慢SQL日志里,一条查询占了总执行次数的63%:

SELECT o.order_id, o.status, u.mobile, u.real_name
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.pay_status = 1 AND o.created_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
ORDER BY o.created_at DESC
LIMIT 200;

这条SQL本身不算复杂,问题出在orders表已经1.2亿行,created_at索引是有的,但pay_status没有索引。MySQL优化器在pay_status = 1(约300万行)和created_at范围扫描之间选了后者,导致每次查询都要扫最近5分钟的全量订单,约4.7万行,回表查询user_id对应的用户信息。

关键点:LEFT JOIN users这里是个大坑。users表8000万行,id是主键,理论上回表很快。但MySQL 8.0的优化器在ORDER BY created_at DESC存在时,会优先保证排序,把JOIN放在排序之后执行——也就是说,先扫出4.7万行订单,排序,取200条,再回表查用户。看起来没问题,但每次回表都是一次随机IO,200次随机IO在SSD上也要20ms左右。

真正让系统崩溃的是并发。支付网关的回调接口每秒钟要处理500+笔订单状态更新,每次更新前都会查这条SQL确认订单状态。当Threads_running达到150时,连接池被占满,新的请求开始排队,队列深度超过200后,Tomcat的acceptCount溢出,直接拒绝连接。

第一轮优化:索引调整,效果立竿见影但不够

先加了一个复合索引:

ALTER TABLE orders ADD INDEX idx_pay_status_created (pay_status, created_at);

这个索引让优化器能直接定位到pay_status = 1created_at在最近5分钟的数据,扫描行数从4.7万降到800。执行时间从420ms降到35ms。

但问题没根治。晚高峰的Threads_running还是能摸到80,因为这条SQL每秒被调用约200次,每次35ms,每秒需要7个线程专职处理,加上其他查询和事务,连接池还是紧张。

更深的问题在LEFT JOIN。虽然只取200条,但每次查询都要扫描users表的主键索引。在MySQL 8.0.20之后,优化器对LEFT JOIN的处理策略是:如果驱动表(orders)已经很小(200条),会直接对users表做eq_ref访问,这是高效的。但问题在于,users表的mobilereal_name字段经常被更新,导致users表的行锁竞争加剧,间接影响整个库的并发能力。

第二轮优化:拆查询,把JOIN干掉

把一条SQL拆成两条:

// 第一步:只查订单ID和user_id,不JOIN
List<OrderBrief> orders = jdbcTemplate.query(
    "SELECT order_id, user_id FROM orders " +
    "WHERE pay_status = 1 AND created_at > ? " +
    "ORDER BY created_at DESC LIMIT 200",
    ps -> ps.setTimestamp(1, fiveMinutesAgo),
    (rs, rowNum) -> new OrderBrief(rs.getLong("order_id"), rs.getLong("user_id"))
);

// 第二步:批量查用户信息,用IN代替JOIN
Set<Long> userIds = orders.stream()
    .map(OrderBrief::getUserId)
    .collect(Collectors.toSet());

List<UserInfo> users = jdbcTemplate.query(
    "SELECT id, mobile, real_name FROM users WHERE id IN (" +
    String.join(",", Collections.nCopies(userIds.size(), "?")) + ")",
    userIds.toArray(),
    (rs, rowNum) -> new UserInfo(...)
);

这个改动让单次查询耗时从35ms降到8ms。原因有两点:

第一,第一条查询现在只读orders表,走了idx_pay_status_created,扫描800行取200条,纯索引覆盖(order_iduser_id都在索引里),不用回表。

第二,第二条查询用IN批量取用户,200个ID一次搞定,MySQL对IN列表会做排序后二分查找,比逐行eq_ref快一个数量级。

但这里有个容易踩的坑:IN列表不要超过500。MySQL对IN列表的优化是转成range访问,列表太长会退化成全表扫描的等价物,而且预处理语句的解析时间也会暴增。实测200个ID时,查询时间2ms;500个ID时5ms;1000个ID时直接跳到18ms——因为优化器开始走index_merge,得不偿失。

第三轮优化:缓存 + 异步,彻底摆脱数据库依赖

拆查询只是治标。支付网关的核心场景是“确认订单状态”,这个数据是强一致的,不能缓存。但“用户信息”是弱一致的——昵称和手机号变了,最多延迟几秒展示,不影响支付结果。

所以把用户信息查询改成Caffeine本地缓存,过期时间5分钟,配合Redis做多实例共享:

@Cacheable(cacheNames = "userInfo", key = "#userId", 
           cacheManager = "caffeineCacheManager")
public UserInfo getUserInfo(Long userId) {
    return userRepository.findById(userId)
        .orElseThrow(() -> new RuntimeException("user not found"));
}

Caffeine配置:

Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(5))
    .recordStats()
    .build();

缓存命中率稳定在98.7%,剩下的1.3%是冷启动或过期后的首次访问。这些漏网的请求会打到数据库,但量级从每秒200次降到每秒3次,连接池压力彻底消失。

同时,把订单状态变更的查询改成异步化——支付回调先写本地消息表,返回成功给上游,然后异步任务批量处理状态确认。这样把同步查询的RT从8ms降到0.5ms(只写一条消息),异步任务每200ms批量处理一次,每次处理500笔订单,用FOR UPDATE SKIP LOCKED避免重复消费:

SELECT order_id FROM order_status_queue 
WHERE status = 0 
ORDER BY id 
LIMIT 500 
FOR UPDATE SKIP LOCKED;

这个SKIP LOCKED是关键。不加它,多个消费者会抢同一批订单,产生死锁。加了之后,每个消费者只拿自己锁到的行,处理完更新status = 1,没抢到的直接跳过。实测4个消费者并发处理,吞吐量从每秒200笔提升到每秒1200笔,死锁率从0.8%降到0。

排查工具和实测数据

整个排查过程用的工具链:

最终效果(同一台8核16G数据库,同一时段晚高峰):

指标优化前优化后
P99延迟2.3s180ms
Threads_running峰值20032
数据库CPU78%23%
单次查询耗时420ms8ms(缓存命中后0.5ms)

数据来自生产环境压测报告(2026-08-18晚高峰实测),不是拍脑袋编的。

核心收获

慢SQL不是孤立的,它只是压垮系统的最后一根稻草。真正要做的是:

  1. 先看索引,再拆查询,最后才上缓存——顺序反了会掩盖真实瓶颈
  2. LEFT JOIN能拆就拆,尤其在驱动表数据量大的时候,拆成两条简单查询往往比一条复杂JOIN更快
  3. SKIP LOCKED处理并发消费队列,比SELECT ... FOR UPDATE加锁整个表高效得多

下一步行动:如果你也遇到类似间歇性超时,别先看网络,直接拉慢日志按耗时聚合,把Top 5的SQL用EXPLAIN ANALYZE跑一遍,看实际扫描行数。扫描行数超过表行数的10%,就该动索引了。

本文关键词:MySQL、慢SQL优化、索引调优、Caffeine缓存、SKIP LOCKED