背景介绍
最近在维护公司互联网医院项目时,负责优化一个名为 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 查询。而且 getPrescriptionOrderStatus 和 getCheckoutPrescriptionOrderStatus 查的是同一张表 (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 移出临界区,把逐条操作改成批量操作。
注:保留旧业务,新建新业务,版本随时切换,风险可控。