优化遗留代码:真实案例中如何将处理时间缩短 99%
与外部系统实现 REST 集成时,经常会遇到性能问题。本文将详细分解我们如何通过 4 个步骤加速处理 75,000 条记录,将执行时间缩短 99%。这个案例基于一个真实的银行系统与外部合作伙伴集成的任务,受严格约束。
问题:与合作伙伴约束下的 REST 集成
初始条件:
- 需要通过 REST API 将用户数据发送到合作伙伴系统
- 合作伙伴仅接受单条请求(不允许批量处理)
- 执行时间限制:凌晨 5 点窗口期以最小化负载
- 数据量:75,000 条 NEW 状态记录
- 不允许异步处理或水平扩展
原始代码使用 Spring Data Pageable 分页加载,每页 2000 条记录。关键错误是将所有修改的实体累积在内存中,直到执行单一的 saveAll() 操作,这导致:
- 每次 SELECT 前自动上下文刷新
- 每页生成 2000 个 UPDATE 查询
- HTTP 调用期间的事务锁
初始基准测试显示,在 Apple M4(16 GB 内存)上处理时间 ≈105 秒。模拟外部系统 1 ms 延迟,使其成为生产环境的关键指标。
第一步:消除冗余数据库操作
SQL 日志分析揭示了致命缺陷:Hibernate 生成 2000 个单独的 UPDATE,而不是批量操作。原因是跨循环迭代在持久化上下文中持有实体。解决方案涉及两个关键更改:
- 将过程结束时的保存替换为中间提交:
content.forEach(this::registerUser);
userRepository.saveAll(content);
- 实现用于批量状态更新的原生查询:
@Modifying
@Query(value = """
UPDATE users SET status = CAST(:status AS text)
WHERE id IN (:ids)
""", nativeQuery = true)
int batchUpdateStatus(@Param("ids") Collection<Long> ids, @Param("status") String status);
这些更改带来了初步结果——时间减少 6%(至 98.4 秒)。关键要点:标准 Spring Data JPA 方法在大规模数据量下会产生隐藏开销。
第二步:微调处理参数
下一次优化聚焦于批次大小。测试显示:
- 大小 500:时间增加 8.41%(106.68 秒)
- 大小 2000:基准 98.4 秒
- 大小 5000:改善 1.86%(96.58 秒)
重要的是理解没有银弹。最优大小取决于:
- 硬件规格(磁盘 IOPS、网络带宽)
- 处理循环中业务逻辑的复杂性
- 数据库连接池设置
推荐方法:在类似生产的环境中运行 A/B 测试,并进行资源剖析(CPU、内存、磁盘 I/O)。在我们的案例中,5000 条记录证明是最优的,减少了事务提交次数。
第三步:安全的水平扩展
尝试双线程处理导致灾难——所有操作重复(150,000 个查询而不是 75,000 个)。解决方案需要更改数据访问策略:
- 实现 SELECT FOR UPDATE SKIP LOCKED:
SELECT * FROM users WHERE status = 'NEW' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 5000
- 实现事务循环:
@Transactional
public void processBatch() {
List<User> batch = userRepository.lockAndSelectBatch(5000);
batch.forEach(this::registerUser);
userRepository.batchUpdateStatus(
batch.stream().map(User::getId).collect(Collectors.toList()),
UserStatus.PROCESSED.name()
);
}
这种方法确保:
- 保证一次处理
- 最小表锁
- 能够运行 N 个并行实例
实施后,处理时间降至 1.05 秒——比原来减少 99%。
关键优化阶段
- 分析隐藏开销
- 启用 show-sql 跟踪生成的查询
- 检查循环中持久化上下文行为
- 切换到批量操作
- 将 saveAll() 替换为中间提交
- 使用原生查询进行批量更新
- 实验参数
- 测试各种批次大小
- 测试期间监控系统指标
- 准备扩展
- 为并发访问实现 SKIP LOCKED
- 将事务分解为原子块
关键要点
- SELECT 前 Hibernate 刷新——大规模数据量下的关键问题。每个数据库查询都会触发自动刷新,导致 UPDATE 级联。
- 批次大小并非固定。需要根据工作负载具体情况进行实证调优。测试显示 5000 条记录最优。
- 用于扩展的事务隔离。SELECT FOR UPDATE SKIP LOCKED 实现无重复的并发处理,但会增加锁持有时间。
- 剖析必不可少。没有类似生产的基准测试,优化可能适得其反(比如将批次大小缩小到 500)。
- 遗留约束兼容性。解决方案必须考虑外部限制(例如合作伙伴禁止批量请求),从而排除简单的异步处理。
最终实现提供了稳定的 1.05±0.05 秒处理时间,在负载测试 100,000 条记录下。成功的关键是将技术优化与对外部系统契约的深刻理解相结合。这种方法适用于任何有请求大小限制和可靠性要求的 REST API 集成场景。
— Editorial Team
暂无评论。