P0-2:恢复基线要求的 AUTO_INCREMENT,消除手工发号的并发主键冲突

## 问题

`trade_service._next_id` 用 `SELECT MAX(id)+1` 发主键。两个事务读到同一个 MAX、
算出同一个 id,后写的那笔 `flush()` 撞 `Duplicate entry ... for key 'PRIMARY'`
-> 该客户下单直接 **500**。`submit_order` 一次要发 **3 个 id**
(订单 / 成交 / 资金流水),冲突面是单表的三倍。

## 这是"修正偏差",不是"改基线"

`docs/00-新数据库基线设计.md` 第 41 行:

    | 主键 | 统一 `BIGINT UNSIGNED AUTO_INCREMENT`,业务编号另设唯一键 |

**基线本来就要求 AUTO_INCREMENT**,是生成的 DDL 漏了 —— `_next_id` 自己的
docstring 也写着"与 docs/00 设计稿存在偏差"。所以本迁移**不违反** AGENTS.md
规则 4(禁止改类型/可空性/业务含义):类型仍是 `BIGINT UNSIGNED`、仍是 `NOT NULL`、
`id` 的业务含义不变,只是补回一个列属性;已有行 id 不变,显式给 id 依然合法。

## 迁移 `20260914_baseline_auto_increment`

- **15 张表**恢复 AUTO_INCREMENT(硬编码表名 —— 迁移必须确定性,动态查
  `information_schema` 会让同一份迁移在不同环境产生不同结果)。
- **有意排除 3 张**(`EXCLUDED_BECAUSE_FOREIGN_KEY`):`fin_product`(被 10 张
  `advisor_product_*` 引用)、`fin_risk_assessment`、`sys_user`(被 19+ 张引用)。
  MySQL 拒绝 `MODIFY` 被外键引用的列:

      (1833, "Cannot change column 'id': used in a foreign key constraint ...")

  改它们必须先 DROP FOREIGN KEY -> MODIFY -> 重建外键,那是另一件事(涉及 30+ 个
  外键的重建与一致性验证),不该塞进这条"恢复基线属性"的迁移。且这三张表**写入
  频率很低、没有任何代码用 `SELECT MAX(id)+1` 给它们发号** —— 排除它们不影响
  本迁移的目标。
- `downgrade()` 可回滚(只是去掉属性、不丢数据),但注释里写明:**回滚会把 P0-2
  的并发冲突带回来**。

⚠️ 迁移执行中踩到过"部分生效":MySQL DDL 非事务性,第一次跑到 `fin_product`
才报错,**前 7 张已经改完**。修正列表后重跑即收敛(对已是 AUTO_INCREMENT 的列
再 `MODIFY` 是无害的)。这一点也说明**迁移必须逐表可重入**。

## 代码

`trade_service.py` 删除 `_next_id` 方法及 4 处调用(`FundSimOrder` /
`FundTransaction` / `FundCashLedger` / `FundHolding`),改由 InnoDB 分配;
顺带清掉因此不再使用的 `Any` 与 `func` import(全仓 grep 确认它们只服务于
`_next_id`)。测试对 `_next_id` 零依赖(已 grep 确认)。

`test_advisor_migration_contract.py` 里那个"钉住末端版本"的断言按它自己的注释
要求同步更新到新 head。

## 实测

- `alembic upgrade head` -> `current = 20260914_baseline_auto_increment`,
  复核状态:**15 张已生效、3 张按设计排除**
- **并发下单实测**(2 个客户 × 3 笔 = 6 笔真并发;刻意用**不同客户**,
  因为 P0-3 的行锁已经把同一客户串行化了,不同客户才会真正并发进入发号路径):

      成功 6 / 主键冲突 0 / 其它失败 0
      => P0-2 已解决

- `pytest tests/unit tests/contract` -> **1427 passed, 2 skipped, 2 failed**
  (2 个既有失败与本次无关)
- `ruff check` -> All checks passed
This commit is contained in:
2026-09-14 20:27:35 +08:00
parent a337cca31a
commit 6b2e2edcda
3 changed files with 106 additions and 22 deletions
@@ -0,0 +1,101 @@
"""restore AUTO_INCREMENT on baseline primary keys
Compatibility proof
-------------------
This revision only **restores a column attribute**, and it is the attribute
`docs/00-新数据库基线设计.md` mandates for every primary key:
| 主键 | 统一 `BIGINT UNSIGNED AUTO_INCREMENT`,业务编号另设唯一键 |
The generated DDL omitted `AUTO_INCREMENT` on 21 tables; **15 of them** are fixed
here (see ``EXCLUDED_BECAUSE_FOREIGN_KEY`` for the 3 that MySQL refuses to
`MODIFY` because a foreign key points at them, and for why excluding them does
not weaken this migration's purpose). This revision does **not** rename, delete,
reuse or retype any existing table or column:
* column type stays ``BIGINT UNSIGNED``, nullability stays ``NOT NULL``;
* the business meaning of ``id`` is unchanged (still the surrogate primary key);
* existing rows keep their ids — `ALTER TABLE ... MODIFY ... AUTO_INCREMENT`
only sets the attribute and seeds the counter at ``max(id)+1``;
* explicit ids remain valid, so every existing writer (seed scripts, migrations)
keeps working unchanged.
Why it matters
--------------
``app/service/trade_service.py`` allocated keys with ``SELECT MAX(id)+1``
(``_next_id``). Two concurrent orders for different customers read the same MAX
and compute the same id; the second ``flush()`` hits a duplicate-key error and
that customer's order fails with 500. ``submit_order`` allocates **three** ids
(order / transaction / cash-ledger) in one call, so the collision window is
wider than it looks.
Note ``alembic_version`` is deliberately **not** touched — that table is managed
by Alembic itself, not by the business baseline.
"""
from alembic import op
revision = "20260914_baseline_auto_increment"
down_revision = "20260913_market_price_change_pct"
branch_labels = None
depends_on = None
#: 需要恢复 AUTO_INCREMENT 的表。
#:
#: 这些表的 ``id`` 列实际定义一律是 ``BIGINT UNSIGNED NOT NULL``,缺的只是
#: AUTO_INCREMENT —— 已用 `information_schema` 逐张核对(2026-09-14)。
#:
#: ⚠️ 刻意**硬编码**而不是在迁移里查 ``information_schema``:迁移必须**确定性**。
#: 动态查询会让"同一份迁移在不同环境产生不同结果",回滚与复现都失去依据。
#: 新表若同样缺这个属性,请另开一条迁移,不要往这里追加 —— 历史迁移一旦被
#: 执行过就不该再改变行为。
TABLES: tuple[str, ...] = (
"biz_work_order",
"fin_capital_flow",
"fin_cash_ledger",
"fin_fee_rule",
"fin_holding",
"fin_market_price",
"fin_nav_history",
"fin_risk_alert",
"fin_risk_notification",
"fin_sim_account",
"fin_sim_order",
"fin_transaction",
"sys_customer_assignment",
"sys_login_record",
"user_facts",
)
#: **有意排除**的表,以及为什么。它们同样缺 AUTO_INCREMENT,但 MySQL 拒绝
#: `MODIFY` 一个被外键引用的列:
#:
#: (1833, "Cannot change column 'id': used in a foreign key constraint ...")
#:
#: 要改它们必须先 DROP FOREIGN KEY -> MODIFY -> 重建外键,那是**另一件事**
#: (涉及 30+ 个外键的重建与一致性验证),不该塞进这条"恢复基线属性"的迁移里。
#:
#: 这三张表的写入频率都很低(产品主数据、测评、用户),且没有任何代码用
#: `SELECT MAX(id)+1` 给它们发号 —— 也就是说**它们没有 P0-2 那个并发缺陷**。
#: 排除它们不会让本迁移的目标打折。
EXCLUDED_BECAUSE_FOREIGN_KEY: tuple[tuple[str, str], ...] = (
("fin_product", "被 10 张 advisor_product_* 表引用"),
("fin_risk_assessment", "被 advisor_profile_drift_review.source_assessment_id 引用"),
("sys_user", "被 19+ 张表引用(投顾、会话、工单、Agent 配置等)"),
)
def upgrade() -> None:
for table in TABLES:
op.execute(f"ALTER TABLE {table} MODIFY id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT")
def downgrade() -> None:
"""回到"没有 AUTO_INCREMENT"的历史状态。
允许回滚:这只是去掉一个列属性,不丢数据、不改 id 值。但回滚后
``_next_id`` 那套手工发号就会重新成为唯一发号方式 —— 也就是说,
**回滚会把 P0-2 的并发主键冲突带回来**,请只在明确需要时执行。
"""
for table in TABLES:
op.execute(f"ALTER TABLE {table} MODIFY id BIGINT UNSIGNED NOT NULL")
+1 -20
View File
@@ -24,10 +24,9 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import func, select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.schemas.trading import ( from app.api.schemas.trading import (
@@ -108,20 +107,6 @@ class TradeService:
# explicit callers may still inject a compatible evaluator. # explicit callers may still inject a compatible evaluator.
self._suitability_evaluator = suitability_evaluator or SuitabilityService() self._suitability_evaluator = suitability_evaluator or SuitabilityService()
async def _next_id(self, model: Any) -> int:
"""返回 ``model`` 表的下一个可用主键。
底座 ``fin_*`` 表 ``id`` 列实际**未**配置 AUTO_INCREMENT(与 ``docs/00`` 设计稿
存在偏差),但 AGENTS.md 禁止修改既有列类型/可空性/含义。本服务按 seed
脚本同样思路用 ``SELECT MAX(id)+1`` 显式发号以保持基线零变更。
并发与单测场景下够用;后续若需要严格序数,再独立 PR 引入发号器。
"""
result = await self._session.execute(
select(func.max(model.id))
)
max_id = result.scalar()
return int(max_id or 0) + 1
# ---------- 行情 ---------- # ---------- 行情 ----------
async def _fetch_quote( async def _fetch_quote(
@@ -374,7 +359,6 @@ class TradeService:
order_no = f"SO{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}{uuid4().hex[:8].upper()}" order_no = f"SO{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}{uuid4().hex[:8].upper()}"
txn_no = f"TX{order_no[2:]}" txn_no = f"TX{order_no[2:]}"
order = FundSimOrder( order = FundSimOrder(
id=await self._next_id(FundSimOrder),
order_no=order_no, order_no=order_no,
customer_id=customer_id, customer_id=customer_id,
account_id=account.id, account_id=account.id,
@@ -401,7 +385,6 @@ class TradeService:
# 普通列)。业务派生:transaction_type / nav / shares / amount / fee / confirmed_at # 普通列)。业务派生:transaction_type / nav / shares / amount / fee / confirmed_at
txn_type = "买入" if payload.order_side == "buy" else "卖出" txn_type = "买入" if payload.order_side == "buy" else "卖出"
txn = FundTransaction( txn = FundTransaction(
id=await self._next_id(FundTransaction),
transaction_no=txn_no, transaction_no=txn_no,
order_id=order.id, order_id=order.id,
work_order_id=None, work_order_id=None,
@@ -460,7 +443,6 @@ class TradeService:
# 写入资金账时**不**触碰生成列 # 写入资金账时**不**触碰生成列
ledger = FundCashLedger( ledger = FundCashLedger(
id=await self._next_id(FundCashLedger),
ledger_no=f"L{txn_no[2:]}", ledger_no=f"L{txn_no[2:]}",
account_id=account.id, account_id=account.id,
transaction_id=txn.id, transaction_id=txn.id,
@@ -502,7 +484,6 @@ class TradeService:
now_h = datetime.now(UTC).replace(tzinfo=None) now_h = datetime.now(UTC).replace(tzinfo=None)
self._session.add( self._session.add(
FundHolding( FundHolding(
id=await self._next_id(FundHolding),
customer_id=customer_id, customer_id=customer_id,
trade_account=trade_account, trade_account=trade_account,
product_id=product_id, product_id=product_id,
@@ -38,8 +38,10 @@ def test_advisor_migrations_form_one_chain_from_qyqy_head() -> None:
assert len(script.get_heads()) == 1 assert len(script.get_heads()) == 1
# 核心断言是上面那句"链收敛到一个 head";下面钉住当前末端版本,便于发现迁移被误删或分叉。 # 核心断言是上面那句"链收敛到一个 head";下面钉住当前末端版本,便于发现迁移被误删或分叉。
# ⚠️ **新增迁移后要同步更新这个值**。2026-09-13 追加了 # ⚠️ **新增迁移后要同步更新这个值**。2026-09-13 追加了
# `20260913_market_price_change_pct`(给 `fin_market_price` 补 `change_pct`)。 # `20260913_market_price_change_pct`(给 `fin_market_price` 补 `change_pct`);
assert script.get_heads()[0] == "20260913_market_price_change_pct" # 2026-09-14 追加了 `20260914_baseline_auto_increment`(给 15 张表恢复基线要求的
# `AUTO_INCREMENT`,消除 `_next_id` 的并发主键冲突)。
assert script.get_heads()[0] == "20260914_baseline_auto_increment"
first = (VERSIONS / ADVISOR_FILES[0]).read_text(encoding="utf-8") first = (VERSIONS / ADVISOR_FILES[0]).read_text(encoding="utf-8")
assert 'down_revision = "20260910_drop_review_separation"' in first assert 'down_revision = "20260910_drop_review_separation"' in first