数据库与网络场景
开篇:接口变慢了,问题在哪一层?
线上接口的 RT(Response Time)突然飙高,监控大盘一片红。打开链路追踪一看,几十个 span,每个都在几十毫秒,到底是哪一层出了问题?
数据库慢查询、网络超时、连接池耗尽 -- 这三个是接口变慢最常见的三大元凶。这篇文章用真实的排查案例,走一遍从发现问题到定位根因再到修复验证的完整过程。每个案例都带实际的命令、SQL、监控截图描述,拿去就能用。
一、慢 SQL 排查实战
案例一:数据回流后 RT 飙高
问题发现
我们在做一次风控定价策略的数据回流 -- 把风险测算出的价格通过数据同步回流到定价配置表中。回流前表中不到 2 万条数据,本次要回流 16 万条。
因为提前预判了可能的问题,我们在第一批回流 2 万条后就暂停观察。果然,报价接口的 RT 出现了明显上升,时间点和回流时间完全吻合。
问题排查
第一时间上服务器用 Arthas 的 trace 命令看接口耗时分布:
[arthas@1658]$ trace com.alibaba.fin.pricing.**.PriceCalculateService trial '#cost > 50' -n 3输出显示方法总时长 264ms,其中 221ms 花在 queryMatchedEffectiveExercisePrice() 上 -- 这个方法正是查询定价配置表的。
进一步观察发现,不是所有报价都变慢了,只有部分请求耗时很长。通过查看那些耗时长的请求的入参,发现它们都属于同一个产品 -- 正是本次要做千人千面定价的产品,其他产品的耗时没有明显变化。
问题定位到了 SQL 层面。这个产品的查询除了和其他产品一样的 product、price_scene、group_code 等字段外,还多了 payer_id、payee_id 字段。虽然已有的联合索引 (product, price_scene, group_code) 能命中,但索引过滤后仍有好几万条数据需要通过 payer_id、payee_id 做二次过滤。索引对这个产品的区分度不够高。
问题解决
针对基于 payer_id、payee_id 定价的方式创建了新的联合索引。发布后 RT 立刻回到正常水平,甚至比回流前还低一些。之后把剩余 16 万条数据全部回流,RT 无明显变化。
思考
随着系统发展,数据库表结构和数据分布不断变化,以前创建的索引可能慢慢变得区分度不够高。做数据库结构变更或代码逻辑改动时,要同步评估索引是否需要调整或新增。
案例二:Sort Aborted 导致定时任务失败
问题发现
一个扫表的定时任务最近频繁失败。日志中的关键错误信息是:
Sort aborted: Query execution was interrupted这条错误表示数据库查询中的排序操作被中断了。
问题排查
Sort aborted 的常见原因有三个:慢 SQL 导致查询超时被中断、查询被 DBA 手动终止、服务器资源不足无法完成排序。
找到导致失败的 SQL(日志中已打印出来),大致如下:
SELECT ..., GROUP_CONCAT(DISTINCT number SEPARATOR ',') AS risk_case_numbers
FROM fraud_risk_case
WHERE product_type_enum = ? AND risk_case_status_enum = 'DRAFT'
AND subject_id LIKE '23%'
GROUP BY subject_id_enum, subject_id
LIMIT ?, ?看执行计划:
type: range | key: idx_subject_product | Extra: Using index condition; Using where; Using filesortSQL 走了索引(idx_subject_product 包含 subject_id 和 product_type_enum),但 GROUP BY 的排序没有用到索引排序,而是用了 filesort。当数据量大时 filesort 很慢,超过查询超时时间就被中断了。
问题解决
目标很明确:让排序也走索引。分析 WHERE 条件和 GROUP BY 字段后,创建了一个新的联合索引:
KEY idx_status_subject (risk_case_status_enum, subject_id_enum, subject_id)risk_case_status_enum = 'DRAFT' 是常量等值条件且该值在表中占比很小,放在索引最前面。后面跟 subject_id_enum 和 subject_id 用于 GROUP BY 排序。
新索引发布后,执行计划变成:
type: range | key: idx_status_subject | Extra: Using index condition;Using filesort 消失了,排序走了索引。发布后不再有报警。
案例三:回表导致的慢 SQL 和数据库 CPU 100%
问题发现
线上突然疯狂报警,某个接口的成功率暴跌。排查发现底层数据库 CPU 已经被拉到了 100%。紧急扩容后开始根因分析。
问题排查
通过数据库诊断工具发现有一个慢 SQL 被高频执行且耗时很长:
SELECT IFNULL(SUM(sum_payment), 0) AS order_actl_pay_fee_sum, COUNT(1) AS order_num
FROM xxxx_payment_message
WHERE byr_id = 221xxxx478 AND biz_date >= ? AND biz_date <= ?这个用户是一个大买家,表中一共 100 多万条数据,他自己就有几十万单(新的业务模式,之前没见过)。
更奇怪的是,这条 SQL 有时候走索引有时候不走索引,而且走不走索引都很慢。用 EXPLAIN FORMAT=json 分析成本:
- 不走索引(全表扫描):扫描 136 万行,CBO 成本 139147,IO 成本 94323,耗时 675ms
- 走索引(
byr_id_date_index):扫描 44 万行,CBO 成本 201704,IO 成本 156881,耗时 709ms
走索引竟然比全表扫描还慢!优化器在两者之间摇摆,不无道理 -- 因为走了索引也没快多少。
MySQL 是基于 CBO(Cost-Based Optimizer)的查询优化器,会对每个索引计算成本。走了索引 IO 成本依然很高是因为 -- 虽然索引只包含 byr_id 和 biz_date,但查询还需要 sum_payment 字段,每次都要回表。这个大买家有几十万条记录,回表几十万次,IO 开销巨大。
问题解决
直接把 sum_payment 加到 (byr_id, biz_date) 索引中组成覆盖索引(Covering Index),消除回表。优化后这个大买家的查询耗时降到 200ms 以内,IO 成本降到原来的 1/3。
案例四:索引不满足最左前缀导致全索引扫描
问题发现
一个反欺诈的定时任务连续多次执行失败。日志显示数据库层面报错:
Slow query leads to a timeout exception. SocketTimeout: 12000 ms定位到这条慢 SQL:
SELECT DISTINCT buyer_id, seller_id FROM fraud_risk_case
WHERE subject_id_enum = 'BUYER_SELLER_BOTH'
AND (buyer_id = ? OR seller_id = ?)
AND product_type_enum = ?
ORDER BY id DESC LIMIT 100问题排查
看执行计划:type = index,Extra = Using where; Using index,扫描行数 413 万行。
type=index 意味着扫描了整棵索引树。查看表的索引定义,发现有一个联合索引 idx_subject_product(subject_id, subject_id_enum, product_type),但 WHERE 条件中 subject_id_enum 不是这个索引的前导列(前导列是 subject_id),所以不满足最左前缀匹配原则,退化成了全索引扫描。
问题解决
创建正确的索引,让 WHERE 条件中的字段满足最左前缀:
ALTER TABLE fraud_risk_case
ADD KEY idx_subject_type_product_user
(subject_id_enum, product_type_enum, buyer_id, seller_id);修改后执行计划变成 type=ref(使用了普通索引),扫描行数骤降,任务失败问题彻底解决。
二、Arthas trace 定位 RT 瓶颈
Arthas 是阿里开源的 Java 诊断工具,在线上排查 RT 问题时极其好用。核心用法:
trace 命令:追踪方法内部的调用耗时分布
# 追踪耗时超过 50ms 的调用,最多记录 3 次
trace com.example.service.OrderService createOrder '#cost > 50' -n 3输出会展示方法内每个子调用的耗时,一眼就能看出瓶颈在哪个方法上。
watch 命令:查看方法的入参和出参
# 查看耗时超过 100ms 的请求的入参
watch com.example.service.OrderService createOrder '{params}' '#cost > 100' -n 5可以找出哪些特定参数的请求导致了慢查询,比如前面案例中发现只有某个产品的请求耗时长。
排查流程
三、数据库死锁排查实战
一个排查了一个月的死锁案例
这个问题前前后后断断续续排查了一个月才定位到根因。问题本身牵扯到的知识点比较多,但最终的原因很有意思。
现象
某天晚上同事在发布,突然线上大量死锁报警:
Deadlock found when trying to get lock;
SQL: UPDATE fund_transfer_stream SET state = ? WHERE fund_transfer_order_no = ?
AND seller_id = ? AND state = 'NEW'背景信息
数据库:MySQL 5.7,InnoDB,隔离级别 READ-COMMITTED。
表上有三个索引:主键索引 PRIMARY(id),二级索引 idx_seller(seller_id),联合索引 idx_seller_transNo(seller_id, fund_transfer_order_no(20))。
注意 idx_seller_transNo 用了前缀索引 -- fund_transfer_order_no 只取前 20 位作为索引值。
死锁日志分析
通过 SHOW ENGINE INNODB STATUS 获取死锁日志,提取关键信息:
- 事务 1 持有
idx_seller_transNo的 X 锁,等待PRIMARY的 X 锁 - 事务 2 持有
PRIMARY的 X 锁,等待idx_seller_transNo的 X 锁 - 两个事务形成循环等待 -> 死锁
两个事务的 SQL 都是 update 同一张表的不同记录,fund_transfer_order_no 分别是 99010015000805619031958363857 和 99010015000805619031957477256。
因为隔离级别是 RC,不会有 Gap 锁和 Next-Key 锁。死锁日志也确认两个事务加的都是 lock_mode X locks rec but not gap(行锁,无间隙锁)。
代码分析
翻代码发现,同一个事务中先后执行了两条 update:
@Transactional(rollbackFor = Exception.class)
public int doProcessing(String sellerId, Long id, String fundTransferOrderNo) {
// SQL 1: 通过 PRIMARY 索引更新
fundTransferStreamDAO.updateFundStreamId(sellerId, id, fundTransferOrderNo);
// SQL 2: 通过 idx_seller_transNo 索引更新
return fundTransferStreamDAO.updateStatus(sellerId, fundTransferOrderNo, "PROCESSING");
}SQL 1 用 WHERE id = ?,走主键索引。SQL 2 用 WHERE fund_transfer_order_no = ? AND seller_id = ?,走 idx_seller_transNo 索引。
根因:前缀索引的坑
关键在于 idx_seller_transNo 是前缀索引,只取 fund_transfer_order_no 的前 20 位。而死锁的两条记录:
9901001500080561903195836385799010015000805619031957477256
前 20 位完全相同!(99010015000805619031)
这意味着在前缀索引 idx_seller_transNo 中,这两条不同的记录有相同的索引值。
MySQL 的行级锁是锁索引而不是锁记录。当 SQL 操作非主键索引时,先锁非主键索引,再锁对应的主键索引。死锁的发生过程:
- 事务 1 执行 SQL 1:通过主键索引更新记录 1 -> 锁住 PRIMARY=1
- 事务 2 执行 SQL 1:通过主键索引更新记录 2 -> 锁住 PRIMARY=2
- 事务 1 执行 SQL 2:通过
idx_seller_transNo更新记录 1 -> 锁住索引值(seller_id, 99010015000805619031)-> 然后要锁 PRIMARY=2(因为前缀索引值相同,索引指向了两条记录)-> 被事务 2 阻塞 - 事务 2 执行 SQL 2:要锁
idx_seller_transNo的相同索引值 -> 被事务 1 持有 -> 死锁
解决方案
修改前缀索引的长度(比如改成 50),让两条记录的索引值不再相同。但这只是权宜之计 -- 如果优化器选了 idx_seller 而不是 idx_seller_transNo,同样的问题还会出现。
根本方案:
- 所有 update 都通过主键 ID 进行
- 同一个事务中避免多条 update 修改同一条记录
反思
- 遇到问题不要猜,在本地复现后再分析
- 不要忽略上下文 -- 一开始只盯着死锁日志,忽略了事务中还有另一条 SQL
- 理论知识再充足,关键时刻不一定想得起来
- 坑都是自己埋的 -- 前缀索引的长度是自己设的
四、数据库连接池满排查
热点更新把连接池耗尽
问题发现
线上频繁出现数据库连接池满的报警:Pool is full. active 10, maxActive 10。
问题排查
流量监控没有异常,排除了并发暴增。查看 SQL 耗时发现有大量耗时 SQL,而且执行耗时和锁等待耗时几乎相等 -- 说明时间都花在了等锁上。
那条 SQL 是一个简单的 update,用了乐观锁(lock_version = lock_version + 1)。按理说乐观锁不需要加锁,为什么还有大量的锁等待?
原因是:乐观锁是应用层面的概念,InnoDB 的 update 语句本身就要加行级锁。当并发冲突大、发生热点更新时,多个 update 排队获取同一行的行级锁。排队过程占用数据库连接,事务越多连接耗尽得越快。
问题解决
四种思路:
- 缓存更新 -- 热点数据放 Redis
- 异步更新 -- 削峰填谷
- 数据拆分 -- 分散到不同库/表
- 合并更新 -- 批量执行
实际选了合并更新:比如本来需要 100 个并发都给用户增加积分,改成 10 分钟汇总一次,一次性更新。损失了实时性,但连接池问题彻底解决。
五、网络问题排查工具箱
网络问题是 C/S 或 B/S 通信中因为网络不稳定或接收方异常导致的数据包丢失、无法连接等问题。以下是 Linux 环境下的常用排查工具:
基础三件套
ping -- 检测到目标主机是否通畅(ICMP 协议):
ping 目标IP或域名 # 不要加 http/https注意:某些主机防火墙会丢弃 ICMP 报文导致 ping 不通但实际网络正常。ping 只探测主机通路,不探测端口。
telnet -- 检测到目标主机端口是否通畅:
telnet 目标IP或域名 端口可以确认端口是否可达,但无法感知响应耗时,交互式体验不适合写脚本,且不支持加密。
traceroute -- 查看到目标主机的路由路径:
traceroute 目标IP或域名
# 常用参数:-w 超时秒数, -m 最大跳数主要用于跟踪路由,排查数据包到达目标主机前在哪一跳出了问题。
进阶工具
curl 耗时分析 -- 打印 HTTP 请求各阶段耗时:
创建模板文件 curl.txt:
time_namelookup: %{time_namelookup}
time_connect: %{time_connect}
time_appconnect: %{time_appconnect}
time_pretransfer: %{time_pretransfer}
time_starttransfer: %{time_starttransfer}
time_total: %{time_total}curl -w '@curl.txt' https://example.com -vvv各阶段含义:
| 指标 | 含义 |
|---|---|
| time_namelookup | DNS 解析耗时 |
| time_connect | TCP 连接建立耗时 |
| time_appconnect | TLS/SSL 握手耗时 |
| time_starttransfer | 到第一个字节传输的耗时 |
| time_total | 总耗时 |
通过这些指标可以精确定位是 DNS 慢、TCP 连接慢、TLS 握手慢还是服务器处理慢。
tcpping -- 对指定端口做 ping(TCP 协议而非 ICMP):
# 或者用更简单的 nc 命令
nc -zv www.example.com 443tcpdump + wireshark -- 抓包分析:
tcpdump -i eth0 dst port 443 -w capture.cap抓到的包用 wireshark 打开做详细分析,可以看到完整的 TCP 握手、数据传输、重传等细节。适合深度排查网络问题。
网络排查流程
六、慢 SQL 排查方法论
EXPLAIN 执行计划速查
排查慢 SQL 的第一步永远是 EXPLAIN。重点关注以下字段:
| 字段 | 关注点 |
|---|---|
| type | 从好到差:const > eq_ref > ref > range > index > ALL。出现 ALL 就是全表扫描,index 是全索引扫描,都需要优化 |
| key | 实际使用的索引。如果是 NULL 说明没走索引 |
| rows | 预估扫描行数。越大越慢 |
| Extra | Using filesort(排序没走索引)、Using temporary(用了临时表)、Using index(覆盖索引,好的) |
FORMAT=json 看成本明细
普通 EXPLAIN 只能看大概,EXPLAIN FORMAT=json 可以看到详细的成本分解:
EXPLAIN FORMAT=json SELECT ... FROM ...输出中重点关注:
read_cost(IO 成本):从内存或磁盘读取数据的消耗eval_cost(CPU 成本):服务器层面评估数据的消耗prefix_cost(总成本):IO + CPUrows_examined_per_scan(扫描行数)
当走索引的总成本竟然高于全表扫描时,往往是因为回表。二级索引只存了索引列和主键,查询需要的其他列要回到主键索引取,每条记录都要回一次表,IO 开销巨大。解决方案就是建覆盖索引把查询需要的列都放进索引中。
常见慢 SQL 模式
- 索引区分度不够 -- 索引过滤后仍有大量数据需要二次过滤。解法:创建区分度更高的索引
- 不满足最左前缀 -- 联合索引中查询条件没有覆盖前导列,退化成全索引扫描。解法:调整索引列顺序或创建新索引
- filesort -- GROUP BY 或 ORDER BY 的列不在索引中。解法:让排序列走索引
- 回表过多 -- 二级索引命中了但需要大量回表取其他列。解法:覆盖索引
- 函数导致索引失效 -- WHERE 条件中对索引列使用函数(如 DATEDIFF)。解法:改成范围查询
- 隐式类型转换 -- 字段类型和查询值类型不匹配导致索引失效
优化器的选择不一定对
MySQL 优化器基于 CBO(基于成本的优化器),会对每个可用索引计算成本选择最优方案。但它的判断不一定准确 -- 特别是在数据分布不均匀、统计信息过时的情况下。
如果你确认某个索引更好但优化器不选,可以用 FORCE INDEX 强制指定。但要谨慎使用 -- 事后查询的执行计划和实际执行时可能不同(数据分布变化、统计信息更新等原因)。
索引设计的原则
- 等值查询列放前面,范围查询列放后面
- 常量值的列适合做索引前缀 -- 比如
status=DRAFT数据占比小,放在联合索引最前面过滤效果好 - 定期 review 索引 -- 业务变化可能导致索引区分度下降
- 避免冗余索引 -- 比如已有
(a, b)联合索引就不需要单独的(a)索引 - 注意前缀索引的坑 -- 前缀长度不够可能导致意外的锁冲突(见死锁案例)
小结
数据库和网络问题的排查有一个共同的方法论:先定位层次,再定位细节。
接口变慢了,先用 Arthas trace 确认是数据库层的问题还是应用层的问题。确认是数据库后,用 EXPLAIN 分析执行计划,找到慢的原因(索引区分度不够、filesort、回表、全索引扫描)。
网络出问题了,先用 ping/telnet 确认是主机不通还是端口不通。确认端口通但响应慢,用 curl 分析各阶段耗时。深度排查用 tcpdump 抓包。
死锁和连接池满这种问题则需要更深入的理解:死锁的关键在于两个事务加锁的顺序不一致,连接池满的关键往往是锁等待时间过长。
每次排查完一个问题,都值得回头想想:是不是可以提前预防?索引是否需要定期 review?SQL 的执行计划是否应该纳入代码评审?监控报警的阈值是否合理?预防永远比修复成本低。