1.1 需求拆解:一户一数据与统一升级

场景:十个客户,十套代码

转 SaaS 之前,我们服务器上跑着十几个客户的站点,每个客户一份代码仓库、一个 MySQL 库、一套 Nginx 配置。前三个客户时这套打法很爽,来单就克隆、改皮肤、导数据、上线,一周交付一个,老板觉得效率真高。到第十个客户时就崩了:有个安全漏洞要修,我们要在十套代码上各改一遍,再联系十个客户约升级窗口,有的客户还拒绝升级——他们的站长自己改过模板,一升级就覆盖掉。

最痛的是数据。客户 A 的销售在客户 B 的后台里看到了订单,因为某个老员工图省事,把两套库的连接配置写串了,而恰好两套库的表名一样。排查一整夜,最后发现是有人手动覆盖了配置文件,事后虽然只改回来两行,但客户那边的信任已经塌了。那天我们下定决心:数据的隔离必须由架构天然保证,而不是靠人自觉。

把客户诉求收集起来其实就三句话:我的数据别人看不到,功能要越来越好但别动我的数据,费用按我用多少算。翻译成技术语言就是数据隔离、统一升级、按量计量。这三条不是并列的:数据隔离是地基,出一次事故客户就敢走;统一升级是 SaaS 区别于定制的核心,也是利润的来源;计量计费是商业模式的闭环,没有它 SaaS 就是个赔钱的项目。

还剩下一个当时没说出来但都很清楚的问题:团队只有八个人,十几个客户已经快把大家逼疯了,如果明年再签十个,要么扩招要么重构。所以这次拆解其实还有一条隐藏主线——把每个租户的边际成本压下去,让加一个新客户的成本趋近于零。这条主线贯穿了全书,后面你会看到开通自动化、缓存隔离、灰度发布、按户计费这些主题,全都服务于此。这一节我们把这三句话拆成开发团队能照着做的约束,因为如果需求拆解这一步做不实,后面选什么架构都救不回来。

方案:把诉求翻译成可验证的约束

需求不能只停留在文档里,每一条都要落到能验证的技术约束上。我们的做法是开一次拆解会,每条需求写一行,再补上怎么验证它,最后形成一张贴在墙上的表。开完全会每个人都知道:做完什么才算做完。

客户诉求 技术约束 验证方式
数据不串 任意查询都带租户号或落独立库 自动化用例:A 户登录拿不到 B 户数据
统一升级 一套代码,行为差异由配置驱动 灰度发布后回归核心流程
按量计费 每次请求与存储都能归户 对账脚本:账单与访问日志对得上
可以定制 皮肤与模板可覆盖,逻辑不动代码 市场改文案不需要开发发版
数据可搬走 提供标准导出格式 每季度做一次退租演练

这张表后来成了需求评审的门槛:新功能必须能填进某一行的验证方式里,填不进就说明它要么是过度定制,要么是想走回定制老路,当场打回。有一回销售接了个大单,客户要求他们的审批流走专属逻辑,评审时我们对着表一看:这条诉求要求代码层面单独一套,直接违反统一升级,于是改成配置项加自定义触发器来满足,单子照样签。

统一升级这条约束最容易被破坏。早期我们允许给某个客户单独开分支改代码,结果半年后这个分支落后主线二十多个提交,合并花了一整周,还顺带引入两个线上故障。从此立了规矩:代码只有一份,租户差异全部走配置中心的数据开关,谁敢为某户开分支,代码评审直接否掉。甚至我们把这个规矩写进了新人入职文档,第一条就是这里没有客户的专属分支。

量化:拆解到位怎么看

约束写下来还不算完,得知道团队执行到什么程度。我们定了三个信号,月会上过一遍,哪个信号变红就说明拆解的约束在被悄悄破坏:

信号 健康值 变红说明什么
最近三十天为客户临时改代码的次数 零或一次 又在走定制老路
发版回归脚本的通过率 百分之百 有租户绑定在旧逻辑上
新租户开通所需人时 半小时内 开通流程里有人工依赖

这张表是后来才加的,起因是我们发现一个季度里为某大客户临时改了五次代码,大家嘴上说统一升级,手上却在不断破例。有了数字之后争论少了很多,破例必须写到月会记录里,谁是需求方一目了然。小团队不一定非要搞得多正式,但量化的东西才会被认真对待,贴在墙上的需求表如果没有指标守着,半年后就会变成一张废纸。

配置:需求清单落到代码里

拆解会的产出是一份机器可读的新租户需求模板,开通租户时填一遍,初始化脚本直接读它来建库、建目录、写配置,不再靠人进后台点半天。下面是简化版:

// tenant_blueprint.php —— 新租户开通时的需求清单
return [
    'tenant_id' => 'acme',            // 租户号,全局唯一,建库建前缀都用它
    'plan'      => 'pro',             // 套餐:free / basic / pro
    'db_mode'   => 'dedicated',       // dedicated 独立库,shared 共享库
    'theme'     => 'spring',          // 皮肤模板
    'modules'   => ['cms', 'shop'],   // 开通哪些功能模块
    'quota'     => [
        'staff'      => 20,           // 最大账号数
        'storage_gb' => 50,           // 存储空间上限
        'rps'        => 200,          // 每秒请求上限
    ],
    'region'    => 'shanghai',        // 数据落在哪个地域
];

这份清单看着简单,却把前面表格的约束全兜住了:db_mode 决定数据隔离方式,modules 决定功能开关,quota 决定限流配额,region 决定数据落点。租户开通脚本读它就能自动完成建库、建对象存储前缀、写配置记录,整个流程我们在 2.4 那一节展开。引入这份清单之前,开通一个新户要两个人对表格干一小时,还经常漏配权限;之后变成跑一条命令、等五分钟、收到开通完成的邮件,错误率也降到接近零。

对应的验收我们也写成了脚本,每次发版前自动跑:用两个测试租户互相探测,确保 A 户的会话、缓存键、文件列表、导出数据里都不会出现 B 户的影子。这个脚本救过我们好几次,有次框架升级后共享库的查询作用域悄悄失效,就是它先报的警,而不是客户投诉。验收脚本跑在 CI 里,绿了才允许发版,把它当作发布的一票否决项。想知道接口层面怎么做契约化的验收测试,可以参考 PHP API 实战 里的做法,思路一脉相承。

清单文件本身也要被守护。我们给 blueprint 加了一个结构校验:字段缺了、套餐不存在、配额超过上限都会在提交时报错,而不是等开通脚本跑到一半才崩。有个细节值得提醒,租户号只允许小写字母数字和短横线,因为它会被拼进数据库名、对象存储前缀和缓存键里,任何特殊字符都会在某一层炸开。早期一个客户想用中文缩写当租户号被我们拦下来,后来说服他们用英文拼写,双方都省心。

踩坑:拆解阶段的三个教训

第一个坑是低估数据迁移。转 SaaS 时老客户的数据要搬进来,我们天真地以为每户导一份 SQL 就行。实际上十个版本的表结构都不一样,有的客户 DBA 还手工加过字段,有的库里存着 GBK 编码的脏数据。最后给每个老版本各写了迁移脚本,先统一到 v1.0 表结构再导入,导入完再跑一遍行数与关键指标的校验,这一步比新写功能还累,早知该把迁移单独排进项目计划并预留缓冲。

第二个坑是口头答应定制。销售为了签单跟客户说想要啥改啥,开发只能为这户加 if 分支,if 越来越多,代码里甚至出现客户名硬编码,新人改代码都要先问这客户还在不在。后来把这些分支全部重构成配置开关才还清债。配置驱动行为的设计可以和 腾讯云实践 里中台化的思路对照着看。

第三个坑是没想清楚谁不能随便升级。有的客户站点对接了内部 OA 或第三方支付回调,升级接口必然影响他们,有一次升级后某客户的三方回调签名校验方式变了,订单整整一个下午没进来。后来我们在需求清单里加了升级依赖声明:哪些租户有外部集成、升级前要先通知,直接挂在灰度发布的名单上,由发布脚本自动跳过或提前发邮件。

第四个坑是验证脚本本身也会腐化。用例写完半年没人维护,租户多了之后 A 户 B 户互相探测的逻辑没跟上新的功能模块,等于安检仪没开。后来把验证用例纳入功能开发流程:新功能上线必须补对应的多租户探测用例,和单元测试一起在 CI 里守着,这才让验收不流于形式。

小结

需求拆解的本质是翻译:把客户的三句话,翻译成每条都能跑脚本验证的技术约束。数据隔离守底线,统一升级保增长,计量计费养商业模式。这一节没写几行代码,却决定了后面二十几节的方向:所有架构选择,都要回头来对这张表负责。如果只能记住一件事,那就是任何不能被自动化验证的约束,最后都会随风消散,变成事故复盘里的抱怨和互相推诿。顺带分享一个优先级的判断依据:三条约束里数据隔离是生死线(出事即事故),统一升级是效率线(出事即加班),计量计费是商业线(出事即少赚钱)。生死线先守、效率线建流程、商业线留数据——团队人力永远不够,这个排序能让你在内部辩论时少拍两小时桌子。

下一节我们算一笔更现实的账:独立部署和共享部署,每户到底差多少钱。

延伸阅读