上传源代码版本
This commit is contained in:
280
.trae/documents/label_download_performance_optimization.md
Normal file
280
.trae/documents/label_download_performance_optimization.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# 面单下载接口性能优化方案
|
||||
|
||||
## 接口信息
|
||||
|
||||
- 路由:`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` 为 null(L355),所以触发条件 `!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 | 优化点 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 回传
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、实施顺序
|
||||
|
||||
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` 两次调用
|
||||
Reference in New Issue
Block a user