静态站最容易被低估的不是 Astro 语法,而是「内容写进数据库之后,产物怎么安全地换上去」。这篇拆解 Limit Blog 的内容构建链路:写入成功后如何触发、2 秒防抖解决什么问题、构建状态怎么推到管理端,以及草稿隔离、原子切换和失败回退分别靠什么保证。

1. 构建不能挂在请求里

假设作者在管理端连续保存五次,每次都触发一次 Astro 构建,会依次遇到三个问题:

  1. 构建风暴:五次保存对应五次全量构建,CPU 被打满,后一次构建还在等前一次释放目录。
  2. 请求超时:如果在 handler 里同步等构建完成,一次构建几十秒,而 server.timeout.request 只有 30 秒,客户端先收到超时,构建还在后台跑。
  3. 发布事故:如果构建失败时已经把 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

预览的隔离分三层:

  1. 路径隔离:预览产物在 preview/<buildId>/,与 releases/、current 分开,永远不参与生产切换。
  2. 数据隔离:预览构建不查公开 API 的文章列表,而是用 HMAC 令牌换一次 PreviewBundle,草稿只存在于这次快照里。
  3. 访问隔离: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 之间切换,三种全文检索差在哪,以及迁移顺序。