自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

NoSQL

  1. 01认识NoSQL
  2. 02聚合数据模型
  3. 03数据模型的深入探索
  4. 04数据分布模型
  5. 05数据一致性
  6. 06版本戳
  7. 07Map-Reduce
  8. 08键值数据库
  9. 09文档数据库
  10. 10列族存储
  11. 11图数据库
  12. 12数据库模式演进
  13. 13多存储持久化
正在加载课程章节内容
课程编程NoSQL版本戳

版本戳:别让旧数据悄悄覆盖新数据

你打开商品页面时,看到某款键盘还剩 8 件,价格是 399 元。你想了一会儿,填好地址,再点击“确认购买”。偏偏就在这几分钟里,运营把价格调成了 429 元,另一位用户也买走了最后几件库存。此时真正棘手的问题并不是“数据库能不能完成一次写入”,而是:你准备提交的决定,仍然建立在最新数据上吗?

后台管理也会遇到同样的麻烦。两位同事同时打开一份自动化流程配置,一个修改重试次数,另一个修改回调地址。如果两个人保存时都直接把整份文档覆盖回去,后保存的人可能完全不知道,自己顺手抹掉了先保存的修改。

版本戳就是为这种时间差准备的。它会随着数据版本变化而变化,让系统在写入前确认:“你现在修改的,还是你刚才读到的那一版吗?”确认一致才写入,不一致就拒绝、重读或进入冲突处理。这个思路通常叫做乐观并发控制:我们允许大家先读、先编辑,不长期占着锁,只在落笔的最后一刻核对版本。

业务事务与系统事务之间的时间窗口

版本戳首先是一枚并发令牌。它最重要的能力是识别“是否仍是原来的版本”,不一定表示真实时间,也不保证你能从两个令牌直接看出谁先谁后。


业务过程很长,数据库事务必须很短

我们先把两种容易混在一起的“事务”拆开。

业务事务是用户心中的完整过程。浏览商品、比较价格、填写地址、提交订单,可能持续几分钟;一份流程配置从打开、讨论、修改到保存,甚至可能持续半小时。

系统事务是数据库真正保证原子性的短操作。它通常只覆盖最后几条读写,例如扣减库存、创建订单、记录支付请求。数据库可以保证这些操作要么一起成功,要么一起失败,但它不会自动知道用户十分钟前看见了什么。

如果我们从用户打开页面那一刻就启动数据库事务,并一直持有到用户点击保存,锁会跟着用户一起“发呆”。有人离开工位去倒杯水,其他请求就可能长时间等待;连接、内存和锁资源也会被白白占住。系统并发一高,这种做法很快就撑不住。

所以实际系统会把事务压缩到最后几毫秒或几秒。这样性能问题解决了,却留下一个时间窗口:页面里的数据可能已经过期。版本戳正好跨过这个窗口,把“当时读到的版本”带回提交现场。

丢失更新是怎么发生的

假设流程文档当前是:

json
{
  "id": "flow-1024",
  "retryLimit": 3,
  "callbackUrl": "/hooks/order",
  "version": 7
}

小林和小周都读到了版本 7。小林把 retryLimit 改成 5 并先保存,数据库里的版本变成 8。小周的页面仍停留在版本 7,他只想改回调地址,但客户端提交的是整份旧文档。如果服务端不检查版本,小周的保存会把 retryLimit 又覆盖成 3,小林的修改就像从没发生过一样。

注意,这里未必出现报错。两次写入都可能返回“成功”,最后的数据却是错的。静默发生,正是丢失更新最危险的地方。

下面这个小实验把两种写法放在一起。先让甲、乙都读取同一版本,再切换写入模式,依次提交两个人的修改,观察第二次保存是覆盖还是被拦住。


乐观并发控制:比较和写入必须是一件事

版本戳的完整流程并不复杂,但每一步都不能省。

读取文档时,服务端把业务字段和当前版本一起返回。客户端既要保存可编辑内容,也要保存这个版本值。

用户提交修改时,把当初读到的版本作为预期版本带回来。它表达的不是“请更新第 7 版”,而是“只有当前仍为第 7 版时才允许更新”。

数据库在同一个原子操作里检查标识与版本,并完成业务字段修改和版本递增。匹配到一条文档代表成功,匹配不到则代表目标不存在或版本已经变化。

失败后由应用选择重新读取、提示用户比较差异、自动合并,或在满足业务条件时重试。不能把失败悄悄改成无条件覆盖。

下面是一段文档数据库风格的伪代码。关键不在某个产品的 API 名称,而在过滤条件里同时带上 id 和 version,并让版本递增与业务修改处于同一次原子更新中。

javascript
async function renameWorkflow(id, expectedVersion, newName) {
  const result = await workflows.findOneAndUpdate(
    { id, version: expectedVersion },
    {
      $set: {
        name: newName,
        updatedAt: new Date().toISOString()
      },
      $inc: { version: 1 }
    },
    { returnDocument: "after" }
  )
 
  if (!result) {
    throw new Error("版本冲突:请重新读取流程后再保存")
  }
 
  return result
}

有些实现会先执行一次“查询当前版本”,确认相等后再发起普通更新。看上去逻辑一样,实际上两条语句之间又出现了新的时间缝隙:检查刚结束,另一位用户就可能写入;随后你的更新仍会覆盖对方。先查再写不是条件写入。 真正可靠的做法是让比较与修改在存储层成为不可分割的一步。

先比较版本再原子写入的条件更新流程

把版本冲突捕获后立刻改成无条件重试,会把保护机制重新拆掉。重试之前必须重新读取,并确认旧操作还能安全地应用到新状态;涉及付款、库存、流程状态跳转时尤其不能盲目重放。

版本冲突与幂等不是一回事

版本戳防的是“拿旧状态覆盖新状态”。幂等键防的是“同一个请求因为超时或重试被执行两次”。一次支付请求既可能重复到达,也可能建立在过期订单上,因此两种保护经常要同时存在:用幂等键识别重复命令,用版本条件确认状态仍允许变化。


HTTP 里的版本戳:ETag 与 If-Match

条件更新并不只存在于数据库 SDK 中。HTTP 资源也可以把版本信息放进 ETag 响应头。客户端读取资源后保存这个值,修改时再通过 If-Match 交回服务器。

http
GET /workflows/flow-1024
 
200 OK
ETag: "flow-1024-v7"
Content-Type: application/json
 
{"name":"订单同步","retryLimit":3}

保存时,客户端明确提出条件:只有服务器当前的强实体标签仍与我手里的值匹配,才执行修改。

http
PUT /workflows/flow-1024
If-Match: "flow-1024-v7"
Content-Type: application/json
 
{"name":"订单同步","retryLimit":5}

如果别人已经把资源更新到新版本,服务器拒绝这次修改并返回 412 Precondition Failed。客户端随后可以展示差异、请求用户确认,或者在业务规则允许时把修改重新应用到最新版。

javascript
async function saveWorkflow(id, draft, etag) {
  const response = await fetch(`/workflows/${id}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
      "If-Match": etag
    },
    body: JSON.stringify(draft)
  })
 
  if (response.status === 412) {
    return { ok: false, reason: "版本已变化,需要重新确认" }
  }
 
  if (!response.ok) {
    throw new Error("保存失败")
  }
 
  return {
    ok: true,
    data: await response.json(),
    etag: response.headers.get("ETag")
  }
}

这里还有一个容易忽略的边界:If-Match 使用强比较。带有 W/ 前缀的弱实体标签只说明两个表示在语义上大致等价,不适合拿来证明“这就是我刚才编辑的同一份字节级表示”。要用条件写入防止丢失更新,服务端应提供能参与强比较的 ETag。


版本戳不是只有一种长相

只要能在版本变化时稳定地换值,很多内容都可以充当版本戳。但不同方案携带的信息不同:有些能排序,有些只能判断相等;有些适合单写入口,有些适合离线多节点;有些生成便宜,却把风险转移给时钟或序列化过程。

1. 单调计数器:最适合单条记录的条件更新

每次成功更新都把 version 加一。它直观、紧凑,还能轻松看出同一条记录在同一条版本链上的先后。

json
{
  "id": "task-88",
  "status": "running",
  "version": 12
}

它不一定需要一台全局发号服务器。只要某条文档的条件检查和递增能在同一存储分区内原子完成,这个计数器就能服务于该文档。真正麻烦的是多个彼此断联的写节点都从 12 独立加到 13:两个不同内容会拿到同样的数字,单一计数器不再能描述并发分支。

2. 随机标识:擅长判等,不负责排序

每次修改都生成新的随机标识,节点无需共享计数器。它发生碰撞的概率可以做到极低,很适合把版本当成不透明令牌传递。

不过随机标识只回答“相同还是不同”。两枚不同标识摆在一起,通常看不出谁更新、更看不出两次修改是否并发。它也比小整数更占空间,日志和人工排查时不够亲切。

3. 内容哈希:让内容自己留下指纹

把选定字段按确定规则序列化,再计算哈希。相同输入会得到相同结果,任何节点都能独立验证内容是否变化。这对缓存校验、内容寻址和完整性检查很有用。

真正使用时必须先说清楚“哪些内容参与计算”。JSON 字段顺序、空白、数字格式、更新时间字段都会影响字节结果。如果两个节点的规范化规则不同,语义相同的文档也可能算出不同哈希。反过来,如果漏掉了会影响业务的字段,关键变化又可能没有反映在令牌中。

4. 物理时间戳:直观,却把正确性交给了时钟

更新时间看起来天然能排序,但多节点时钟会漂移、校时可能让时间向前或向后跳,同一时间精度内还可能发生多次写入。如果系统采用“时间更大者获胜”,一台走快的机器就可能长期压过另一台真正更晚发生的写入。

时间戳可以用于展示、审计和粗粒度排序;若要让它决定冲突胜负,就要认真处理时钟同步、精度、并列值和客户端伪造时间等问题。把 updatedAt 直接当作绝对因果顺序,通常过于乐观。

5. 组合修订标识:一枚令牌里放入多种线索

有些文档数据库会把“修订代数”和内容签名组合起来,例如 3-某段签名。前半部分说明这条分支走了几代,后半部分帮助识别具体修订。保存文档时需要带回旧修订标识,旧标识已经被替代就会触发冲突。

但这种修订标识不等于通用的版本控制历史,也不意味着代数大的分支一定包含代数小的任意分支。两条分支可以各自发展到第三代,仍然互不包含。它首先服务于复制、冲突检测和修订树管理,应用不能只截取前面的数字来决定业务上的“最新”。

计数器、唯一标识、内容哈希、时间戳与组合版本的选型比较

方案能否直接判等能否直接排序主要优势主要代价
单调计数器能同一版本链上能小、快、容易调试多写节点会产生同号分支
随机标识能不能可在各节点独立生成更长,不表达因果关系
内容哈希能不能确定性强,可校验内容依赖统一序列化与字段范围
物理时间戳能表面上能直观,便于展示时钟漂移会破坏写入顺序
组合修订标识能只能理解局部分支兼顾修订代数与内容识别不能当作全局时间或完整历史

下面的选择器不是替你拍板,而是逼你先把需求说清楚。切换条件,看看推荐方案为什么会变化。


从单主走向多主:数字为什么突然不够用了

在单主模式里,所有写入都经过同一个权威入口。主节点可以稳定地把版本从 7 推到 8,再推到 9;从节点只复制结果。此时计数器简单又有效。

多主模式换了一个目标:即使北京与上海之间的网络暂时中断,两地也要继续接收写入。可用性提高了,系统却失去了唯一的排队者。北京把版本 7 改成 8,上海也把自己手里的版本 7 改成 8。两条记录都叫“8”,内容却不同。

我们不能因为它们编号一样就说内容一致,也不能随便挑一个覆盖另一个。真正需要回答的是:

  • 一个版本是否包含另一个版本之前的全部变化?
  • 两个版本是否在互不知情的情况下各自发展?
  • 如果是并发分支,应该保留、合并还是按业务规则决胜?

单主写入与多主写入的版本差异

向量时钟记录“每个参与者知道了多少”

向量时钟给每个写入参与者保留一个计数位置。假设只有北京、上海、广州三个节点,一枚向量可以写成:

text
{ 北京: 2, 上海: 1, 广州: 0 }

它表达的是这条版本链已经包含北京的 2 次事件、上海的 1 次事件,还没有包含广州的事件。缺少的节点通常按 0 处理。

每次本地写入,可以按下面的顺序理解:

  1. 先带上本次写入所基于的上下文,也就是客户端读到的向量;
  2. 接收节点把已知向量逐项取最大值,吸收自己见过的历史;
  3. 再把代表本次事件的那个节点计数加一;
  4. 新内容与新向量一起保存。

例如北京在 {北京: 2, 上海: 1, 广州: 0} 上继续修改,就得到 {北京: 3, 上海: 1, 广州: 0}。如果广州只见过 {北京: 2, 上海: 1, 广州: 0},随后离线修改,就会得到 {北京: 2, 上海: 1, 广州: 1}。这两个结果来自共同祖先,却各自多了一项对方没有见过的事件,因此它们并发。

比较规则:逐项看,而不是把数字相加

给定向量 A 和 B:

  • 如果每个位置都有 A >= B,并且至少一个位置严格大于,那么 A 支配 B。A 包含 B 的历史,还多出了一些事件。
  • 如果每个位置都相等,那么两枚向量代表相同的因果进度。
  • 如果 A 在某些位置更大,B 又在另一些位置更大,那么两者并发。谁也没有完整见过对方的历史。

把向量里的数字相加再比较是错的。{北京: 4, 上海: 0} 和 {北京: 0, 上海: 5} 的总和一边是 4、一边是 5,但它们仍是并发分支;总和更大不能证明包含关系。

向量时钟的支配、并发与缺项补零比较

下面可以直接输入两枚三节点向量。试试“明确先后”“完全相等”和“并发分支”三个预设,再自己改动任意一项。

向量时钟也有成本

向量里需要保存参与者身份和计数。节点不断加入、退出,元数据可能越来越大;如果把每台临时机器都当作永久参与者,维护成本会很快失控。真实系统会限制参与者集合、截断旧信息,或采用更紧凑的因果元数据。

还有一点更重要:向量时钟只能判断因果关系,不能替应用决定内容。它能告诉你“这两份购物车并发修改了”,却不知道同一件商品数量应该相加、取最大值,还是以用户最后确认的值为准。冲突检测和冲突解决是两件事。


发现冲突之后,系统到底该怎么办

不存在一个适合所有数据的合并按钮。我们需要先看字段代表什么,再选择策略。

拒绝并让用户重读

后台流程配置、合同内容、文章正文很适合这种方式。系统拒绝旧版本保存,展示“服务器版”和“你的修改”,让用户确认。它牺牲了一点操作流畅度,却避免了自动合并猜错业务意图。

自动合并互不重叠的字段

如果甲只修改 retryLimit,乙只修改 callbackUrl,系统可以按字段合并。不过,“字段不同”不等于“一定独立”。回调地址和签名密钥可能必须成对更新;状态和完成时间也可能存在约束。自动合并之前,要把这些不变量写进规则和测试。

用领域规则合并

购物车可以把“添加商品”记录成操作,再合并不同节点上的增量;计数器可以使用专门的可合并数据结构;标签集合可以把新增与删除建模为不同操作。这样做比整份文档互相覆盖更贴近用户动作,但设计和存储成本也更高。

选择一个确定的胜者

最后写入胜出容易实现,也能让所有副本快速收敛。不过它只是稳定地丢掉一个版本,不代表业务上更正确。若胜负依赖物理时间戳,还要承担时钟漂移的风险。适合可重建的缓存、低价值状态或明确接受覆盖的字段,不适合余额、库存和不可逆审批。

暂存多个兄弟版本

系统也可以先保留所有并发叶子,让应用稍后合并。某些文档数据库复制时会为同一文档保留多个冲突修订,并以确定规则选出一个默认可见版本。所谓“胜者”只是默认读取结果,其他分支仍需要应用识别和处理;否则它们只是被藏起来,并没有真正解决。

向量时钟发现冲突后由业务规则完成合并

“所有副本最终看见同一个胜者”只说明系统收敛了,不说明胜者符合业务。确定性规则解决的是副本分歧,业务正确性仍要由字段语义、不变量和冲突处理策略保证。


自动化流程案例:旧看门狗不能终止新任务

版本戳在自动化系统里格外实用,因为任务状态总在异步变化。考虑下面这份任务文档:

json
{
  "id": "job-2048",
  "status": "running",
  "attempt": 2,
  "workerId": "worker-bj-17",
  "startedAt": "2026-08-11T02:10:00Z",
  "version": 12
}

看门狗在 02:15 读到版本 12,判断任务似乎超时。就在它准备写入 timed_out 时,执行节点已经完成任务,把状态改成 succeeded,版本递增到 13。如果看门狗只按任务 ID 更新,它会把已经成功的任务重新标成超时,甚至触发第三次执行。

可靠写法把状态和版本一起放进条件:

javascript
async function markTimedOut(jobId, expectedVersion) {
  const updated = await jobs.findOneAndUpdate(
    {
      id: jobId,
      status: "running",
      version: expectedVersion
    },
    {
      $set: {
        status: "timed_out",
        finishedReason: "租约到期"
      },
      $inc: { version: 1 }
    },
    { returnDocument: "after" }
  )
 
  return updated ?? { skipped: true, reason: "任务状态或版本已变化" }
}

这里同时核对 status 和 version 很有价值。版本保证整条文档没有在你不知情时变化,状态条件又把允许的状态迁移写得一目了然。即使将来某段代码忘记解释版本含义,存储层也不会让 succeeded 直接倒退到 timed_out。

如果任务涉及跨服务操作,例如先调用供应商接口、再更新本地状态,单条文档版本戳不能自动提供跨系统原子性。我们还需要幂等请求、发件箱、补偿动作或状态机约束。版本戳能守住自己的那次条件写入,却不能把网络另一端也拉进同一个事务。


把版本控制真正落进项目

一套能长期运行的版本机制,不只是在文档里多放一个字段。你还要把它贯穿读取、接口、更新、错误处理和观测。

先划清保护粒度。是整份流程文档共享一个版本,还是不同子资源分别版本化?粒度太粗会制造无关冲突,粒度太细又可能破坏跨字段不变量。

让所有修改入口遵守同一规则。后台页面、定时任务、批处理脚本和数据修复工具都必须携带版本;只要留下一条无条件更新通道,保护就可能被绕开。

在存储层执行原子条件更新。成功时业务数据与版本一起变化,失败时返回明确的冲突结果,不把“未匹配文档”误报为普通成功。

为不同业务设计冲突路径。可以提示刷新、展示差异、自动合并或排队重试,但每种做法都要说明可接受的数据损失与用户体验。

记录冲突率、重试次数和热点对象。冲突突然增多,可能说明保护粒度过粗、页面编辑时间过长,或者某个后台任务正在与用户反复争抢同一文档。

常见误区清单

  • 只在前端比较版本。 前端的判断之后仍可能发生新写入,最终条件必须落到数据库或资源服务器。
  • 更新成功却没有换版本。 只要会影响后续决策的内容发生变化,令牌就必须一起变化。
  • 把随机标识按字符串排序。 随机令牌只适合判等,字符大小没有业务时间含义。
  • 把内容哈希当作秘密。 哈希用于识别内容,不等于加密,也不能替代访问控制。
  • 用客户端时间决定胜负。 用户设备可能走快、走慢或被手动修改,不能自然成为全局权威时钟。
  • 检测到冲突就永久失败。 冲突是正常并发现象,应用应该给出可恢复路径,而不是只抛一条技术错误。
  • 假设版本戳替代所有事务。 它保护条件写入,却不自动保证多文档、多分区和外部服务之间的一致性。

练一练

1
甲、乙都读取版本 5,甲先保存后版本变为 6。乙要避免覆盖甲的修改,最关键的写入条件是什么?
2
下面哪些说法准确描述了向量时钟?
3
只要所有副本最终选出同一个版本,业务数据就一定正确。

场景题

一个流程编辑器把整份配置作为单文档保存。近期冲突率很高,日志显示多数冲突发生在“通知模板”和“重试策略”这两个互不相关的区域。你会怎样调整?

可以先确认两个区域是否真的不存在跨字段约束。如果它们能够独立演进,可以拆成两个子资源或分别维护版本,从而减少无关修改互相阻挡;如果仍需原子发布完整流程,则可以保留草稿子资源,在发布动作里统一校验并生成一个不可变版本。重点不是盲目缩小版本粒度,而是在减少冲突的同时守住跨区域不变量。


小结

版本戳解决的是一个很具体的问题:用户或程序准备写入时,它所依据的旧状态是否仍然有效。单条记录里,我们通常用计数器、修订标识或 ETag 做条件更新,并把比较、业务修改和版本变化放进一个原子操作。

到了多个独立写节点,单一数字无法表达并发分支,向量时钟这类因果版本会逐项记录不同参与者的进度。它能区分“包含旧历史”和“彼此并发”,但发现冲突之后,仍要由业务选择拒绝、合并、确定性选胜或保留兄弟版本。

最后记住一个实用判断:先问版本令牌要回答什么,再决定它长什么样。 只要判等,可以用不透明令牌;需要单链排序,计数器更直接;需要多节点因果关系,就要承担更丰富元数据的成本。选型不是追求最复杂,而是让系统在真正发生并发时,既不悄悄丢数据,也能给出清楚的恢复路径。

上一章数据一致性下一章Map-Reduce