Prisma 乐观并发控制:版本号必须参与最终写条件

Prisma 乐观并发控制:版本号必须参与最终写条件原创封面

丢失更新来自“读后思考”的时间窗口

Prisma 查询返回对象后,用户、后台任务或另一个请求都可能先修改同一行。当前请求基于旧对象完成校验,再执行只按 id 定位的 update,就会把中间合法变化静默覆盖。文章编辑尤其明显:管理员打开版本三,定时器把版本四发布,旧页面随后保存正文并把状态一起写回。两个操作都成功并不代表结果正确,最后写入获胜只是数据库默认行为,不是业务冲突策略。

乐观并发控制适合冲突相对少、操作不值得长时间持锁的聚合。它不阻止别人修改,而是在最终提交点证明“我所依据的版本仍是当前版本”。因此 version 必须出现在 UPDATE 的 WHERE 条件里,并在同一语句原子递增。若先 findUnique 检查版本,再执行不带版本的 update,两个请求仍可同时通过检查,保护窗口在第二条语句前已经打开。

版本字段属于聚合契约而非界面装饰

version 应是非空整数,从稳定初值开始,每次会改变聚合业务含义的提交都递增。哪些关联记录属于同一聚合需要明确:文章正文、分类关系、标签和发布状态若必须一致,就应在一个事务里由主记录版本裁决;浏览量这类高频计数通常不应与编辑版本竞争,可以使用独立原子增量。把所有字段都挂在一个版本上会产生无意义冲突,把必须一致的字段拆开又会留下部分更新。

API 读取详情时返回 version,写请求把它作为必填的整数,而不是可选请求头。客户端不能自行递增或用更新时间替代,因为时间精度、时区和相同时间戳会引入模糊。数据库 schema 为 version 设置默认值只能方便创建,不能替服务端校验。批量脚本、定时发布器和后台管理接口必须遵守同一版本规则;只让浏览器页面带版本,会让自动任务继续成为覆盖来源。

在 Prisma 中使用条件更新判断胜负

提交可以使用 updateMany,把 id、旧 version 以及必要的当前状态放入 where,data 中写新值并对 version increment。返回 count 等于一才表示本次修改拥有提交权;count 为零可能是记录不存在、版本变化或状态不再允许,需要重新读取后分类。直接使用 update 并只依赖唯一 id 无法表达版本条件;如果所用 Prisma 版本允许扩展唯一条件,也仍要清晰处理未匹配异常,不能捕获后当作保存成功。

状态机操作应进一步限定旧状态。例如自动发布必须匹配 SCHEDULED 和原 scheduledAt,归档必须匹配允许转移的状态。仅匹配版本可以防止并发覆盖,却不能阻止一段逻辑从业务上非法的状态出发。条件更新成功之后,在同一 Prisma 事务里创建修订和审计;其中任何一步失败都回滚主记录。先更新、事务外补审计会在日志写失败时留下无法解释的版本跃迁。

冲突响应要帮助用户保住工作

影响零行不应自动读取新版本然后重试整个写入,因为这会把旧输入重新盖到新状态上,等价于取消冲突检测。服务端可返回明确的 409,并提供当前版本标识;敏感资源不要在错误体中泄露完整新内容。编辑器应保留本地草稿,获取最新版本后展示字段差异,让用户选择放弃、手工合并或以新的明确意图再次提交。发布和权限变更等状态操作通常不适合自动合并。

对于可交换的变化可以设计更窄的命令,而不是提交完整对象。例如“新增一个标签”可以在事务中确认标签不存在后插入关系并递增主版本;“浏览量加一”使用数据库增量且不参与编辑版本。客户端发送 PUT 风格整对象时最容易把未展示或已经陈旧的字段一起覆盖,后台表单应只提交自己拥有的字段,服务端也要拒绝额外属性。减少写入面能降低冲突频率,但不能取代最终版本条件。

关联写入和外部文件需要不同补偿

Prisma 交互式事务可以把主记录条件更新、标签关系和 PostRevision 组合为数据库原子单元。事务回调要短,不能等待用户输入或长时间调用外部 API。若事务读取当前记录后做复杂计算,最终仍应以 updateMany 的版本条件收口;事务隔离级别本身不会自动理解业务版本。遇到数据库死锁或序列化失败时可以对整个无外部副作用事务做有限重试,但版本冲突必须返回调用者。

媒体文件不受 PostgreSQL 事务控制。上传流程应先把文件写到不可公开的临时位置,完成类型、尺寸与哈希校验,再尝试数据库版本提交。若数据库冲突,删除本次精确创建的临时文件;若数据库已引用最终文件但移动失败,需要状态或补偿任务修复,不能返回普通成功。绝不能在冲突时覆盖已有目标路径,因为那份文件可能属于赢得版本竞争的另一请求。

审计与可观测性围绕版本转换

一条有用审计应记录资源 id、旧版本、目标版本、动作类型、操作者和请求标识,并在必要时保存字段差异摘要。不要复制整篇正文、Cookie 或凭据到 details。冲突日志记录 expectedVersion 与实际已变化这一事实即可,避免为诊断再次读取并暴露他人内容。指标可以按接口统计成功、版本冲突、业务状态冲突和数据库故障,冲突率突然升高常常揭示自动保存过密或后台任务与人工操作边界不清。

版本号还帮助串联事件,但它不是全局顺序。不同文章的 version 三没有可比性,数据库回放时间也不应由版本推断。若下游消费变更事件,事件应携带 postId 与 aggregateVersion,消费者为每个聚合拒绝倒退版本并正确处理重复。审计创建必须与主变更同事务,事件投递则可使用 outbox;把消息发送放进事务会延长锁持有,消息成功而事务回滚也会产生幽灵事件。

测试要制造真正同时提交的请求

最小集成测试创建版本一的记录,让两个独立 Prisma 客户端都读取它,再通过屏障同时执行相同 expectedVersion 的条件更新。最终必须只有一个 count 为一,数据库版本为二,修订和审计各新增一条。随后分别测试不存在 id、相同版本但非法状态,以及事务中审计写失败,确认返回语义不同且失败事务没有递增。仅顺序调用两个 mock 无法证明数据库条件真的参与竞争。

编辑场景还要覆盖旧管理员页面与定时发布器的竞争、断网后的重复提交和用户合并后的再次保存。把 contentJson 对象经过 PostgreSQL JSONB 往返,因为对象键顺序可能变化,内容比较应使用语义规范化而不是直接 JSON.stringify。对于时间字段,测试同一计划时间参与条件,确保旧任务不能发布管理员已经改期的新版本。所有测试结束后核对没有孤立修订、重复标签或临时文件。

迁移现有数据时避免制造虚假冲突

为旧表增加 version 可以先设置非空默认一,再部署能够读取和返回版本的新服务,最后才要求所有写接口提交 expectedVersion。滚动发布期间旧实例仍会无条件写入,因此不能提前宣称保护已经生效;要么安排短暂写入切换,要么让兼容层为旧命令执行受控条件。审计中记录客户端协议版本,便于确认旧写路径归零后删除兼容逻辑。

上线后观察冲突率并抽样验证用户体验。大量冲突不一定说明机制错误,可能意味着聚合边界太大、自动保存没有防抖,或后台任务修改了不应共享的字段。优化应从命令所有权入手,而不是在服务端静默重试。定期检查所有更新入口是否包含版本和状态条件,包括一次性脚本与运维工具;一个遗漏的无条件 update 足以绕过其他接口建立的全部保护。

删除、批处理与离线脚本仍然需要 expectedVersion

删除不是并发控制的例外。软删除命令匹配 expectedVersion 与可删除状态,递增版本并写 deletedAt;硬删除还要在事务中验证媒体、修订和评论关系。用户基于版本五确认删除,而版本六已经发布时,服务端应返回冲突让用户重新确认,不能因为动作不可逆就允许旧页面覆盖更新后的事实。

批量操作为每个资源携带版本并逐项返回成功、冲突和不存在,或在业务明确要求全有全无时放进有严格数量上限的事务。只发送一组 id 会让列表加载后发生的合法编辑被归档覆盖。跨数千行的维护更适合可重入小批次,每条仍执行版本条件,失败项进入报告而不是拖住巨大事务。

一次性脚本先输出候选 id、当前版本和预期差异,dry-run 审核后用相同条件提交。脚本扫描与执行之间发生的在线修改应表现为冲突,而不是被运维工具静默抹掉。脚本版本、输入哈希、操作者和影响行数写审计,生产连接使用受限账户,不以“只跑一次”为理由执行无条件 update。

备份恢复或跨环境同步不能简单以 version 数值较大者覆盖。只有共享同一聚合历史时版本才可比较,分支环境可能各自增长。恢复先在隔离库完成,对需要合并的资源依据修订时间线和业务状态逐项决定;删除、回滚和关联变化都纳入协议,避免通用 upsert 把在线状态变回历史快照。

实现片段

await tx.post.updateMany({ where: { id, version }, data: { version: { increment: 1 } } })

一手参考资料

评论 · 0

还没有评论留下第一句经过思考的话。

游客评论需审核。注册后可直接公开,无需审核。

SHARE / 分享

分享这篇文章

WECHAT / 微信

用微信扫一扫

在手机微信中打开文章后,再从微信右上角分享给朋友或朋友圈。