要让中英文版本真正“同时上线”,不能只连续调用两次保存接口。更可靠的做法是把双语正文视为一个发布单元:草稿独立编辑,公网只读发布快照,MongoDB 原子切换状态,Redis 只做可丢弃缓存。这样才能防止半发布、并发覆盖和旧请求重放。本文验证的是实现边界与故障语义,不提供生产吞吐量基准。

为什么双语发布不能只是两个普通的 PUT 请求?

最直接的实现通常是先保存中文,再保存英文,最后分别把两个页面标记为已发布。这条链路有三个隐患:

  1. 第一个请求成功、第二个请求失败时,搜索引擎只能访问一种语言。
  2. 两名编辑同时保存时,后到的请求可能静默覆盖先到的修改。
  3. 发布请求超时后,客户端不知道服务器究竟“没有执行”还是“已经执行但响应丢失”,盲目重试可能重复改变状态。

对于双语 SEO 页面,这不仅是内容管理问题。hreflang 需要稳定的对应页面,Sitemap 也应同时列出两个语言 URL。如果英文页已经进入 Sitemap,而中文页仍是旧版本,页面对用户可读,却已经失去发布单元的一致性。

更可靠的边界是:

  • 一个文章 slug 对应一个 MongoDB 文档;
  • 文档内同时保存中文和英文草稿;
  • 发布时一次生成两个语言的已消毒 HTML;
  • 一次数据库条件更新切换整个公开快照;
  • 公开路由只读取 published,从不读取正在编辑的 draft

如果你还在决定双语页面应该输出哪些搜索标记,可以先看动态 SSR 双语站 SEO 实战;如果想理解这套架构为什么更适合 AI 搜索,可回到2026 年 GEO/AEO 官方指南

一个可发布的文档需要哪些状态?

下面是简化后的数据结构。它不是要求所有 CMS 都照搬的标准,而是本项目用于隔离“编辑状态”和“公网状态”的实现。

{
  "_id": "fastapi-mongodb-bilingual-publishing",
  "recordType": "article",
  "state": "draft",
  "draftRevision": 1,
  "publishedRevision": 0,
  "stateRevision": 0,
  "draft": {
    "topic": {
      "slug": "ai-search-engineering",
      "zhName": "AI 搜索工程",
      "enName": "AI Search Engineering"
    },
    "zh": { "title": "...", "markdown": "...", "sources": [] },
    "en": { "title": "...", "markdown": "...", "sources": [] }
  },
  "published": null
}

三个修订号承担不同职责:

字段 回答的问题 典型变化
draftRevision 编辑者基于哪一版草稿保存或发布? 每次成功保存草稿递增
publishedRevision 公网快照来自哪一版草稿? 发布时设置为目标草稿修订
stateRevision 发布状态经历了多少次真实切换? 每次发布或下线递增

draftpublished 必须分离。否则编辑者刚输入一半的标题、未完成的英文段落,甚至尚未消毒的 Markdown,都可能被公开查询读到。发布快照生成后保持不变,直到下一次完整发布成功。

内部可以保留创建、更新和首次发布等时间戳用于排序与审计;是否在公网展示它们,是另一项产品决策。本系统公开页面不输出作者、发布日期或修改时间,也不在 Sitemap 中伪造 <lastmod>

乐观锁如何阻止并发编辑互相覆盖?

保存草稿时,客户端必须携带刚刚读取到的 expectedDraftRevision。新文章从 0 开始,数据库过滤条件同时匹配文章 ID 和当前修订号:

document = collection.find_one_and_update(
    {
        "_id": slug,
        "recordType": "article",
        "draftRevision": expected_draft_revision,
    },
    {
        "$set": {"draft": complete_bilingual_draft},
        "$inc": {"draftRevision": 1},
    },
    upsert=expected_draft_revision == 0,
    return_document=ReturnDocument.AFTER,
)

假设两个编辑者都读取了修订 7。A 先保存,文档变为 8;B 再以 expectedDraftRevision=7 保存,过滤条件不再匹配。服务应返回 409 Conflict,要求 B 重新读取并合并,而不是让“最后写入者获胜”。

这里的重点不是使用了某个字段名,而是把“我基于哪个版本做出这个修改”放进数据库条件。MongoDB 官方文档也建议在更新过滤条件中匹配预期当前值,以避免并发更新在调用方不知情时互相覆盖。

中英文如何在一次数据库更新中同时发布?

发布请求先读取目标草稿,完成严格校验和服务端渲染:

  • 专题 slug 与双语名称完整;
  • 两种语言都有标题、摘要、Markdown 和可见来源;
  • Markdown 转换为 HTML 后经过消毒;
  • 正文中的 H1 降为 H2,标题 ID 被归一化;
  • 两种语言的阅读时间分别计算;
  • 发布快照保留审核过的 Markdown 与渲染 HTML;公网查询通过 inclusion projection 和序列化白名单,只暴露标题、摘要、已消毒 HTML、来源等必要字段。

随后,用同一个 MongoDB 文档执行条件更新:

updated = collection.find_one_and_update(
    {
        "_id": slug,
        "draftRevision": expected_draft_revision,
        "publishedRevision": current_published_revision,
        "stateRevision": expected_state_revision,
    },
    {
        "$set": {
            "state": "published",
            "published": {
                "topic": topic,
                "zh": rendered_zh,
                "en": rendered_en,
            },
            "publishedRevision": expected_draft_revision,
        },
        "$inc": {"stateRevision": 1},
    },
    return_document=ReturnDocument.AFTER,
)

MongoDB 保证单文档写入是原子的。因此,读者不会看到只写入一半的 published 对象。这个方案刻意把一个选题的双语版本放在同一文档中;如果拆成两个文档,就需要事务、补偿流程或额外的发布指针,而 standalone MongoDB 也不能提供多文档事务。

原子性不等于所有业务规则自动正确。发布前校验、过滤条件、失败响应和重试语义仍然要由应用设计。

修订号、If-Match 和幂等键应该怎样选择?

这三种机制都能出现在“防止重复或冲突”的讨论中,但它们表达的意图不同。

draftRevision 或 HTTP If-Match 适合保护一个已有资源的编辑:客户端明确说“只在服务器仍是我读到的版本时保存”。如果版本已经变化,请求就应该冲突,由人或编辑器决定如何合并。它防的是丢失更新,不应该自动吞掉差异。

幂等键更适合“创建一次业务结果”的命令,例如创建订单、触发一次结算或提交一个不可自然寻址的任务。服务端保存请求键与结果,让相同业务意图在网络重试时返回同一个结果。幂等键必须有作用域、过期策略和参数一致性检查;只缓存一个 200 响应并不能解决“相同键、不同意图”。

双语文章发布同时具有资源版本和状态命令的特征。本实现按目标操作选择修订元组:

publish:   (published, expectedDraftRevision, expectedStateRevision)
unpublish: (unpublished, expectedPublishedRevision, expectedStateRevision)

发布元组回答“发布哪版草稿、基于哪个状态世代”,下线元组回答“移除哪版公开快照、基于哪个状态世代”。对外部支付这类没有自然状态世代的操作,独立 idempotency key 往往更合适。REST 编辑接口如果用 If-Match 表达草稿前置条件,必须为编辑资源提供独立的强 ETag;本文后面用于公网 HTML If-None-Match 重验证的是弱 ETag,不能复用到 If-Match 的强比较。

关键不是在所有接口中统一使用同一种字段,而是先区分三类失败:网络重试不应重复副作用,并发编辑不应互相覆盖,已经下线的旧状态不应被迟到请求复活。一个机制通常无法同时完整表达三件事。

例如,内容管理界面在收到 409 后应展示“服务器已有新草稿”,保留用户尚未提交的文本,并提供重新加载或人工合并;机器发布器遇到同一冲突则应停止,而不是自动把期望修订改成最新值。后者看似提高成功率,实际上绕过了审核边界。若 API 使用标准 HTTP 前置条件,也可以对编辑冲突返回 412 Precondition Failed;关键是团队为状态码定义稳定语义,并让客户端根据“冲突、临时故障、已提交但待清缓存”采取不同动作。

还要避免把数据库 _id、请求追踪 ID 和幂等键混为一谈。文章 slug 表示长期资源身份,trace ID 只帮助关联一次调用链,幂等键表示可重试的业务意图,修订号则证明客户端观察过哪个状态。它们可以同时存在,但不能互相替代。

为什么只有 draftRevision 还挡不住 ABA 重试?

考虑下面的状态序列:

时刻 操作 publishedRevision stateRevision
S0 初始草稿 0 0
S1 发布草稿 1 1 1
S2 下线文章 1 2
S3 旧的“发布草稿 1”请求迟到 1 ?

如果发布条件只检查 publishedRevision=1,S3 看到的内容修订号与 S1 相同,可能把已经下线的文章再次上线。这就是典型的 ABA 问题:值从 A 变成 B,又看起来回到 A,但中间已经发生过有意义的状态变化。

stateRevision 用单调递增的状态世代解决这个问题。发布和下线请求都必须带上读取时的 expectedStateRevision。S3 仍基于 0,而数据库已经是 2,所以只能收到冲突,不能复活旧状态。

安全重试还要区分两种情况:

  • 目标状态尚未提交:条件更新可以正常执行。
  • 同一目标刚刚提交,但响应或缓存失效结果丢失:服务器识别“恰好是下一状态世代且目标一致”,把重试视为幂等成功。

这与 AWS 关于幂等 API 的核心建议一致:调用方需要表达请求身份或意图,服务端则必须判断重复请求是否代表同一个业务操作,而不是把所有相同参数都机械地再执行一次。

MongoDB 已提交,但 Redis 失效失败怎么办?

缓存不是事实源。发布链路应先提交 MongoDB,再让 Redis 中的内容版本号递增。页面缓存键包含这个版本号:

seo_articles:page:<bundle-version>:<content-version>:detail:zh:<digest>

发布成功后执行一次 INCR,新请求自然使用新的缓存命名空间;旧键等待 TTL 到期,无需扫描删除所有列表页、专题页、详情页和 Sitemap。

真正棘手的是:MongoDB 已经发布成功,但 Redis 暂时不可用。此时不能声称整个操作“回滚了”,因为公网事实源已经改变;也不应该返回普通 200,因为旧缓存可能仍可读。

更诚实的响应是可重试的 503,并明确返回:

{
  "stateCommitted": true,
  "articleState": "published",
  "committedRevision": 1,
  "committedStateRevision": 1,
  "cacheInvalidated": false
}

管理客户端随后必须携带原来的草稿修订和状态修订重试同一操作。服务端识别数据库状态已由该请求提交,只重做缓存失效,不再次推进发布世代。这里不能改用最新修订号,否则“恢复缓存”会意外变成“发布后来编辑的新内容”。

ETag 如何减少重复传输而不制造旧页面?

服务端渲染不意味着每次都要传输完整 HTML。可以对最终响应体计算弱 ETag:

etag = f'W/"{sha256(body.encode("utf-8")).hexdigest()}"'

浏览器或 CDN 下一次发送 If-None-Match 时,如果当前页面语义内容没有变化,服务返回 304 Not Modified。RFC 9110 定义了 ETag、弱比较和条件请求的语义。

双语页面应该分别计算响应:

  • 中文 URL 返回中文 HTML 与 Content-Language: zh-CN
  • 英文 URL 返回英文 HTML 与 Content-Language: en
  • 两个页面有各自的 canonical;
  • 页面互相声明 hreflang
  • 发布或下线后,Redis 内容版本变化,新请求会重新读取 MongoDB 快照。

ETag 解决的是“响应是否变化”,发布修订解决的是“状态变更是否安全”。不要用 ETag 代替写入侧的乐观锁,也不要用数据库修订号代替 HTTP 缓存验证器。

哪些失败必须有明确结果?

场景 推荐结果 是否改变公网状态
草稿缺少一种语言或来源 422 Unprocessable Content
expectedDraftRevision 已过期 409 Conflict
expectedPublishedRevision 已过期 409 Conflict
expectedStateRevision 已过期 409 Conflict
Markdown 安全渲染组件不可用 503 Service Unavailable,发布失败关闭
MongoDB 更新失败 503 Service Unavailable 否或未知,需按请求语义复查
MongoDB 已提交、Redis 失效失败 503 + stateCommitted=true
公网读取时 Redis 失败 回源 MongoDB
MongoDB 也不可用且无缓存 同语言 HTML 503 + noindex

“失败”不能只是日志中的异常。调用方需要知道能否重试、应该使用哪组修订号,以及数据库是否已经提交。否则最容易出现的并不是服务崩溃,而是运维人员为了“再试一次”发布了错误版本。

实施时可以按什么顺序检查?

  1. 把一个双语选题放进同一聚合根,确定唯一稳定的英文 slug。
  2. 分离 draftpublished,所有公网查询只读取后者的白名单投影。
  3. 草稿保存要求 expectedDraftRevision,不允许静默覆盖。
  4. 发布前同时校验两种语言,并在服务端完成 Markdown 渲染与消毒。
  5. 发布条件同时匹配草稿修订、当前发布修订和状态世代。
  6. 发布、下线都递增 stateRevision,用它阻止 ABA。
  7. 明确定义“状态已提交但缓存失效失败”的响应和重试流程。
  8. Redis 只保存可重建页面,故障时优先回源 MongoDB。
  9. 对 HTML 与 Sitemap 输出 ETag,并正确处理 GET 和 HEAD。
  10. 用并发保存、重复发布、发布后下线、旧请求迟到和 Redis 故障测试状态机。

这份清单的目标不是增加字段,而是让每次状态变化都能回答三个问题:基于什么版本、是否已经提交、能否安全重试。

常见问题

为什么不用 MongoDB 多文档事务?

当一个选题的中英文版本天然属于同一个聚合根时,把它们存入同一文档即可利用单文档原子性,边界更小、故障语义也更清楚。只有当数据确实必须跨多个独立聚合根保持事务一致时,才需要评估 replica set 或分片集群上的多文档事务。

POST 本身不是幂等方法,发布接口还能安全重试吗?

可以。HTTP 方法的默认语义与业务操作的幂等设计不是一回事。服务端可以要求显式修订号或幂等键,并保证同一意图的重复请求不会产生第二次状态变化。

为什么缓存失效失败要返回 503,而不是 200?

因为数据库状态已经成功,但对外可见缓存尚未得到确认。503 促使受控客户端重试;stateCommitted=true 则阻止客户端误以为数据库也失败并改用新的发布参数。

draftRevision 和 ETag 能合并成一个字段吗?

不建议。draftRevision 是写入并发控制,ETag 是 HTTP 表示的缓存验证器。两者生命周期、可见范围和比较语义不同。

双语版本必须逐句完全一致吗?

不必。英文应按英文读者的搜索意图重新组织,中文也应自然表达;原子发布保证的是同一选题的两个完整版本同时可用,而不是机械逐句对应。