基金转换 T-13:全量回归 + 50 并发压测 + 性能实测补录
交付:新增 tests/test_convert_concurrency.py(9 条 · CONVERT_STRESS 门禁)+ test_convert_service 同键重试 3 条;app/repository/convert_repository.insert_placeholder 改三步法(R-a)、app/service/convert/convert_service.py 幂等判定 pending→202。
修复 2 处并发缺陷(压测暴露,sqlite 不可见):① 阶段一失败后同键重试永久 503(占位朴素 INSERT 撞 uk_group/uk_idem);② 同键竞态子窗口 A 真·双扣 / B 双扣+503(撞 uk_idem 直穿)。
验证:pytest 736 passed / 10 skipped(+5,跑两遍稳定);CONVERT_STRESS=1 并发 9/9;按 SOP 重灌双库复跑零回归;7 个 convert 真库脚本复跑零回归;突变 4 组全部精准命中。50 并发不超卖(成交 20~25)、紧池退避 40/50=80%(不够用)、松池 100%、跨客户 1213 死锁登记待评估;性能端到端 P50 44.2/max 59.8ms、阶段一 P50 9.8/max 16.7ms(未触阈值、未改实测值)。
文档:PRD v0.9.3(§9 第 18 条实测补录)+ 开发计划 §10(10.1~10.5)+ docs/memory/{TODO,MEMORY,FRAMEWORK,ITERATION,2026-09-10}。
This commit is contained in:
+69
-19
@@ -125,16 +125,19 @@ T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算
|
||||
| | **T-7** ✅ | `convert_service` 编排(八步 + 执行权 + 幂等 + 三阶段 + 阶段 1.5)—— **17 用例 + 真库 35/35** | T-2~T-6 | **高(关键路径)** |
|
||||
| **第 4 批 · 引擎与网关** | **T-8** ✅ | `_amount_view` + `engine.process_convert_event` + `alert_service.events` —— **2026-09-10 完成(15 用例 + 真库 31/31)** | 无(可与 T-2 并行) | 中 |
|
||||
| | **T-9** ✅ | `api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 + **展示位数口径修复** —— **2026-09-10 完成(新增集成 8 条 / 11 条错误码映射)** | T-7 | 中 |
|
||||
| | T-11 | `core_tools` 汇总去重 + 持仓 `qty <= 0` 过滤 + `sum_trades_on_date` 去重 | T-8 | 中 |
|
||||
| **第 5 批 · 高风险专项** | **T-10** | 普通申赎批次维护(FR-C16,含 D8 兜底补建)+ `rebuild_lots.py` | T-3(排在 T-7 后) | **最高(打穿 510)** |
|
||||
| **第 6 批 · 补偿** | T-12 | `rebuild_alerts --convert-group` + `cleanup_pending_convert.py` | T-4/T-7 | 低 |
|
||||
| **第 7 批 · 收口** | T-13 | 全量回归 + 集成测试 + 50 并发压测 + 性能实测补录 | 全部 | 中 |
|
||||
| | **T-11** ✅ | `core_tools` 汇总去重 + 持仓 `qty <= 0` 过滤 + `sum_trades_on_date` 去重 —— **2026-09-10 完成(+4 用例 · 真库 14/14)** | T-8 | 中 |
|
||||
| **第 5 批 · 高风险专项** | **T-10** ✅ | 普通申赎批次维护(FR-C16,含 D8 兜底补建)+ `rebuild_lots.py` —— **2026-09-10 完成(+17 用例 · 真库 20/20)** | T-3(排在 T-7 后) | **最高(打穿 510)** |
|
||||
| **第 6 批 · 补偿** | **T-12** ✅ | `rebuild_alerts --convert-group` + `cleanup_pending_convert.py` —— **2026-09-10 完成(+12 用例 · 真库 34/34)** | T-4/T-7 | 低 |
|
||||
| **第 7 批 · 收口** | **T-13** ✅ | 全量回归 + 集成测试 + 50 并发压测 + 性能实测补录 —— **2026-09-10 完成(+5 用例 · 真库并发 9/9 · 突变 4 组 · 实测补录 PRD v0.9.3)** | 全部 | 中 |
|
||||
|
||||
**关键路径**:`T-0 → T-1 → T-2 → T-6 → T-7 → T-13`(**T-7 已通;T-9 已完成**)
|
||||
**关键路径**:`T-0 → T-1 → T-2 → T-6 → T-7 → T-13`(**全程已通,T-13 收口完成**)
|
||||
**并行组 A**:T-3 / T-4 / T-5(✅ 全部完成)
|
||||
**并行组 B**:T-8 全程可与 T-2 之后任意任务并行(✅ 已完成)
|
||||
**硬门禁**:`T-0` 与 `T-0b` **双双绿**才允许启动 T-1 及之后(T-0 用例 = `test_db.py::test_core_holding_columns`)
|
||||
**测试基线**:**697**(2026-09-10 T-9 后;批 0~3 路线 510 → 516 → 609 → 634 → 639 → 656 → 672 → **697**)→ 剩余任务(T-10~T-13)预计再加 **10~40** → **707~737**(估算)
|
||||
**测试基线**:**736 passed / 10 skipped**(2026-09-10 T-13 后;批 0~3 路线 510 → 516 → 609 → 634 → 639 → 656 → 672 → 697 → 714 → 718 → 719 → 731 → **736**)
|
||||
|
||||
> **✅ T-0 ~ T-13 全部结项(2026-09-10)。** 本线第 5 步(todo_list 逐步开发)走完;
|
||||
> 剩余仅为「第 6 步:最终集成测试」的独立复跑(SOP 重灌后全链路已验证,见 §10.1)。
|
||||
|
||||
---
|
||||
|
||||
@@ -1441,25 +1444,72 @@ sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点
|
||||
## 10. 第 7 批 · 回归与实测(T-13)
|
||||
|
||||
### 10.1 全量回归
|
||||
- `pytest -q` 全绿,计数 ≥ 585(CI 内预计约 597,压测 10 条不进门禁)
|
||||
- 集成测试(真 MySQL,`CNV-TEST-`/`TRD-TEST-` 前缀)绿
|
||||
- 按 SOP 重灌双库后复跑一次(避免残留数据污染)
|
||||
- ✅ `pytest -q` → **736 passed / 10 skipped**(基线 731 **+5**;10 skip = 7 条压测门禁 + 3 条 D20 账号未配)
|
||||
- ✅ 真库集成测试(`test_convert_integration.py`,`CNV-TEST-`/`TRD-TEST-` 前缀)绿
|
||||
- ✅ **按 SOP 重灌双库后复跑一次**:`DROP jinrong_core` → `scripts/core/00~09*.sql` 全 OK →
|
||||
`scripts/demo/prepare_risk_demo.sql` → `scripts/sync/sync_advisor_rel.py`(33 行 upsert)→ 复跑仍 **736/10**
|
||||
(Neo4j 未启动,`sync_neo4j.py` 按 reset.ps1 的 `-SkipNeo4j` 语义跳过)
|
||||
- ✅ 全部 convert 真库脚本复跑零回归:seed 全 PASS / apply 24 / service 35 / engine 31 / lots 20 / tools 14 / compensate 34
|
||||
|
||||
### 10.2 50 并发压测(评审 Q6 · **不进 CI 门禁**)
|
||||
同一 `(customer_id, product_id)` 上 50 并发争抢同一批份额,断言三件事:
|
||||
1. `LotConflict`(409) 命中数与剩余可转份额一致 → **不许超卖**
|
||||
2. 按 `100/200/400ms` 退避重试 ≤3 次后的**最终成功率**(验证 §8.3 建议间隔是否够)
|
||||
3. `core_trade` 中 `convert_group_id` **无重复**;`core_share_lot.remain_qty` 之和 = 初始值 − 实际成交份额
|
||||
新建 `tests/test_convert_concurrency.py`(9 条)。门禁语义用环境变量实现:
|
||||
常规 `pytest -q` 下 7 条重载用例逐条 skip;`CONVERT_STRESS=1` 显式开启全套。
|
||||
真库缺失时整模块 skip(模块级 `ensure_risk_demo_ready()`)。
|
||||
|
||||
同 `(customer_id, product_id)` 上 50 并发争抢同一批份额,三条断言:
|
||||
1. **不许超卖** ✅ 硬不变量成立:50 × 2000 争 50000 → 扣减恒 ≤ 50000、余 = 池 − 扣、
|
||||
每组恰好 2 条流水;**实测成交 20~25(理论上限 25)**——浮动原因是
|
||||
`plan_lots` 会把一笔跨批次拆成多笔扣减、`_deduct_lots` 要求本次全部成功否则整体回滚
|
||||
2. **退避重试最终成功率** ✅ 已量化(**结论见下**)
|
||||
3. `convert_group_id` 无重复、`remain_qty` 守恒 ✅
|
||||
|
||||
**退避间隔是否够用的结论(评审 Q6 的答案)**
|
||||
|
||||
| 场景 | 成功率 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| **紧池**(需求 50000 = 供给 50000,最紧张) | **40/50 = 80%** | **3 次 × 100/200/400ms 不够**。根因不是"间隔太短",而是**近空批次的反复争抢**:一笔需求被拆成 `400+600` 这类碎片后,碎片所在批次随时被别人清零 → 重试仍可能抢不到。要收敛到 100% 需**增加重试次数**或**冲突后换批次重规划**,单纯拉长间隔无效 |
|
||||
| **松池**(需求 25000 < 供给 50000,对照) | **50/50 = 100%** | 非耗尽场景下哨兵 + 退避链路完全可靠 |
|
||||
|
||||
> 24 小时口径外的补充发现:**跨客户并发会触发 InnoDB 1213 死锁**
|
||||
> (不同客户在 `core_holding`/`core_share_lot` 唯一索引上的插入意向锁互斥),
|
||||
> 8 路并发实测累计 3~5 次;`apply_convert` **不重试死锁**,按 §8.3 阶梯把它当可重试异常
|
||||
> 收敛后 8/8 成功。**"服务端不重试死锁"已登记为待评估项**(见交接文档 §B)。
|
||||
|
||||
### 10.3 性能实测补录(PRD §9 第 18 条)
|
||||
- 端到端响应 **< 2s**(本地模拟库)
|
||||
- 阶段一单库事务实测耗时**补录真实值**(PRD 原文的「< 100ms」是**预估值、非验收硬指标**)
|
||||
- **若实测超阈值 → 优化索引/锁策略后重定阈值;不得反向修改实测数据迁就指标**
|
||||
- ✅ 端到端 **P50 44.2ms / P95 51.5ms / max 59.8ms** → 远低于 **2s** 硬指标
|
||||
- ✅ 阶段一单库事务实测 **P50 9.8ms / P95 12.8ms / max 16.7ms** → 远低于「预估 < 100ms」
|
||||
- ✅ **未触发**"超阈值 → 优化索引/锁策略"分支;**未修改任何实测值**;数据已回填 PRD §9 第 18 条(v0.9.3)
|
||||
|
||||
**DoD**
|
||||
- [ ] 三项完成且**落点明确**:① 性能实测数据**回填 `docs/PRD/PRD-基金转换交易.md` §9 第 18 条**(用实测值替换「预估 < 100ms」);
|
||||
② 50 并发结论(是否超卖 / 退避间隔够不够)写入 项目根 `交接文档.md` §B;③ 若实测超阈值 → 优化索引/锁策略后**重定阈值**(**不得反向修改实测数据迁就指标**)
|
||||
- [ ] 50 并发结果写入交接文档(含退避间隔是否够用的结论)
|
||||
- [x] 三项完成且**落点明确**:① 性能实测数据**已回填** `docs/PRD/PRD-基金转换交易.md` §9 第 18 条(v0.9.3,实测值替换预估值);
|
||||
② 50 并发结论(不超卖 / 退避 80% 不足)写入 项目根 `交接文档.md` §B;③ 实测**未超阈值** → 无需重定阈值(**未修改实测数据**)
|
||||
- [x] 50 并发结果写入交接文档(含退避间隔**不够用**及根因、1213 死锁登记)
|
||||
|
||||
### 10.4 执行记录(2026-09-10 · 已落地)
|
||||
|
||||
**执行期发现并修复的 2 处缺陷**(均为压测暴露,非需求变更;详见 §10.5)
|
||||
|
||||
| # | 缺陷 | 影响面 | 修法 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 阶段一失败(`LotConflict` 409)后带**同一** `client_request_id` 重试 → 幂等占位撞 `uk_group`/`uk_idem` → `IdempotencyUnavailable`(503) | **确定性永久失败**:§8.3 明说 409 可重试,此路径**重试永不成功** —— T-13 退避成功率实测的直接前置阻断 | `ConvertRepository.insert_placeholder` 改**三步法**(R-a):已存在则置回 `pending` |
|
||||
| 2 | 同键并发的**两个竞态子窗口**:<br>· 子窗口 A(T2 在占位后进入)→ 各自 `group_id` 不同 → 旧代码重跑阶段一 → **真·双扣**(突变实测 120→**240**、两组流水)<br>· 子窗口 B(T2 在占位前进入)→ 撞 `uk_idem` → `IntegrityError` **直穿 503** | **资金安全**:A 会重复扣客户份额 | ① 幂等判定区分「确定没跑成」(`failed`/`expired` → 复用 group_id 重跑)与「在飞/未知」(`pending` → **202**);② `insert_placeholder` 返回"是否持有占位",撞键即让路 → 调用方 **202**(架构 §9「同键并发 → 202」) |
|
||||
|
||||
**验证**
|
||||
- `pytest -q` → **736 passed / 10 skipped**(+5,零回归;跑两遍稳定)
|
||||
- `CONVERT_STRESS=1 pytest tests/test_convert_concurrency.py` → **9 passed**
|
||||
- **突变验证 4 组,全部精准命中**(见 §10.5)
|
||||
- **重灌双库后复跑**:736/10 + 并发 9/9,零回归
|
||||
|
||||
### 10.5 突变验证明细(防假绿 · 逐组留痕)
|
||||
|
||||
| 组 | 突变 | 命中用例 | 实测失效形态 |
|
||||
| --- | --- | --- | --- |
|
||||
| ① | 拆掉 `_deduct_lots` 的并发哨兵 `remain_qty >= :q` | `test_50_concurrent_requests_never_oversell`(1 红) | 成交 **50/50**、余 **−50000**、扣减 **100000** → 超卖被抓 |
|
||||
| ② | 幂等判定把 `pending` 也当"未成"重跑 | `test_same_client_request_id_interleaving_must_not_leak_5xx`(1 红) | T1 直穿 `IntegrityError` |
|
||||
| ③ | 忽略 `insert_placeholder` 的让路信号 | `test_..._placeholder_race_must_not_leak_5xx`(并发文件,1 红)**+** `test_placeholder_uk_idem_race_returns_processing_without_writing`(sqlite 版,1 红) | **真·双扣:扣 240 份、两组流水** |
|
||||
| ④ | `insert_placeholder` 退回朴素 INSERT | `test_retry_after_phase_one_failure_*`(2 红) | `IdempotencyUnavailable`:`UNIQUE constraint failed` |
|
||||
|
||||
> 已恢复全部突变,`grep -rn "MUTATION\|1 = 1" app/ tests/ scripts/` **无残留**。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user