12 KiB
面单下载接口性能优化方案
接口信息
- 路由:
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 查询改为并行执行。
方案:将 GetLabelReplaceRequestByWaybillNumberAsync 和 GetValidCacheAsync 并行执行(二者无依赖关系),同时将 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 为 null(L355),所以触发条件 !string.IsNullOrWhiteSpace(dto.Label) 永远为 false,CheckTriggerAsync 的最终结果永远是 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 查询。
优化点 3:RecordScanAsync 异步化(消除 finally 阻塞)
问题:finally 块中的 RecordScanAsync 是 await 的,这意味着 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 之前将所有需要的变量值捕获到局部变量(customerId、waybillNumber、scanResult 等已为值类型/字符串,天然安全)。
预期收益:消除约 5-20ms 的 DB 写入等待时间,每次请求均受益。
优化点 4:_customerRepository.GetByIdAsync 增加内存缓存
问题:finally 块(L713)每次都直接查库获取客户信息,而客户数据变更极少。
方案:利用已注册的 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 积压风险。
优化点 6:GetPdfPageCount 调用两次的问题
问题: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 | 优化点 3:RecordScanAsync 异步化 | 每次请求 -5~20ms | 低 |
| P0 | 优化点 1:并行执行 DB 查询 | 整体延迟减半 | 中 |
| P1 | 优化点 4:客户信息内存缓存 | 重复客户请求 -5~15ms | 低 |
| P1 | 优化点 2:消除 GetValidCacheAsync 重复查询 | 缓存命中时 -1次DB | 中 |
| P2 | 优化点 5:修复 HttpClient 实例化 | URL标签路径 -10~50ms | 低 |
| P2 | 优化点 6:GetPdfPageCount 重复调用 | 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标签分支
↓ 解码/下载 labelBytes(URL 路径使用 _httpClientFactory)
↓ GetPdfPageCount(labelBytes) 一次,复用结果
↓ 校验 pageCount、文件大小
↓ 后台异步:SaveCacheAsync + 条码识别
↓ return File(labelBytes, ...)
finally(不阻塞响应):
↓ [Task.Run] RecordScanAsync
↓ [Task.Run(已有)] GetCustomerWithCacheAsync + Webhook 回传
五、实施顺序
- P0 - 优化点 3:
RecordScanAsync改为Task.Run(最低风险,最快收益) - P0 - 优化点 1:重构主流程为并行查询 + 修复缓存命中路径的提前判断顺序
- P1 - 优化点 4:注入
ICacheService,为GetByIdAsync加内存缓存 - P1 - 优化点 2:重构
GetValidCacheAsync,支持传入已有的LabelReplaceEntity - P2 - 优化点 5:
new HttpClient()改为_httpClientFactory.CreateClient() - P2 - 优化点 6:合并
GetPdfPageCount两次调用