先说结论:把 Badget 这种体量的小项目搬到托管平台上,真正的难点从来不是“点一下部署按钮”,而是把“提交代码”到“用户看到新版本”这条链路做成可重复、可回滚、可观测的流程。我前阵子把手上一个记账类小项目 Badget(名字是 budget 的变体拼写,功能上就是个轻量的个人收支与预算管理工具)从前端到数据库整体迁到某 Serverless 前端托管平台,全程没买服务器、没配 Nginx、没写一行运维脚本,但在 CI/CD 这条链路上踩的坑一点不少:构建环境 Node 版本不一致导致锁文件报错、预览环境连到了生产库、环境变量只在本地生效、构建期跑数据库迁移把表锁了十几分钟。这篇就把这套“Badget 部署实战”的完整链路摊开讲,从思路选型、配置细节、实操步骤到排错速查,全部按我当时真实的操作顺序来。下文我用「V 平台」代指这类以 Git 集成为核心、带边缘网络与 Serverless 函数的托管服务,具体产品你按自己团队的习惯替换即可,方法论是通用的。适合两类人看:一类是手里有小项目想快速上线、又不想被运维拖住的后端或全栈;另一类是用过这类平台但一直停留在“能跑就行”,想把它做成正经流水线的同学。
1. Badget 的整体部署设计与思路拆解
1.1 这个项目到底需要一套什么样的部署方案
Badget 的功能边界很清晰:用户注册登录、记一笔收支、按月看预算执行情况、导出 CSV。数据量小、并发低、读多写少,典型的“个人工具型”应用。这类项目的部署诉求和电商、社交完全不是一回事,我在动手前先把它拆成三条硬指标:第一,上线要快,改一行文案不该等十分钟构建加人工上传;第二,出错要能立刻退回上一个版本,记账数据不能因为一次错误发布而丢失;第三,成本要可控,一个没什么人用的工具不该背上固定的月度服务器开销。
对照这三条,传统“买台云主机 + 装 Node + pm2 守护 + Nginx 反代”的方案就被我排除了。不是它不好,而是它的固定成本太高:机器要一直开着,系统要打补丁,证书要续期,日志要自己转储。这些事在项目只有几十个用户的时候纯属负收益。Serverless 托管平台刚好把这几件事都吃掉了——静态资源走边缘节点,接口走函数,证书和 CDN 平台代管,我只对代码负责。
但这里有个必须提前想清楚的点:Serverless 不是“什么都不用管”,而是“把运维的粒度从机器级下沉到函数级”。你要开始关心冷启动、关心函数执行时长上限、关心连接池、关心构建产物的体积。所以我在方案设计阶段就给自己定了个原则:把重活放在构建期,把轻活放在运行期。能在构建时算出来的东西,绝不放到请求里算。
1.2 为什么最终选择 Git 驱动的 CI/CD,而不是手动上传
我见过不少人的做法是本地npm run build之后把dist目录拖到某个静态托管后台。这种方式在 Demo 阶段没问题,但一旦项目有了第二个贡献者,问题立刻暴露:谁的本地环境是准的?构建产物和源码哪个才是真实的?出问题怎么回退?
Git 驱动的 CI/CD 解决的核心不是“自动化省事”,而是构建的可信来源唯一化。当流水线固定在“推送到主分支 → 平台拉取代码 → 在干净容器里安装依赖 → 执行构建 → 发布产物”这条链上时,构建结果就变成了一个可以被复现、被追溯、被回滚的制品。我把这个原则落成了三条具体规则:
- 所有环境变量只存在于平台的配置面板里,仓库中只保留
.env.example,绝不提交真实密钥; - 生产环境的部署只能由主分支的合并触发,任何人的本地构建都不允许直接上线;
- 每一次部署都有一个不可变的历史记录,回滚就是把这个指针指回上一个制品,而不是重新构建。
第三条尤其重要。很多人回滚的方式是“把代码 revert 再推一次”,这中间要经历一次完整构建,耗时不说,还可能因为依赖版本漂移构建出和上次不一样的东西。回滚应该是指针操作,不是重建操作。
1.3 目录结构与产物约定
Badget 用的是 Next.js 风格的目录结构(app 路由 + API 路由),这是这类托管平台支持最顺、坑最少的形态。我在动手前先把结构和产物约定固定下来,避免后面配置反复改。
| 目录/文件 | 作用 | 部署时是否需要 |
|---|---|---|
app/ | 页面与 API 路由 | 是,参与构建 |
lib/ | 数据库客户端、工具函数 | 是,参与构建 |
prisma/schema.prisma | 数据模型定义 | 是,构建期生成客户端 |
prisma/migrations/ | 迁移脚本 | 否,单独走发布流程 |
public/ | 静态资源 | 是,直接进边缘缓存 |
.env.example | 环境变量模板 | 是,仅作说明 |
platform.json | 平台侧路由与响应头配置 | 是,平台读取 |
scripts/migrate.mjs | 数据库迁移入口 | 否,手动/独立任务触发 |
这张表里最关键的一行其实是prisma/migrations/那个“否”。我吃过一次亏:早期图省事,把prisma migrate deploy塞进了构建命令里,结果那次改表结构恰好碰上平台同时启动了多个构建实例,两个迁移进程互相抢锁,构建卡了快二十分钟最后超时失败,线上还是旧代码。从那以后我给自己定了规矩:构建只负责产出制品,数据库变更永远走独立的一次性任务。
2. 平台选型与环境准备的关键细节
2.1 为什么是这类托管平台,而不是自建或容器编排
选型这件事我对比过三条路:云主机自建、容器编排、Serverless 托管。为了不让结论变成拍脑袋,我列了个对照表,权重按 Badget 的实际情况给——团队规模 1 到 2 人、日活两位数、预算尽量接近零。
| 维度 | 云主机自建 | 容器编排 | Serverless 托管 |
|---|---|---|---|
| 首次上线耗时 | 半天到一天 | 一到两天 | 十分钟到半小时 |
| 固定月成本 | 有,即使零流量 | 更高,控制面也要钱 | 零流量接近零成本 |
| 弹性能力 | 手动扩容 | 自动但需配置 | 平台自动 |
| 运维负担 | 高,系统补丁、证书、日志 | 中高,集群与网络 | 极低 |
| 冷启动影响 | 无 | 无 | 有,但可优化 |
| 长任务支持 | 无限制 | 无限制 | 有执行时长上限 |
算下来结论很明确:在日活两位数这个量级,前两种方案的固定成本和组织成本都远超收益。真正让我下决心的是第四列和第五列的取舍——用一点点冷启动延迟,换掉几乎全部运维工作。对一个记账工具来说,首屏多 200 毫秒没人会退订,但服务器宕机两小时就是灾难。
还有一个容易被忽略的点:这类平台的边缘网络对静态资源的分发是“免费附赠”的。Badget 的public目录里有图标、字体和一份示例 CSV,这些内容走边缘节点之后,回源请求基本为零。自建方案要做到同样的效果,得再叠一层 CDN,配置量翻倍。
2.2 账号、仓库与本地环境的准备清单
准备阶段我按“从外到内”的顺序走,避免中途被打断。以下是我实际执行的清单,你可以直接照着核对:
- 确认代码已推送到代码托管平台的远程仓库,主分支受保护,禁止直接推送;
- 在 V 平台用代码托管账号授权登录,只授予读取仓库的权限,不要给全量权限;
- 在平台侧新建项目,选择导入已有仓库,而不是初始化模板;
- 本地安装平台 CLI 工具,用于拉取环境变量和查看构建日志;
- 本地 Node 版本锁定,写入
.nvmrc并在package.json里声明engines; - 准备三套环境变量:本地开发、预览、生产,命名带前缀区分。
第三步有个细节值得说:选“从模板初始化”会给你一个能跑的空壳,但它同时会改写你的目录结构和构建配置。我当时的做法是选“导入已有仓库”,让平台先按默认规则探测框架类型,探测完再手动纠正。这样平台不会动我的代码,只会生成一份可编辑的配置。
第五步的版本锁定是最容易被跳过、也最容易出问题的一步。本地 Node 20、平台默认 Node 18,这个差异会导致锁文件里的可选依赖解析结果不同,表现就是“本地构建通过、线上构建报模块找不到”。我在.nvmrc里写了20,同时在package.json里补了:
{ "engines": { "node": ">=20.0.0 <21" } }注意engines只是声明,不一定被强制执行;真正生效的是平台项目设置里的 Node 版本选项,或者配置文件里的运行时字段。两边同时写,本地和线上才对得上。
2.3 环境变量的分层管理与作用域陷阱
环境变量管理是这类平台最容易出事的地方,没有之一。它的问题不在于“怎么配”,而在于“配在哪一层”。这类平台通常把变量分成开发、预览、生产三个作用域,而预览作用域默认会被所有非主分支的部署共享。
我在这里踩过一个很典型的坑:预览环境的作用域里,我把数据库地址配成了生产库的连接串,想着“预览反正也没人用”。结果有一次在分支上调试导出功能,一个跑飞的循环把生产库里的测试数据全清洗了。没造成真实损失纯属运气。
从那以后我的分层规则是这样的:
| 变量类型 | 开发 | 预览 | 生产 |
|---|---|---|---|
| 数据库连接串 | 本地容器 | 独立测试实例 | 生产实例,仅主分支可读 |
| 第三方接口密钥 | 沙箱密钥 | 沙箱密钥 | 正式密钥 |
| 站点根地址 | localhost | 平台自动注入 | 正式域名 |
| 功能开关 | 全开 | 按需 | 默认关闭 |
关键点在于第三列和第二列必须物理隔离,不能只是“约定上不混用”。我后来干脆给生产环境的敏感变量加了“仅生产作用域可见”的限制,预览部署根本读不到这个键,报错总比误删数据强。
另外,NEXT_PUBLIC_这类前缀的变量会被打进客户端产物,等于公开。我在 Badget 里只把站点名称、统计脚本 ID 这类无敏感度的东西放进去,任何带密钥的变量一律不加前缀。这条规则建议直接写进项目的贡献指南里,靠人记是会忘的。
3. 构建配置与流水线落地的核心细节
3.1 构建命令、输出目录与依赖安装的三个关键参数
平台对项目的识别依赖三样东西:构建命令、输出目录、安装命令。默认值不一定匹配你的项目,尤其是用了工作区(monorepo)结构的时候。
Badget 是单仓库单应用,配置相对简单,但仍有几个参数值得逐项确认:
- 安装命令:保持
npm ci而不是npm install。前者严格按锁文件安装,后者可能悄悄升级次要版本,是“线上和本地不一致”的头号元凶; - 构建命令:
npm run build,但我在package.json的build脚本前面挂了prisma generate,保证构建期一定有最新的数据库客户端; - 输出目录:Next.js 用默认的
.next;如果是纯静态导出,输出目录是out,这两个别填反,填反的表现是部署成功但访问 404。
我把package.json的关键片段整理如下,重点是postinstall和build的职责划分:
{ "scripts": { "postinstall": "prisma generate", "build": "next build", "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit" } }prisma generate放在postinstall里而不是build里,理由是这样依赖安装完立刻生成客户端,后续的next build类型检查才有类型可用。如果放进build,某些缓存命中的场景下类型检查会先于生成执行,报一堆“找不到 PrismaClient”。这类顺序问题在本地很少暴露,因为本地客户端早就生成好了。
关于构建缓存,还有一个经验:平台的构建缓存是按依赖锁文件哈希命中的。只要你改了package.json里的任意字段(哪怕只是加个 script),锁文件哈希就可能变,缓存全失效,构建时间从 40 秒涨到 3 分钟。所以我把不涉及依赖的脚本改动尽量攒在一起提交,别一天改八次。
3.2 分支策略:预览、生产与“一个人的团队”也要有规矩
即便是单人项目,我也建议把分支策略定死,因为它的价值不在协作,而在隔离风险。我的规则只有三条,但覆盖了绝大多数场景:
main分支对应生产环境,任何合并都要经过一次 CI 检查(类型检查 + 构建);- 功能分支推送后自动生成预览部署,每个预览部署有独立地址,连的是测试库;
- 紧急修复从
main切出短分支,修完合并,不走预览直接上生产的例外情况一概不批。
第二条的价值在于“看得见”。改完样式之后,我能把预览地址直接发给帮忙试用的朋友,对方点开就是最新版本,不用我打包发文件。这一点在小项目里体验提升非常明显。
第三条看起来严苛,但它挡住了我最大的一个坏习惯:直接在平台后台改配置然后点“重新部署”。这种操作绕过了代码审查,事后完全想不起来当时改了什么。后来我强制自己走分支,配置变更也提交到仓库里的配置文件中,这样每一次变更都有 diff 可查。
3.3 用配置文件固化路由、缓存与安全响应头
平台后台能点的东西,尽量落到配置文件里。原因很简单:后台配置不可版本化、不可评审、不可回滚。Badget 的配置文件我命名为platform.json(各平台约定的文件名不同,按你所用平台的文档来),核心内容如下:
{ "buildCommand": "npm run build", "outputDirectory": ".next", "installCommand": "npm ci", "headers": [ { "source": "/assets/(.*)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] }, { "source": "/(.*)", "headers": [ { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, { "key": "X-Frame-Options", "value": "SAMEORIGIN" } ] } ], "rewrites": [ { "source": "/s/:code", "destination": "/share?code=:code" } ] }这里有两个参数值得展开算一下。第一处是max-age=31536000, immutable,31536000 秒正好是一年。带哈希文件名的静态资源内容一变文件名就变,所以可以放心给一年缓存并标记immutable,浏览器连条件请求都不会发,这是首屏提速里性价比最高的一招。第二处是immutable不能加在 HTML 上,HTML 必须保持短缓存或不缓存,否则用户永远看到旧页面。我在配置里没有给 HTML 设长缓存,就是靠这个默认值。
rewrites那一条对应 Badget 的分享功能:把/s/abc123这种短链映射到带查询参数的分享页。用重写而不是重定向的好处是地址栏不变,用户复制出去的还是短链。
响应头里的三个安全项是我后来补的。nosniff防止浏览器猜 MIME 类型执行脚本,X-Frame-Options: SAMEORIGIN防止页面被第三方内嵌做点击劫持,Referrer-Policy控制跨站请求时不泄露完整路径。这三条不花任何性能成本,属于“顺手就加上”的项。
3.4 数据库连接、连接池与迁移的独立流程
Serverless 函数最大的结构性问题是连接数。传统常驻服务开 20 个数据库连接就够用,函数是按请求实例化的,并发一上来可能瞬间开出上百个连接,直接把数据库的最大连接数打满。
Badget 用的是托管型 PostgreSQL,最大连接数 60。我做的第一件事是给连接串加连接池参数,第二件事是限制每个函数实例的连接数:
postgresql://user:pass@host:5432/badget?connection_limit=3&pool_timeout=10connection_limit=3意味着单个函数实例最多占 3 个连接,pool_timeout=10表示排队超过 10 秒就报错而不是无限等。这两个值是算出来的:假设平台单区域并发峰值 20 个实例,20 × 3 = 60,刚好卡在数据库上限,再高就要上外部连接池服务。如果你不想引入额外组件,就把connection_limit再压低到 2,用排队换稳定。
数据库迁移我单独写了个脚本,只在需要变更表结构时手动执行一次:
# 本地验证迁移 SQL,不实际执行 npx prisma migrate diff \ --from-schema-datasource prisma/schema.prisma \ --to-schema-datamodel prisma/schema.prisma \ --script # 确认无误后在目标环境执行 DATABASE_URL="$PROD_DATABASE_URL" npx prisma migrate deploy第一条命令的价值在于“打印而不执行”。它会把将要跑的 SQL 打出来,你能提前看到有没有DROP COLUMN这种不可逆操作。凡是看到删除列、改列类型的语句,我的习惯是先加新列、双写一段时间、再删旧列,三步走,任何一步都能单独回滚。这套流程在小项目上显得啰嗦,但它救过我一次——那次我想把金额字段从整数改成小数,如果直接改类型,历史数据的精度会丢,双写方案让我有时间做数据校验。
4. 从第一次提交到生产上线的完整实操
4.1 首次导入与首构建的现场记录
第一次导入的流程比想象中短,但每一步都有确认点。我按时间顺序记录一下当时的操作:
- 平台上点击“导入项目”,授权代码托管账号,选中 Badget 仓库;
- 平台自动探测出框架类型,构建命令填
npm run build,输出目录填.next; - 展开“环境变量”,先只填数据库连接串和会话密钥两项,其余留空;
- 点击部署,等待首次构建。
首次构建花了 2 分 40 秒,其中依赖安装占 1 分 50 秒,构建占 40 秒。这个时间在第二次部署之后降到了 55 秒,因为依赖缓存命中了。所以第一次构建慢不要慌,看日志确认是“下载依赖”还是“编译代码”慢,前者是正常的,后者才需要优化。
首构建失败了一次,报错是Error: Cannot find module '.prisma/client/default'。原因很清楚:我在本地有生成的客户端,但仓库里.gitignore把node_modules排除了,平台拉下来的干净环境里没有客户端,而我的build脚本没有包含prisma generate。修复方式就是前面提到的,把它挂到postinstall上。这个错误在“本地能跑线上跑不了”的问题里出现频率极高,看到这个报错先去检查postinstall。
4.2 提交、预览、合并的完整链路
链路跑顺之后,日常开发的节奏是这样的:
# 1. 切分支开发 git checkout -b feat/export-csv # 2. 本地起服务,连本地测试库 npm run dev # 3. 提交并推送,触发预览部署 git add . && git commit -m "feat: 支持按月导出 CSV" git push origin feat/export-csv # 4. 在平台看构建状态,拿到预览地址做验证 # 5. 验证通过后开合并请求,CI 检查通过再合入主分支第 4 步的验证我有个固定清单,不管改的是什么,都按这四项过一遍:页面首屏能否正常渲染、登录态能否保持、新增的接口返回结构是否符合预期、浏览器控制台有没有报错。这四项覆盖了 90% 的低级错误。
第 3 步推送之后,平台会在几十秒内给出预览地址。这里有个细节:预览部署的地址是每次唯一的,但预览环境的环境变量是共享的。所以如果你在分支上改了数据库结构又忘了回滚测试库,下一次别人做预览就会撞上不一致的结构。我的做法是给每次涉及表结构的实验单独建一个测试库分支,用完即删,不共用。
合并到主分支之后,生产部署会自动触发。这时候我会做两件事:一是看构建日志有没有警告(peer dependency警告可以忽略,unused variable之外的类型警告要处理),二是打开线上地址做一次冒烟测试,重点看登录和记账两个核心动作。
4.3 回滚与灰度:出问题时怎么把损失压到最小
回滚这件事,我坚持的原则是“先止血,再查因”。发现问题后的第一动作永远是回滚到上一个健康版本,而不是在线上调试。
V 平台这类服务的回滚是“提升历史部署”的操作:在部署历史里找到上一个状态正常的版本,点提升为当前生产版本。整个过程几秒钟,不触发构建。这里要注意一点:回滚只回滚代码,不回滚数据库。如果那次发布包含表结构变更,回滚代码之后新代码可能读不懂旧结构。这就是我坚持迁移独立执行的原因——迁移和代码发布必须能各自独立回滚。
灰度方面,这类平台通常提供按比例的流量分配或者基于请求头的定向。Badget 用户少,我没做正式灰度,但用了一个简化版:新版本上线后先自己用一天,观察错误日志和数据库慢查询,没有异常再把分享链接发出去。这个“自己先用一天”的习惯比任何灰度机制都实在。
另外我在平台侧配了一条最简单的可用性检查:每分钟请求一次健康检查接口,连续三次失败就发通知到邮箱。接口本身只做两件事,查一次数据库连通性、返回版本号。这个检查跑了一个月,抓到过一次数据库连接数打满的问题,比等用户反馈快得多。
4.4 上线后的验证清单
部署成功不等于上线成功,这两件事之间的差距,全靠验证清单填。Badget 的清单我贴在项目文档里,每次发布照着勾:
| 检查项 | 具体动作 | 判定标准 |
|---|---|---|
| 静态资源 | 打开首页,看网络面板 | 资源全部 200,无 404 |
| 核心流程 | 登录后记一笔支出 | 列表实时出现新记录 |
| 接口鉴权 | 未登录状态直接访问数据接口 | 返回 401,不返回数据 |
| 缓存策略 | 刷新两次页面 | HTML 走协商缓存,静态资源命中强缓存 |
| 错误监控 | 手动触发一个异常 | 能在日志中定位到堆栈 |
| 移动端 | 手机浏览器打开 | 布局不错位,按钮可点 |
这张表里第四项最容易被忽略。有段时间我发现改了文案线上不生效,排查半天发现是某次实验给 HTML 也加了长缓存头,用户浏览器一直用本地副本。从那以后我把“刷新两次看缓存行为”写进了清单。
5. 常见问题与排查技巧实录
5.1 构建期错误速查表
构建失败是最常见的,也是最容易自己解决的,因为日志完整。我把踩过的按报错关键词整理成表,遇到直接对号入座:
| 报错关键词 | 真实原因 | 处理方式 |
|---|---|---|
Cannot find module '.prisma/client' | 构建期没生成数据库客户端 | 把生成命令挂到postinstall |
ERR_PNPM_OUTDATED_LOCKFILE | 锁文件与清单不一致 | 本地重装依赖后提交新锁文件 |
Module not found: Can't resolve 'fs' | 服务端模块被打进了客户端包 | 检查是否在客户端组件里引用了服务端代码 |
Type error: Property does not exist | 类型检查比本地严格或版本不一致 | 对齐 TypeScript 版本,本地跑tsc --noEmit |
Exceeded maximum build time | 构建超时 | 检查是否在构建期做了网络请求或全量图片处理 |
Out of memory | 构建内存不足 | 关闭构建期类型检查或拆分构建步骤 |
第三行那个错误我印象最深。当时我在一个客户端组件里引用了工具函数,那个函数文件顶部又import fs用于读写本地文件。本地开发时因为模块解析宽松没报错,构建时静态分析直接拦下。修复方式是把服务端专用函数拆到独立文件,客户端只引纯函数。
5.2 运行时问题:404、500 与“部署成功但打不开”
运行时问题比构建期难查,因为日志分散。我总结了一个定位顺序,从外到内一层层剥:
先看 HTTP 状态码。返回 404 说明路由没匹配上,重点查输出目录和重写规则;返回 500 说明代码执行时抛了异常,去函数日志里找堆栈;返回 504 说明函数超时,去查是不是有慢查询或者外部接口没设超时。
再区分“静态资源 404”和“页面 404”。前者一般是资源路径写死成了绝对路径,构建后部署在子路径下就找不到;后者是路由配置问题。Badget 遇到过一次静态资源 404,原因是字体文件用了大写的目录名,而部署环境的文件系统大小写敏感,本地开发机不敏感,所以本地一切正常。这条经验值一条:所有资源引用路径一律小写,别依赖本地文件系统的大小写宽容。
还有一种“部署成功但打不开”的情况,页面白屏、控制台无报错。这种多半是服务端渲染时抛了异常但被错误边界吞掉了。我的排查手段是在页面最外层临时加一段日志输出,把渲染阶段的关键变量打出来,定位到具体是哪个数据为空导致的。
5.3 踩坑心得:缓存、环境变量与时区
这三类问题不会报错,只会“表现得很奇怪”,所以单独拎出来说。
缓存的问题前面提过,补充一个更隐蔽的:函数的响应被边缘缓存了。有次用户反馈登录后刷新页面变成了未登录状态,我查了半天,发现是某个数据接口的响应头被设成了可缓存,边缘节点把 A 用户的响应缓存下来给了 B 用户。修复很简单,所有涉及用户身份的接口统一加Cache-Control: private, no-store,静态资源才允许公共缓存。判断标准就一条:响应内容是否因人而异。是,就禁止公共缓存。
环境变量的问题在于“构建期”和“运行期”的读取时机不同。带公共前缀的变量在构建时被内联进产物,构建之后再改平台上的值,不重新构建是不生效的。我遇到过一次改了统计脚本 ID 但线上没变化,就是因为只改了变量没触发重新部署。习惯做法是:改完公共变量,手动触发一次重新部署,别指望它自动生效。
时区问题更隐蔽。Badget 按“月”统计收支,服务端算月份边界时用的是 UTC,用户在东八区,每月最后一天晚上记的账会被算进下个月。修复方式是在数据库里存时间戳(带时区),统计时明确按用户所在时区做边界转换,而不是直接用服务端本地时间。这类问题在账单、报表类应用里几乎必踩一次,提前想清楚能省很多解释成本。
6. 成本估算、性能优化与后续扩展
6.1 免费额度够不够:按真实流量算一遍
成本这件事不能凭感觉,得算。我按 Badget 的实际情况做了一次估算,参数都是实测值:
- 月活跃用户 200,人均日访问 2 次,月请求量约 12000 次;
- 每次访问触发 3 个函数调用,月函数调用约 36000 次;
- 平均函数执行时长 120 毫秒,月总执行时长约 4320 秒;
- 静态资源月流量约 5GB。
对照这类平台的免费额度(通常包含十万次函数调用、100GB 流量、每月一定时长的执行时间),结论是这个量级连免费额度的十分之一都用不到。真正可能超的只有带宽,如果哪天分享链接被大量转发导致图片流量暴涨,成本会从零跳到个位数金额,仍然可接受。
我也算过自建方案的对照成本:最便宜的云主机按年付折算每月也要几十元,加上域名和证书,一年下来是三位数。省下这笔钱不是重点,重点是省下了打补丁、看监控、处理证书过期的时间。对一个副业项目来说,时间的价值远高于那几十块钱。
6.2 性能优化的几个有效抓手
优化这件事要按收益排序,别一上来就做微优化。我按实测效果从高到低列:
第一是静态资源长缓存加边缘分发,前面已经讲过,首屏能省几百毫秒。第二是图片按需处理,把原始大图交给平台的图片优化能力,按设备返回合适尺寸,一张 800KB 的头像能降到 60KB 左右,降幅接近 90%。第三是减少函数冷启动,手段是把依赖体积压下去——我清掉了两个只在构建期用的重型依赖,函数包体积从 40MB 降到 12MB,冷启动从 1.2 秒降到 400 毫秒左右。
第四是数据查询优化。Badget 的收支列表原来每次都全量查当月数据,加了索引并把聚合放到数据库侧之后,单次查询从 180 毫秒降到 15 毫秒。这里有个小技巧:把常用的聚合结果做短期缓存,但缓存的键必须包含用户 ID 和数据版本号,否则用户新记一笔账看到的还是旧汇总。
6.3 后续可以怎么扩展
这套架构不是终点。我给自己列了三个可选的扩展方向,都不需要推翻现有结构。
一是把数据库迁移接进流水线,做成需要人工确认的独立任务。现在的做法是本地手动跑,安全但依赖人。改成流水线任务的关键是加一道人工审批闸门,避免自动执行不可逆 SQL。
二是加一层错误聚合服务,把函数日志里的异常按类型归一。现在靠翻日志,项目再大一点就翻不过来了。
三是把预览环境的数据库做成按分支自动创建、合并后自动销毁。这一步能彻底解决“预览环境共用测试库导致互相污染”的问题,成本是实现复杂度上升,适合项目有多人协作之后再做。
我个人在这个项目上最深的体会是:部署流程的价值不在于第一次跑通,而在于第三十次还能一样稳定。第一次上线我花了四十分钟,后面每次发布基本是推代码、等一分钟、点两下验证清单,全程不到五分钟,中间没有一次因为“环境不一致”返工。真正值得投入时间的不是把构建时间从 60 秒压到 40 秒,而是把那些“偶尔会出问题”的环节变成“不可能出问题”的规则——环境变量分层、迁移独立执行、回滚只动指针。这三条定下来之后,剩下的都是细节。