欢迎光临
我们一直在努力

后端实战:一次自动核销接口 504 的排查与优化

摘要

最近处理了一个自动核销接口 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 索引、数据量、跨库查询、任务设计等多个问题。

后续如果继续优化,可以考虑异步任务、进度记录、分批处理、减少循环查询、跨区查询缓存和批量写入优化。

生产系统里的优化,很多时候不是一次性把性能做到极致,而是先让链路稳定,再逐步把性能压下来。

大家加油:)


赞(0)
未经允许不得转载:171主机测评 » 后端实战:一次自动核销接口 504 的排查与优化
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址