此中文翻译尚未经过母语人士校对,如有不通顺之处,请以英文版为准。

我不小心删掉了自己写的功能,然后连夜重建了整个数据库架构,只为了容纳一群素不相识的陌生人

U-PRO Build Journal(产品构建日记)— 第 1 期

这是本系列的第一篇。我们记录的是 U-PRO——一款面向汽修厂的门店管理系统——从零到一独自搭建的真实过程:每一个决策、每一次失误,以及它们真正付出的代价。全部内容都直接取自项目的 commit 历史。故事要从那个数据库结构不得不彻夜重构的夜晚说起。

有一种特殊的恐慌感:你发现自己刚刚亲手删掉了半小时前才写完的 835 行代码——而且直到打开 diff 逐行检查之前,你完全搞不清楚这是怎么发生的。

那是七月中旬一个普通的周二晚上。当时我独自开发 U-PRO 已经三个星期了。没有联合创始人,没有工程团队,只有我自己,以及一堆没有任何人替我把关的决策。那天我刚合并了主分支的一些导航修复——都是小改动,比如图片查看器里方向键的响应——结果在合并过程中,车辆信息编辑页面不知怎么就混入了另一个尚未完成的身份验证分支的代码。页面表面上看不出任何异常,实际上底层叠了一层错误版本的代码。

于是我开始修。修完发现不对,又修了一遍。而正是在第二次"恢复正确版本"的过程中,我把整个 platform-admin(平台管理)模块删掉了——API 路由、管理页面本身、还有一个工具库,加起来将近 600 行,跟我原本要修的 bug 毫无关系。就这样在一次提交里全部消失了,原因很简单:我求快,而且盲目相信合并工具知道什么才是"正确版本"。

大约一分钟后我发现了,立刻回滚。但正是这一分钟,我至今仍会想起——因为那一刻我才真正理解了独自开发意味着什么,这是读再多软件工程方法论都学不到的:当你是自己工作唯一的审查者时,"犯错"和"有人指出错误"之间的间隔会直接归零。没有第二双眼睛替你把关,只有你自己,注意到,或者没注意到。

深夜昏暗的家庭办公室里,双手悬停在键盘上方,笔记本屏幕显示密密麻麻的红绿代码差异

小问题背后藏着的大问题

一个发光的数据库结构裂开,分成多个独立的隔间

那次合并事故其实不是重点,它只是一个症状。真正的故事是我那整整一个月都在做的事情,以及为什么那样的夜晚几乎无法避免。

U-PRO 最初是一个单租户(single-tenant)系统——一家店,一份数据,几乎每一条查询语句里都埋着一个隐含假设:查看数据的人一定属于那一家店,因为当时确实只有一家。这个假设在最初几周完全没问题,还让我开发速度飞快——不用考虑数据隔离、权限边界,也不用管"谁能看到什么",因为当时的"谁"只有一个。

但 U-PRO 的整个立项前提,就是要同时服务许多家汽修厂——不同的老板、不同的团队、不同的客户,彼此之间不应该看到对方任何一个字节的数据。这意味着单租户这条捷径从第一天起就注定要过期。之后每多建一周功能,都是在这份技术债上再加一笔——早晚要用一次庞大且侵入性极强的重构,把"只有一家店"这个假设连根拔起,换成"有很多家店,数据库必须自己在它们之间筑起隔离墙"。

那次重构,就是七月那段时间的 commit message 里直白写着的那句:"migrate production to full multi-tenant system (auth, jobs, customer portal, reports)"(将生产环境迁移为完整的多租户系统,涉及登录认证、工单、客户门户、报表)。没有花哨的名字,也不是什么代号,就是纯粹的字面描述——把一个正在运行的系统脚下的地板整个抽掉,换上一块新地板,让认证、工单记录、客户门户、报表这四大块,从此都必须先回答"这是哪家店的数据"这个问题。

如果你没亲手做过这种事,"多租户迁移"这个词听起来会比实际情况轻松太多。它不是拨一下开关就完事的。每一个显示车辆列表的页面,都需要一层新的过滤条件——不再是"把车全部显示出来",而是"只显示当前登录用户所属那家店的车"。每一个创建工单的表单,都必须准确无误地标记归属的店铺,且每次都不能出错——因为一旦出错,系统不会报错,它只会悄无声息地把某位客户的维修工单挂到别家店铺名下。登录系统本身,必须先判断你有权限看哪家店的数据,才能决定给你展示什么。认证、工单、客户门户、报表——这四个模块此前统统建立在"这个问题不需要问"的假设之上。那段时间我做的,就是同时在这四个模块里补上这个问题——而且系统里已经跑着真实数据。

"带着系统在跑,同时原地迁移"到底是什么样子

我想说实话:这不是那种一夜奋战、最后一个漂亮 commit 收尾的戏剧化故事。如果那样讲,故事会好听很多。但实际情况要混乱得多,我认为这也更真实地反映了独自快速开发的常态。

多租户迁移第一次真正落地,是在七月中旬一个周四下午——一点都不轰轰烈烈,也不是凌晨两点,就是普通工作日里,一个标题为"migrate production to full multi-tenant system"的 commit 被推送上去,改动超过一百个文件、一万一千多行代码。但这远不是终点。同样的 commit message 四天后又出现了一次,就在下一个周一早上。然后又一次。整整一周里至少出现了六次,每一次都是把 staging 环境里的又一部分多租户改动同步进生产环境。

一开始,承认这一点让我有点难为情——"迁移"不应该是一次干净利落的切换吗?但越想越觉得,这种反复本身才是最诚实的教训。多租户不是一个可以"上线交付"的功能,而是一个必须对所有东西成立的属性——每一条查询、每一个 API 路由、每一个渲染列表的页面。你不可能靠一次 commit 就迁移完成,就像你不可能靠一次行动就做到"永远小心"一样。你只能不断在代码库的各个角落里,找出那些还假设"只有一家店"的地方,一个一个修,直到再也找不到新角落为止。

其中一些修复还顺带破坏了本来运行正常的功能。那一周有一次同步,悄悄地把三个跟多租户毫无关系的功能都还原成了旧版本——一个公司名称字段、一个必传照片的提醒、一个显示当前登录人的小 UI 元素。没有人故意去动它们,只是当你把一个庞大的分支合并回一个仍在持续变动的代码库时,一些东西会在大体量 diff 的噪音中被悄悄覆盖。我当天下午才发现,因为我去找这几个功能时发现它们不见了,只好专门写一个后续 commit 把它们找回来——重新造一遍我本来已经造好的东西,就因为一项更大、更紧急的工作路过时把它们碾压了过去。

如果要用一个画面概括那一周,那绝不是"工程师英雄在凌晨两点力挽狂澜地迁移数据库",而更接近于:一边让船继续漂在水面上,一边给它补漏洞,每补好一个就发现新的一个,慢慢地你只能接受,这就是没有人可以分担时的工作常态。

深夜,一双饱经风霜的手正用布片按压修补一艘木船船体上的裂缝,头顶一盏灯笼照明,船身仍浮在漆黑的水面上

这是一种 build-in-public(公开构建)写作很少提到的疲惫,因为它不够戏剧化,撑不起一个好标题:不是某个惨烈的夜晚,而是一整周,每天都以为找到了最后一个角落,结果打开另一个页面,发现它仍然以为这个世界上只有一家店。到了那周中段,我已经不再对自己说"这是最后一处了"——这句话我说错太多次,早就失去意义了。

为什么我没有等到"安全"了再动手

一个孤独的身影站在悬空窄道边缘,手放在控制杆上,脚下是一片深不见底的黑暗

如果我是一个旁观者在读这篇文章,一定会这样问:为什么要在系统已经在跑、已经有真实用户的情况下把它迁移成多租户架构,而不是一开始就把架构设计对,或者至少在一个独立环境里搭好之后再一次性干净切换?

"一开始就设计对"这个选项其实从未真正存在过,因为在我把"错误版本"先做出来之前,我根本不知道"正确"长什么样。单租户版本不是一个失误——它是我用来搞清楚门店实际工作流程需求的方式,之后才有可能让它同时服务多家店。我不是从一张白纸开始重建,我手边一直有一个可以参照的实现。

至于搭建独立环境、按计划一次性切换——那是有团队、有维护窗口的项目才有的剧本。我没有。我有的是一份代码库、一小批早期用户,还有一堆越拖越贵的多租户改造任务。等到"安全"的那一天,本质上就是在这笔债上持续支付利息。

所以我选择了带着系统运行,直接在生产环境原地迁移,除了"回滚 commit,祈祷别出事"之外没有任何真正的回滚方案。这不是我会推荐给有 SLA 承诺的团队的做法。这是一个独自求快的开发者才会采用的策略——因为唯一的替代方案是:冻结所有新功能开发好几周,去搭一套并行系统,而这足以在项目还没有客户可失去之前,就先扼杀掉它的势头。

还有一个更隐蔽的原因:七月中旬那会儿,我真的不知道最终会有多少家店来用这个系统,也不知道会以多快的速度增长。经过测试的回滚脚本,加上一个精心安排的切换窗口,只有在你已经确定系统需要扛住几十家企业每天的真实依赖时才划算。我当时并没有这种确定性,只有一个假设,以及在需要拿到真实信号、判断是否值得继续投入之前所剩不多的时间。花一整周去为一个尚未证实会达到的规模搭建迁移基础设施,等于是在解决一个可能根本不会出现的问题。

差点出的大问题,以及最终没有出问题的部分

如果我说什么都没出问题,那是在撒谎。确实出了问题,那几个被悄悄还原的功能就是证据。这个故事完全有可能往更危险的方向发展——比如某个权限校验被悄悄还原,而不只是一个 UI 标签;或者两家店之间的数据隔离在合并过程中被意外削弱,而不是当天就被修复。这一次没有发生那种情况,但我很清楚它本可能发生,这一点至今仍留在我心里。

真正救了我的不是什么回滚方案,而是一件小得多、也远没那么"高明"的事:我一直在盯着。每次同步之后,我都会回去把整个应用重新过一遍,检查真实存在的东西,而不是相信 commit message 里说应该存在的东西。这就是为什么那几个被还原的功能,我能在几个小时内发现,而不是等到几周之后。这是一种非常薄弱的部署前验证替代方案,我当时也很清楚这一点。但那是我当时所拥有的一切,而且它确实起了作用,让那一周没有留下任何长期挂着的用户可见故障。

那段时间我真正学到的东西,其实和数据库无关,而是关于独自开发时风险的形态。那个月里每一个"明显鲁莽"的决定——不经过完整审查就合并代码、在没有经过测试的回滚方案下迁移生产环境、深夜十点还在修了又修——如果用一个人手更多、时间更充裕的团队的标准来衡量,确实鲁莽。但如果用另一个标准来衡量——赶在跑道耗尽之前,把有用的东西真正交到店主手里——它就不算鲁莽。把这两套标准混为一谈,正是很多独立开发者要么彻底停摆、要么把自己耗尽的原因,因为他们在用不属于自己拥有的资源标准要求自己。

这种权衡不会在第一个月之后就结束,它只是换了个形态。三周时的风险,是不小心删掉了什么却没有察觉。几个月之后,光靠在应用里到处看一看已经很难发现风险了,因为问题已经不再是某个功能是否还"看得见",而是店与店之间的那堵墙,在还没被真正测试过的压力下,究竟撑不撑得住。

而这,正是那次多租户迁移打开的问题——即便迁移在技术上已经落地,那一周我也没能完全回答它:我刚刚做出的决定,是把互不相关的多家店铺的数据,放进了同一个数据库里,靠一整套权限校验的"墙"把它们隔开,而不是选择更直接的那道墙——每家店单独用一个数据库。理论上,分开数据库听起来更安全——如果两家店根本不在同一个地方,一家的错误就不可能泄露到另一家。但分开数据库也意味着要单独跑迁移脚本、单独管理备份、每新增一家店就要把这一整套东西再复制一遍。对我想做的这类生意来说,这种成本的扩展方式非常糟糕——我希望一家小型汽修厂能够直接试用系统,而不需要我为它单独搭一套基础设施。

于是我选择了共享数据库的路径——隔离靠代码和策略来强制执行,而不是物理上的分离。一部分原因是在已有的基础上继续开发更快,另一部分原因是我相信,从长期架构的角度看,这才是能够以低成本规模化服务大量小微企业的正确方向。但相信不等于验证。"我选择了更便宜的那堵墙"和"我已经证明了这堵更便宜的墙真的能扛住",是两句完全不同的话,而这中间的空白,正是第 2 期要接着讲下去的地方。

信任那堵墙,到底是不是正确的决定,还是只是更快的那个选择?当时我并不知道答案。我唯一知道的是,站在七月那段日子的尾声,看着一个终于把"这是哪家店"当作一个真实问题、而不是一句假设来对待的系统——从这里开始,之后每一个架构决策都会建立在这个决定之上,而接下来的六个月里,不会有哪个版本能让我轻轻松松地把它推翻重来。

疲惫的身影靠坐在椅子上,微弱的黎明之光开始洒落,身旁笔记本电脑已归于平静,几个发光的隔间结构此刻已密封、稳定


这是 U-PRO Build Journal(产品构建日记)的第 1 期——一份关于独自搭建汽修厂门店管理系统的实时记录,全部内容取自真实的 commit 历史和沿途做出的决策。第 2 期将探讨:为什么共享数据库式的多租户架构,最终战胜了看起来更安全的替代方案——为每家店单独配置数据库,并回答本期留下的问题:U-PRO 今天的数据隔离模型到底是什么样的,而不只是第一晚做决定时的样子。