12 KiB
dealerhub · AGI 自主迭代报告(第 4 轮 · 并发序列 + N+1 优化)
承接:第 3 轮报告末尾提出的"
_generate_bill_no用 count()+1 取序号,并发下有竞态"。 本轮坐实并修复,同时扫描出另外 4 处同类问题;顺带做性能基线, 发现并修复列表接口的 N+1(5.3 倍提速)。 回归:SQLite 454 passed / PostgreSQL 458 passed,check0 issues。
一、单号生成竞态(PG 实测坐实)
症状
9 个并发建单 → 多个拿到同一单号:
IntegrityError: 重复键违反唯一约束 "sales_bill_tenant_id_bill_no_uniq"
DETAIL: 键值"(tenant_id, bill_no)=(1, XS202609110001)" 已经存在
根因:count() + 1 有两个缺陷
seq = Model.objects.filter(tenant=tenant, bill_no__startswith=head).count() + 1
- 并发撞号:两个事务同时
count()拿到同一个数 → 生成相同单号 - 删除后复用(更隐蔽):建了 001/002 后删掉 001 →
count()=1→ 下一张又是 002 → 撞唯一约束(因为 002 已存在)
修复:MAX(序号)+1 + 唯一约束重试
新增共享工具(apps/core/services.py):
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 个场景)
# 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... ← 主查询
三个根因:
tax_total用obj.lines.aggregate(Sum(...))—— 每张单一次聚合lines未预取- 行内
product/source_unit未预取
修复
1. 基类加预取声明机制(apps/core/viewset.py):
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 改为内存汇总:
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:
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) |
累计修复的高危问题:
- 负数量出库(凭空造库存)
- 负单价(倒贴出货)
- 极小金额 / 0 金额单据
- 6 个页面 500(缺列)
- 迁移未生成导致风控失效
- 前端校验被控件掩盖
- 关键操作无审计
- 并发首次入库竞态(PG 实测)
- seed 幂等被共享 rng 破坏
- 单号生成并发撞号 + 删除后复用(5 处)
- 列表接口 N+1(311 → 5 SQL)
- 采购侧漏接价格校验(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:
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 缓存 |