v6 一维拍扁:10 万订单级对账的性能优化
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 的数据模型长什么样:
// 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 明细:
// 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[] | 无(直接冗余到行) |
| 物料明细 FK | supplyOrderItemId | supplyOrderId(直接行 id) |
这个模型带来的最直接收益是查询路径的简化:聚合从「JOIN 子表 → GROUP BY 头 → GROUP BY 子」变成「单表 GROUP BY」。
看一张骨架图理解 v6 拍扁的拓扑:

这张图想表达的核心就一句:「业务订单号聚合 + SKU 明细追溯」在 v6 是同一次 GROUP BY。v5 里这两个动作在不同表上,必须 JOIN;v6 直接 GROUP BY businessOrderNo, skuCode 一把出结果。
把视角再拉远一点,看 v3 重构到 v6 拍扁的整体架构对比:

这张对比图揭示了 v6 拍扁在整体架构里的位置——它不是孤立的技术点,而是「去过度设计」哲学在数据模型层的具体落地:把 20 张表(含抽象基类)精简到 24 张有效模型、v6 拍扁把供应链单表化、删除 tenantId / 软删除 / Python 脚本这些「听起来很标准但其实没用」的东西。拍扁的本质和 v3 重构一脉相承:模型复杂度不要为了「标准」而保留,能直接表达就别抽象。
拍扁的代价:行级冗余可接受吗?
读者立刻会问:拍扁后头表字段(orderType / orderTime / customerCode 等)冗余到每一行,是不是浪费存储?
是的,存储有冗余——但代价可接受,原因有 3 条:
- PostgreSQL TOAST 透明压缩:行级冗余字段(字符串 + Decimal)在 8KB 块内自然压缩,且访问时按需解压,I/O 反而比跨表 JOIN 更可预测;
- 热数据集中:拍扁后整行在同一块,相邻 SKU 明细大概率也在相邻块——顺序扫描 / 索引扫描的缓存命中率比「跨表跳指针」高一个数量级;
- 写入路径只多一份冗余:更新头表字段变成「按
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 个标识字段全部挂在同一行,每个字段承担不同角色:
| 字段 | 类型 | 唯一性 | 角色 | 典型来源 |
|---|---|---|---|---|
id | String(64) | 唯一 | 行级主键(ETL 幂等键) | buildSupplyOrderId() 或金蝶 FORDERENTRYID |
no | String | 多行共享 | 业务单据编码(同出库单多 SKU 行共享) | 金蝶 FBillNo / 手工单号 |
businessOrderNo | String | 多行共享 | 对账聚合键(与 BillRow 同名同语义) | 业务订单号 / 平台单号 |
businessOrderId | String? | 多行共享 | 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 的硬约束:
// 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 的另一个关键决策是按方向分向聚合:
// 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 行:
| id | no | businessOrderNo | orderType | skuCode | amount |
|---|---|---|---|---|---|
manual:JD12345:SKU-1001:0 | SO-20260717-001 | JD12345 | sale | SKU-1001 | 89.5 |
manual:JD12345:SKU-1002:1 | SO-20260717-001 | JD12345 | sale | SKU-1002 | 129.0 |
manual:JD12345:SKU-1001:2 | SO-20260717-002 | JD12345 | sale | SKU-1001 | 89.5 |
对账聚合:WHERE businessOrderNo = 'JD12345' → 3 行命中 → SUM(amount) = 308 → 与 BillRow 同订单号金额比对。
v5 同样场景:3 张头表 × 1 个 item 表 JOIN = 3 条头表记录但子表查询 3 次;v6 直接 3 行扁平数据,聚合路径少了一半。
场景 2:销售 + 退货混合
真实电商订单最常见的场景——同一订单号下既有出库、又有退货(消费者 7 天无理由退货):
| id | no | businessOrderNo | orderType | skuCode | amount |
|---|---|---|---|---|---|
manual:JD12345:SKU-1001:0 | SO-20260717-001 | JD12345 | sale | SKU-1001 | 89.5 |
manual:JD12345:SKU-1002:1 | SO-20260717-001 | JD12345 | sale | SKU-1002 | 129.0 |
manual:JD12345:SKU-1001:0 | RT-20260717-001 | JD12345 | return | SKU-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:
// 单 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 态状态机的完整流程:

注意图中 RECONCILING 步骤——这就是 v6 拍扁模型发挥作用的环节:按 businessOrderNo + customerCode 双码定位供应链行,按方向分向过滤后 SUM(amount) 聚合,最后比对 BillRow 与 SupplyOrder 的金额差异。整个聚合过程就是图里那个看似不起眼的「聚合供应链」动作,但背后是 v6 一维拍扁的工程支撑。
下面这张图展示了供应链管理页的真实形态——11 行退货明细,每行都是独立的 SKU 行:

可以看到列表里全部都是负数 amount(退货类型),每行挂自己的 no、businessOrderNo、skuCode——这就是 v6 拍扁模型的真实落地形态。同 businessOrderNo 出现多次正是分批发货/退货的体现,与设计完全吻合。
id 拼接的三级降级:保证 ETL 幂等的关键
v6 把主键从 cuid 改成 String(64) 自定义唯一码,这是拍扁模型能跑起来的工程基础——id 必须稳定且幂等,否则 ETL 重跑会产生重复行。
buildSupplyOrderId() 用三级降级规则拼接:
// 优先级 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 个工程问题:
- ETL 幂等:同一个业务数据无论跑多少次 ETL,产出的 id 完全相同 → 写入是
upsert而非 insert,重复数据自动合并; - 跨源兼容:金蝶有
FORDERENTRYID(行级内码)就直接用,没有的就用no + entrySeq,完全手工的就用业务订单号兜底; - 64 字符上限:拼接后若 > 64 字符就
slice截断——核心三种拼接实际最长 ~50 字符,留 14 字符余量给将来加字段(如:orgCode)。
反直觉的点是:id 不是数据库生成的,而是 ETL 生成的。这违反了「主键应该由数据库管」的常识,但收益是幂等性 + 跨源一致性——ETL 重跑、数据修复、跨库迁移都能靠 id 稳定来支撑。在生产环境里走这条路,可以考虑轻易云智能对账系统的 ETL 设计:上游三源(金蝶应收单 / 手工录入 / 其他 ETL)共用同一套拼接规则,幂等键与金蝶行级内码 FORDERENTRYID 完全兼容。
索引设计:14 个索引覆盖所有常用查询路径
v6 拍扁后查询模式从「跨表」变成「单表」,索引设计的重点也从「JOIN 加速」变成「单表过滤加速」。
supply.prisma 给 SupplyOrder 表定义了 14 个索引:
@@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 条:
- 聚合键优先:
businessOrderNo是对账聚合最高频的过滤字段,单独索引而不是组合索引——PG 的 BitmapAnd 在多条件场景下反而更慢; - 业务查询路径全覆盖:所有可能的 WHERE 字段(
customerCode/skuCode/orderTime/setAccountType等)都有独立索引,避免查询时全表扫描; - 金蝶字段单独索引:
sourceErpDocId/sourceErpEntryId/billTypeCode/documentStatus都是金蝶 PULL 阶段的反查键,独立索引保证 PULL 性能。
这套索引设计的代价是写入稍慢(每行多维护 14 个索引项),但对账场景是读多写少(月底一次性写入,平时全是查询),写入开销完全可接受。
索引设计的更多权衡可以参考项目内的
income-reconciliation-developmentskill(v3/v6 对齐基线)与test-supply-orderskill——后者详细列出了 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-developmentskill:v3/v6 对齐版对账计划开发与排障指南,包含 7 态状态机、平台聚合范式、SupplyOrder 聚合取数等核心知识。test-supply-orderskill:供应链 E2E 测试 6 场景预设(精确匹配 / 缺单 / 差异 / 分批 / 退货混合 / 大体量),是验证 v6 拍扁聚合逻辑的标准输入。reconciliation-script-developmentskill:对账沙箱脚本契约(整单轧差口径、23 个 diffReason 标签码、判定纪律),与 v6 拍扁的聚合结果强耦合。