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

281 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 面单下载接口性能优化方案
## 接口信息
- 路由:`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 标签分支,不影响正常路径)。
**具体做法**
```csharp
// 并行执行三个独立查询
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)` 永远为 false`CheckTriggerAsync` 的最终结果永远是 `false`。可以在 `request.Label` 确认不为空之后再调用 `CheckTriggerAsync`,这样可以在 request 为 null 或 ReplaceStatus=="N" 等早退出情况下完全跳过这次 DB 查询。
**预期收益**:并行执行将 3~4 次串行 DB 查询的时间压缩为 1~2 次的时间,理论上减少 50-70% 的等待时间。
---
### 优化点 2消除 `GetValidCacheAsync` 内的重复查询
**问题**`GetValidCacheAsync` 内部会在已有 `LabelReplaceEntity` 的情况下再次查询 `LabelReplace` 表做兜底校验。
**方案**:重构 `GetValidCacheAsync` 方法,增加一个重载,支持传入已查询好的 `LabelReplaceEntity order` 对象,跳过内部的二次查询:
```csharp
// 新增重载(或修改签名)
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 回传逻辑保持一致:
```csharp
// 改为后台执行,不阻塞响应
_ = 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 分钟:
```csharp
// 在 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`
**方案**
```csharp
// 将:
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。
**方案**:提取到同一个变量中使用:
```csharp
int pageCount = GetPdfPageCount(labelBytes);
if (pageCount > 1)
{
// 返回错误
}
// else 分支直接使用已计算的 pageCount不再重复调用
```
**预期收益**:避免重复解析 PDF 字节流(节省 CPU取决于实现
---
### 优化点 7可选内存缓存 `LabelReplaceEntity` 短期缓存
对于高频扫描的运单号,`GetLabelReplaceRequestByWaybillNumberAsync` 可能在极短时间内被重复调用。可在 ICacheService 中缓存 `LabelReplaceEntity` 30 秒,减少 DB 压力:
```csharp
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 - 优化点 3**`RecordScanAsync` 改为 `Task.Run`(最低风险,最快收益)
2. **P0 - 优化点 1**:重构主流程为并行查询 + 修复缓存命中路径的提前判断顺序
3. **P1 - 优化点 4**:注入 `ICacheService`,为 `GetByIdAsync` 加内存缓存
4. **P1 - 优化点 2**:重构 `GetValidCacheAsync`,支持传入已有的 `LabelReplaceEntity`
5. **P2 - 优化点 5**`new HttpClient()` 改为 `_httpClientFactory.CreateClient()`
6. **P2 - 优化点 6**:合并 `GetPdfPageCount` 两次调用