Naixxfl
← 全部笔记

选型算得清引入成本,算不清退出成本

技术选型FlywayMySQL架构

选型会上争得最凶的往往是"哪个框架性能更好"。而真正在一年后咬人的,几乎从来不是性能。

下面几个是我在自己项目上踩出来的,不是听来的。放在一起看会发现它们长得很像。

Flyway 的占位符,把注释也一起解析了

表结构走 Flyway 脚本管,这个选型我到现在都觉得对。ddl-auto: validate 配上版本化脚本,每次改表有据可查,线上和本地不会长成两个样子。

代价是某天我写了这么一行注释:

-- WCL 凭据优先级:本表 > 环境变量 ${WCL_CLIENT_ID}
CREATE TABLE app_setting (...);

然后容器起不来了,无限重启。

Flyway 默认开着 placeholderReplacement,它对整个脚本文本做占位符替换,注释也算文本。${WCL_CLIENT_ID} 被当成一个占位符去解析,解析不到直接抛异常。

真正难查的地方在后面:迁移是在解析阶段炸的,压根没执行到,所以 flyway_schema_history 里一条记录都没留。查历史表看到的现象是"这个迁移根本没跑过",而文件明明就在那儿。我第一反应是去查 classpath 扫描和文件名格式,方向完全错了。

后来定的规矩很简单:迁移脚本里不准出现美元符加大括号,连注释里也不行。要提环境变量名就写成 WCL_CLIENT_ID,不带符号。

这个坑的性质值得琢磨一下。Flyway 本身没问题,出事的是一个我引入时压根不知道存在的默认行为,而它的失败模式是零线索。选一个工具的时候,除了看它能干什么,还得看它出错的时候肯不肯告诉你。

一个 NOT NULL 把回滚路堵死了

做数据归属重构的时候,我把归属抽成了一张中间表,四张业务表各加一列 owner_id BIGINT NOT NULL 指过去。

设计是对的,迁移也跑顺了。问题出在我完全没想过的方向上:这个版本没法只回滚 JAR。

旧版本的实体类不映射 owner_id 这一列,而列是 NOT NULL 又没有默认值。JAR 一退回去,这四个模块的所有新建操作立刻全部失败 —— 表还是新表,代码已经是旧代码。

发现这件事的时候我盯着那条 ALTER TABLE 看了很久。它本身一点毛病没有,问题是它把"出事了先退版本"这条最常用的退路给封了。要退只剩两个办法:恢复 mysqldump 备份,或者应急把列改成可空。

从那之后我改表前会多问一句:这个改动做完,旧版本的代码还跑得起来吗?跑不起来的话,回滚方案是什么、演练过没有。

给列留一个默认值、或者分两个版本走(先加可空列并双写,下个版本再收紧),成本都不高。难的是在写那条 ALTER 的时候想起来要问。

演练库和生产库不是一个东西

上面那次重构我是做过演练的,在一个克隆库上跑了一遍,绿灯。

结果上生产还是炸了一次,因为我要给那张中间表加一个生成列:

ALTER TABLE data_owner
  ADD COLUMN own_of BIGINT AS (IF(is_own, user_id, NULL)) STORED;

生产上报 errno 1215。原因是这张表被四个外键引用着,而加 STORED 生成列会触发整表重建,MySQL 直接拒绝。改成 VIRTUAL 就过了。

演练库为什么没复现?因为我是用 CREATE TABLE ... LIKE 建的克隆表,而这个语法不复制外键。克隆库里那张表干干净净一个外键都没有,自然重建得动。

后来改成用 SHOW CREATE TABLE 把原始建表语句抄过来。麻烦一点,但它至少是同一张表。

同一批改动里还有一个类似的:两张表的 collation 不一样,一个是 utf8mb4_unicode_ci,一个是 utf8mb4_0900_ai_ci。迁移脚本里的 JOIN 跨了这两者,直接报 Illegal mix of collations,Flyway 挂掉,容器起不来。要显式写 COLLATE utf8mb4_0900_ai_ci 才行。

这两件事合起来的教训是:验证手段本身也是选型的一部分。 一个跑得比生产轻的演练环境,给你的绿灯是打折的,而你不会知道打了几折。

零依赖是有收益的,但要挑场合

这个站的静态页面是我自己写的生成器,没引 marked 之类的 Markdown 库。

这个决定后来证明挺划算,划算的地方在于它把语法范围锁死了。我只实现了笔记真正用得到的那一小撮,标题、列表、代码块、链接这几样,表格和嵌套列表都没做。写笔记的时候遇到限制,改写法就行,比维护一个我只用到 10% 的库省心。

但这条路有它自己的账单,我付过一次。

行内代码的实现要先把 ` ... 抠出来占位,免得里面的星号方括号被当成语法。占位符我一开始写成了转义序列,落盘变成了真实的空字节,git 直接把整个文件当二进制。后来用 sed 去修,GNU sed 把 \u 理解成了"下一个字符转大写",占位符变成了 0000 这个普通字符串 —— 而笔记正文里完全可能出现 0000`,于是会被错误替换掉。

最后是用 String.fromCharCode(0) 生成、正则写 [0-9] 才彻底修好,源码里一个转义都不写。

期间我犯了一个更值得记的错:中途验证时我比对了修复前后的产物,一模一样,就以为逻辑没坏。实际上那批笔记里恰好没有 0000 这种字符串。产物一致只证明这批输入没变,证明不了逻辑没坏。 后来专门写了一篇含 0000 和 00005 0000、12300001230000 的测试笔记才把问题逼出来。

所以零依赖不是免费的,省下的是依赖体积和升级负担,付出的是这类边界问题得自己踩。判据大概是:需求面窄、边界清楚、出问题你有能力自己修,就自己写;反过来就引库。

HTTP 这个选择,账单是分期付的

项目跑在 HTTP 上跑了很久,理由是"内部自用,没必要折腾证书"。

这个决定的账单是一笔一笔来的,而且每一笔都长一个样:

  • 到点提醒想弹桌面通知,Notification API 不存在
  • 文件查找页想用 showDirectoryPicker 选文件夹,不存在
  • 生成一个 id 用 crypto.randomUUID,不存在
  • 复制路径用 navigator.clipboard,不存在

这几个全是 secure context 特性。它们的共同点是:localhost 被浏览器算作安全上下文,所以本地开发一切正常,一上生产静默失效。 不报错,不抛异常,功能就是没反应。

我在同一个坑里栽了三次才开始条件反射。每次都是本地测得好好的,上去之后用户说"点了没反应"。

现在写前端功能会先问一句:这个 API 有没有安全上下文要求。有的话,要么准备降级路径(execCommand('copy')、<input type="file"> 这类),要么就把 HTTPS 的事先办了。

后来还是上了 HTTPS。上线当天就撞了另一个问题,反向代理后面的协议识别,那个我另外写了一篇。

我现在会先问的几个问题

比起"哪个性能好",这几个我问得更早:

这个决定可逆吗? 换个 JSON 库是一天的事,换数据库是几个月。可逆的快点定,别开三次会;不可逆的值得慢慢来。两类决策该用完全不同的速度。

出错的时候它肯说话吗? Flyway 那次难受的地方在于它不报错,历史表干干净净,看起来像没发生过。一个失败模式是静默的工具,排查成本要按十倍算。

退出路径是什么? owner_id NOT NULL 那次我是被迫学会问这个的。加一个字段、开一个开关、接一个组件,都要顺手想一下怎么撤回。

验证手段能不能代表生产? 克隆库少了外键,本地是 localhost 而生产是 HTTP,这两次的根因是一样的:我以为我验证过了。

谁来养它? 上线之后监控谁配、故障谁排、版本谁升。答不上来,就是还没准备好。

引入一个东西的真实成本,是它从此要被喂养多久,以及想送走的时候送不送得掉。"接入花了几天"只是账单的第一行。