此中文翻译尚未经过母语人士校对,如有不通顺之处,请以英文版为准。
为什么我让陌生人的数据,在同一个数据库里相邻而居
U-PRO Build Journal(产品构建日记)— 第 2 期
第 1 期结尾时,我刚把一个正在运行的系统迁移成了多租户(multi-tenant)架构——这是那种做一次就要背负好几年的决定。每一家将来会用 U-PRO(一款面向汽修厂的门店管理系统)的店铺,数据都会和其他所有店铺一起,放在同一个数据库里。同样的表,同样的行,唯一的区别只是归属哪家店。不同的老板,不同的技师,不同的客户,彼此之间本不该看到对方哪怕一个字节的信息——而这一切,在物理层面上,全都放在同一个地方。
在解释我为什么还是选了这条路之前,我想先停下来,感受一下这句话听起来有多奇怪。
如果你把这套架构描述给一个不懂软件的人听——"我要把每一个客户的生意都放进同一个共享数据库里,唯一把它们隔开的东西,是我自己写的代码"——对方自然的反应大概会是某种版本的"这听起来很冒险啊,为什么不干脆给每个人一个自己的数据库?"这是个合理的问题,也是我问过自己的问题。而诚实的答案并不是"因为共享数据库本质上更安全"。答案要具体得多,而且几乎和安全无关,几乎全部关乎我想做的到底是一门什么样的生意。

两种墙

大体上,防止一家店的数据泄露到另一家店,有两种办法。
第一种是物理隔离:给每家店一个属于自己的数据库,或者至少一个属于自己的 schema,这样一来,即便应用代码里出现灾难性的 bug,也没法跨越边界——因为根本没有边界可跨,数据压根就不在同一个地方。这是用混凝土浇筑出来的墙。它很直觉,在某些场景下,也是正确的选择。
第二种是逻辑隔离:一个共享的数据库,一套共享的表,墙完全靠规则来强制执行——每一条查询都会被强制附加一层过滤条件,一条校验规则会说"你只能看到属于你所在那家店的行",每次都自动生效,不管写这段代码的人当时有没有想到这一点。这是用策略搭建出来的墙。从外部看它没那么直觉,因为你没法指着它给别人看——它以逻辑的形式运行在数据库内部,而不是一堵能拍下照片的墙。
我选择了第二种。不是因为我觉得它本质上更安全——一堵靠策略撑起来的墙,完全取决于这套策略是否正确、是否处处都被执行到、是否从未被悄悄绕过,而这比"这两样东西根本不在同一栋楼里"要难保证得多。我选它,是因为在我实际要面对的规模下,两种选择各自的成本不一样。
"每店一个数据库"真正的代价
这里有一部分东西,只有当你真去想象要把它运营起来时才会浮现:每店一个专属数据库,付出的不只是更多的计算资源,付出更多的其实是我自己。
每一家注册的店,都需要单独开通一个数据库——这是一个必须每次都可靠成功的独立操作,哪怕有人碰巧是在凌晨两点完成注册。我发布的每一次 schema 改动——新加一列、新建一张表、修一个函数——都得在每一个数据库上分别跑一遍,而不是跑一次就完事。每一条备份策略都得按店分别配置,而不是全局统一处理。每一个监控看板、每一个迁移脚本、每一次"这个修复是不是真的所有地方都生效了"的排查——所有这些都会随着店铺数量成倍增长,而且只要这门生意还在,就永远不会停。
如果你面对的是少数几个大型企业客户,每一个都付得起足够的钱来支撑专属基础设施和一支维护团队,那么这种成倍增长完全没问题,甚至更值得选。但如果你的整个立项前提——我的确是——是一家只有三个技师、一台桌面电脑的小型汽修厂,也应该能注册、试用、判断这套系统值不值这个钱,而不需要我为它单独搭建和维护一套专属基础设施,那这笔账就换算得非常糟糕。"每店一个专属数据库"的经济账,和"许多小客户、单个客户运营成本要低"的经济账,是朝着相反方向拉扯的两件事,我不可能两个都要。U-PRO 存在的全部理由——足够便宜、足够快、摩擦足够小,小店主不需要走一遍采购流程就能真正用起来——都指向了共享模式。
有一个来自软件行业之外的框架,比任何技术解释都更能说清楚这件事:这其实是一个"自建还是外购"(build versus buy)的决策,只不过我要"购买"的不是某个供应商的产品,而是运营上的简单性——付出的货币,是在应用代码内部主动承担更多责任。诚实计算的总拥有成本(TCO),从来不只是基础设施账单,而是按我期望最终能达到的客户数量,去运营那套基础设施的持续成本——对于一个想要低成本吸纳大量小汽修厂的产品来说,"每客户一个专属数据库"这条成本曲线,会从第一天起就与生意本身作对,远早于它变成一个技术问题之前。
墙必须真的能扛住
以上这些都不是在论证逻辑隔离天生就是安全的——它不是。这只是在论证,从经济账上看,这是正确的赌注——但"赌对了"不等于"证明它能扛住"。选了更便宜的那堵墙,就意味着你必须付出真实的努力,去确保它真的承重,因为不像混凝土版本的墙,没有人能走过去用肉眼确认它就在那儿。
多租户迁移之后真正花力气做的工作,就发生在这里。迁移落地后的头几天,有一批改动专门用来在数据库层面锁死对车辆记录的访问——限制底层哪些数据访问函数可以被调用,并且对所有不属于该店管理角色的人,隐藏掉整块整块的界面区域。这两项改动,都没有给用户增加任何他们会注意到、或者会主动要求的功能。它们存在的唯一目的,就是一条访问路径接一条访问路径地,缩小"这堵墙应该能扛住"和"这堵墙被证明确实能扛住"之间的差距。

这正是人们把共享数据库多租户描述成一种"聪明的省钱架构"时,很少提到的那一面:选择它,是容易的那一部分。这是一个花一下午读读行级安全(row-level security)相关资料就能做出的决定。它真正要求你付出的,是一种永久的、持续的自律——每一张新表都要接上同样的隔离逻辑,每一个碰到客户数据的新函数都要每次都显式地检查"是哪家店在问",永远如此。这项工作没有"做完"的那一刻,只有"追平了目前已有的一切"的那一刻——而下一个功能马上又会带来需要同样处理的新东西。
做决定的那一刻,我并没有完全意识到这一点。我把它理解成一个成本决策——运营更便宜、在上面继续搭建更快、更适合大量小客户而不是少数大客户。但我当时还没真正内化的是,我同时也在为未来每一个功能,选定了一项持续征收的"税":从此以后,任何东西都不再能快速搭建出来了,除非同时问一句"这个新东西有没有正确地尊重那堵墙"——因为这堵墙不像物理隔离那样,会自己强制生效。
这笔税在悄悄地复利累加
听到"自律"这个词很容易点头认同,却感受不到它日复一日实际要求的分量。下面是具体版本。

从现在起,我加进系统的每一张新表,都需要接上同样的隔离逻辑——一列标明这一行属于哪家店,以及一条在数据库层面(而不仅仅是应用代码层面)强制执行的规则:除非是该店经过验证的成员,否则任何人都不能读写这一行。每一个碰到客户数据的新函数,都必须在函数内部每次都显式地检查这层成员关系,因为任何强大到可以绕过常规访问规则的函数,只要有一次漏掉这层检查,也就同样强大到可以让数据泄露过墙。这一切对在产品里点来点去的普通用户来说都是不可见的。它是一套必须永远处处正确的隐形脚手架,否则这堵墙就不是真正的墙——它是一堵某处藏着一扇没有标记的门的墙,而你不会知道那扇门在哪,直到有人真的走了进去。
这是一种和人们听到"技术债"时通常想到的完全不同的工程成本。通常意义上的技术债,是那种你能指出来、排优先级、按自己方便的节奏慢慢还掉的东西。这个不太一样。它更接近一种持续征收的税——每一次新功能碰到客户数据,它都会自动被征收,不管我当时有没有想到要考虑这件事。这份成本不会以一条账目的形式出现,而是变成了我不得不养成的一种习惯,融进我审查自己工作的方式里——每写一段新的数据库逻辑,先反射性地问一句"这尊重边界吗",然后才轮到问这个功能本身能不能跑通。
我不认为这笔税是后悔当初决定的理由。我认为它就是我上面描述的那笔交易——更便宜的基础设施,更昂贵的自律——真实而诚实的代价,假装这个代价不存在,只会让整个故事听起来比它实际上更干净利落。这堵墙到目前为止之所以还撑得住,不是因为选了更安全的架构,而是因为每一次都刻意地在缴这笔税,而且愿意只要产品还在,就一直缴下去。
为什么我觉得自己不会做出不同的选择
如果对七月做出这个决定的那个自己公平一点,我会再做一次同样的选择——而不只是因为这是我已经做过、现在必须接受的决定。另一种方案——每店一套专属基础设施——解决的是一个我实际上并不存在的问题。我卖的对象不是少数几家需要有保障的物理隔离、而且愿意为此付钱的大型车队运营商。我想做的东西,是让一家独立的小汽修厂能便宜、快速、几乎零安装摩擦地用起来,并且在一周之内就能判断值不值得继续用下去。这种客户画像,在一个我要为每一家单独开通并维护一个数据库的世界里,根本不成立。在那个规模上,这笔经济账根本算不过来。
如果能给七月那一周的自己捎一句话,我不会说"换一种架构",而是"早点明白过来,你刚刚签下的是一份永久的自律义务,而不是一次性的决定"。选择共享一个数据库,是一次性做完的决定。而真正确保这个数据库对里面每一家店都安全,是要在往后的每一个功能里、只要产品还在,就持续不断地重新做出的决定。前半句我当时就立刻明白了。后半句,是我不得不一次又一次重新学习的部分。
还有一点我想诚实地说清楚,因为把这个故事讲成"单靠商业逻辑,事后看决定显而易见"实在太容易了。但在当时,它一点都不显而易见。它感觉更像一次信息不完整时下的赌注,下注的人根本不知道最终会有多少家店真的会来注册,也不知道会有多快。我选的是那个如果生意真如我所愿地增长、成本就会更低,但不管怎么增长、工程上的自律代价都会更高的选项。这不是一个有着干净、令人满意的理由的决定。它只是一个站在我当时所处的位置上,凭我能看到的东西,讲得通的决定——而且每当一个新功能让这堵墙又多了一分维护上的复杂度,我都得悄悄地在心里,再为这个决定辩护一次。
这个决定没有回答的问题
选定架构回答了"数据住在哪里"这个问题。但它没有回答一个更平常、在某些方面也更能说明问题的问题:墙立起来之后,我到底要先在墙后面盖什么?
进入那段项目历程之前,我曾假设迁移之后的第一个真正的功能,会是那种直接面向日常用户的东西——库存搜索、工单追踪,某个技师每天都会打开的功能。但实际发生的并不是这样。多租户基础打好之后,我构建的第一个像样的功能,根本不是给做日常维修的技师用的。它是为一个窄得多、也怪得多的使用场景服务的,一个我原本没打算这么早就优先去做的场景——结果它反而揭示出一些关于 U-PRO 早期那批要求最高的客户,究竟是谁、真正需要什么的东西,这是我从外部根本猜不到的。

这是 U-PRO Build Journal 的第 2 期。第 1 期讲的是多租户数据库迁移本身——把店铺级别的隔离,硬生生装进一个正在运行的系统里,那些具体的机制和混乱。第 3 期讲的是在那个基础之上真正构建出来的第一个功能,以及为什么它不是你会预想到的那一个。