站点是 zendot.org ,开源项目推荐。后端 Rust ( Actix Web + SeaORM + Postgres ),前端 Next.js ,后端约 1.4 万行,跑在一台 4G 内存的小机器上。
先说结论,免得看到最后:Rust 在这个项目里给我最大的好处不是性能,是「部署就是一个二进制、没有运行时依赖」和「没有 GC 尖刺」。最大的代价是编译时间。
下面全是具体的表和数字,没有心得。
一、数据模型我是直接抄 WordPress 的
内容站要解决的问题,WordPress 二十年前就解完了。我没想到任何必须重新发明的地方,所以表结构基本照着 WP 抄:
posts ≈ wp_posts 文章本体,多两列 title_en / body_en 存英文
terms ≈ wp_terms + wp_term_taxonomy 我把 taxonomy_id 直接放在 terms 上
taxonomies ≈ wp_taxonomies 只有三条:category / post_tag / domain
post_terms ≈ wp_term_relationships
post_revisions ≈ wp_posts 的 revision 单独拆表
options ≈ wp_options
media ≈ attachment
menus / menu_items
抄的好处很具体:后期想加任何「内容站都会有」的需求,WP 里都有成熟答案,我只需要判断要不要实现,不用重新推演一遍。
比如 options 表:
create table options (
key varchar primary key,
value jsonb not null,
autoload boolean not null default false,
updated_at timestamptz not null default now()
);
站点标题、描述、评论开关全在里头,加一个配置就是加一行数据。autoload 那列也是抄的——每次请求都要读的配置标 true 。
再说一个我踩的坑。terms 的唯一约束我建的是 (taxonomy_id, slug),不是 slug 。于是「 analytics 」可以同时存在于 domain (中文名「数据分析」)和 post_tag 两个分类法里。
我某天排查数据时写了这么一句:
select slug, count(*) from terms group by slug having count(*) > 1;
返回 analytics | 2 ,我当场判定是脏数据,准备写脚本合并。看了表结构才发现是自己漏了 taxonomy_id——不同分类法下同名 slug 完全合法。
教训:查数据之前先看约束。这个错误值我一个下午。
二、分层和 trait ,收益到底在哪
大概是 domain / app / infra / api 四层,ObjectStorage 、各种 Repository 都是 trait 。
我不想吹「架构清晰」,说两个具体的:
一是一个二进制里塞多个运维命令。cargo build --bins 一次编出主服务和 seed-admin /backfill-avatars / backfill-stats 三个脚本。它们和主服务共用同一份 domain 和 infra ,所以运维脚本操作数据的口径和线上完全一致——不会出现「脚本把文件写到另一个存储后端」这种事。这才是端口抽象给我的实际价值。
二是存储后端能换。ObjectStorage 有 LocalFs 和 S3 两个实现,本地开发落本地盘。
代价也说:Arc<dyn Trait> 到处飞,每个仓储方法都要 async_trait ,样板量不小。
三、Actix 用下来的真实感受
顺手的:
- web::Data<T> 共享状态很直接,不用引 DI 框架
- actix-files 一行挂出上传目录:
.service(Files::new("/uploads", upload_dir.clone()))
- 中间件能精确包一层。后台鉴权我只包了 /admin 那一层:
web::scope("/admin")
.wrap(HttpAuthentication::bearer(middleware::validator))
.service(handlers::auth::register)
.service(handlers::posts::create)
- 重构时编译器会兜住你。改了 domain 层的字段,所有没跟上的地方会被列出来。
不顺手的:
- 错误处理要自己搭。我写了 ApiError + ResponseError 把 DomainError 映射成状态码,
否则每个 handler 都得手写 match 。
- HttpAuthentication::bearer 和自己那套 Claims 的接法,文档得翻一会儿。
- 生态比 axum 小。我需要的它都有,但差距是客观存在的。
四、SeaORM 的坑(这段信息量最大)
SeaORM 够用,但别以为它生成的 SQL 理所当然是最优的。三个具体的:
1 ) .count() 会生成 SELECT COUNT(*) FROM (SELECT 所有列 ...)
慢查询日志里抓到的:
SELECT COUNT(*) AS num_items FROM (
SELECT "repo_items"."id", ..., "repo_items"."readme", ...
FROM "repo_items" WHERE "repo_items"."status" = $1
) AS "sub_query"
readme 是整篇 README 的正文。为了数一行数,把几百条记录的 README 都 SELECT 出来。Postgres 一般会把这个子查询优化掉,但这条 SQL 本身就不该存在。改成 count(id) 或者裸 SQL 就完了。
(列表查询是同一个毛病,SELECT * 把 body / body_en 一起拖出来,一次 12 篇正文。这是我还欠着的优化项。)
2 ) 预编译语句让查询日志翻倍
开 log_min_duration_statement = 0 排查时,一条查询在日志里是两行:bind 和 execute 。我第一次统计「每页多少条 SQL 」直接算成了两倍。只数 execute 。
3 ) 迁移里表达不了 partial index
我需要这么一个索引:
create index ... on repo_items (status, language)
where language is not null and language <> '';
SeaORM 的 Index builder 表达不了这个 WHERE ,最后是在迁移里 execute_unprepared 写裸 SQL 。能用 Builder 就用,不能的地方别硬拗。
五、一个 Rust 特有的坑:feature gate 导致的静默降级
这个我觉得最值得单独拎出来说。
我把对象存储做成可选 feature:
[features]
default = []
s3 = ["dep:rust-s3"]
读取时:
match env_or("STORAGE_DRIVER", "local").as_str() {
#[cfg(feature = "s3")]
"s3" => Arc::new(S3Storage::new(...)),
_ => Arc::new(LocalFsStorage::new(...)),
}
问题在于:如果构建镜像时没带 --features s3 ,那个 "s3" 分支在编译后就整个不存在了,match 会安静地落到 _ => 本地磁盘。
于是你设了 STORAGE_DRIVER=s3 启动服务,它不报错、不警告、照常工作,只是文件全写到本地硬盘上了。你以为切过去了。
这类「编译期消失导致运行期静默降级」是 Rust 里很容易犯的错,因为 cfg 让代码不是「条件成立」,而是「根本不存在」。可选 feature 和 match 兜底默认值这两样东西放在一起,特别容易出事。
我的处理是把「要切 S3 必须重编镜像」写进部署文档,并在代码注释里写明这个行为。
六、部署:多阶段构建 + 本地编译
服务器上不装 Rust ,也不需要。流程是本地 Docker Desktop 构建 → docker save →scp → 服务器 docker load 。
FROM rust:1-slim AS build
COPY backend/Cargo.toml backend/Cargo.lock ./
COPY backend/migrations ./migrations
COPY backend/src ./src
COPY backend/seed ./seed # include_str! 内嵌的文件,编译期必须存在
RUN cargo build --release --bins -j 4
FROM debian:bookworm-slim
COPY --from=build /src/target/release/zendot-backend /usr/local/bin/
实际经验:
- 全量 release 编译约 20 分钟。-j 4 是刻意限的并发,构建机内存不大,不限会被
OOM killer 干掉。
- 依赖层缓存是命根子。Cargo.toml / Cargo.lock 单独一层,之后改业务代码只重编自己
的 crate 。但第一次构建和依赖升级就是 20 分钟。
- include_str! 是容易漏的依赖。seed 数据是内嵌的,构建时文件必须存在,
COPY backend/seed ./seed 少一行直接编译失败。
- 最终镜像 209MB ,后端二进制 26MB 。
七、资源占用实测
Rust 后端 25 MB
Postgres 89 MB
Next.js standalone 119 MB
数据库体积 18 MB ( 492 篇文章 / 768 条项目 / 2984 条关联)
后端那一格是我最满意的。但这里要说实话:省的这点内存在总成本里不是大头,真正吃资源的是数据库和 Node 。如果你的站点还没到需要榨内存的程度,为了省这 100MB 去学 Rust ,投入产出比很差。
八、读路径优化:先测量,别猜
站点是典型读多写少。我没有直接凭手感加索引,而是先开 Postgres 的全量查询日志抓真实 SQL——这个用 reload 就生效,不用重启:
alter system set log_min_duration_statement = 0;
select pg_reload_conf();
跑一轮真实页面,拿到三个事实:
1. 一次页面渲染要打 8~33 条 SQL
2. 项目列表的执行计划是 Seq Scan + top-N heapsort
——每次都把该状态下的全部行扫出来再排序
3. 响应头是 cache-control: private, no-cache, no-store
——全链路零缓存
补上索引之后:
项目列表:Seq Scan + 排序( 72 buffers ) → 索引扫描、无排序节点( 14 buffers )
计数查询:Seq Scan ( 66 buffers ) → Index Only Scan ( 14 buffers )
重点不是省了那零点几毫秒,是复杂度。改前每次请求的代价是 O(命中行数),数据涨到几万行就线性劣化;改后是 O(LIMIT),基本恒定。
再在 nginx 上加一层 60 秒的微缓存:
首页 0.109s → 0.028s
项目列表 0.167s → 0.029s
sitemap 0.391s → 0.028s
命中时数据库查询数是 0 。连打 20 次,20 次全命中。
九、加缓存踩的三个坑
1 ) nginx 默认尊重上游的 Cache-Control ,于是缓存静默失效
Next 动态渲染的响应带 no-store ,nginx 看到它直接拒绝缓存。我一开始配了 proxy_cache_valid 200 60s ,结果 X-Cache-Status 永远 MISS 、缓存目录恒为 0.0000M 。
proxy_cache_valid 只负责设 TTL ,覆盖不了上游的头,必须显式:
proxy_ignore_headers Cache-Control Expires;
2 ) 缓存会把客户端导航打崩
Next.js 点链接不刷新整页时,会带 RSC: 1 请求同一个 URL ,但期望的是 RSC 载荷而不是 HTML 。缓存住它,导航就会拿到整页 HTML 直接炸。必须让这个头绕过缓存。
这条不看文档基本想不到,因为它平时完全正常,只有用户点链接时才出问题。
3 ) 我自己制造了一个回退
为了破掉动态页的 no-store ,我把 location / 的响应头换成了 max-age=60 。但 Next 的构建产物本来带 immutable (一年),它们也走这条路径——等于回访用户每分钟都要重新下载一遍 JS 。发现后单独给 /_next/static/ 开了一个 location 保住长缓存。
加缓存时,你很容易顺手把原本正确的缓存策略破坏掉。
另外补一个 nginx 老坑:add_header 不跨层级合并。某个 location 一旦自己写了 add_header ,父层的安全头在该 location 全部失效,得重写一遍。
十、几个小决策
语言进 URL 。中英双语,英文走 /en 前缀。页面文件结构一行没改,用 middleware 把/en/xxx rewrite 回 /xxx ,同时把语言写进请求头给服务端组件读。好处是爬虫不带 cookie 也能拿到正确的语言版本。
代价是拼 URL 容易错——我第一版英文列表页的 hreflang 被拼成了 /en/en/posts 。另外要记住服务端判断语言优先读中间件写的请求头、cookie 只当兜底,这样缓存才能安全地按 URL 分键。
头像只存组织,不存个人。个人头像是自然人肖像,国内《民法典》第 1019 条和 GDPR 的风险都落在这一侧,而展示它能获得的收益是负的。个人项目在前台回落到色块。
这个决定让文件数少了一大截:768 个项目里只有 317 条有头像(只有组织才有),按 owner 去重后落成 307 个文件。
头像文件名按 owner 前两个字符分桶。不是为了现在( 307 个文件随便放),是为了这个目录将来不会被 ls 或者逐文件操作的备份脚本拖死。用 owner 前缀而不是哈希分桶,因为前者还能一眼看出文件在哪。
十一、最后说点实话
Rust 在这个项目里真正的好处,按重要性排:
1. 部署就是一个二进制 + 一个配置文件,没有运行时依赖
2. 没有 GC 尖刺,响应时间的稳定性是实打实的
3. 常驻内存 25MB
4. 重构的时候编译器兜得住
不值得的地方:
1. 编译时间会改变你的开发节奏。改一行等 20 分钟不能接受,我的做法是开发时
cargo run 走增量编译(几秒),只有发版才走 Docker 全量构建
2. 生态确实比 Node / Go / Python 小,偏门需求自己写的时间要算进去
3. 如果你只是想做个小站,PHP / Go 的投入产出比明显更好
关于 ORM:SeaORM 够用,但你要接受「它生成的 SQL 需要自己盯」。慢查询日志在开发前期就该打开——这次读路径优化能做成,靠的全是那几个小时的实测数据,不是经验。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.