处方物流信息同步优化:从 36 秒到亚秒级的踩坑记录

背景介绍

最近在维护公司互联网医院项目时,负责优化一个名为 syncDeliveryInfo(同步处方物流信息)的接口。这个接口由 HIS 系统回调,负责同步处方的发药/退药状态到我们的订单系统,最终我们调用第三方物流api返回物流信息给his。

在一次线上监控中发现,这个接口的处理时间异常缓慢(调用链特别长,屎山代码啊!!!):

日志样本 处理耗时
日志1 35,993 ms
日志2 43,167 ms
日志3 50,059 ms

平均 36~50 秒的处理时间,远超正常接口响应时间,高峰期甚至导致数据库连接池耗尽,影响其他依赖数据库的功能。我不得不深入代码,找出瓶颈所在。

问题排查过程

入口链路梳理

接口调用链路为:HIS → Portal(TenantCallbackController)→ Patient(OrderController)→ OrderServiceImpl.syncOrderDeliveryInfo

核心逻辑在 OrderServiceImpl 的三个方法中:

  • syncOrderDeliveryInfo(入口):加锁 → 查机构 → 查租户配置 → 路由

  • syncOrderDeliveryInfoByDelivery(正常发药模式):@Transactional 包裹

  • syncOrderDeliveryInfoByDispense(配药即发物流模式):@Transactional 包裹

通过逐行代码审查 + 日志时间戳对照,我定位了 7 个性能瓶颈

发现的 7 个问题点

问题1:外部 API 调用被包在事务里,事务范围过大(最致命)

这是最严重的问题。syncOrderDeliveryInfoByDelivery 方法标了 @Transactional(rollbackFor = Throwable.class),而在事务内部调用了外部物流下单接口:

java 复制代码
@Override
@Transactional(rollbackFor = Throwable.class)  // 整个方法都是事务
public SyncDeliveryResultVO syncOrderDeliveryInfoByDelivery(...) {
    // ... 大量DB查询 ...
    
    // ⚠️ 外部物流API调用在事务内!会一直占用数据库连接
    deliveredInfo = rpDeliveryService.mode(rpDeliveryMode)
        .handleDelivered(logisticsCompanyEnum, rpDeliveryData);
    
    // ... 后续DB更新 ...
}

为什么致命rpDeliveryService.handleDelivered 是调用第三方物流系统下单,网络 IO 耗时不可控。在 @Transactional 内调用,意味着数据库连接在整个外部调用期间被占用不释放。如果物流系统响应慢 30 秒,数据库连接就被占 30 秒。并发一高,连接池直接耗尽。

生活中的例子:这好比你去政务大厅办事,从进门开始就占用一个窗口,中间需要打电话让家人送材料,打电话的 20 分钟里窗口一直被你占着,后面排队的人只能干等。正确做法是:先打电话让家人送材料,材料到了再去窗口办业务。

问题2:同一请求内重复远程调用

入口方法和子方法各自独立查询了相同的数据:

java 复制代码
// 入口方法 syncOrderDeliveryInfo 中:
Result<HospitalVo> hospitalVoResult = hospitalApi.queryByOrgCode(hosCode);  // 第1次
Result<ConsultationConfigVO> configResult = tenantConfigApi
    .tenantConfigInfoByHospitalId(hospitalId);  // 第1次
​
// 然后调用子方法 syncOrderDeliveryInfoByDelivery,里面又查了一遍:
Result<HospitalVo> hospitalVoResult = hospitalApi.queryByOrgCode(hosCode);  // 第2次!
Result<ConsultationConfigVO> configResult = tenantConfigApi
    .tenantConfigInfoByHospitalId(hospitalId);  // 第2次!

每次 Feign 远程调用都是一次网络 IO,4 次能压缩到 2 次。

问题3:N+1 查询(2N 次查询)

在修改订单状态的 for 循环中,每个订单都触发了多次 DB 查询:

java 复制代码
for (Order orderInfo : orderList) {
    // 每个订单查一次处方(内部再查 OrderPrescription 表)
    RpOrderStatus rpOrderStatus = getPrescriptionOrderStatus(orderInfo);  // N次查询
    
    // 又查一次检验检测处方(同样查 OrderPrescription 表)
    RpOrderStatus inspectionRpOrderStatus = 
        orderPrescriptionService.getCheckoutPrescriptionOrderStatus(orderInfo);  // N次查询
    
    // 更新订单状态后,又查一次最新订单
    Order order = this.getById(orderInfo.getId());  // N次查询
    
    // 记录日志
    orderLogService.addOrderLog(order, OrderLogType.MODIFY, "syncOrderDeliveryInfo");
}

如果有 5 个订单,就是 5×3 = 15 次 DB 查询。而且 getPrescriptionOrderStatusgetCheckoutPrescriptionOrderStatus 查的是同一张表OrderPrescription by orgCode + orderSeq),完全是重复查询。

生活中的例子:这就像你要核对 100 个员工的考勤,却每个员工都单独跑一趟档案室调档案,而不是一次性把 100 份档案全调出来摆在桌上核对。

问题4:Redisson 锁全程持有(锁范围过大)

java 复制代码
String lockName = "lock:order:%s:%s".formatted(orgCode, userId);
RLock orderLock = redissonClient.getLock(lockName);
boolean lockSuccess = orderLock.tryLock(10, TimeUnit.SECONDS);
try {
    // 锁住了一切:远程调用、物流下单、DB操作
    Result<HospitalVo> hospitalVoResult = hospitalApi.queryByOrgCode(hosCode);  // 持锁远程调用
    deliveredInfo = rpDeliveryService.handleDelivered(...);  // 持锁外部API
    // ... DB操作 ...
} finally {
    orderLock.unlock();
}

锁的粒度太大,把远程调用和外部 API 都锁住了,严重降低并发度。

问题5:大对象日志

java 复制代码
log.info("药品订单回调,参数:{}", JSONUtil.toJsonPrettyStr(dto));

JSONUtil.toJsonPrettyStr(dto) 会把整个请求 DTO 序列化成格式化 JSON 字符串,对象大时既耗 CPU 又耗内存,还撑爆日志文件。

详细问题可参考:Java 日志打印:别再 log.info("dto:{}", dto) 了,可能比你想的更坑

问题6 & 7:租户配置无缓存 + 事务内做不必要的数据准备

租户配置(ConsultationConfigVO)几乎不变,却每次请求都远程查询;事务内还做了大量数据组装和校验,拉长了事务持有时间。

优化方案实施

不修改任何现有代码 的前提下,我新建了独立的 V2 实现类 SyncDeliveryInfoV2ServiceImpl,应用了以下优化:

优化1:外部 API 移出事务 + TransactionTemplate 短事务

java 复制代码
// ① 外部物流下单在事务外执行(不占用DB连接)
if (RpStatus.DELIVERED.equals(rpStatus)) {
    deliveredInfo = rpDeliveryService.mode(rpDeliveryMode)
        .handleDelivered(logisticsCompanyEnum, rpDeliveryData);  // 无事务
}
​
// ② 批量预算订单状态(事务外)
Map<Long, StatusResult> statusMap = computeRpOrderStatusBatch(orderList, orgCode);
​
// ③ 只在DB写入时开短事务
RLock orderLock = redissonClient.getLock(lockName);
try {
    orderLock.tryLock(10, TimeUnit.SECONDS);
    transactionTemplate.execute(status -> {
        // 只有DB更新操作:处方状态、订单状态、hisOrder状态、日志
        orderPrescriptionService.lambdaUpdate()...update();
        // ...
        return null;
    });  // 短事务,毫秒级完成
} finally {
    orderLock.unlock();
}

效果:数据库连接持有时间从"外部API耗时 + DB耗时"缩短到"仅DB耗时"。

优化2:消除重复远程调用 + Caffeine 本地缓存

java 复制代码
// Caffeine 本地缓存租户配置,TTL 5分钟
private final Cache<Long, ConsultationConfigVO> configCache = Caffeine.newBuilder()
    .maximumSize(100)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .build();
​
// 入口处只查一次,通过方法参数传递给子方法
HospitalVo hospitalInfo = hospitalApi.queryByOrgCode(hosCode).getData();  // 仅1次
ConsultationConfigVO config = configCache.get(hospitalId, 
    id -> tenantConfigApi.tenantConfigInfoByHospitalId(id).getData());  // 5分钟内命中缓存
​
// 子方法通过参数接收,不再重复查询
syncByDelivery(logisticsCompanyEnum, dto, hospitalInfo, config, lockName);

为什么用 Caffeine 而非 Redis:租户配置是单机读多写少的数据,Caffeine 本地缓存无网络开销,纳秒级命中,比 Redis 更快。TTL 5 分钟保证配置变更能及时生效。

优化3:批量查询替代 N+1

java 复制代码
// 1次SQL查所有订单的处方,在内存按 orderSeq 分组
private Map<Long, StatusResult> computeRpOrderStatusBatch(List<Order> orderList, String orgCode) {
    Set<String> orderSeqSet = orderList.stream().map(Order::getOrderSeq).collect(toSet());
    // 1次查询替代 2N 次
    List<OrderPrescription> allPrescriptions = orderPrescriptionService.lambdaQuery()
        .eq(OrderPrescription::getOrgCode, orgCode)
        .in(OrderPrescription::getOrderSeq, orderSeqSet)
        .list();
    
    Map<String, List<OrderPrescription>> groupByOrderSeq = allPrescriptions.stream()
        .collect(groupingBy(OrderPrescription::getOrderSeq));
    
    // 在内存中复刻 getPrescriptionOrderStatus + getCheckoutPrescriptionOrderStatus 的推导逻辑
    Map<Long, StatusResult> result = new HashMap<>(orderList.size() * 2);
    for (Order order : orderList) {
        List<OrderPrescription> prescriptions = groupByOrderSeq.get(order.getOrderSeq());
        result.put(order.getId(), computeStatus(order, prescriptions));
    }
    return result;
}
​
// 记录日志时也批量查询
List<Order> latestOrders = orderService.listByIds(orderIds);  // 1次替代N次getById

效果:2N+1 次查询 → 2 次查询。

优化4:缩小锁范围

锁不再包裹远程调用和外部 API,仅包裹 DB 写入段

java 复制代码
// 远程调用、物流下单、批量状态预算 → 全在锁外
HospitalVo hospitalInfo = hospitalApi.queryByOrgCode(hosCode);  // 无锁
deliveredInfo = rpDeliveryService.handleDelivered(...);          // 无锁
Map<Long, StatusResult> statusMap = computeRpOrderStatusBatch(); // 无锁
​
// 只有DB写入加锁
orderLock.tryLock(10, TimeUnit.SECONDS);
try {
    transactionTemplate.execute(status -> { /* DB写入 */ });
} finally {
    orderLock.unlock();
}

优化5:精简日志 + 步骤耗时监控

java 复制代码
// 移除大对象日志,改为关键字段
log.info("syncOrderDeliveryInfoV2 开始 hosCode:{}, recipeSize:{}, deliveryStatus:{}",
    dto.getInput().getHosCode(), CollUtil.size(dto.getInput().getRecipeList()),
    dto.getInput().getDeliveryStatus());
​
// 关键步骤耗时监控
long t = System.currentTimeMillis();
Result<HospitalVo> hospitalVoResult = hospitalApi.queryByOrgCode(hosCode);
log.info("查询机构信息耗时:{}ms", System.currentTimeMillis() - t);

架构设计:新旧完全隔离

为了不影响线上业务,V2 与 V1 完全隔离**(屎山代码你懂的!再不影响住业务的情况下,只能按医院一步步去放开)**:

TypeScript 复制代码
旧接口(不动)          新接口(独立入口)
/syncDeliveryInfo       /syncDeliveryInfoV2
       ↓                       ↓
OrderServiceImpl         SyncDeliveryInfoV2ServiceImpl
.syncOrderDeliveryInfo   .syncOrderDeliveryInfo
       ↓                       ↓
@Transactional           TransactionTemplate 短事务
全程持锁                  仅DB写入段持锁
N+1查询                  批量查询

平滑切换

(1)方案1:通过 Nacos 配置 syncDeliveryInfo.version 控制 V2 入口路由,默认走 V2 优化逻辑,配置 v1 时自动回退到 V1 实现,无需 HIS 改 endpoint 即可灰度回退。

(2)方案2:通过在医院配置表,为每个医院单独增加一个"物流同步版本"的配置,一家一家医院的去测试,避免整体放开引发全面崩溃**(因为测试环境确实有些东西测不出来!!屎山代码谨慎优化!!可能会有没有测出来的坑!!!)**

优化前后对比

维度 V1 旧实现 V2 新实现
事务范围 整个方法 @Transactional(含外部API) TransactionTemplate 仅包裹 DB 写入
外部 API 事务内调用,占用 DB 连接 事务外调用,不占 DB 连接
机构信息查询 2 次远程调用 1 次
租户配置查询 2 次远程调用 1 次 + Caffeine 缓存(5min TTL)
订单状态查询 2N+1 次 DB 查询 2 次批量查询
锁范围 全程持有(含远程调用+外部API) 仅 DB 写入段
日志 JSONUtil.toJsonPrettyStr 大对象 关键字段 + 步骤耗时
线上处理时间 36~50 秒 目标 < 5 秒(待压测验证)

说明:V2 的性能指标(<5s/<10s)尚未经过生产压测验证,但已消除三大主因(事务内外部调用、N+1、重复远程调用),理论上可达成。

收获与总结

通过这次 syncDeliveryInfo 接口优化,我有几点深刻体会:

1. 外部 API 调用绝不能放在事务里。 这是最容易被忽视的性能杀手------代码能跑,单测能过,但线上高并发时连接池直接耗尽。判断标准很简单:@Transactional 方法内如果出现了 Feign 调用、HTTP 请求、Thread.sleep,就要警惕。

2. N+1 查询要靠"批量查 + 内存组装"解决。 不要在循环里查数据库,哪怕每次只查一条。把所有 ID 收集起来,一次 IN 查询搞定,然后在内存里用 Map 组装。这次 2N 次查询降到 2 次,效果立竿见影。

3. 锁的粒度要尽可能小。 锁是保护共享资源不被并发修改的,不是保护整个业务流程的。远程调用、外部 API 这些不需要锁保护的操作,应该放在锁外。这次把锁范围从"全程"缩小到"仅 DB 写入段",并发度大幅提升。

4. 缓存要选对层次。 读多写少的单机配置数据,Caffeine 本地缓存比 Redis 更合适------无网络开销,纳秒级命中。不必所有缓存都往 Redis 里塞。

5. 性能优化要"新建不改动"。 对于线上核心接口,不要直接改现有代码,而是新建 V2 实现 + 独立入口 + 配置切换。这样既能灰度验证,又能随时回退,风险可控。

6. 性能问题要靠数据定位,不能靠猜。 这次通过 3 份线上日志的时间戳对照,精确定位了每个瓶颈的耗时占比,而不是凭感觉优化。

希望这篇踩坑记录能帮助遇到类似问题的开发者少走弯路。性能优化的核心思路其实就一句话:把不必要的 IO 移出临界区,把逐条操作改成批量操作。

注:保留旧业务,新建新业务,版本随时切换,风险可控。

相关推荐
Wang's Blog1 小时前
JavaWeb快速入门: MyBatis入门与实战
java·mybatis
隐形的萝莉1 小时前
再回到技术面,研究 T-SQL 的 UNION、EXISTS、EXCEPT、INTERSECT 运算符。
数据库·sql
EntyIU1 小时前
Java NIO 实战
java·开发语言·nio
二宝哥1 小时前
springboot项目使用Gradle工具实现依赖版本控制
java·spring boot·后端·gradle·版本
济*沧*海2 小时前
Set集合深度解析:原理、特性与应用场景全攻略
java
geovindu2 小时前
java: Singleton Pattern
java·开发语言·后端·单例模式·设计模式·创建型模式
Nturmoils2 小时前
JOIN 不是拼上就完事,多表查询这几个坑先跑一遍
数据库·后端
yoothey2 小时前
MySQL 数据库连接池调优实战
数据库·mysql
一路向北North2 小时前
Spring Security OAuth2.0(16):密码模式
java·后端·spring