摘要
最近处理了一个自动核销接口 504 的问题。表面看是接口超时,继续排查后发现,问题并不只是单点慢 SQL,而是包含空集合异常、业务过滤条件不一致、SQL 执行计划不理想、生产数据量大、跨区查询等多个因素。本文记录一次从异常定位、SQL 执行计划分析,到补索引和链路兜底的完整优化过程。
前言
最近工作比较忙,文章更新少了一些。
这段时间主要在处理一些生产问题,其中有一个自动核销接口 504 的问题,挺适合整理成一篇后端实战文章。
这类问题有个特点:
刚开始看起来只是接口超时,但真正查下去,往往不是一个点的问题,而是一整条链路的问题。
这次问题大概涉及几个方面:
- 接口执行时间过长,最终出现 504;
- 代码里存在空集合直接取值的风险;
- 部分业务数据因为过滤条件不一致,导致后续数据为空;
- 核心 SQL 执行计划不理想;
- 生产数据量已经达到千万级别;
- 部分跨区域场景还需要查询目标库;
- UAT 执行很快,但生产环境耗时明显更长。
这篇文章就简单记录一下这次排查和优化过程。
一、问题背景
系统中有一个自动核销接口。
它的大致作用是:
根据外部到账数据,自动匹配系统内的业务记录,然后完成后续核销处理。
这个流程不是简单查一张表就结束,而是一整条处理链路:
到账数据查询
↓
业务记录匹配
↓
客户信息校验
↓
明细数据组装
↓
批量核销处理
↓
结果回写
正常情况下,这个接口应该可以自动完成一批数据处理。
但在生产环境执行时,出现了接口 504。
二、表面现象:接口 504
刚开始看到的问题很直接:
接口请求超时,返回 504
这种问题第一反应通常是:
- 是不是 SQL 太慢?
- 是不是数据量太大?
- 是不是接口同步执行时间太长?
- 是不是某个循环里反复查库?
- 是不是网关超时时间太短?
但生产问题不能只靠猜。
所以第一步还是看日志。
三、第一层问题:空集合直接 get(0)
排查日志时,先看到一个比较明显的异常:
IndexOutOfBoundsException: Index: 0, Size: 0
继续定位代码,发现某个处理逻辑里有类似这样的写法:
String batchNo = paymentList.get(0).getBatchNo();
这段代码的问题很明显:
默认 paymentList 一定有数据
但实际生产场景下,paymentList 是有可能为空的。
一旦为空,再直接 get(0),就会直接抛出异常。
这种问题在开发环境或者 UAT 环境不一定容易暴露,因为测试数据往往比较干净,场景也没那么复杂。
但生产环境数据复杂,任何一个过滤条件、异常数据、跨区数据,都可能导致最终集合为空。
所以这里第一步优化就是加空集合保护。
示例代码:
if (paymentList == null || paymentList.isEmpty()) {
log.warn("自动核销未匹配到有效业务记录,batchNo={}", batchNo);
return;
}
String currentBatchNo = paymentList.get(0).getBatchNo();
但这只是解决了异常报错,并没有解决根因。
因为还要继续问一句:
为什么前面查到了数据,后面组装出来的 paymentList 却是空的?
四、第二层问题:数据为什么会被过滤掉?
继续排查后发现,前面的到账数据和部分业务记录是能查到的。
但在后续组装核销数据时,会根据一些条件继续过滤,例如:
- 客户编号;
- 组织编码;
- 业务状态;
- 区域标识;
- 是否允许当前组织处理;
- 是否需要跨区域查询;
- 是否存在有效业务明细。
问题就出在这里。
有些数据在前面能查到,但进入后续处理时,因为组织、区域、状态等过滤条件不完全一致,导致数据被跳过。
大致链路可以理解成:
到账记录能查到
↓
业务记录也能查到
↓
客户信息或组织范围校验不通过
↓
数据被 continue 掉
↓
paymentList 没有组装出有效数据
↓
后续代码直接 get(0)
↓
抛出空集合异常
这个问题说明:
批处理链路里,不能只保证第一步查得到数据,还要保证每一步过滤后的结果都有兜底。
否则数据在中间某一步被过滤光了,后续继续执行,就很容易出问题。
五、第三层问题:为什么会出现 504?
空集合异常解决后,问题还没结束。
因为这次接口 504,不只是异常导致的,后面继续查耗时,发现还有 SQL 执行慢的问题。
这个接口的处理链路比较长,涉及多次查询和批量处理。
生产环境相关数据已经达到千万级别,一些 SQL 如果没有合适索引,或者执行计划不理想,很容易被放大成明显耗时。
所以后面开始重点看 SQL。
六、排查 SQL:先看执行计划
遇到慢 SQL,不能只凭感觉加索引。
比较稳的方式是先看执行计划。
比如:
EXPLAIN ANALYZE
SELECT
t1.id,
t1.batch_no,
t1.customer_code,
t1.amount,
t1.status
FROM biz_payment_record t1
WHERE t1.customer_code = 'C000001'
AND t1.status = 'WAIT'
AND t1.org_code = '0101';
重点看几个点:
- 是否走索引;
- 是否出现全表扫描;
- 过滤条件是否命中索引;
- join 条件是否合理;
- 排序是否额外消耗较大;
- 预估行数和实际行数差距是否明显;
- 大表是否被反复扫描。
如果执行计划里出现类似:
Seq Scan
Rows Removed by Filter 很大
Execution Time 很长
那就要重点关注了。
这通常说明:
数据库扫描了大量不需要的数据,然后再进行过滤。
数据量小的时候可能没感觉。
但到了千万级数据量,这种 SQL 就很容易成为接口 504 的重要原因。
七、补索引前,先确认查询条件
索引不是越多越好。
加索引前,要先确认这个 SQL 的核心查询条件是什么。
这次主要关注的是几类字段:
- 批次号;
- 客户编号;
- 组织编码;
- 业务状态;
- 创建时间或处理时间;
- 外部流水号。
比如某个查询经常按下面几个条件过滤:
WHERE customer_code = ?
AND org_code = ?
AND status = ?
那就可以考虑组合索引。
示例:
CREATE INDEX idx_biz_payment_record_customer_org_status
ON biz_payment_record (customer_code, org_code, status);
如果某些查询经常按批次号查:
WHERE batch_no = ?
可以考虑:
CREATE INDEX idx_biz_payment_record_batch_no
ON biz_payment_record (batch_no);
如果是按外部流水号匹配:
WHERE out_trade_no = ?
可以考虑:
CREATE INDEX idx_biz_income_log_out_trade_no
ON biz_income_log (out_trade_no);
这些表名和字段都是示例,实际项目里要根据真实 SQL 和数据分布来定。
八、补索引后,再看执行计划
索引加完,不是结束。
还要重新看执行计划,确认 SQL 是否真的走了索引。
继续执行:
EXPLAIN ANALYZE
SELECT
t1.id,
t1.batch_no,
t1.customer_code,
t1.amount,
t1.status
FROM biz_payment_record t1
WHERE t1.customer_code = 'C000001'
AND t1.status = 'WAIT'
AND t1.org_code = '0101';
重点看执行计划是否从:
Seq Scan
变成类似:
Index Scan
Bitmap Index Scan
Bitmap Heap Scan
如果补了索引但执行计划还是不走索引,可能有这些原因:
- 查询条件选择性不高;
- 字段类型不一致,导致隐式转换;
- where 条件写法不适合索引;
- 组合索引字段顺序不合理;
- 数据量分布导致优化器认为全表扫更划算;
- 表统计信息不准确;
- 使用了函数或表达式导致索引失效。
所以索引优化不是“加了就完事”,一定要回看执行计划。
九、除了索引,还要处理代码兜底
这次优化不是只加索引。
代码层也做了几类兜底。
1. 空集合保护
对关键集合统一判断:
if (paymentList == null || paymentList.isEmpty()) {
log.warn("未匹配到有效核销记录,requestNo={}", requestNo);
return;
}
避免空集合继续往下走。
2. 异常原因记录
自动处理链路里,最怕的不是失败,而是失败后不知道为什么。
所以对于未匹配、被过滤、跨区查询失败、数据状态不符合等情况,需要尽量记录原因。
例如:
log.warn("业务记录被过滤,customerCode={}, orgCode={}, reason={}",
customerCode, orgCode, "组织范围不匹配");
这样后续排查时,不需要重新从头猜。
3. 统一查询口径
如果前面查询用一套组织范围,后面校验又用另一套组织范围,就容易出现:
前面查得到,后面处理不了
所以要尽量统一核心查询口径。
比如:
- 查询条件统一;
- 组织范围统一;
- 状态判断统一;
- 跨区处理规则统一;
- 过滤原因可追踪。
4. 避免循环里重复查库
批处理里还有一个常见坑:
for (Data item : list) {
queryDatabase(item.getCustomerCode());
}
数据量小的时候问题不明显。
数据量一大,这种写法就容易把数据库打爆。
更推荐的方式是:
先批量查出来
再放到 Map 里
循环时从内存中取
示例:
List<String> customerCodes = records.stream()
.map(BizRecord::getCustomerCode)
.distinct()
.collect(Collectors.toList());
List<CustomerInfo> customers = customerService.queryByCustomerCodes(customerCodes);
Map<String, CustomerInfo> customerMap = customers.stream()
.collect(Collectors.toMap(CustomerInfo::getCustomerCode, item -> item, (a, b) -> a));
后面循环时:
CustomerInfo customer = customerMap.get(record.getCustomerCode());
这样可以减少大量重复查询。
十、生产和 UAT 的差异
这次还有一个很明显的现象:
UAT 环境执行很快,十几秒就能跑完。
但生产环境优化后,完整跑批仍然需要 40 多分钟。
这个差异一开始看起来有点大,但其实很正常。
因为 UAT 和生产完全不是一个数据量级。
UAT 数据量较少,很多 SQL 即使写得一般,也能很快跑完。
但生产环境相关数据已经达到千万级别,查询、匹配、过滤、写入的成本都会被明显放大。
另外,部分业务存在跨区域处理场景。
如果当前数据涉及跨区域,还需要额外查询对应区域的目标库。
这就意味着生产环境不只是单库单表查询,还可能涉及:
当前库查询
↓
判断是否跨区
↓
查询目标库
↓
合并处理结果
↓
继续核销流程
所以 UAT 跑得快,并不能说明生产一定快。
UAT 更多只能证明:
逻辑链路基本可用
真正的性能瓶颈,往往只有在生产级数据量下才会暴露。
十一、优化结果:504 消失,但性能优化还没结束
这次优化前后效果大概是这样。
优化前:
生产执行超过 1 个小时,并且接口出现 504。
优化后:
生产不再出现 504,流程可以完整跑完。
但需要说明的是:
优化后生产完整跑批仍然需要 40 多分钟。
所以这次优化不能简单理解成:
已经把所有性能问题都彻底解决了。
更准确地说,这次主要解决的是:
- 接口 504;
- 链路不稳定;
- 空集合异常;
- SQL 执行计划不理想;
- 部分查询未有效走索引;
- 异常数据没有兜底;
- 问题定位困难。
也就是说,这次优化把流程从:
执行很久,可能超时,可能异常中断
变成了:
执行时间仍然较长,但可以稳定跑完
这在生产系统里其实已经是很重要的一步。
因为很多时候,优化不是一步到位的。
第一阶段先解决稳定性和可用性。
第二阶段再继续做深度性能优化。
十二、后续还能怎么继续优化?
如果后面继续优化,可以从这些方向入手。
1. 同步接口改成异步任务
如果一个接口天然就是长耗时任务,就不应该一直让前端或调用方等着。
更合理的方式是:
提交任务
↓
返回任务编号
↓
后台异步执行
↓
前端轮询任务进度
↓
完成后查看结果
这样可以避免网关超时问题。
2. 增加任务进度表
长任务最好有进度记录。
比如:
- 总数据量;
- 已处理数量;
- 成功数量;
- 失败数量;
- 当前状态;
- 开始时间;
- 结束时间;
- 失败原因。
这样即使任务执行很久,也知道它跑到哪里了。
3. 批量处理,不要一次性全量跑
如果一次处理数据太多,可以分批。
例如:
每次处理 1000 条或 5000 条
这样可以降低单次事务压力,也方便失败重试。
4. 减少循环查询
把循环里的单条查询改成批量查询。
这类优化对批处理通常很有效。
5. 跨区查询结果缓存
如果跨区域目标库查询比较频繁,可以考虑在一次任务周期内做临时缓存。
比如相同客户、相同组织、相同目标库,不要反复查。
6. 优化批量写入
如果后续存在大量 insert 或 update,也要关注批量写入方式。
比如:
- 批量 update;
- 批量 insert;
- 分批提交;
- 控制事务大小;
- 避免单事务过大。
十三、这次问题的几个经验
这次问题总结下来,有几个经验比较重要。
1. 504 不一定只是网关问题
看到 504,不要只想着调大超时时间。
更应该排查:
- 后端接口是否执行过久;
- SQL 是否慢;
- 是否存在循环查库;
- 是否有异常导致重试或卡住;
- 是否同步处理了长任务。
2. UAT 快,不代表生产快
UAT 数据量小,很多问题暴露不出来。
真正的性能问题,经常要到生产数据量下才明显。
尤其是千万级数据量、跨库查询、批量处理场景,更不能只看 UAT 结果。
3. 空集合一定要兜底
批处理链路里,任何一步都有可能查不到数据。
所以不要默认集合一定有值。
类似这种代码要小心:
list.get(0);
更稳的写法是:
if (list == null || list.isEmpty()) {
return;
}
4. 慢 SQL 要看执行计划
不要凭感觉加索引。
先看执行计划,再决定索引怎么加。
加完索引后,还要再次确认是否生效。
5. 自动处理链路要能解释失败原因
自动处理最怕黑盒。
如果失败了,但没有记录原因,后面排查成本会很高。
所以异常原因、过滤原因、匹配失败原因,都应该尽量记录。
十四、总结
这次自动核销接口 504,表面看是接口超时,实际排查下来,问题包含多层:
- 代码里空集合直接取值;
- 中间过滤条件导致数据被过滤光;
- SQL 执行计划不理想;
- 缺少合适索引;
- 生产数据量达到千万级别;
- 跨区域数据还需要查询目标库;
- UAT 和生产数据量差异明显。
优化后,生产不再出现 504,流程可以稳定跑完。
虽然完整跑批仍然需要 40 多分钟,但相比优化前执行超过 1 小时并伴随 504,已经从“不可稳定完成”变成了“可以稳定完成”。
这也说明一个问题:
生产问题排查,不能只看表面现象。
一个 504 背后,可能同时有代码兜底、SQL 索引、数据量、跨库查询、任务设计等多个问题。
后续如果继续优化,可以考虑异步任务、进度记录、分批处理、减少循环查询、跨区查询缓存和批量写入优化。
生产系统里的优化,很多时候不是一次性把性能做到极致,而是先让链路稳定,再逐步把性能压下来。
大家加油:)






