Files
LabelChange-server/.trae/documents/label_download_performance_optimization.md
2026-06-01 16:30:29 +08:00

12 KiB
Raw Permalink Blame History

面单下载接口性能优化方案

接口信息

  • 路由:GET /api/label/label-replace/waybill/{waybillNumber}/download
  • 方法:DownloadLabelByWaybillNumber
  • 文件:src/CONTROLLER/Controllers/LabelController.cs#L141-L760

一、现状与瓶颈分析

1.1 当前串行调用链(热路径)

在正常命中 PDF 缓存(GetValidCacheAsync 返回非 null之前实际执行了以下串行数据库查询:

请求进入
  ↓ [DB查询 1] GetLabelReplaceRequestByWaybillNumberAsync
       → LabelReplaceEntity 表(按 NeutralWaybillNumber
  ↓ [DB查询 2] CheckTriggerAsync → GetScanRecordsByNeutralWaybillNumberAsync
       → LabelScan 表(按 NeutralWaybillNumber全量返回后 OrderBy + FirstOrDefault
  ↓ [DB查询 3+4] GetValidCacheAsync
       → LabelPdfCache 表(按 NeutralWaybillNumber
       → LabelReplace 表(再次查一次,兜底校验)
  ↓ 返回 PDF 字节流

结论:正常命中缓存时,主流程执行了 4 次独立的数据库查询,全部串行执行。

1.2 finally 块中的额外串行查询L688-L758

响应已经 return File(...) 之后,finally 块中还同步执行:

finally
  ↓ [DB写入] RecordScanAsync
       → 写入 LabelScan 表 + 写入 OrderLog 表2次 DB 写入)
  ↓ [DB查询 5] _customerRepository.GetByIdAsync(customerId)
       → 查询 Customer 表
  ↓ [异步] 发送 Webhook已经是 Task.Run不阻塞

结论:finally 块中 RecordScanAsync 是同步等待的,这意味着响应实际上要等 DB 写入完成后才真正结束。这是导致"时快时慢"的核心原因之一。

1.3 缓存命中路径中的重复查询

GetValidCacheAsync 内部会第二次查询 LabelReplace 表(用于兜底校验),而主流程在 L190 已经查过一次该表。这是一次明显的冗余 DB 查询。

1.4 CheckTriggerAsync 效率问题

CheckTriggerAsync 内调用 GetScanRecordsByNeutralWaybillNumberAsync,该方法返回全量扫描记录列表,然后在内存中做 OrderBy(s => s.CreatedAt).FirstOrDefault()。实际只需要第一条(最早)记录,全量查询是浪费。

1.5 _customerRepository.GetByIdAsync 在 finally 块中无缓存

finally 块 L713 调用 _customerRepository.GetByIdAsync(customerId),该客户数据极少变化,但每次请求都实时查库,既无内存缓存也无 TTL 保护。而项目中已注册了 IMemoryCache(通过 ICacheService)。

1.6 new HttpClient() 直接实例化L528

在 URL 标签路径下,代码直接 new HttpClient(),绕过了已注入的 _httpClientFactory,存在连接池耗尽风险,且每次请求都创建新连接,无法复用 TCP 连接,影响延迟。


二、优化方案

优化点 1并行执行独立 DB 查询(最高收益)

目标:将串行的多次 DB 查询改为并行执行。

方案:将 GetLabelReplaceRequestByWaybillNumberAsyncGetValidCacheAsync 并行执行(二者无依赖关系),同时将 CheckTriggerAsync 也并行触发(其结果只影响是否走 STOP 标签分支,不影响正常路径)。

具体做法

// 并行执行三个独立查询
var requestTask = _labelReplaceService.GetLabelReplaceRequestByWaybillNumberAsync(waybillNumber);
var cacheTask = _labelPdfCacheService.GetValidCacheAsync(waybillNumber);

await Task.WhenAll(requestTask, cacheTask);

request = requestTask.Result;
var cache = cacheTask.Result;

// 仅在 request 有效且有 Label 数据时才检查触发器
// (CheckTriggerAsync 可在拿到 request 后串行,因为它依赖 request.Label)

注意:CheckTriggerAsync 使用了 dto.Label = label,而此时 label 为 nullL355所以触发条件 !string.IsNullOrWhiteSpace(dto.Label) 永远为 falseCheckTriggerAsync 的最终结果永远是 false。可以在 request.Label 确认不为空之后再调用 CheckTriggerAsync,这样可以在 request 为 null 或 ReplaceStatus=="N" 等早退出情况下完全跳过这次 DB 查询。

预期收益:并行执行将 34 次串行 DB 查询的时间压缩为 12 次的时间,理论上减少 50-70% 的等待时间。


优化点 2消除 GetValidCacheAsync 内的重复查询

问题GetValidCacheAsync 内部会在已有 LabelReplaceEntity 的情况下再次查询 LabelReplace 表做兜底校验。

方案:重构 GetValidCacheAsync 方法,增加一个重载,支持传入已查询好的 LabelReplaceEntity order 对象,跳过内部的二次查询:

// 新增重载(或修改签名)
Task<LabelPdfCache?> GetValidCacheAsync(string waybillNumber, LabelReplaceEntity? order = null);

内部逻辑改为:若 order 参数不为 null则跳过内部的 _labelReplaceRepository.GetByWaybillNumberAsync 调用。

预期收益:缓存命中路径减少 1 次 DB 查询。


优化点 3RecordScanAsync 异步化(消除 finally 阻塞)

问题finally 块中的 RecordScanAsyncawait 的,这意味着 HTTP 响应实际上在 DB 写入完成前无法返回给客户端ASP.NET Core 流式响应行为),直接增加了客户端感知延迟。

方案:将 RecordScanAsync 改为 Task.Run 后台执行,与 Webhook 回传逻辑保持一致:

// 改为后台执行,不阻塞响应
_ = Task.Run(async () =>
{
    try
    {
        await _labelScanService.RecordScanAsync(...);
    }
    catch (Exception scanEx)
    {
        _logger.LogWarning(scanEx, "Failed to record scan for waybill number: {number}", waybillNumber);
    }
});

注意:需要在进入 Task.Run 之前将所有需要的变量值捕获到局部变量(customerIdwaybillNumberscanResult 等已为值类型/字符串,天然安全)。

预期收益:消除约 5-20ms 的 DB 写入等待时间,每次请求均受益。


优化点 4_customerRepository.GetByIdAsync 增加内存缓存

问题finallyL713每次都直接查库获取客户信息而客户数据变更极少。

方案:利用已注册的 ICacheService 为客户查询添加内存缓存TTL 设置为 10 分钟:

// 在 LabelController 注入 ICacheService
private readonly ICacheService _cacheService;

// 使用缓存包装 GetByIdAsync
private async Task<CustomerEntity?> GetCustomerWithCacheAsync(int customerId)
{
    var cacheKey = $"customer:{customerId}";
    var cached = await _cacheService.GetAsync<CustomerEntity>(cacheKey);
    if (cached != null) return cached;

    var customer = await _customerRepository.GetByIdAsync(customerId);
    if (customer != null)
        await _cacheService.SetAsync(cacheKey, customer, expirationMinutes: 10);
    return customer;
}

在 STOP 标签分支L231、L377和 finally Webhook 分支L713_customerRepository.GetByIdAsync(customerId) 替换为 GetCustomerWithCacheAsync(customerId)

预期收益:对同一客户的重复请求,彻底消除 DB 查询,内存读取时间 < 0.1ms。


优化点 5修复 new HttpClient() 改用 _httpClientFactory

问题L528 使用 new HttpClient() 下载 URL 格式的标签,未使用已注入的 _httpClientFactory

方案

// 将:
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromSeconds(_appSettings.ApiSettings.LabelDownloadTimeout);
labelBytes = await httpClient.GetByteArrayAsync(request.Label, token);

// 改为:
using var httpClient = _httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(_appSettings.ApiSettings.LabelDownloadTimeout);
labelBytes = await httpClient.GetByteArrayAsync(request.Label, token);

预期收益:复用 TCP 连接,避免每次创建新连接的握手开销(约 10-50ms减少 TIME_WAIT socket 积压风险。


优化点 6GetPdfPageCount 调用两次的问题

问题L558 调用 GetPdfPageCount(labelBytes) 做校验pageCount > 1 时返回错误L596 在 else 分支中再次调用 GetPdfPageCount(labelBytes) 获取用于缓存的 pageCount。

方案:提取到同一个变量中使用:

int pageCount = GetPdfPageCount(labelBytes);
if (pageCount > 1)
{
    // 返回错误
}
// else 分支直接使用已计算的 pageCount不再重复调用

预期收益:避免重复解析 PDF 字节流(节省 CPU取决于实现


优化点 7可选内存缓存 LabelReplaceEntity 短期缓存

对于高频扫描的运单号,GetLabelReplaceRequestByWaybillNumberAsync 可能在极短时间内被重复调用。可在 ICacheService 中缓存 LabelReplaceEntity 30 秒,减少 DB 压力:

var cacheKey = $"label_replace:{waybillNumber}";
request = await _cacheService.GetAsync<LabelReplaceEntity>(cacheKey);
if (request == null)
{
    request = await _labelReplaceService.GetLabelReplaceRequestByWaybillNumberAsync(waybillNumber);
    if (request != null)
        await _cacheService.SetAsync(cacheKey, request, expirationMinutes: 1); // 1分钟短期缓存
}

注意:由于 Label URL / Label 内容可能更新,缓存时间不宜过长(建议 1 分钟以内)。


三、优化优先级与预期效果汇总

优先级 优化点 预期收益 实现难度
P0 优化点 3RecordScanAsync 异步化 每次请求 -5~20ms
P0 优化点 1并行执行 DB 查询 整体延迟减半
P1 优化点 4客户信息内存缓存 重复客户请求 -5~15ms
P1 优化点 2消除 GetValidCacheAsync 重复查询 缓存命中时 -1次DB
P2 优化点 5修复 HttpClient 实例化 URL标签路径 -10~50ms
P2 优化点 6GetPdfPageCount 重复调用 CPU优化
P3 优化点 7短期内存缓存LabelReplace 高频访问场景

四、详细代码改造说明(热路径重构后的伪代码)

请求进入
  ↓ 字符串清理USPS 420 前缀)
  ↓ [并行启动] Task1: GetLabelReplaceRequestByWaybillNumberAsync
                Task2: GetValidCacheAsync传入 null先查缓存
  ↓ await Task.WhenAll(Task1, Task2)
  
  ↓ 若 request == null → 早返回NoOrderData
  ↓ 若 ReplaceStatus == "N" → STOP标签分支使用缓存后的 GetCustomerWithCacheAsync
  ↓ 若 cache != null → 直接返回缓存 PDF命中最快路径
  ↓ 若 request.Label 为空 → 早返回NoLabelData
  
  ↓ [串行] CheckTriggerAsync此时才需要传入真实 Label 值)
  ↓ 若 shouldTrigger → STOP标签分支
  
  ↓ 解码/下载 labelBytesURL 路径使用 _httpClientFactory
  ↓ GetPdfPageCount(labelBytes) 一次,复用结果
  ↓ 校验 pageCount、文件大小
  
  ↓ 后台异步SaveCacheAsync + 条码识别
  ↓ return File(labelBytes, ...)
  
finally不阻塞响应
  ↓ [Task.Run] RecordScanAsync
  ↓ [Task.Run已有] GetCustomerWithCacheAsync + Webhook 回传

五、实施顺序

  1. P0 - 优化点 3RecordScanAsync 改为 Task.Run(最低风险,最快收益)
  2. P0 - 优化点 1:重构主流程为并行查询 + 修复缓存命中路径的提前判断顺序
  3. P1 - 优化点 4:注入 ICacheService,为 GetByIdAsync 加内存缓存
  4. P1 - 优化点 2:重构 GetValidCacheAsync,支持传入已有的 LabelReplaceEntity
  5. P2 - 优化点 5new HttpClient() 改为 _httpClientFactory.CreateClient()
  6. P2 - 优化点 6:合并 GetPdfPageCount 两次调用