处方物流信息同步优化:从 36 秒到亚秒级的踩坑记录
背景介绍最近在维护公司互联网医院项目时负责优化一个名为syncDeliveryInfo同步处方物流信息的接口。这个接口由 HIS 系统回调负责同步处方的发药/退药状态到我们的订单系统最终我们调用第三方物流api返回物流信息给his。在一次线上监控中发现这个接口的处理时间异常缓慢调用链特别长屎山代码啊日志样本处理耗时日志135,993 ms日志243,167 ms日志350,059 ms平均 36~50 秒的处理时间远超正常接口响应时间高峰期甚至导致数据库连接池耗尽影响其他依赖数据库的功能。我不得不深入代码找出瓶颈所在。问题排查过程入口链路梳理接口调用链路为HIS → PortalTenantCallbackController→ PatientOrderController→OrderServiceImpl.syncOrderDeliveryInfo。核心逻辑在OrderServiceImpl的三个方法中syncOrderDeliveryInfo入口加锁 → 查机构 → 查租户配置 → 路由syncOrderDeliveryInfoByDelivery正常发药模式Transactional包裹syncOrderDeliveryInfoByDispense配药即发物流模式Transactional包裹通过逐行代码审查 日志时间戳对照我定位了7 个性能瓶颈。发现的 7 个问题点问题1外部 API 调用被包在事务里事务范围过大最致命这是最严重的问题。syncOrderDeliveryInfoByDelivery方法标了Transactional(rollbackFor Throwable.class)而在事务内部调用了外部物流下单接口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同一请求内重复远程调用入口方法和子方法各自独立查询了相同的数据// 入口方法 syncOrderDeliveryInfo 中 ResultHospitalVo hospitalVoResult hospitalApi.queryByOrgCode(hosCode); // 第1次 ResultConsultationConfigVO configResult tenantConfigApi .tenantConfigInfoByHospitalId(hospitalId); // 第1次 // 然后调用子方法 syncOrderDeliveryInfoByDelivery里面又查了一遍 ResultHospitalVo hospitalVoResult hospitalApi.queryByOrgCode(hosCode); // 第2次 ResultConsultationConfigVO configResult tenantConfigApi .tenantConfigInfoByHospitalId(hospitalId); // 第2次每次 Feign 远程调用都是一次网络 IO4 次能压缩到 2 次。问题3N1 查询2N 次查询在修改订单状态的 for 循环中每个订单都触发了多次 DB 查询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查的是同一张表OrderPrescriptionby orgCode orderSeq完全是重复查询。生活中的例子这就像你要核对 100 个员工的考勤却每个员工都单独跑一趟档案室调档案而不是一次性把 100 份档案全调出来摆在桌上核对。问题4Redisson 锁全程持有锁范围过大String lockName lock:order:%s:%s.formatted(orgCode, userId); RLock orderLock redissonClient.getLock(lockName); boolean lockSuccess orderLock.tryLock(10, TimeUnit.SECONDS); try { // 锁住了一切远程调用、物流下单、DB操作 ResultHospitalVo hospitalVoResult hospitalApi.queryByOrgCode(hosCode); // 持锁远程调用 deliveredInfo rpDeliveryService.handleDelivered(...); // 持锁外部API // ... DB操作 ... } finally { orderLock.unlock(); }锁的粒度太大把远程调用和外部 API 都锁住了严重降低并发度。问题5大对象日志log.info(药品订单回调,参数:{}, JSONUtil.toJsonPrettyStr(dto));JSONUtil.toJsonPrettyStr(dto)会把整个请求 DTO 序列化成格式化 JSON 字符串对象大时既耗 CPU 又耗内存还撑爆日志文件。详细问题可参考Java 日志打印别再 log.info(“dto:{}“, dto) 了可能比你想的更坑问题6 7租户配置无缓存 事务内做不必要的数据准备租户配置ConsultationConfigVO几乎不变却每次请求都远程查询事务内还做了大量数据组装和校验拉长了事务持有时间。优化方案实施在不修改任何现有代码的前提下我新建了独立的 V2 实现类SyncDeliveryInfoV2ServiceImpl应用了以下优化优化1外部 API 移出事务 TransactionTemplate 短事务// ① 外部物流下单在事务外执行不占用DB连接 if (RpStatus.DELIVERED.equals(rpStatus)) { deliveredInfo rpDeliveryService.mode(rpDeliveryMode) .handleDelivered(logisticsCompanyEnum, rpDeliveryData); // 无事务 } // ② 批量预算订单状态事务外 MapLong, 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 本地缓存// Caffeine 本地缓存租户配置TTL 5分钟 private final CacheLong, 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批量查询替代 N1// 1次SQL查所有订单的处方在内存按 orderSeq 分组 private MapLong, StatusResult computeRpOrderStatusBatch(ListOrder orderList, String orgCode) { SetString orderSeqSet orderList.stream().map(Order::getOrderSeq).collect(toSet()); // 1次查询替代 2N 次 ListOrderPrescription allPrescriptions orderPrescriptionService.lambdaQuery() .eq(OrderPrescription::getOrgCode, orgCode) .in(OrderPrescription::getOrderSeq, orderSeqSet) .list(); MapString, ListOrderPrescription groupByOrderSeq allPrescriptions.stream() .collect(groupingBy(OrderPrescription::getOrderSeq)); // 在内存中复刻 getPrescriptionOrderStatus getCheckoutPrescriptionOrderStatus 的推导逻辑 MapLong, StatusResult result new HashMap(orderList.size() * 2); for (Order order : orderList) { ListOrderPrescription prescriptions groupByOrderSeq.get(order.getOrderSeq()); result.put(order.getId(), computeStatus(order, prescriptions)); } return result; } // 记录日志时也批量查询 ListOrder latestOrders orderService.listByIds(orderIds); // 1次替代N次getById效果2N1 次查询 → 2 次查询。优化4缩小锁范围锁不再包裹远程调用和外部 API仅包裹 DB 写入段// 远程调用、物流下单、批量状态预算 → 全在锁外 HospitalVo hospitalInfo hospitalApi.queryByOrgCode(hosCode); // 无锁 deliveredInfo rpDeliveryService.handleDelivered(...); // 无锁 MapLong, StatusResult statusMap computeRpOrderStatusBatch(); // 无锁 // 只有DB写入加锁 orderLock.tryLock(10, TimeUnit.SECONDS); try { transactionTemplate.execute(status - { /* DB写入 */ }); } finally { orderLock.unlock(); }优化5精简日志 步骤耗时监控// 移除大对象日志改为关键字段 log.info(syncOrderDeliveryInfoV2 开始 hosCode:{}, recipeSize:{}, deliveryStatus:{}, dto.getInput().getHosCode(), CollUtil.size(dto.getInput().getRecipeList()), dto.getInput().getDeliveryStatus()); // 关键步骤耗时监控 long t System.currentTimeMillis(); ResultHospitalVo hospitalVoResult hospitalApi.queryByOrgCode(hosCode); log.info(查询机构信息耗时:{}ms, System.currentTimeMillis() - t);架构设计新旧完全隔离为了不影响线上业务V2 与 V1 完全隔离屎山代码你懂的再不影响住业务的情况下只能按医院一步步去放开旧接口不动 新接口独立入口 /syncDeliveryInfo /syncDeliveryInfoV2 ↓ ↓ OrderServiceImpl SyncDeliveryInfoV2ServiceImpl .syncOrderDeliveryInfo .syncOrderDeliveryInfo ↓ ↓ Transactional TransactionTemplate 短事务 全程持锁 仅DB写入段持锁 N1查询 批量查询平滑切换1方案1通过 Nacos 配置syncDeliveryInfo.version控制 V2 入口路由默认走 V2 优化逻辑配置v1时自动回退到 V1 实现无需 HIS 改 endpoint 即可灰度回退。2方案2通过在医院配置表为每个医院单独增加一个“物流同步版本”的配置一家一家医院的去测试避免整体放开引发全面崩溃因为测试环境确实有些东西测不出来屎山代码谨慎优化可能会有没有测出来的坑优化前后对比维度V1 旧实现V2 新实现事务范围整个方法Transactional含外部APITransactionTemplate仅包裹 DB 写入外部 API事务内调用占用 DB 连接事务外调用不占 DB 连接机构信息查询2 次远程调用1 次租户配置查询2 次远程调用1 次 Caffeine 缓存5min TTL订单状态查询2N1 次 DB 查询2 次批量查询锁范围全程持有含远程调用外部API仅 DB 写入段日志JSONUtil.toJsonPrettyStr大对象关键字段 步骤耗时线上处理时间36~50 秒目标 5 秒待压测验证说明V2 的性能指标5s/10s尚未经过生产压测验证但已消除三大主因事务内外部调用、N1、重复远程调用理论上可达成。收获与总结通过这次syncDeliveryInfo接口优化我有几点深刻体会1. 外部 API 调用绝不能放在事务里。这是最容易被忽视的性能杀手——代码能跑单测能过但线上高并发时连接池直接耗尽。判断标准很简单Transactional方法内如果出现了 Feign 调用、HTTP 请求、Thread.sleep就要警惕。2. N1 查询要靠批量查 内存组装解决。不要在循环里查数据库哪怕每次只查一条。把所有 ID 收集起来一次IN查询搞定然后在内存里用Map组装。这次 2N 次查询降到 2 次效果立竿见影。3. 锁的粒度要尽可能小。锁是保护共享资源不被并发修改的不是保护整个业务流程的。远程调用、外部 API 这些不需要锁保护的操作应该放在锁外。这次把锁范围从全程缩小到仅 DB 写入段并发度大幅提升。4. 缓存要选对层次。读多写少的单机配置数据Caffeine 本地缓存比 Redis 更合适——无网络开销纳秒级命中。不必所有缓存都往 Redis 里塞。5. 性能优化要新建不改动。对于线上核心接口不要直接改现有代码而是新建 V2 实现 独立入口 配置切换。这样既能灰度验证又能随时回退风险可控。6. 性能问题要靠数据定位不能靠猜。这次通过 3 份线上日志的时间戳对照精确定位了每个瓶颈的耗时占比而不是凭感觉优化。希望这篇踩坑记录能帮助遇到类似问题的开发者少走弯路。性能优化的核心思路其实就一句话把不必要的 IO 移出临界区把逐条操作改成批量操作。注保留旧业务新建新业务版本随时切换风险可控。