Files
dealerhub/PROGRESS_AGI_ITERATION_4.md

12 KiB
Raw Permalink Blame History

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 有两个缺陷

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):

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...             ← 主查询

三个根因:

  1. tax_total 用 obj.lines.aggregate(Sum(...)) —— 每张单一次聚合
  2. lines 未预取
  3. 行内 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)

累计修复的高危问题:

  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:

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 缓存