FDE·案例复盘
基础目录
德莉玛 monorepo 订购单流水线 会员制 CRM 订购单系统(单体) 玻璃图纸与拆单 定价引擎 P0~P3

复盘 29 · 德莉玛单体订购单系统:为什么是单体,以及那 33,176 单的真相

这一篇讲「承载」:一条业务流水线要跑起来,底下那套生产系统得长成什么样。它不是一个 monorepo,是一个单体 server.js——但它扛着 33,176 单真实历史订单。这篇要回答三件事:单体为什么够、它和 monorepo 为什么不合并成一个、以及那批数据到底能证明什么。

项目锚点

项目:德莉玛(dlmware.cn)门店订购单系统,淋浴房产供销一体化的生产级单体。
技术栈:单体 server.js,本地端口 8787;Express + better-sqlite3 + pm2 + rsync;SQLite 开 WAL;git tag + md5 校验做版本;部署在 ubuntu server(备案域名环境,域名 dlmware.cn)。
业务:5 大系列 H / H4 / K / M / S,在产版本 v2.5.0;v2.4.9 新增「拆单员 → 技术确认岗」这一岗位环节。

真实数据资产数量它在系统里的角色
历史订单33,176 单流水线的输入语料,也是唯一有规模的一层
经销商387 家报价的归属方,缺对照表就归不到人头上
型号2,799 个主数据词表,命不中后面全空转

本篇是承载层的复盘。流水线四段(订单 → BOM → 玻璃图纸 → 报价)的详细拆解在 复盘 25 · 订购单流水线;与这套系统并存的那个 monorepo(四包、27 张表、npm workspaces)在 复盘 24。

01

这套系统长什么样:一个 server.js、一个库文件、一个端口

第一次看这套系统的人会问:就这么一个文件,怎么敢叫生产系统?答案是:生产系统不是由文件数量定义的,是由「它上面跑着多少真实业务」定义的。

Expressbetter-sqlite3SQLite WALpm2rsyncgit tagmd5端口 8787ubuntu serverdlmware.cn
层实际做法为什么这么选
运行时单体 server.js,Express 起一个进程订单系统的并发量根本到不了要拆服务的程度;一个进程能说清的事,不值得拆成三个
存储SQLite 开 WAL库就是一个文件。WAL 让读写不互斥——门店录单的写不会挡住别人查单的读(机制详见 模块 16 · 数据库怎么存东西)
进程管理pm2挂了自动拉起,不用人守
发布rsync 推文件 + git tag + md5 校验库是单文件 → 备份 = 拷文件,换机器 = 拷文件;tag 标记版本,md5 校验「推过去的东西跟本地是不是同一个」
接口/api/bom、/api/glass_dwg、/api/quote流水线四段各自一个口。这三个口现在都是「先用脚本做过反向验证」的状态,不是已经在业务上全量跑通的口径
我要说清楚一件事,免得被误读:这里没有 MySQL、没有 PostgreSQL、没有消息队列、没有多机部署。「生产级」指的是「上面跑着真实订单、真实经销商、真实交付」,不是「用了多复杂的中间件」。
单体的收益在这里非常具体:
• 排障路径最短:报「算不出价」,我看 /api/quote 一个口 → 它调的函数 → 那张表。没有服务间跳转,没有调用链日志要串。
• 回滚代价最低:rsync 回退上一个 tag + 重推,几十秒的事。
• 没有分布式一致性问题:单进程内的顺序是确定的,不用想幂等、不用想分布式锁。
这三句话是这套形态能被我一直保留到今天的全部理由,不是嘴上说「简单点好」。
02

它和 monorepo 为什么并存:不是两套系统,是两条路线

同一件事做两遍,是浪费;两条路线各跑一段,是取舍。这套单体和 复盘 24 里那个 monorepo(四包、27 张表、npm workspaces)不是一回事,我把它们的关系讲成「路线 B 已落地、路线 A 仍在收敛」。

路线 B:生产级单体(本篇)路线 A:monorepo(24 篇)
形态一个 server.js + 一张 SQLite四包:apps/server / apps/web / packages/db / packages/engine
包管理无 workspaces,直接跑npm workspaces(不是 pnpm/turbo/lerna)
数据SQLite(WAL),单文件packages/db 收敛 27 张表 schema + migrate + seed
engine逻辑写在 server.js 里,随包发布纯 TypeScript,dependencies 为空,可脱离数据库单测
它在干什么已经在生产上承接真实订单把散落的约束收敛回来:schema、枚举、算法边界
现在的状态在产版本 v2.5.0约束先行,不是替换单体的排期表
两者并存最容易被人理解成「架构没想清楚、重复造了两套」。不是。区分点在这里:
单体的价值是「跑」——真实订单已经落在它上面,换系统等于重来一次数据迁移 + 一次业务断档;
monorepo 的价值是「收」——把报价公式、表结构、状态枚举这些会被反复引用、反复改的东西收敛到一处。
跑的东西和收的东西不是同一个东西。我可以一边让单体服务真实订单,一边让 monorepo 把算法层写干净,等某一天迁移成本够了再合。硬把两者合并成一套,那时候要付的代价是「停掉能跑的东西去换一套没跑过的」。

什么条件下我会考虑合流(还没到触发点)

信号说明现在到了吗
算法要被第二处调用报价逻辑不能只在一处用了,才需要把它抽成有版本、可单测的 packages/engine还没到
表结构开始反复改schema 一处定义比散在几个文件里更省钱时,收敛才回本还没到
前后端类型对不上开始出事前端按旧字段取值、页面不崩只是显示空白——这类 bug 出现两次以上就该收敛还没到
只是「怕以后不好维护」——不构成合流理由(这条和 复盘 24 里「只是怕以后不够就不换数据库」是同一个判断标准)
合流的触发条件应该是「出现了具体的重复与错配」,不是「架构 diagrams 不好看」。我说不出我现在有哪一条已经命中。
03

版本纪律:git tag + md5,以及 v2.4.9 那一次岗位改动

生产系统最贵的一件事不是写代码,是「改完之后怎么告诉你我改了什么」。这套系统用最土的组合:git tag 标版本、md5 校验推过去的文件。

在产版本与最近一次改动

版本发生了什么为什么值得记
v2.5.0在产版本对外报的口径,任何结论都贴这个版本号
v2.4.9新增「拆单员 → 技术确认岗」这一岗位环节这是一次「岗位」层面的改动,不是字段改动:它在流水线中间插入了一个人的确认动作
为什么把「插一个岗位」当成版本点:因为这个动作改变了订单状态的合法迁移路径。以前一条单从「拆完」直接往下走,现在中间多了一道技术确认,状态机多了一条边。
这类改动的风险在于:它不会报错,只会让某些单卡住不动。代码能跑通不等于业务跑得通——所以版本号要能追到「哪一批单是在哪条路径上产生的」。

发版三步(一直没变过)

1

本地打 tag,记下 md5

推之前先算好本地产物的 md5,写进发布记录。没有这一步,等于发布完也说不清「推过去的是不是我这一版」。

2

rsync 推文件,推完复核 md5

核对不一致就停手,不要「应该是一样的吧」——这个「应该」在跨机器传输上出过一次大麻烦(教训汇总见 复盘 20 · 跨模块踩坑总账)

3

用接口反向验证,再对外报版本

不靠肉眼观察「好像起来了」,用脚本打一遍关键接口,确认返回的是预期结果才宣布这一版。

这套纪律里最值钱的是第 3 步:它把「我以为生效了」变成「我验过生效了」。 rsync + tag + md5 都不难,难的是每次都走完——尤其在你很确定「这次就改了一行」的时候。
04

业务事实先摆清楚:5 个系列,只有 3 个有价

聊技术之前先把业务口径钉死。这个项目里,业务事实是有数字的,而且数字很难看——难看到直接决定了系统能交付什么。

系列H / H4 / K / M / S5 大系列,走同一套订购单入口
有价的H · S · M能进报价段的三个
靠兜底的K(以及 H4)没有精确价,走 BOM 模板
系列有没有价格缺价的时候靠什么对引擎的影响
H✅ 有——可进入报价段
S✅ 有——可进入报价段
M✅ 有——可进入报价段
K❌ 无BOM 模板兜底(能算「要哪些料、多少量」,算不出金额)可出物料清单,出不了真价
H4❌ 无同 K,走模板同上
「只有 3 个系列有价」这句话,等于给系统划了一条能力边界。任何演示如果只拿 H / S / M 走通一遍,看上去非常完整;但真实订单里有相当比例落在 K 和 H4 上,那部分只能出清单、出不了价。
这是我坚持「先讲脏数据、再讲演示视频」的原因:演示能通的那 3 个系列,恰恰是最不需要系统的 3 个。
05

那 33,176 单到底能证明什么:能当校验语料,不能当训练数据

这是本篇最想讲透的部分。「有 33,176 单历史数据」听起来是一句很强的话,直到你把字段打开来看。

资产数字能干什么不能干什么
历史订单33,176跑匹配率校验、发现字段缺失规律不能证明「我的引擎算得准」
经销商387对账、分渠道统计不能直接当「订单归属客户」用(见第 06 节卡点③)
型号2,799建主数据词表词表大不等于覆盖全,命中率要实测

抽样体检的四个真实数字

校验项用 33,176 单实测命中率怎么读
型号96.6%✅ 引擎认得型号,第 1 段(订单解析)基本能过
玻璃厚度95.2%✅ 物理参数大多齐,BOM 拆单还站得住
尺寸29%🔴 真卡点。玻璃图纸段直接废掉七成
客户匹配0%🔴 最狠的一个——算得出价,也归不到某个人头上

再看那个 400 单样本

观察真实含义
400 单抽样里 97% 的 spec 是 null 或「否」(等于没有尺寸)这不是录单习惯差,是链路里从来没人要求过必填尺寸。没被要求过的字段,填不填全靠运气
真实有尺寸的只有 3 单基数太小,拿 3 个点去验证一套报价算法,没有统计意义
这 3 单属于 K / Y 系列最扎心的一条:有尺寸习惯的是冷门系列的人,主流 H / S / M 反而一份样本都没有——我最需要验证引擎的那批数据,恰恰摸不到
结论要说到很死才有分量:
33,176 单是「数量级资产」,不是「质量资产」。它能让我把匹配率跑出来、把缺失规律摸清楚,它不能让我把报价算准。
「算不了」不是失败输出,是这套系统目前最诚实的输出。(这条判断的完整推导在 复盘 25 · 订购单流水线,那里讲了为什么我宁可报「数据不足」也不填默认值。)
那这一批数据是不是白存了?不是。它现在有三个用处:①校验主数据词表(2,799 个型号哪些是真在用、哪些是 Historic);②暴露字段缺口,倒逼录入表单改造;③给引擎留一条可复现的回归样本,改算法时能拿老单子跑一遍看有没有把命中率打下去。
这三件事都不需要「数据质量高」就能做——这大概就是它现在最大的价值。
06

引擎出真价的三个前置依赖:三个卡点,缺一个都出不了价

报价是整条流水线唯一需要「钱」的一段。我把能不能出真价,拆成三个前置依赖——这三个不是代码问题,是数据问题。这也是本篇最想让面试官问的一节。

①
出厂价红线(PV / KW / K1)价格主数据。没有它,面积算得再准也是无水之源——只能出「料清单」,出不了金额。现状:5 个系列里只有 H / S / M 三个配了价格。
②
尺寸数据源就是上面那个 spec。没有尺寸,玻璃图纸段出不了图,报价段连面积都算不出。现状:400 单抽样 97% 为空,实测覆盖 29%。
③
订单 ↔ 经销商 ID 对照表报价要落到一个归属方。现状:客户匹配命中率 0%——也就是说,就算①②都补齐,这一环还是断的,系统不知道该把价报给谁、记到谁的账上。
依赖缺了会怎样是代码问题吗当前状态
① 出厂价红线只能出物料清单,出不了金额否部分缺(3 / 5 个系列)
② 尺寸数据源出不了图,算不出面积否严重缺(29% 覆盖)
③ 订单 ↔ 经销商对照归不到人,没法报价、没法对账否实测 0%
为什么我一定要把「这是数据问题不是代码问题」说出口:因为这句话决定了下一步该干什么。
如果有人来看这套系统,第一反应通常是「引擎不够聪明,我们把它改改」。但三个卡点里,没有一个能靠改算法解决——改算法只会让「算不出来」这件事看起来像算出来了,那正是 复盘 25 里说的假精确。
所以我的排期一直很硬:先补数据链路(录单必填 + 对照表),再谈引擎。
有一个我没测 / 还没量化的点,也一并说清楚:①和②补齐之后,报价结果的业务误差率到底是多少(比如与实际成交价相差多少算可接受),这个我没做量化——因为样本量根本不足以支撑一个误差结论。在有足够「尺寸完整的真实成交单」之前,谈误差率就是编数字。
07

生产系统变更纪律:先看,不要改任何东西

这一节是最「FDE」的一节,也是我被要求做对最多的一次事:面对别人的生产系统,变更前一律只读诊断。用户当时的原话是——「先看,不要改任何东西」。

纪律具体怎么做为什么
先只读,不写只跑查询、只打接口、只做采样,不建表、不改字段、不写测试库生产库上的每一次写,都是把「以后要解释」变成「现在要解释」
证据先于方案先拿数据,再把结论和方案一起给;不先抛「我觉得可以怎么改」方案一旦先出口,就变成了立场,后面即使拿到新证据也改不过口
用 curl 打接口直接请求关键接口看返回,不靠页面「点了没报错」页面不崩 ≠ 数据对。「点了一下没弹错误」是最容易被误当成功的信号
字节 + 内容采样把库文件拷贝一份再读,比对本机字节与内容,不在原库上跑破坏性语句单文件库的好处就在这:拷走 = 备份 = 可以放心折腾副本
API 反向验证拿已知道结果的历史数据,倒着验证接口产出;对不上的,先把差异列出来再谈修复反向验证能一次暴露「接口口径」问题,而这种问题在正向测试里永远测不出来
这套纪律跑出来的产物不是「改了什么」,而是一张结论表:哪一项命中率多少、哪一项缺失多少、缺在哪一层。
把这张表交上去,对方自己就能判断该不该动、该动哪里。这就是「诊断优先于动手」的实际价值——它不是慢,它是把一次高风险改动,换成一次零风险的谈话。
踩过的坑我直接认:我处理生产问题时的本能是从「改」开始(调个参数、补个默认值、加个 try/catch 把报错吞掉)。这几个动作看着都很小,但在「先看不要改任何东西」的约束下,任何一个都是越界。
正确顺序是:只看 → 出证据 → 等许可 → 才动手。这四个词我给自己加了顺序,因为漏掉前两个,动手就是在猜。
08

两笔没还完的账:collections 那个 blob,和一个没接数据的演示页

这一节不讲成绩。生产系统复盘里如果只写做的对的部分,那篇复盘就没有价值了。

账一笔:collections 的 JSON blob

问题机制状态
写坏一次整库读不出来blob 改一个字段 = 重新序列化整对象 + 重写整行;中途任何一页失败,这行就截断在半路,截断的 JSON 是非法 JSON,JSON.parse 直接抛,同一次查询里其他正常数据也一起取不到❌ 还没还
(机制详见 模块 16 深入版;风险排序错在哪见 复盘 24 第 07 节)
没法按字段查索引用在列上,建不到 JSON 内部的键上;只能全表拉出来在应用层 parse 再 filter,分页、排序、分组统计全失效
并发只能串行「读 → 改一字段 → 整个写回」不是原子操作,两个请求会丢更新,且整行互斥

账二:shower-cpq-demo.html 这个演示页

它是什么它不是什么为什么还需要它
一个单文件演示页,用来讲 P0 / P1 两类能力没有服务器、没有真实价格数据、不是可交付的报价结果给干系人讲引擎逻辑时非常有用:先让他看懂「P0 是什么、P1 差什么」,再谈系统,比直接给一个跑不通的 demo 有效得多
把这两笔账并排写,才看得出问题在哪:blob 那笔是系统的债(低频高损,一年可能一次都不爆,爆了就是整库读不出来);演示页这笔是沟通上的必要妥协(真实数据不能用,只能用符号系统讲逻辑)。
我不把演示页当成「已经交付的成果」,我把它当成「还没交付的说明书」。这两者的区别,就是「我在推我自己的东西」和「我在解决别人的问题」的区别。
本篇涉及的交付模板清单在 模板库(全库 7 张交付模板),本篇对应的是「只读诊断结论」与「数据体检口径」两张。
09

重来一次我会怎么取舍(7 条)

不是马后炮,是把决策逐个翻出来,标出愿意改哪几条、哪几条坚持不改。

#当时怎么做重来怎么改理由
1直接开始写订单系统先跑数据体检三个口径(有单 / 有字段 / 有价格)体检结论直接改掉了项目走向。这是这个项目最正确的顺序决策,见 复盘 25
2生产用单体 server.js不改单进程 + 单文件库下,排障路径最短、回滚代价最低。拆服务只会把这三样变长
3SQLite + WAL不改永远单台单机单进程,「怕以后不够」不是换库理由(标准同 复盘 24 第 06 节)
4发布 rsync + git tag + md5不改简单到没有理由换;真正难的是每次都复核 md5,不是技术本身
5带着「33,176 单」的乐观开工先看 400 单抽样再定工期数量级资产 ≠ 质量资产。97% 空字段这件事,早知道三个月能省掉三个月的返工
6把 collections blob 的债排在功能后面按「发生概率 × 恢复时长」排,高损债前置我当初按「修要几个人天」排,排错了,见 复盘 20
7遇到生产问题先想「我改点什么」固定成「只看 → 出证据 → 等许可 → 才动手」这是被明确要求过的一条:先看,不要改任何东西。漏掉前两步,动手就是在猜
第 2、3、4 三条我会一直坚持,因为它们都不是技术亮点,是「不给自己找麻烦」。
而真正的判断力体现在第 5 条和倒数第二列:能认出「33,176 单撑不起报价」这件事,比把单体写得多漂亮有价值得多。能说出自己哪里做不到,才是这行最硬的交付。
✓

自查清单

  • ✓我说得出德莉玛订购单系统是生产级单体:server.js、Express + better-sqlite3、SQLite 开 WAL、pm2、rsync、git tag + md5、本地端口 8787
  • ✓我讲得清它和 monorepo 为什么并存:一个在「跑」、一个在「收」;合流触发条件是出现具体重复与错配,不是架构不好看
  • ✓我能背出业务口径:5 个系列 H / H4 / K / M / S,只有 H / S / M 三个有价,其余靠 BOM 模板兜底
  • ✓我记住了在产版本号 v2.5.0,也说得出 v2.4.9 加了「拆单员 → 技术确认岗」这个岗位环节
  • ✓我能背出三个真实数字:33,176 单 / 387 经销商 / 2,799 型号,并知道数量级资产不等于质量资产
  • ✓我能复述四个命中率:型号 96.6% / 厚度 95.2% / 尺寸 29% / 客户匹配 0%,并知道最狠的是归不到人头上那个 0%
  • ✓我承认 97% 空字段不是习惯问题是链路问题,而真实有尺寸的 3 单还是 K / Y 系列——最需要验证引擎的数据一份都摸不到
  • ✓我讲得清引擎出真价的三个前置卡点(出厂价红线 / 尺寸数据源 / 订单↔经销商对照表),并且明确说这是数据问题不是代码问题
  • ✓我给出一个没量化的点:补齐①②之后报价的业务误差率可接受区间是多少,我没测,不会编
  • ✓我量产系统的变更纪律:先看 → 出证据 → 等许可 → 才动手,用 curl、字节+内容采样、API 反向验证拿证据
  • ✓我承认 collections 的 blob 债还没还,也知道 shower-cpq-demo.html 是讲引擎逻辑的说明书,不是交付成果
模板库 7 张交付模板 · 本篇对应「只读诊断结论」与「数据体检口径」→
面试怎么答

题目:你做过的最有价值的一个落地动作是什么?

我会讲「先在别人的生产系统上做了一次只读诊断,然后什么都没改」。因为多数人讲不出这个动作的勇气和纪律——不是技术问题,是边界问题:面对真实在跑的系统,先看、出证据、等许可、才动手。用户原话是「先看,不要改任何东西」。
证据怎么拿:用 curl 打接口(不靠页面没报错)、字节 + 内容采样(把库拷一份再读)、API 反向验证(拿已知结果的历史单倒着打回接口)。这套东西跑出来的不是一条修复,是一张「哪一项命中多少、缺在哪一层」的结论表——把表交上去,对方自己就能判断该不该动。

题目:你的系统架构是什么样的?(如果只答 monorepo 就太单薄,我会两套一起讲。)
我的项目里有两条路线并存,不是两套重复系统:路线B 是生产级单体——一个 server.js,Express + better-sqlite3,SQLite 开 WAL,pm2 守着,本地端口 8787,rsync 推文件 + git tag + md5 校验发版,部署在 ubuntu server(备案域名环境,域名 dlmware.cn);路线A 是唯一一个 monorepo,npm workspaces,四包:apps/server、apps/web、packages/db(27 张表 schema 收敛)、packages/engine(纯 TypeScript,dependencies 为空)。
区别是:单体在「跑」——真实订单落在它上面;monorepo 在「收」——把报价公式、表结构、状态枚举这些会被反复改的东西收敛到一处。跑的东西和收的东西不是同一个东西。所以不合并。合流的触发条件我定得很硬:算法要被第二处调用、表结构开始反复改、或者前后端类型对不上已经出过实际事故——只到「怕以后不好维护」的程度,不构成合并理由。

为什么敢用单体?因为单体的收益在这里非常具体:排障路径最短(报「算不出价」,我看 /api/quote 一个口 → 它调的函数 → 那张表,没有服务间跳转)、回滚代价最低(回退上一个 tag 重推,几十秒)、没有分布式一致性问题。
同时要诚实说:这里没有 MySQL、没有 PostgreSQL、没有 MQ、没有多机部署。「生产级」指的是上面跑着真实订单和真实经销商,不是用了多少中间件。

题目:你的系统数据规模多大?
德莉玛订购单系统:33,176 单历史订单、387 家经销商、2,799 个型号,型号 / 厚度 / 尺寸 / 客户四个口径的实测命中率是 96.6% / 95.2% / 29% / 0%。
但我会立刻补一句:这 33,176 单是数量级资产,不是质量资产。抽样 400 单里 97% 的 spec 是 null 或「否」,真实有尺寸的只有 3 单,而且这 3 单还是 K / Y 系列——我最需要验证引擎的那批主流数据(H / S / M),一份样本都摸不到。
所以结论很硬:能出图、能出物料清单,报不了真价。报价段三个前置卡点——出厂价红线(PV / KW / K1,5 个系列只有 H / S / M 三个有价)、尺寸数据源(29% 覆盖)、订单↔经销商 ID 对照表(实测 0%)——三个都不是代码问题,是数据问题。改引擎只会让「算不出来」看起来像算出来了,那叫假精确。
最后一个我主动说的点:补齐前两个之后,报价误差率的可接受区间是多少,这个我没测、也没法量化——样本量不够。我不编这个数字。

题目:你有哪些没解决的?
两笔账。第一笔是 collections 表把整个对象存成单行 JSON blob:写坏一次整行截断、JSON.parse 抛异常,同一次查询里其他正常数据也一起取不到;索引用不到 JSON 内部键,只能全表拉出来应用层 filter;读写不原子,两个请求会丢更新,整行互斥导致并发串行。我知道机制,但还没修——因为我当初按「修要几个人天」排优先级,把它排在功能后面,这个排序是错的:它是低频高损的债,一年可能一次都不爆,爆了就是整库读不出来。下次排债我用一条新标准:按「发生概率 × 恢复时长」排。
第二笔是 shower-cpq-demo.html——一个单文件演示页,讲 P0 / P1 两类能力,没有服务器、没有真实价格数据。我把它定位成「还没交付的说明书」,不是成果:给干系人先讲懂引擎逻辑,比甩一个跑不通的 demo 有用。
还有一条纪律上的坑我自己认:我处理生产问题的本能是从「改」开始——调参数、补默认值、加 try/catch 把报错吞掉。这几个动作都很小,但面对「先看,不要改任何东西」的约束,每一个都是越界。所以我把顺序焊死:只看 → 出证据 → 等许可 → 才动手。