304 lines
12 KiB
Markdown
304 lines
12 KiB
Markdown
# dealerhub · AGI 自主迭代报告(第 4 轮 · 并发序列 + N+1 优化)
|
||
|
||
> **承接**:第 3 轮报告末尾提出的"`_generate_bill_no` 用 count()+1 取序号,并发下有竞态"。
|
||
> 本轮坐实并修复,同时扫描出**另外 4 处同类问题**;顺带做性能基线,
|
||
> 发现并修复列表接口的 **N+1**(5.3 倍提速)。
|
||
> **回归**:SQLite **454 passed** / PostgreSQL **458 passed**,`check` 0 issues。
|
||
|
||
---
|
||
|
||
## 一、单号生成竞态(PG 实测坐实)
|
||
|
||
### 症状
|
||
|
||
9 个并发建单 → **多个拿到同一单号**:
|
||
|
||
```
|
||
IntegrityError: 重复键违反唯一约束 "sales_bill_tenant_id_bill_no_uniq"
|
||
DETAIL: 键值"(tenant_id, bill_no)=(1, XS202609110001)" 已经存在
|
||
```
|
||
|
||
### 根因:`count() + 1` 有两个缺陷
|
||
|
||
```python
|
||
seq = Model.objects.filter(tenant=tenant, bill_no__startswith=head).count() + 1
|
||
```
|
||
|
||
1. **并发撞号**:两个事务同时 `count()` 拿到同一个数 → 生成相同单号
|
||
2. **删除后复用**(更隐蔽):建了 001/002 后删掉 001 → `count()=1` → 下一张又是 002
|
||
→ 撞唯一约束(因为 002 已存在)
|
||
|
||
### 修复:`MAX(序号)+1` + 唯一约束重试
|
||
|
||
新增共享工具(`apps/core/services.py`):
|
||
|
||
```python
|
||
next_bill_no(tenant, prefix, model, *, date_str=None, field="bill_no")
|
||
# 扫已有单号取 MAX(序号),而非 count() —— 删除后不回退
|
||
|
||
create_with_unique_bill_no(model, *, tenant, prefix, defaults, field="bill_no")
|
||
# 取号 → 尝试创建(保存点包裹)→ 撞唯一约束则换号重试,最多 20 次
|
||
```
|
||
|
||
**为什么用重试而不是锁**:`select_for_update()` **锁不住不存在的行**
|
||
(这是第 3 轮修库存竞态时学到的教训)。乐观重试更适合"创建时取名"场景。
|
||
|
||
**保存点必不可少**:`IntegrityError` 会把外层事务标记为 aborted,
|
||
后续任何查询都报 `TransactionManagementError`。
|
||
|
||
### 修复范围:5 处同类序列
|
||
|
||
| 位置 | 用途 | 修复方式 |
|
||
|---|---|---|
|
||
| `sales.services._generate_bill_no` | 销售单 XS | `create_with_unique_bill_no` |
|
||
| `purchase.services._generate_bill_no` | 进货单 PB | 同上 |
|
||
| `storefront.services._next_order_no` | 商城单 HD | 同上(`field="order_no"`) |
|
||
| `channel.services` | 电商转单 SO | `create_with_unique_bill_no` |
|
||
| `finance.services._generate_ar_ap_no` | 应收/应付 RC/PY | `next_bill_no` + 凭证号重试 |
|
||
| `finance.services` 凭证号 V | 记账凭证 | 保存点 + 换号重试 |
|
||
|
||
### 验证(3 个场景)
|
||
|
||
```python
|
||
# 1. 并发
|
||
test_concurrent_bill_no_generation_unique # 9 线程并发建单,单号必须全唯一
|
||
# 2. 删除后不复用
|
||
test_bill_no_not_reused_after_deletion # 建 001/002/003 → 删 001 → 新单必须是 004
|
||
# 3. 空洞不回退
|
||
test_bill_no_survives_gap # 人工造 0099 → 新单必须是 0100(不填空洞)
|
||
```
|
||
|
||
修复前 1 失败(撞唯一约束);修复后 3 个全过。
|
||
|
||
---
|
||
|
||
## 二、性能基线(发现 N+1)
|
||
|
||
### 背景
|
||
|
||
前几轮补了数据(22 商品 / 90 单据 / 200+ 库存流水),数据量上来后低效查询才暴露。
|
||
单测只验证"对不对",不验证"代价多大"。
|
||
|
||
### 新增工具:`scripts/perf_baseline.py`
|
||
|
||
对 12 个主要读接口各发 N 次请求,报告 P50/P95/最大耗时 + 响应体积,按接口设阈值:
|
||
|
||
```
|
||
接口 P50 P95 最大 响应 状态
|
||
sales_bill_list 236.2ms 265.7ms 278.3ms 42,757B 200 ⚠ 慢 ← 唯一超标
|
||
product_list 50.5ms 53.4ms 64.1ms 10,828B 200 ✓
|
||
risk_ranking 104.7ms 112.2ms 113.8ms 3,106B 200 ✓
|
||
dashboard_summary 14.3ms 14.7ms 14.7ms 257B 200 ✓
|
||
...
|
||
```
|
||
|
||
### N+1 定位
|
||
|
||
用 `CaptureQueriesContext` 数 SQL:
|
||
|
||
```
|
||
序列化 50 张销售单:168.0ms,SQL 查询数:311
|
||
|
||
105× SELECT catalog_product... ← 每行的商品
|
||
105× SELECT catalog_unit... ← 每行的单位
|
||
50× SELECT SUM(tax_amount)... ← 每张单的税额聚合(SerializerMethodField)
|
||
50× SELECT sales_bill_line... ← 每张单的明细
|
||
1× SELECT sales_bill... ← 主查询
|
||
```
|
||
|
||
**三个根因**:
|
||
1. `tax_total` 用 `obj.lines.aggregate(Sum(...))` —— 每张单一次聚合
|
||
2. `lines` 未预取
|
||
3. 行内 `product` / `source_unit` 未预取
|
||
|
||
### 修复
|
||
|
||
**1. 基类加预取声明机制**(`apps/core/viewset.py`):
|
||
|
||
```python
|
||
class BaseTenantViewSet:
|
||
select_related_fields: tuple = ()
|
||
prefetch_related_fields: tuple = ()
|
||
|
||
async def get_queryset(self):
|
||
...
|
||
if self.select_related_fields:
|
||
qs = qs.select_related(*self.select_related_fields)
|
||
if self.prefetch_related_fields:
|
||
qs = qs.prefetch_related(*self.prefetch_related_fields)
|
||
```
|
||
|
||
**2. 各 ViewSet 声明自己的关联**(销售单/销售订单/采购单/库存/批次/应收/应付)。
|
||
|
||
**3. `tax_total` 改为内存汇总**:
|
||
|
||
```python
|
||
def get_tax_total(self, obj):
|
||
return str(sum((ln.tax_amount or 0) for ln in obj.lines.all()))
|
||
# 旧实现 obj.lines.aggregate(Sum("tax_amount")) 每张单一条 SQL
|
||
```
|
||
|
||
### 效果
|
||
|
||
| 指标 | 修复前 | 修复后 | 提升 |
|
||
|---|---|---|---|
|
||
| SQL 查询数(50 张单) | **311** | **5** | 62× |
|
||
| 序列化耗时 | 168ms | 31ms | 5.4× |
|
||
| 接口 P95 | 266ms ⚠ | **50ms** | **5.3×** |
|
||
|
||
其他接口连带改善:`stock_list` 41→13ms、`batch_list` 31→13ms。
|
||
|
||
### 守门测试:`tests/test_query_efficiency.py`(7 例)
|
||
|
||
用 SQL 计数把"查询次数"钉死,防止后人改回 N+1:
|
||
|
||
```python
|
||
def test_sales_bill_list_scales_flat(...):
|
||
"""单据翻倍(20 → 40)时查询次数不增加。"""
|
||
...
|
||
assert n40 <= n20 + 2, f"查询数随数据量增长:{n20} → {n40}(N+1 回归)"
|
||
```
|
||
|
||
**验证守门有效性**(关键步骤):
|
||
临时撤掉 `SalesBillViewSet` 的预取声明 → 测试立刻失败:
|
||
|
||
```
|
||
AssertionError: 查询数随数据量增长:124 → 244(N+1 回归)
|
||
```
|
||
|
||
还原后 7 例全过。**这一步不能省**——否则可能是个永远为真的摆设
|
||
(第 1 轮就踩过 pytest 版迁移检查假通过的坑)。
|
||
|
||
**插曲**:我第一次验证时改错了地方(删的是 `SalesOrderViewSet` 的声明,
|
||
而测试测的是 `SalesBillViewSet`),导致"测试没抓到"的假象。
|
||
教训:验证守门测试时,要确认改动的是**测试真正覆盖的那条路径**。
|
||
|
||
---
|
||
|
||
## 三、验证汇总
|
||
|
||
```
|
||
$ DJANGO_SETTINGS_MODULE=config.settings.test python -m pytest tests/
|
||
449 passed, 4 skipped in 54.20s
|
||
|
||
$ DJANGO_SETTINGS_MODULE=config.settings.pgtest python -m pytest tests/
|
||
453 passed in 94.53s
|
||
|
||
$ python manage.py check
|
||
System check identified no issues (0 silenced).
|
||
|
||
$ python scripts/perf_baseline.py
|
||
✓ 全部接口在阈值内
|
||
```
|
||
|
||
### 本轮新增测试(+11)
|
||
|
||
| 模块 | 用例 | 覆盖 |
|
||
|---|---|---|
|
||
| `test_concurrency.py` | +3 | 单号并发唯一 / 删除不复用 / 空洞不回退 |
|
||
| `test_query_efficiency.py` | +7 | 4 个列表接口的 SQL 计数 + 分页上限 + 数据正确性 |
|
||
| `scripts/perf_baseline.py` | — | 12 接口性能基线(输出 JSON 便于对比) |
|
||
|
||
---
|
||
|
||
## 四、变更文件
|
||
|
||
```
|
||
backend/
|
||
├── apps/core/services.py [改] +next_bill_no / create_with_unique_bill_no
|
||
├── apps/core/viewset.py [改] +select_related_fields / prefetch_related_fields
|
||
├── apps/sales/services.py [改] 建单走并发安全单号
|
||
├── apps/sales/serializers.py [改] tax_total 改内存汇总(消除 50 次聚合)
|
||
├── apps/sales/views.py [改] 声明预取
|
||
├── apps/purchase/services.py·views.py [改] 同上
|
||
├── apps/storefront/services.py [改] 商城单号并发安全
|
||
├── apps/channel/services.py [改] 电商转单号并发安全
|
||
├── apps/finance/services.py [改] 应收/应付/凭证号并发安全
|
||
├── apps/inventory/views.py [改] 声明预取
|
||
├── apps/finance/views.py [改] 声明预取
|
||
├── scripts/perf_baseline.py [新] 性能基线(12 接口 + 阈值)
|
||
├── perf_baseline.json [新] 基线结果(便于回归对比)
|
||
└── tests/ test_concurrency.py(+3) · test_query_efficiency.py(7)
|
||
```
|
||
|
||
---
|
||
|
||
## 五、四轮迭代累计
|
||
|
||
| 指标 | 迭代前 | R1 | R2 | R3 | **R4** |
|
||
|---|---|---|---|---|---|
|
||
| 测试用例 | 348 | 393 | 421 | 443 | **458** |
|
||
| 修复缺陷 | — | 10 | 13 | 16 | **20** |
|
||
| 数据库后端 | SQLite | SQLite | SQLite | +PG | PG |
|
||
| 演示数据 | 5 商品 | 5 | 5 | 22 商品 | 22 商品 |
|
||
| 列表接口 P95 | 未测 | 未测 | 未测 | 未测 | **50ms**(原 266ms) |
|
||
|
||
**累计修复的高危问题**:
|
||
1. 负数量出库(凭空造库存)
|
||
2. 负单价(倒贴出货)
|
||
3. 极小金额 / 0 金额单据
|
||
4. 6 个页面 500(缺列)
|
||
5. 迁移未生成导致风控失效
|
||
6. 前端校验被控件掩盖
|
||
7. 关键操作无审计
|
||
8. 并发首次入库竞态(PG 实测)
|
||
9. seed 幂等被共享 rng 破坏
|
||
10. **单号生成并发撞号 + 删除后复用(5 处)**
|
||
11. **列表接口 N+1(311 → 5 SQL)**
|
||
12. **采购侧漏接价格校验(0 成本污染加权平均成本)**
|
||
|
||
---
|
||
|
||
## 六、前端表单校验审计(逼出 1 个后端缺口)
|
||
|
||
方法(沿用第 2 轮的"填非法值看是否真被拦"):用 Playwright 逐页扫描 15 个页面,
|
||
对能输入的页面实际填入非法值,观察"是否标红 + 是否被拦 + 是否有提示"。
|
||
|
||
### 扫描结果
|
||
|
||
15 个页面全部**无渲染错误**(无 `请求失败` / `undefined` / `NaN`)。
|
||
销售开单页实测:
|
||
|
||
| 测试 | 标红 | 提交结果 |
|
||
|---|---|---|
|
||
| 数量填 0 | ✓ | 被拦,提示"有 1 行数量不合法(需 ≥0.001 且 ≤10 亿)" |
|
||
| 单价填 -5 | ✗ | **无提示、无标红**(控件静默把 -5 纠正了) |
|
||
|
||
### 发现的缺口
|
||
|
||
**1. 前端:单价校验缺失**
|
||
`isValidPrice` 允许 0,且提交前**没有校验价格**(只校验了数量)。
|
||
用户填 -5 后被 `el-input-number` 静默改回,没有任何反馈——用户不知道发生了什么。
|
||
|
||
**2. 后端:采购侧漏接价格校验(真缺口)**
|
||
|
||
顺着前端问题查后端,发现 `purchase.services._resolve_line` **没有调用 `parse_price`**:
|
||
|
||
```python
|
||
price = Decimal(str(ln["unit_price"])) # ← 直接转换,未校验
|
||
```
|
||
|
||
第 1 轮我给销售侧接了校验,**采购侧遗漏了**。0 元进货当时只能靠下游"金额 > 0"兜住
|
||
(错误码是模糊的 `invalid_line`),而且 **0 成本入库会污染加权平均成本**
|
||
(把真实成本拉低 → 毛利虚高)。
|
||
|
||
### 修复
|
||
|
||
- 前端:`isValidPrice` 改为 `> 0`;提交前增加价格校验;单价非法标红(`.price-invalid`)
|
||
- 后端:`parse_price` 从 `≥ 0` 收紧为 `> 0`;采购 `_resolve_line` 接入 `parse_price`
|
||
- 新增 5 个测试:0 价销售/采购被拒、负价被拒、正常价格不受影响、
|
||
**0 成本不污染加权平均成本**(单元级)
|
||
|
||
---
|
||
|
||
## 七、下一轮候选
|
||
|
||
| 优先级 | 项目 | 说明 |
|
||
|---|---|---|
|
||
| P0 | 前端表单校验审计 | 用第 2 轮方法(填非法值看是否真被拦)逐页验证,只做过销售/采购 |
|
||
| P1 | 生产切 PG | 本机已完全跑通(含并发),服务器 192.168.5.7 也应切;SQLite 不适合生产 |
|
||
| P1 | 索引审查 | PG 上开 `EXPLAIN ANALYZE` 看大表是否有全表扫描(如 bill_date 范围查询) |
|
||
| P1 | 审计日志保留策略 | 当前无限增长(demo 已有 267 条,生产会更快) |
|
||
| P2 | 并发场景扩展 | 额度并发占用(两个订单同时用尽额度)、库存 + 单据混合并发 |
|
||
| P2 | 缓存层 | `risk_ranking` 110ms(遍历客户算分),可加短 TTL 缓存 |
|