轻易云
注册体验

v6 一维拍扁:10 万订单级对账的性能优化

· 尹春锐· AI 财务对账· 10 次浏览· 约 12 分钟读完
v6 一维拍扁SupplyOrder性能优化三码并行双码匹配分向聚合SKU同步

v6 一维拍扁:10 万订单级对账的性能优化

摘要:当对账系统跑到 10 万订单级规模,最先撞墙的几乎一定是「供应链聚合」这一步——把 50 万条订单级数据按业务订单号 GROUP BY 到对账计划的体行。v5 二维模型(supply_orders + supply_order_items)要 JOIN、再聚合、再按分向过滤,常规 PostgreSQL 优化手段只能把 8 秒压到 4 秒,再往下就动不了了。v6 把这个模型拍扁成单表(每行 = 一份 SKU 明细),把三码(id / no / businessOrderNo / businessOrderId)挂在同一行,配合 customerCode 双码匹配与按方向(POSITIVE / NEGATIVE)分向聚合,10 万订单级别的聚合耗时稳定在 3 秒以内 [来源:v6 一维拍扁压测记录 2026-09]。

关键词:v6 一维拍扁、SupplyOrder、性能优化、三码并行、双码匹配、分向聚合、SKU 明细

月底 10 万订单,对账卡在聚合这一步

电商财务团队最不想听到的两个字是「超时」。

月底对账,老板要看收入侧的真实到账金额,要看费用侧有没有超支,要看每个 SKU 的毛利有没有算错。京东、抖店、亚马逊的账单加起来往往 20-30 万行,供应链 ETL 落库后又是几十万的订单行。系统在「把供应链订单按业务订单号聚合到对账计划体行」这一步卡住——前端 30 秒转圈还没出结果,财务已经开始用 Excel 手动核对。

性能优化的传统三板斧——加索引、改 SQL、调 PG 参数——在这条聚合链路上几乎全部失效。原因不是 PostgreSQL 不行,而是模型就不对:v5 的二维表设计(supply_orders 头表 + supply_order_items 体表)天然要求 JOIN + GROUP BY + 双层过滤,10 万订单场景下 SQL 执行计划再优化也不可能跨数量级。

v6 一维拍扁就是为这个问题设计的。它不是某种索引技巧或缓存策略,而是把数据模型本身拍扁成一张表——每行就是一份 SKU 明细,三码挂在同一行,按方向分向聚合不再需要 JOIN。结果是从「8 秒优化到 4 秒、再也下不去」变成了「直接 3 秒以内」,且没有走任何特殊硬件或缓存。

v6 一维拍扁:模型本身的革命

v5 二维模型为什么撑不住

先看 v5 的数据模型长什么样:

prisma
// v5:SupplyOrder 头表
model SupplyOrder {
  id              String  @id @default(cuid())
  no              String  @unique            // 单据编码
  businessOrderNo String                     // 业务订单号
  orderType       String                     // sale / return
  totalAmount     Decimal
  // ...
  items           SupplyOrderItem[]          // 嵌套体表
}

// v5:SupplyOrderItem 子表
model SupplyOrderItem {
  id              String  @id @default(cuid())
  supplyOrderId   String
  skuCode         String
  quantity        Decimal
  amount          Decimal
  // ...
}

这种二维模型很「标准」——教科书式的 1:N 设计。但「标准」在 10 万订单规模下变成了性能绞肉机:

  • JOIN 开销:聚合时必须先 JOIN items,50 万行 × 平均 3 行明细 = 150 万行临时表;
  • GROUP BY 双层:先按 businessOrderNo 聚合头表、再按 skuCode 聚合体表,两层聚合嵌套;
  • 按方向过滤:POSITIVE 体行对正数、NEGATIVE 体行对负数,过滤必须在 JOIN 之后,扫描集进一步膨胀;
  • cuid 主键:32 字符随机串,无业务语义,索引膨胀 + 缓存命中率低。

结果是:单店 10 万订单的聚合耗时稳定在 8-12 秒区间,再优化也只能到 4-5 秒——因为模型本身限制了下界。

v6 拍扁模型:每行 = 一份 SKU 明细

v6 把头表 + 子表合并成单表,每行就是一份 SKU 明细:

prisma
// v6:SupplyOrder 单表(一维拍扁)
model SupplyOrder {
  id              String   @id @db.VarChar(64)
  //   ↑ v5 cuid → v6 String(64) 自定义唯一码:buildSupplyOrderId() 拼接
  no              String   // v5 unique → v6 多行可共享(分批发货/退货)
  businessOrderNo String   // 对账聚合键(不唯一,与 BillRow 同名同语义)
  businessOrderId String?  @db.VarChar(64)  // v6 新增:ERP 系统层业务订单 id
  // ... 头表字段冗余到行级 ...
  entrySeq        Int?     // 分录序号
  skuCode         String   // SKU 编码(行级唯一)
  quantity        Decimal  @db.Decimal(20, 4)
  amount          Decimal  @db.Decimal(20, 4)  // 行级金额
  // ...
}

v6 拍扁的关键变化有 4 个:

维度v5 二维v6 一维拍扁
表数2 张(头 + 子)1 张
行粒度一行 = 一张单据一行 = 一份 SKU 明细
主键cuid() 32 字符String(64) 自定义拼接
no@unique多行共享
关联子表items: SupplyOrderItem[]无(直接冗余到行)
物料明细 FKsupplyOrderItemIdsupplyOrderId(直接行 id)

这个模型带来的最直接收益是查询路径的简化:聚合从「JOIN 子表 → GROUP BY 头 → GROUP BY 子」变成「单表 GROUP BY」。

看一张骨架图理解 v6 拍扁的拓扑:

供应链 v6 一维拍扁架构图,三码并行 id / no / businessOrderNo / businessOrderId 挂在同一行,单表 GROUP BY 让 10 万订单聚合耗时稳定在 3 秒内

这张图想表达的核心就一句:「业务订单号聚合 + SKU 明细追溯」在 v6 是同一次 GROUP BY。v5 里这两个动作在不同表上,必须 JOIN;v6 直接 GROUP BY businessOrderNo, skuCode 一把出结果。

把视角再拉远一点,看 v3 重构到 v6 拍扁的整体架构对比:

v3 重构 v6 拍扁架构对比图,左侧 v4.0 旧版 20 张表含抽象基类、tenantId 软删除等过度设计,右侧新版 v6 一维拍扁 + 三码并行 + 单子计划 + 双子对账,去掉所有非必要复杂度

这张对比图揭示了 v6 拍扁在整体架构里的位置——它不是孤立的技术点,而是「去过度设计」哲学在数据模型层的具体落地:把 20 张表(含抽象基类)精简到 24 张有效模型、v6 拍扁把供应链单表化、删除 tenantId / 软删除 / Python 脚本这些「听起来很标准但其实没用」的东西。拍扁的本质和 v3 重构一脉相承:模型复杂度不要为了「标准」而保留,能直接表达就别抽象。

拍扁的代价:行级冗余可接受吗?

读者立刻会问:拍扁后头表字段(orderType / orderTime / customerCode 等)冗余到每一行,是不是浪费存储?

是的,存储有冗余——但代价可接受,原因有 3 条:

  1. PostgreSQL TOAST 透明压缩:行级冗余字段(字符串 + Decimal)在 8KB 块内自然压缩,且访问时按需解压,I/O 反而比跨表 JOIN 更可预测;
  2. 热数据集中:拍扁后整行在同一块,相邻 SKU 明细大概率也在相邻块——顺序扫描 / 索引扫描的缓存命中率比「跨表跳指针」高一个数量级;
  3. 写入路径只多一份冗余:更新头表字段变成「按 businessOrderNo 更新所有行」——单语句 updateMany,无 JOIN,PG 执行计划简单。

实测下来,v6 拍扁 + 冗余字段的存储开销比 v5 二维表多 20-30%,但查询性能提升 5-10 倍(10 万订单聚合从 8 秒到 1.5 秒)。这是一个清晰的取舍:拿一点点存储换数量级的查询性能。

把这个观点推到底层,可以参考我们另一篇关于 v3 重构的对比——《v3 重构:16 项关键决策》中的「去过度设计」理念,拍扁就是它的延续:模型复杂度不要为了「标准」而保留,能直接表达就别抽象。如果走系统化这条路,可以考虑轻易云智能对账系统的 v6 拍扁落地:单表 14 个索引覆盖所有常用查询路径,写入走 buildSupplyOrderId() 幂等生成。

三码并行:id / no / businessOrderNo / businessOrderId 一起挂在同一行

v6 拍扁的同时引入了「三码」设计——4 个标识字段全部挂在同一行,每个字段承担不同角色:

字段类型唯一性角色典型来源
idString(64)唯一行级主键(ETL 幂等键)buildSupplyOrderId() 或金蝶 FORDERENTRYID
noString多行共享业务单据编码(同出库单多 SKU 行共享)金蝶 FBillNo / 手工单号
businessOrderNoString多行共享对账聚合键(与 BillRow 同名同语义)业务订单号 / 平台单号
businessOrderIdString?多行共享v6 新增:ERP 系统层业务订单 id金蝶 FORDERID / 内部 ID

三码并行的价值在不同场景下显现:

  • 京东、抖店、亚马逊:用 businessOrderNo 做对账聚合键(业务订单号),id 用 ETL 自定义拼接;
  • 金蝶云星空:用 id 直接接金蝶 FORDERENTRYID(行级内码),businessOrderNo 仍做对账键;
  • 手工录入:用 manual:<businessOrderNo>:<skuCode>:<entrySeq> 兜底拼接 id。

实测对账:聚合 SQL WHERE businessOrderNo IN (...) AND customerCode = ? 命中 idx_business_order_no + idx_customer_code 双索引扫描,10 万订单约 80ms 返回原始行;聚合计算走 Decimal reduce,全程内存操作不触 DB。

双码匹配:对账键的双保险

收入对账的双码匹配口径是 v6 的硬约束:

typescript
// v6 双码匹配:customerCode = Shop.externalCode + businessOrderNo
const supplyOrders = await prisma.supplyOrder.findMany({
  where: {
    businessOrderNo: { in: orderNos },
    customerCode: plan.shop.externalCode,  // 平台侧店铺编码 = 客户编码
  },
});
// ↑ 双索引走 idx_business_order_no + idx_customer_code,10 万行 ~80ms

为什么需要双码? 因为 businessOrderNo 在多个平台可能撞号(同一家店铺在京东和抖店的订单号可能相同),仅按 businessOrderNo 匹配会导致跨平台串单。customerCode 是店铺在供应链系统里的客户编码(与 Shop.externalCode 一致),加上这一道就锁死了「同店同单」的语义。

E2E 测试时最容易踩的坑就在这里:店铺主数据缺失 externalCode 或测试用店铺编码与生产撞号,会导致所有对账匹配失败。这种 case 的 debug 路径在 test-supply-order skill 的「Gotchas」段落有详细说明。

按方向分向聚合:POSITIVE / NEGATIVE 不轧差

v6 的另一个关键决策是按方向分向聚合:

typescript
// v6 分向口径:POSITIVE 体行对 amount > 0 的供应链行,NEGATIVE 体行对 amount < 0
const directedOrders = orders.filter((o) => {
  const amount = new Decimal(o.amount.toString());
  return isPositive ? amount.greaterThan(0) : amount.lessThan(0);
});

// 行级聚合(v6:直接 SUM 行 amount,不走 JOIN)
const supplyIncome = directedOrders.reduce(
  (sum, o) => sum.plus(o.amount.toString()),
  new Decimal(0),
);

为什么要分向?退货和出库必须分别对账,不能轧差。一家店铺同一天出库 100 单 + 退货 5 单,轧差后等于出库 95 单——但财务上出库单和退货单是两笔不同的业务凭证,不能合并。v6 把这一约束写进行级筛选,POSITIVE 体行只对正数行、NEGATIVE 体行只对负数行,聚合结果是两笔独立的财务记录。

这一决策的反直觉之处在于:很多工程师下意识想做「整单轧差 = 更简单」,但财务对账的实质就是「不允许轧差」——出库单和退货单是两张不同的 ERP 单据凭证。v6 把这一业务约束直接翻译成数据模型,省掉了运行时做正负分类的逻辑。

实战:分批发货 / 退货 / 大体量三个场景

下面看 3 个真实场景下的 v6 表现,所有数据来自供应链 E2E 压测:

场景 1:分批发货(同 businessOrderNo 多行)

京东一笔订单拆 3 次发货(出库 1 / 出库 2 / 出库 3),v6 拍扁后落 3 行:

idnobusinessOrderNoorderTypeskuCodeamount
manual:JD12345:SKU-1001:0SO-20260717-001JD12345saleSKU-100189.5
manual:JD12345:SKU-1002:1SO-20260717-001JD12345saleSKU-1002129.0
manual:JD12345:SKU-1001:2SO-20260717-002JD12345saleSKU-100189.5

对账聚合:WHERE businessOrderNo = 'JD12345' → 3 行命中 → SUM(amount) = 308 → 与 BillRow 同订单号金额比对。

v5 同样场景:3 张头表 × 1 个 item 表 JOIN = 3 条头表记录但子表查询 3 次;v6 直接 3 行扁平数据,聚合路径少了一半。

场景 2:销售 + 退货混合

真实电商订单最常见的场景——同一订单号下既有出库、又有退货(消费者 7 天无理由退货):

idnobusinessOrderNoorderTypeskuCodeamount
manual:JD12345:SKU-1001:0SO-20260717-001JD12345saleSKU-100189.5
manual:JD12345:SKU-1002:1SO-20260717-001JD12345saleSKU-1002129.0
manual:JD12345:SKU-1001:0RT-20260717-001JD12345returnSKU-1001-89.5

v6 拍扁 + 分向聚合:POSITIVE 体行对 amount > 0 → 218.5;NEGATIVE 体行对 amount < 0 → -89.5;两笔独立对账,不轧差。

如果按整单轧差,结果是 129.0——表面上「看起来对」,但财务侧把出库单 218.5 + 退货单 -89.5 分别挂两张凭证,轧差等于把两笔业务凭证强行合并,月底对账不平。

场景 3:单订单 200 行 SKU 明细(大体量)

最考验 v6 拍扁模型的是「一个超大订单」——B 端大客户一次下单 200 个不同 SKU:

typescript
// 单 businessOrderNo 下 200 行 SKU 明细
// v6 单表 GROUP BY 聚合:~5ms
// v5 JOIN + GROUP BY 头 + GROUP BY 子:~200ms
// 性能差距:40 倍

实测在 10 万订单(含 200 行 SKU 明细的大订单占 5%)的混合集下:

  • v6 拍扁:聚合耗时稳定 1.5-3 秒(P95 < 3s)
  • v5 二维:同数据集聚合耗时 8-12 秒(P95 ≈ 10s)

10 万订单级别的聚合耗时压到 3 秒以内,不是靠任何特殊硬件或缓存策略,而是模型本身的简化带来的。这是数据规模治理最直接的体现:把模型拍扁,就是给未来 10 倍、100 倍数据量留余量。

来看 v6 拍扁在整体收入对账链路里的位置——从创建对账计划到 7 态状态机的完整流程:

收入对账完整流程图,从创建对账计划 3 步向导到 PENDING → READY → RECONCILING → RECONCILED → CONFIRMED 7 态状态机迁移,v6 拍扁聚合发生在 RECONCILING 步骤中

注意图中 RECONCILING 步骤——这就是 v6 拍扁模型发挥作用的环节:按 businessOrderNo + customerCode 双码定位供应链行,按方向分向过滤后 SUM(amount) 聚合,最后比对 BillRow 与 SupplyOrder 的金额差异。整个聚合过程就是图里那个看似不起眼的「聚合供应链」动作,但背后是 v6 一维拍扁的工程支撑。

下面这张图展示了供应链管理页的真实形态——11 行退货明细,每行都是独立的 SKU 行:

供应链订单管理列表页,11 行退货明细展示 v6 拍扁模型的实际形态:每行 = 一份 SKU 明细,amount 为负数表示退款,AR260912... 单据号 + Petrel 超轻钛分体式气炉等 SKU 明细行级展示

可以看到列表里全部都是负数 amount(退货类型),每行挂自己的 no、businessOrderNo、skuCode——这就是 v6 拍扁模型的真实落地形态。同 businessOrderNo 出现多次正是分批发货/退货的体现,与设计完全吻合。

id 拼接的三级降级:保证 ETL 幂等的关键

v6 把主键从 cuid 改成 String(64) 自定义唯一码,这是拍扁模型能跑起来的工程基础——id 必须稳定且幂等,否则 ETL 重跑会产生重复行。

buildSupplyOrderId() 用三级降级规则拼接:

typescript
// 优先级 1:sourceErp + sourceErpDocId + sourceErpEntryId(适用:金蝶等带「头内码 + 分录内码」的 ERP)
// 优先级 2:sourceErp + no + entrySeq(适用:无分录内码但有 no 的老 ETL)
// 优先级 3:manual + businessOrderNo + skuCode + entrySeq(适用:完全手工录入的兜底)

export function buildSupplyOrderId(input: SupplyOrderIdInput): string {
  const sourceErp = normalize(input.sourceErp);
  // ... 归一化:含 ":" 替换为 "_",避免破坏分隔符语义 ...
  
  // 优先级 1
  if (sourceErp && sourceErpDocId && sourceErpEntryId) {
    return clamp(`${sourceErp}:${sourceErpDocId}:${sourceErpEntryId}`);
  }
  // 优先级 2
  if (no) {
    return clamp(`${sourceErp}:${no}:${entrySeq}`);
  }
  // 优先级 3
  if (businessOrderNo) {
    return clamp(`manual:${businessOrderNo}:${skuCode || "unknown"}:${entrySeq}`);
  }
  throw new Error("无法生成 SupplyOrder.id:所有关键字段均为空...");
}

这个三级降级设计解决了 3 个工程问题:

  1. ETL 幂等:同一个业务数据无论跑多少次 ETL,产出的 id 完全相同 → 写入是 upsert 而非 insert,重复数据自动合并;
  2. 跨源兼容:金蝶有 FORDERENTRYID(行级内码)就直接用,没有的就用 no + entrySeq,完全手工的就用业务订单号兜底;
  3. 64 字符上限:拼接后若 > 64 字符就 slice 截断——核心三种拼接实际最长 ~50 字符,留 14 字符余量给将来加字段(如 :orgCode)。

反直觉的点是:id 不是数据库生成的,而是 ETL 生成的。这违反了「主键应该由数据库管」的常识,但收益是幂等性 + 跨源一致性——ETL 重跑、数据修复、跨库迁移都能靠 id 稳定来支撑。在生产环境里走这条路,可以考虑轻易云智能对账系统的 ETL 设计:上游三源(金蝶应收单 / 手工录入 / 其他 ETL)共用同一套拼接规则,幂等键与金蝶行级内码 FORDERENTRYID 完全兼容。

索引设计:14 个索引覆盖所有常用查询路径

v6 拍扁后查询模式从「跨表」变成「单表」,索引设计的重点也从「JOIN 加速」变成「单表过滤加速」。

supply.prisma 给 SupplyOrder 表定义了 14 个索引:

prisma
@@index([no])                  // 同单据多行定位
@@index([businessOrderNo])     // 对账聚合键(不 unique;走 idx_business_order_no)
@@index([businessOrderId])     // v6 新增:按 ERP 业务订单 id 反查
@@index([shopId])
@@index([orderTime])
@@index([sourceErp, sourceBillNo])
@@index([customerCode])        // 双码匹配第二键
@@index([supplierCode])
@@index([currency])
@@index([sourceErpDocId])      // 金蝶应收单内码反查
@@index([billTypeCode])
@@index([documentStatus])
@@index([skuCode])             // SKU 级追溯
@@index([stockCode])
@@index([sourceErpEntryId])    // 金蝶分录内码反查
@@index([setAccountType])      // 2026-08-24:按立账类型筛暂估单(推送设计新增)

设计原则有 3 条:

  1. 聚合键优先:businessOrderNo 是对账聚合最高频的过滤字段,单独索引而不是组合索引——PG 的 BitmapAnd 在多条件场景下反而更慢;
  2. 业务查询路径全覆盖:所有可能的 WHERE 字段(customerCode / skuCode / orderTime / setAccountType 等)都有独立索引,避免查询时全表扫描;
  3. 金蝶字段单独索引:sourceErpDocId / sourceErpEntryId / billTypeCode / documentStatus 都是金蝶 PULL 阶段的反查键,独立索引保证 PULL 性能。

这套索引设计的代价是写入稍慢(每行多维护 14 个索引项),但对账场景是读多写少(月底一次性写入,平时全是查询),写入开销完全可接受。

索引设计的更多权衡可以参考项目内的 income-reconciliation-development skill(v3/v6 对齐基线)与 test-supply-order skill——后者详细列出了 v6 一维拍扁下的 6 个场景预设(exact-match / partial-missing / amount-diff / split-shipment / return-mix / large-volume)和每种场景的预期结果。

收尾:把模型拍扁,是给未来留余量

回到开篇的问题:10 万订单级别的聚合能不能做到 3 秒以内?

答案是能,且不需要任何特殊硬件或缓存。前提是模型本身就要对——把 v5 二维(头 + 子)拍扁成 v6 一维(单表),三码挂在同一行,按方向分向聚合不走 JOIN。剩下要做的只是给单表加 14 个常用索引、id 拼接三级降级保证幂等,最后让 PostgreSQL 自己跑——它跑得比想象中快。

数据规模治理的核心,不是「数据库优化到极致」,而是「模型表达力到位」。v6 拍扁的本质是把「跨表 JOIN」这种数据库不擅长的事情,变成「单表 GROUP BY」这种数据库极其擅长的事情——这是数量级的差异,不是百分比的差异。

把模型拍扁、把数据规模治理做好,对账系统的下一阶段才有空间谈 AI Agent、谈实时风控、谈月结自动化——这些都是 v6 拍扁之后才有余量展开的事。

模型拍扁这件事最反直觉的一点是:它看起来像是退步(去掉了规范化的子表),但实际上是进步(让数据库做它最擅长的事)。

把模型拍扁、把数据规模治理做好,对账系统的下一阶段才有空间谈 AI Agent、谈实时风控、谈月结自动化——这些都是 v6 拍扁之后才有余量展开的事。

模型拍扁这件事最反直觉的一点是:它看起来像是退步(去掉了规范化的子表),但实际上是进步(让数据库做它最擅长的事)。

延伸阅读

  • income-reconciliation-development skill:v3/v6 对齐版对账计划开发与排障指南,包含 7 态状态机、平台聚合范式、SupplyOrder 聚合取数等核心知识。
  • test-supply-order skill:供应链 E2E 测试 6 场景预设(精确匹配 / 缺单 / 差异 / 分批 / 退货混合 / 大体量),是验证 v6 拍扁聚合逻辑的标准输入。
  • reconciliation-script-development skill:对账沙箱脚本契约(整单轧差口径、23 个 diffReason 标签码、判定纪律),与 v6 拍扁的聚合结果强耦合。
本文为原创内容,转载请注明出处:https://www.qeasy.cloud/insights/reconciliation/2-2-7-v6-flatten-supply-order-performance

评论