这一篇讲「承载」:一条业务流水线要跑起来,底下那套生产系统得长成什么样。它不是一个 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。
第一次看这套系统的人会问:就这么一个文件,怎么敢叫生产系统?答案是:生产系统不是由文件数量定义的,是由「它上面跑着多少真实业务」定义的。
| 层 | 实际做法 | 为什么这么选 |
|---|---|---|
| 运行时 | 单体 server.js,Express 起一个进程 | 订单系统的并发量根本到不了要拆服务的程度;一个进程能说清的事,不值得拆成三个 |
| 存储 | SQLite 开 WAL | 库就是一个文件。WAL 让读写不互斥——门店录单的写不会挡住别人查单的读(机制详见 模块 16 · 数据库怎么存东西) |
| 进程管理 | pm2 | 挂了自动拉起,不用人守 |
| 发布 | rsync 推文件 + git tag + md5 校验 | 库是单文件 → 备份 = 拷文件,换机器 = 拷文件;tag 标记版本,md5 校验「推过去的东西跟本地是不是同一个」 |
| 接口 | /api/bom、/api/glass_dwg、/api/quote | 流水线四段各自一个口。这三个口现在都是「先用脚本做过反向验证」的状态,不是已经在业务上全量跑通的口径 |
/api/quote 一个口 → 它调的函数 → 那张表。没有服务间跳转,没有调用链日志要串。同一件事做两遍,是浪费;两条路线各跑一段,是取舍。这套单体和 复盘 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 | 约束先行,不是替换单体的排期表 |
| 信号 | 说明 | 现在到了吗 |
|---|---|---|
| 算法要被第二处调用 | 报价逻辑不能只在一处用了,才需要把它抽成有版本、可单测的 packages/engine | 还没到 |
| 表结构开始反复改 | schema 一处定义比散在几个文件里更省钱时,收敛才回本 | 还没到 |
| 前后端类型对不上开始出事 | 前端按旧字段取值、页面不崩只是显示空白——这类 bug 出现两次以上就该收敛 | 还没到 |
| 只是「怕以后不好维护」 | —— | 不构成合流理由(这条和 复盘 24 里「只是怕以后不够就不换数据库」是同一个判断标准) |
生产系统最贵的一件事不是写代码,是「改完之后怎么告诉你我改了什么」。这套系统用最土的组合:git tag 标版本、md5 校验推过去的文件。
| 版本 | 发生了什么 | 为什么值得记 |
|---|---|---|
| v2.5.0 | 在产版本 | 对外报的口径,任何结论都贴这个版本号 |
| v2.4.9 | 新增「拆单员 → 技术确认岗」这一岗位环节 | 这是一次「岗位」层面的改动,不是字段改动:它在流水线中间插入了一个人的确认动作 |
推之前先算好本地产物的 md5,写进发布记录。没有这一步,等于发布完也说不清「推过去的是不是我这一版」。
不靠肉眼观察「好像起来了」,用脚本打一遍关键接口,确认返回的是预期结果才宣布这一版。
聊技术之前先把业务口径钉死。这个项目里,业务事实是有数字的,而且数字很难看——难看到直接决定了系统能交付什么。
| 系列 | 有没有价格 | 缺价的时候靠什么 | 对引擎的影响 |
|---|---|---|---|
| H | ✅ 有 | —— | 可进入报价段 |
| S | ✅ 有 | —— | 可进入报价段 |
| M | ✅ 有 | —— | 可进入报价段 |
| K | ❌ 无 | BOM 模板兜底(能算「要哪些料、多少量」,算不出金额) | 可出物料清单,出不了真价 |
| H4 | ❌ 无 | 同 K,走模板 | 同上 |
这是本篇最想讲透的部分。「有 33,176 单历史数据」听起来是一句很强的话,直到你把字段打开来看。
| 资产 | 数字 | 能干什么 | 不能干什么 |
|---|---|---|---|
| 历史订单 | 33,176 | 跑匹配率校验、发现字段缺失规律 | 不能证明「我的引擎算得准」 |
| 经销商 | 387 | 对账、分渠道统计 | 不能直接当「订单归属客户」用(见第 06 节卡点③) |
| 型号 | 2,799 | 建主数据词表 | 词表大不等于覆盖全,命中率要实测 |
| 校验项 | 用 33,176 单实测命中率 | 怎么读 |
|---|---|---|
| 型号 | 96.6% | ✅ 引擎认得型号,第 1 段(订单解析)基本能过 |
| 玻璃厚度 | 95.2% | ✅ 物理参数大多齐,BOM 拆单还站得住 |
| 尺寸 | 29% | 🔴 真卡点。玻璃图纸段直接废掉七成 |
| 客户匹配 | 0% | 🔴 最狠的一个——算得出价,也归不到某个人头上 |
| 观察 | 真实含义 |
|---|---|
400 单抽样里 97% 的 spec 是 null 或「否」(等于没有尺寸) | 这不是录单习惯差,是链路里从来没人要求过必填尺寸。没被要求过的字段,填不填全靠运气 |
| 真实有尺寸的只有 3 单 | 基数太小,拿 3 个点去验证一套报价算法,没有统计意义 |
| 这 3 单属于 K / Y 系列 | 最扎心的一条:有尺寸习惯的是冷门系列的人,主流 H / S / M 反而一份样本都没有——我最需要验证引擎的那批数据,恰恰摸不到 |
报价是整条流水线唯一需要「钱」的一段。我把能不能出真价,拆成三个前置依赖——这三个不是代码问题,是数据问题。这也是本篇最想让面试官问的一节。
spec。没有尺寸,玻璃图纸段出不了图,报价段连面积都算不出。现状:400 单抽样 97% 为空,实测覆盖 29%。| 依赖 | 缺了会怎样 | 是代码问题吗 | 当前状态 |
|---|---|---|---|
| ① 出厂价红线 | 只能出物料清单,出不了金额 | 否 | 部分缺(3 / 5 个系列) |
| ② 尺寸数据源 | 出不了图,算不出面积 | 否 | 严重缺(29% 覆盖) |
| ③ 订单 ↔ 经销商对照 | 归不到人,没法报价、没法对账 | 否 | 实测 0% |
这一节是最「FDE」的一节,也是我被要求做对最多的一次事:面对别人的生产系统,变更前一律只读诊断。用户当时的原话是——「先看,不要改任何东西」。
| 纪律 | 具体怎么做 | 为什么 |
|---|---|---|
| 先只读,不写 | 只跑查询、只打接口、只做采样,不建表、不改字段、不写测试库 | 生产库上的每一次写,都是把「以后要解释」变成「现在要解释」 |
| 证据先于方案 | 先拿数据,再把结论和方案一起给;不先抛「我觉得可以怎么改」 | 方案一旦先出口,就变成了立场,后面即使拿到新证据也改不过口 |
| 用 curl 打接口 | 直接请求关键接口看返回,不靠页面「点了没报错」 | 页面不崩 ≠ 数据对。「点了一下没弹错误」是最容易被误当成功的信号 |
| 字节 + 内容采样 | 把库文件拷贝一份再读,比对本机字节与内容,不在原库上跑破坏性语句 | 单文件库的好处就在这:拷走 = 备份 = 可以放心折腾副本 |
| API 反向验证 | 拿已知道结果的历史数据,倒着验证接口产出;对不上的,先把差异列出来再谈修复 | 反向验证能一次暴露「接口口径」问题,而这种问题在正向测试里永远测不出来 |
这一节不讲成绩。生产系统复盘里如果只写做的对的部分,那篇复盘就没有价值了。
| 问题 | 机制 | 状态 |
|---|---|---|
| 写坏一次整库读不出来 | blob 改一个字段 = 重新序列化整对象 + 重写整行;中途任何一页失败,这行就截断在半路,截断的 JSON 是非法 JSON,JSON.parse 直接抛,同一次查询里其他正常数据也一起取不到 | ❌ 还没还 (机制详见 模块 16 深入版;风险排序错在哪见 复盘 24 第 07 节) |
| 没法按字段查 | 索引用在列上,建不到 JSON 内部的键上;只能全表拉出来在应用层 parse 再 filter,分页、排序、分组统计全失效 | |
| 并发只能串行 | 「读 → 改一字段 → 整个写回」不是原子操作,两个请求会丢更新,且整行互斥 |
shower-cpq-demo.html 这个演示页| 它是什么 | 它不是什么 | 为什么还需要它 |
|---|---|---|
| 一个单文件演示页,用来讲 P0 / P1 两类能力 | 没有服务器、没有真实价格数据、不是可交付的报价结果 | 给干系人讲引擎逻辑时非常有用:先让他看懂「P0 是什么、P1 差什么」,再谈系统,比直接给一个跑不通的 demo 有效得多 |
不是马后炮,是把决策逐个翻出来,标出愿意改哪几条、哪几条坚持不改。
| # | 当时怎么做 | 重来怎么改 | 理由 |
|---|---|---|---|
| 1 | 直接开始写订单系统 | 先跑数据体检三个口径(有单 / 有字段 / 有价格) | 体检结论直接改掉了项目走向。这是这个项目最正确的顺序决策,见 复盘 25 |
| 2 | 生产用单体 server.js | 不改 | 单进程 + 单文件库下,排障路径最短、回滚代价最低。拆服务只会把这三样变长 |
| 3 | SQLite + WAL | 不改 | 永远单台单机单进程,「怕以后不够」不是换库理由(标准同 复盘 24 第 06 节) |
| 4 | 发布 rsync + git tag + md5 | 不改 | 简单到没有理由换;真正难的是每次都复核 md5,不是技术本身 |
| 5 | 带着「33,176 单」的乐观开工 | 先看 400 单抽样再定工期 | 数量级资产 ≠ 质量资产。97% 空字段这件事,早知道三个月能省掉三个月的返工 |
| 6 | 把 collections blob 的债排在功能后面 | 按「发生概率 × 恢复时长」排,高损债前置 | 我当初按「修要几个人天」排,排错了,见 复盘 20 |
| 7 | 遇到生产问题先想「我改点什么」 | 固定成「只看 → 出证据 → 等许可 → 才动手」 | 这是被明确要求过的一条:先看,不要改任何东西。漏掉前两步,动手就是在猜 |
server.js、Express + better-sqlite3、SQLite 开 WAL、pm2、rsync、git tag + md5、本地端口 8787collections 的 blob 债还没还,也知道 shower-cpq-demo.html 是讲引擎逻辑的说明书,不是交付成果我会讲「先在别人的生产系统上做了一次只读诊断,然后什么都没改」。因为多数人讲不出这个动作的勇气和纪律——不是技术问题,是边界问题:面对真实在跑的系统,先看、出证据、等许可、才动手。用户原话是「先看,不要改任何东西」。
证据怎么拿:用 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 把报错吞掉。这几个动作都很小,但面对「先看,不要改任何东西」的约束,每一个都是越界。所以我把顺序焊死:只看 → 出证据 → 等许可 → 才动手。