上传源代码版本

This commit is contained in:
Im-Jenisson
2026-06-01 16:30:29 +08:00
commit b2a9b7d3c2
462 changed files with 104365 additions and 0 deletions

View 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` 为 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` 两次调用