静态站最容易被低估的不是 Astro 语法,而是「内容写进数据库之后,产物怎么安全地换上去」。这篇拆解 Limit Blog 的内容构建链路:写入成功后如何触发、2 秒防抖解决什么问题、构建状态怎么推到管理端,以及草稿隔离、原子切换和失败回退分别靠什么保证。
1. 构建不能挂在请求里
假设作者在管理端连续保存五次,每次都触发一次 Astro 构建,会依次遇到三个问题:
- 构建风暴:五次保存对应五次全量构建,CPU 被打满,后一次构建还在等前一次释放目录。
- 请求超时:如果在 handler 里同步等构建完成,一次构建几十秒,而
server.timeout.request只有 30 秒,客户端先收到超时,构建还在后台跑。 - 发布事故:如果构建失败时已经把
current指向新目录,线上会直接 404;旧版本明明还在磁盘上,却已经不可达。
所以这条链路的第一条原则是:数据库是事实来源,静态产物只是派生物。构建可以失败、可以重跑、可以延迟,但不能影响写入结果,也不能破坏正在服务的旧版本。
围绕这条原则,系统做了五个约束:
- 写入成功之后才触发,触发动作不阻塞 HTTP 响应;
- 短时间内的多次触发合并成一次;
- 构建服务串行执行,同一时刻只跑一个构建;
- 新产物完整生成后,才原子切换;
- 状态可查询、可推送,失败可手动重试。
2. 从一次保存到一次切换
链路:
管理端 PUT /api/admin/articles/{articleId}
├─ Service 写库成功(事务提交)
├─ Controller: go TriggerBuild(context.Background())
▼
Debouncer(2 秒窗口,只保留最后一次)
▼ 窗口结束
POST site-builder:9000/build(X-Builder-Secret)
▼ 立即返回 202
Astro 构建(读 /api/public/*)
→ releases/<buildId>
→ 原子替换 data/site/current
▼
Nginx 服务 current,下一次请求即新版本
有两个容易写错的细节。
第一,触发必须脱离请求上下文:
func notifySiteBuild(siteService *service.SiteService) {
if siteService == nil {
return
}
// 请求结束后 ctx 会被取消,构建触发不能挂在请求 ctx 上。
go siteService.TriggerBuild(context.Background())
}
如果直接把 handler 的 ctx 传进去,响应一返回,context 就被取消,发往 builder 的 HTTP 请求会立刻失败。构建触发是「写成功之后的副作用」,它的生命周期不该由某一次请求决定。
第二,触发点不在 Service 里,而在 Controller 写成功之后。文章、分类、标签、导航、友链、页面、项目、公告、设置、媒体、主题的写接口都遵循同一模式:先落库,再 notifySiteBuild。这样 Service 不需要知道「站点要不要重建」,构建只是一个可选的订阅者。
builder 本身不暴露给公网:它只监听回环地址或容器内网,请求必须携带 X-Builder-Secret。否则任何人都能触发构建,用很低的成本打满 CPU。这个密钥与预览令牌也不是一回事:前者证明「调用方是 app」,后者证明「这一次预览被授权」。
3. 2 秒防抖与手动构建的取消
防抖器本身很小,核心是一个代际计数器和可重置的定时器:
type Debouncer struct {
mu sync.Mutex
delay time.Duration
timer *time.Timer
generation uint64
fn func()
}
func (d *Debouncer) Trigger() {
d.mu.Lock()
d.generation++
generation := d.generation
if d.timer != nil {
d.timer.Stop()
}
d.timer = time.AfterFunc(d.delay, func() {
d.mu.Lock()
if generation != d.generation {
d.mu.Unlock()
return
}
d.timer = nil
d.mu.Unlock()
d.fn()
})
d.mu.Unlock()
}
Cancel 的实现只有两步:generation++ 与 timer.Stop()。代号先变,旧回调即使已经被调度也会发现自己过期,于是直接返回。
generation 解决的是竞态:定时器回调真正执行时,可能已经有更新的 Trigger 重置了窗口。旧回调发现代号不匹配就退出,不会多构建一次。
窗口默认 2 秒,由 site.build.debounceSeconds 配置。这个值的取舍很直接:窗口越长,合并效果越好,但发布越慢;窗口越短,越接近「保存即构建」,也越容易在连续编辑时重复构建。对人工编辑,2 秒大约覆盖「改完标题再改摘要」的操作间隔;对批量导入,仍然要靠 builder 的串行队列兜底。
手动重建则要反过来,先取消待执行的自动构建:
func (s *SiteService) Rebuild(ctx context.Context) (*vo.SiteBuildResult, error) {
if err := s.ensureEnabled(); err != nil {
return nil, err
}
// 手动重建会立即执行,取消尚未触发的自动构建,避免重复构建。
if s.autoBuild != nil {
s.autoBuild.Cancel()
}
if err := s.client.Trigger(ctx, sitebuild.BuildRequest{Mode: sitebuild.ModeProduction}); err != nil {
return nil, api.ErrInternalServerError.WithMessage("触发站点重建失败")
}
return &vo.SiteBuildResult{Accepted: true}, nil
}
如果少了 Cancel,用户点「重建站点」后,2 秒前的自动构建仍会触发,结果同一个版本构建两次。防抖只能合并「窗口内的重复」,管不了「手动与自动的重复」,这两件事要分开处理。
需要说清楚的边界是:防抖器在进程内存里,没有持久化队列。进程重启会丢掉尚未执行的窗口,但内容已经在数据库里,下一次写入或手动重建就能恢复。对单机博客,这比引入一个消息队列划算得多。
4. 构建状态机与串行队列
site-builder 对外暴露的状态只有四个值:
idle ──POST /build──▶ running ──成功──▶ success
│
└──失败──▶ failed
running ──新请求──▶ queued(只保留最后一个)
└──当前构建结束──▶ running
queued 不是对外状态,而是 builder 内部的一个槽位。请求进来时如果已有构建在跑,builder 不排队列,而是直接覆盖 queued 并返回 202 {accepted:true, queued:true}:
if (running) {
queued = request;
return json(res, 202, { accepted: true, queued: true });
}
void runBuild(request);
return json(res, 202, { accepted: true, queued: false });
当前构建结束后,在 finally 里取出 queued 继续跑。这样做的理由是:连续十次保存,中间九个版本的产物没有意义,只有最后一个状态需要落地。代价是「只保留最后一个」也会作用在预览请求上——如果预览在排队时被后续的生产构建挤掉,管理端需要重新发起预览。
为什么必须串行?因为多个构建会共享同一个 frontend/web/dist 输出目录,并同时操作 releases/ 与 current。并行构建不是「更快」,而是互相覆盖。串行还顺带把 CPU 峰值限制在一个构建内,API 进程不会被构建拖慢。
5. 用 SSE 推送状态
管理端需要知道「现在有没有在构建、成功还是失败」,但又不想为每个标签页维持 WebSocket。SSE 更合适:单向、文本、断线重连逻辑简单。
接口是 GET /api/admin/site/build/events,权限点 site:rebuild,由 Huma 的 sse.Register 注册。服务端并不订阅事件总线,而是每秒轮询一次 builder 的 /status:
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
lastSignature := ""
lastSent := time.Time{}
sendStatus := func(force bool) bool {
var event vo.SiteBuildEvent
if !siteService.BuildEnabled(ctx) {
event = vo.SiteBuildEvent{State: "idle", Message: "站点构建未启用"}
} else if status, err := siteService.Status(ctx, ""); err != nil {
event = vo.SiteBuildEvent{State: "failed", Message: "构建服务不可用", Error: err.Error()}
} else {
event = siteBuildEvent(status)
}
signature := event.State + "|" + event.BuildID + "|" + event.Error
if !force && signature == lastSignature && time.Since(lastSent) < 15*time.Second {
return true
}
if err := send.Data(event); err != nil {
return false
}
lastSignature, lastSent = signature, time.Now()
return true
}
三个设计点:
- 去重:状态签名没变就不重复发,避免每秒一条无意义消息;
- 心跳:即使状态不变,15 秒也补发一次,防止中间代理把空闲连接掐掉;
- 首帧强制发送:连接建立后立刻给一个
idle或当前状态,管理端不用等下一个 tick。
推送的 data 是一个 JSON 事件:
{
"state": "running",
"message": "站点构建中…",
"buildId": "1712345678901"
}
还有一个必须改的中间件细节:Chi 的 Timeout 默认给请求 30 秒上下文,SSE 连接会在 30 秒时被取消,浏览器表现为不断重连。项目用 TimeoutExcept 把 /api/admin/site/build/events 排除在外,长连接才能活到构建结束。
管理端没有用浏览器原生的 EventSource,因为 EventSource 不能设置 Authorization 头。它用 fetch 读 response.body 的流,手动按 \n\n 分帧,401 时先刷新 token 再重连。这是一个小取舍:多写几十行解析代码,换来不用把 JWT 塞进查询参数。
6. 发布一致性靠什么保证
「发布一致性」经常被说成一个词,其实是四件事。
6.1 草稿不进入生产
公开 API 的查询条件始终是「已发布且到点」:
Where("status = ? AND published_at IS NOT NULL AND published_at <= ?", "published", time.Now())
draft、pending、private、trash 都不会出现在公开响应里;定时发布用 published_at > now() 表达,不依赖后台调度器。生产构建读的是公开 API,所以草稿天然进不了产物。
预览走另一条路:/api/preview/bundle 返回除回收站外的全部文章,并额外带上 status 字段,让预览站能渲染草稿、待审、定时与私密内容。两条数据路径在入口处分叉,而不是靠前端「不要显示草稿」这种约定。
6.2 原子切换的是产物,不是数据
构建成功后,site-builder 用「临时符号链接 + rename」替换 current:
async function replaceSymlink(target, linkPath) {
const temporary = `${linkPath}.tmp-${Date.now()}`;
// 相对链接让 symlink 在挂载点不同的容器里仍然有效。
const relative = path.relative(path.dirname(linkPath), target);
await rm(temporary, { force: true });
await symlink(relative, temporary, 'dir');
await rename(temporary, linkPath);
}
rename 在同一个文件系统内是原子的,Nginx 要么看到旧链接,要么看到新链接,不会看到「链接存在但指向空目录」。新产物先复制到 releases/<buildId>,完整之后才切换;旧 release 保留,回滚可以把链接指回去(当前没有做成接口,属于手工操作)。
这里必须区分两件事:原子切换保证产物完整,不保证数据快照一致。生产构建是 Astro 逐页调用公开 API 取数的,如果构建过程中又有人发布文章,不同页面可能读到不同时刻的数据。2 秒防抖降低概率,但不能消除。预览反而没有这个问题:它先拉一次完整的 PreviewBundle 写成 JSON,再交给 Astro,所以预览是一个一致快照。
要彻底解决生产构建的混合状态,需要在构建开始时生成一次数据库快照,或者让公开 API 支持「按构建 ID 读固定版本」。这个项目没有做,属于已知边界。
6.3 失败保留旧版本
构建失败时,current 完全不动,Nginx 继续服务上一个 release。builder 把状态置为 failed 并带上错误信息,SSE 会把它推给管理端。失败构建留下的半成品目录不在服务路径上,最多占磁盘,不会造成 404。
这也是「先写 releases,再切链接」的顺序价值:如果反过来先切链接再复制,任何一次复制失败都会直接打穿线上。
7. 预览隔离与 TTL
预览的隔离分三层:
- 路径隔离:预览产物在
preview/<buildId>/,与releases/、current分开,永远不参与生产切换。 - 数据隔离:预览构建不查公开 API 的文章列表,而是用 HMAC 令牌换一次
PreviewBundle,草稿只存在于这次快照里。 - 访问隔离:URL 里的
buildId是 16 字节随机数的十六进制,不可枚举;响应带X-Robots-Tag: noindex, nofollow与Cache-Control: no-store。
令牌格式是 base64url(buildID).exp.hmacSHA256(buildID.exp),默认 15 分钟过期。校验失败返回 401,过期返回「预览令牌已过期」。
但要把 TTL 的真实作用说清楚:令牌保护的是构建期拉取快照,不是预览页面的每次访问。产物生成后由 Nginx 或 Go 静态服务直接提供,不再校验令牌;真正限制访问的是「随机路径 + 一小时清理窗口」。把预览链接发给别人,在清理前都能打开。这是刻意的取舍:预览是给作者和编辑看的临时产物,不是需要登录的私有站点。
site-builder 每 10 分钟清理一次超过 1 小时的预览目录。清理失败只记日志,不影响构建主流程。
8. 错误、重试与幂等
这条链路的语义是「至少触发一次,最终收敛」,不是「恰好一次」。
- Go 侧调用 builder 的 HTTP 客户端超时 15 秒;失败只记日志,不自动重试。构建是重操作,盲目重试会把一次故障放大成构建风暴。
- builder 返回
202只代表「已接受」,不代表成功;真正的结果要通过/status或 SSE 获取。 - 生产构建 ID 用时间戳,每次构建写入新的
releases/<id>。重复构建不会覆盖旧产物,内容最终一致,但磁盘会持续增长;当前只自动清理预览,不清理 release。 - 预览构建 ID 随机生成,令牌只对这一次构建有效;重试预览必须重新申请。
常见故障与表现:
| 故障 | 表现 | 恢复方式 |
|---|---|---|
| builder 未启动 | 触发返回连接错误,SSE 显示「构建服务不可用」 | 启动 builder 后手动重建 |
| Astro 构建失败 | 状态 failed,旧站继续服务 |
修依赖或内容后重建 |
| 拉取预览快照失败 | 预览构建失败,生产不受影响 | 重新发起预览 |
| 符号链接切换失败 | 构建失败,current 仍指向旧版本 |
检查目录权限后重建 |
| app 重启 | 待执行防抖丢失,builder 状态仍在 | 下一次写入或手动重建 |
一个容易被忽略的细节:builder 的状态只存在内存里,重启后回到 idle,没有历史记录。对单机博客够用;如果要做「最近十次构建」的审计,得把状态落库,而不是加一个内存队列。
9. 边界与取舍
这套链路有几个明确的前提:
- 单实例:防抖器在 app 进程内。多实例部署时,每个实例都会触发一次构建,需要把防抖改成共享队列或选主。
- 单构建服务:builder 串行执行,适合单机 Astro 构建。要并行构建,必须先隔离
dist与输出目录,否则会互相覆盖。 - 无持久队列:进程重启会丢失待执行窗口与构建历史,靠数据库内容和手动重建兜底。
- 无数据快照:生产构建是逐页读公开 API 的,构建期间的内容变更可能造成混合状态;预览有快照,生产没有。
- 磁盘容量:release 目录不自动清理,需要外部保留策略;预览只保留一小时。
如果这些边界里有一条不成立,链路就要升级:多实例加分布式锁或队列,强一致加快照接口,构建量大加独立 worker。但对一个自托管博客,当前这套「内存防抖 + 串行 builder + 原子链接」已经把复杂度控制在可维护范围内。
如果要把这套链路做得更稳,优先补的不是并行构建,而是两个指标:构建耗时与失败率。耗时决定发布体验,失败率决定你是否敢频繁发布;两者都比「多开一个 worker」更早暴露问题。
另一个常被忽略的成本是磁盘:release 保留得越久,回滚越从容,容量压力也越大。最好在部署时就把保留策略写进运维手册,而不是等磁盘告警再删;这条策略没有代码可优化,只能靠运维约定。
10. 小结
核心不是 SSE 或符号链接,而是把构建当成可失败、可合并、可观察的异步副作用:写库先成功,防抖合并,串行执行,成功才切换,失败留旧版。
下一篇转到数据层:同一套代码如何在 SQLite、PostgreSQL 和 MySQL 之间切换,三种全文检索差在哪,以及迁移顺序。




评论
加载中…