【码动四季·秋】稿件被覆盖事故的根治方案:应用层乐观锁与 409 冲突设计

本文为 AtomGit 码动四季·开源同行征稿活动参与文章

摘要:两个人同时编辑同一篇稿件,后保存的人静默覆盖先保存的修改——这是内容管理系统最隐蔽的数据事故。ArticlePilot 用十几行代码根治了它:服务层版本比对 + 独立冲突异常 + HTTP 409 语义映射,无数据库锁、无轮询、无额外中间件。本文基于实仓ArticleServiceImpl.update()的真实实现拆解:为什么编辑场景只能选乐观锁、版本守卫的三处设计意图、并发验证怎么做,以及@Version注解静默失效、状态流转旁路、前端 409 处理三个坑的完整闭环。

内容管理类系统有个经典事故:两个人同时编辑同一篇稿件,后保存的人把先保存的修改静默覆盖掉——没有人报错,丢的内容无人察觉。ArticlePilot 的稿件中心支持多人共用,这个场景从设计第一天就是红线。我用应用层乐观锁解决了它:不带任何数据库锁,只在版本号比对失败时返回 409。这篇把这个最小实现讲清楚:为什么选乐观锁、十几行代码长什么样、前端怎么配合、坑在哪。

一、并发写稿的现实与方案选择

先看清场景的真实频率:稿件是长文本编辑,一写就是十几分钟,保存间隔远大于接口耗时——冲突概率低,但冲突的代价是内容静默丢失。这个特征决定了方案选型:

悲观锁要求编辑期间持有行锁,而一次编辑长达十几分钟——这是不可能持锁的时长,方案直接出局。乐观锁把冲突处理推迟到保存时刻,不冲突的读写零开销,冲突时返回 409 让界面提示"刷新后重试",把决策权交回给人。编辑场景里,这是唯一合理的档位。三个方案的完整对比:

方案加锁时机持锁时长冲突时行为适用场景
无保护无无静默覆盖(数据事故)单人使用、无并发写
悲观锁编辑期间锁行长达十几分钟后者阻塞等待冲突高频、短事务
应用层乐观锁保存瞬间比对零持有409 + 冲突出口冲突低频、编辑耗时长

二、十几行代码的最小实现

实现分三件套:实体版本字段、服务层比对、异常到 409 的映射。核心就是更新入口的版本守卫:

// ArticleServiceImpl.update —— 版本比对守卫(节选)Articlea=requireOwned(id);// 已发布/已归档的稿件本身不可编辑,先于版本比对拦截if(a.getStatus()==ArticleStatus.PUBLISHED||a.getStatus()==ArticleStatus.ARCHIVED){thrownewBusinessException(ErrorCode.ARTICLE_NOT_EDITABLE,"稿件状态为 "+a.getStatus()+",不可编辑");}// 乐观锁核心:客户端提交的 version 与库内不一致,说明有人已改过if(!a.getVersion().equals(request.getVersion())){thrownewConflictBusinessException("稿件已被他人修改,请刷新后重试");}writeSnapshot(a);// 版本递增前先留存修改前快照,可回溯// ... 应用字段变更 ...a.setVersion(a.getVersion()+1);articleMapper.updateById(a);

仓库里的实际代码——版本守卫、快照、递增的相对位置一目了然:

三处设计意图值得展开。第一,版本号从创建时就是契约的一部分:create时setVersion(1),之后每次成功保存加一,前端每次提交必须带它上一次读到的版本号——服务端把"你看到的版本"和"现在的版本"做比对,不一致即冲突。第二,快照先于版本递增:writeSnapshot在改字段之前执行,任何一次被拒绝的保存都不会污染快照链,历史版本永远可回溯。第三,冲突异常单独建模:ConflictBusinessException由全局异常处理器统一映射为 HTTP 409,而不是混在通用 500 里——语义正确的状态码让前端可以精确分流。

第三条的关键配套是 DTO:请求与响应两侧都带version字段,409 映射收敛在全局异常处理器一处。仓库里的实际链路:


三、前端配合与两个坑

1:只弹错误码,用户不知道怎么办

现象:第一版前端把 409 当普通错误弹 toast"保存失败",用户反复点保存、反复失败,最后关页面——比静默覆盖好一点,但体验仍是灾难。

根因:后端语义对了,前端没有把 409 翻译成"需要人决策"的交互。

解决:捕获 409 后进入专门的冲突流程——弹窗说明"他人在此期间保存过",提供"刷新看最新版本"与"复制我的内容"两条出路,把合并动作交还给编辑者。

效果:冲突流程上线后,再没有出现"反复点保存反复失败"的用户反馈,冲突从死路变成岔路。

感受:后端把状态码给对只是及格,前端把 409 翻译成"人能做的决策"才算交付完整。

教训:409 不是错误,是协作信号,前端必须为它设计专门出路。

2:版本号被"部分更新"接口绕过

现象:状态流转接口(如提交评审)最初不带版本校验,绕过了编辑接口的乐观锁——两人同时操作时,一个改内容、一个改状态,状态变更把内容编辑的版本假设打破了。

根因:乐观锁只保护了"全量更新"一条路径,同一实体的其他写路径成为旁路。

解决:同一实体的所有写路径统一走版本守卫,或至少在服务层串行校验状态机;ArticlePilot 的做法是内容更新走版本守卫,状态流转走状态机校验、非法流转在 Service 层直接拒绝——两条写路径各有守卫,没有漏网之鱼:

效果:补齐状态流转校验后,"改内容 + 改状态"并发操作不再破坏版本假设,写路径全景图成为 code review 的对照清单。

感受:画"全部写路径"这张图只花了十分钟,但它把"乐观锁有没有锁全"从感觉问题变成了肉眼可查的清单。

教训:给实体上乐观锁时,先枚举它的全部写路径,漏一条就等于没锁。

3:@Version 注解不配拦截器,乐观锁静默失效

现象:选型阶段我最初倾向 MyBatis-Plus 自带的@Version注解——实体字段加个注解就完事。评估时按官方文档走了一遍才发现:注解必须配合OptimisticLockerInnerInterceptor拦截器才生效,只加注解的话两个客户端先后保存两次都成功、覆盖照旧发生,而且全程无任何报错。

根因:乐观锁插件是 MyBatis-Plus 的可选组件,不装配拦截器,@Version就只是一个没有消费者的标记——缺失是静默的,只能靠显式并发验证抓出来。

解决:两条路二选一——补拦截器配置,或在 Service 层手动比对版本。本项目选了后者:稿件更新前本来就要做状态机校验和快照留存,服务层已接管整个更新流程,手动比对零额外成本。

效果:手动比对上线后并发验证恰好"一个 200、一个 409",行为与预期完全一致;选型结论同步进了真实性声明的代码依据。

感受:注解式能力的"自动生效"是一种错觉——每个注解背后都有装配条件,不确认就敢上是给自己埋静默炸弹。

教训:框架的可选插件不配置就等于不存在,而这类缺失不报错,只能靠设计前查文档 + 上线前显式验证双重把关。

四、验证方式与边界

并发验证的接口与参数全部来自实仓(PUT /api/articles/{id}见docs/security-audit.md越权复核明细),两个客户端各带 version=3 同时提交:

# 并发验证:两个客户端各带 version=3 提交同一稿件curl-s-XPUT http://localhost:8080/api/articles/1\-H"Authorization: Bearer$TOKEN"-H"Content-Type: application/json"\-d'{"title":"甲的修改","version":3}'&curl-s-XPUT http://localhost:8080/api/articles/1\-H"Authorization: Bearer$TOKEN"-H"Content-Type: application/json"\-d'{"title":"乙的修改","version":3}'&wait

断言恰好一个 200、一个 409,409 侧的响应体:

{"code":409,"message":"稿件已被他人修改,请刷新后重试"}

再验证重试链路(409 后刷新重读、携带 version=4 保存成功)。边界也说清楚:这个方案保护的是同一实体行的覆盖问题,不解决跨实体的业务一致性问题(那需要事务边界之外的设计);冲突频率高的场景(秒杀类)需要转向服务端合并或队列化,409 风格反而会成为体验灾难。

五、总结

十几行代码换来"内容永不静默丢失",是投入产出比最高的一类基础设施改造。要点收拢:编辑场景天然适配乐观锁;版本比对放在服务层、冲突异常单独建模、状态码用 409 表达协作语义;前端为 409 设计专门的冲突出口;所有写路径统一过守卫。做到这四条,并发编辑就从事故源变成了可预期的协作流程。

真实性声明

本文代码节选自 ArticlePilot 仓库ArticleServiceImpl.update()与GlobalExceptionHandler,字段与异常类型名均与源码一致,可在仓库复核;并发验证方式来自docs/testing.md的测试口径。

开源仓库地址

  • ArticlePilot(乐观锁完整实现可直接对照阅读):https://atomgit.com/dickeryang/articlepilot

如果本文对你有帮助,欢迎点赞、收藏、转发。有任何问题或建议,请在评论区留言交流。行文仓促,定有不足之处,欢迎各位朋友在评论区批评指正,不胜感激。