要让中英文版本真正“同时上线”,不能只连续调用两次保存接口。更可靠的做法是把双语正文视为一个发布单元:草稿独立编辑,公网只读发布快照,MongoDB 原子切换状态,Redis 只做可丢弃缓存。这样才能防止半发布、并发覆盖和旧请求重放。本文验证的是实现边界与故障语义,不提供生产吞吐量基准。
为什么双语发布不能只是两个普通的 PUT 请求?
最直接的实现通常是先保存中文,再保存英文,最后分别把两个页面标记为已发布。这条链路有三个隐患:
- 第一个请求成功、第二个请求失败时,搜索引擎只能访问一种语言。
- 两名编辑同时保存时,后到的请求可能静默覆盖先到的修改。
- 发布请求超时后,客户端不知道服务器究竟“没有执行”还是“已经执行但响应丢失”,盲目重试可能重复改变状态。
对于双语 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 |
发布状态经历了多少次真实切换? | 每次发布或下线递增 |
draft 和 published 必须分离。否则编辑者刚输入一半的标题、未完成的英文段落,甚至尚未消毒的 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 |
否 |
“失败”不能只是日志中的异常。调用方需要知道能否重试、应该使用哪组修订号,以及数据库是否已经提交。否则最容易出现的并不是服务崩溃,而是运维人员为了“再试一次”发布了错误版本。
实施时可以按什么顺序检查?
- 把一个双语选题放进同一聚合根,确定唯一稳定的英文 slug。
- 分离
draft与published,所有公网查询只读取后者的白名单投影。 - 草稿保存要求
expectedDraftRevision,不允许静默覆盖。 - 发布前同时校验两种语言,并在服务端完成 Markdown 渲染与消毒。
- 发布条件同时匹配草稿修订、当前发布修订和状态世代。
- 发布、下线都递增
stateRevision,用它阻止 ABA。 - 明确定义“状态已提交但缓存失效失败”的响应和重试流程。
- Redis 只保存可重建页面,故障时优先回源 MongoDB。
- 对 HTML 与 Sitemap 输出 ETag,并正确处理 GET 和 HEAD。
- 用并发保存、重复发布、发布后下线、旧请求迟到和 Redis 故障测试状态机。
这份清单的目标不是增加字段,而是让每次状态变化都能回答三个问题:基于什么版本、是否已经提交、能否安全重试。
常见问题
为什么不用 MongoDB 多文档事务?
当一个选题的中英文版本天然属于同一个聚合根时,把它们存入同一文档即可利用单文档原子性,边界更小、故障语义也更清楚。只有当数据确实必须跨多个独立聚合根保持事务一致时,才需要评估 replica set 或分片集群上的多文档事务。
POST 本身不是幂等方法,发布接口还能安全重试吗?
可以。HTTP 方法的默认语义与业务操作的幂等设计不是一回事。服务端可以要求显式修订号或幂等键,并保证同一意图的重复请求不会产生第二次状态变化。
为什么缓存失效失败要返回 503,而不是 200?
因为数据库状态已经成功,但对外可见缓存尚未得到确认。503 促使受控客户端重试;stateCommitted=true 则阻止客户端误以为数据库也失败并改用新的发布参数。
draftRevision 和 ETag 能合并成一个字段吗?
不建议。draftRevision 是写入并发控制,ETag 是 HTTP 表示的缓存验证器。两者生命周期、可见范围和比较语义不同。
双语版本必须逐句完全一致吗?
不必。英文应按英文读者的搜索意图重新组织,中文也应自然表达;原子发布保证的是同一选题的两个完整版本同时可用,而不是机械逐句对应。