乐观锁的完整闭环:版本校验、冲突提示与重试

多人同时改同一条数据时,后写入的人可能在不知情的情况下覆盖先写入的人。用户甲读出一篇草稿,标题还是旧的;用户乙把标题改完并保存;甲随后只改了正文并提交整篇文档,于是乙的标题消失。数据库事务本身并不自动阻止这件事。若甲的事务只是按主键更新,它看到的是“行还在”,不会知道自己读到的内容已经过期。乐观锁用来补上这个判断:写入时不只按主键匹配,还要证明自己基于哪一个版本。版本对不上,就拒绝这次写入,让调用方重新读取再决定如何合并。

乐观锁的完整闭环,版本校验、冲突提示与重试
乐观锁的完整闭环,版本校验、冲突提示与重试放大图解 ↗

它叫乐观,是因为默认冲突不多,平时不加排他锁,只在提交的那一下做比较。冲突真的频繁时,反复失败的成本会高于一开始就排队。所以乐观锁适合读远多于写、同一行同时编辑的概率较低的数据,例如个人资料、文章草稿、配置项、工单里彼此独立的字段。库存扣减、账户余额、秒杀名额这类热点行,冲突是常态,乐观锁会让大量事务读到版本再失败重试,数据库仍然很忙,用户也不断收到冲突。那些场景更适合有上限的悲观锁、原子增减,或在缓存里做有容量控制的预扣,再异步落账。

版本从哪里来

常见的版本是整数。插入时定为起点值,每次成功更新加一。更新语句的条件同时包含主键和旧版本,影响行数必须是一。影响行数为零,表示行被删了,或者版本已经变了。这两件事要分开查询才能向用户说清。只返回“保存失败”,调用方无法判断该刷新还是该放弃。

时间戳也能当版本,但分辨率和时钟会捣乱。同一毫秒内的两次更新、应用服务器时钟回拨、数据库时间与应用时间不一致,都会让比较失真。数据库提供的行级系统列若能随每次更新单调变化,可以作为版本来源,避免应用自己忘记加一。无论来源是什么,版本只能由成功的写入推进,不能让客户端指定下一个版本号。客户端若能把版本写成任意值,校验就形同虚设。

版本要覆盖“一次逻辑写入”的全部列。有人只在更新标题时校验版本,更新正文时却按主键直写,冲突仍会从正文那条语句进来。若不同字段由不同接口维护,可以为字段组各设版本,例如正文版本和权限版本。这样改权限不会迫使正在写正文的人整体冲突。分组要稳定,不能这次按字段组、下次又按整行,否则客户端无法理解自己持有的版本代表什么。

有的系统用整行校验和代替版本号。校验和能发现内容变了,但两个不同的更新可能在极小概率下得到同一个摘要,而且客户端必须保存原始全文才能重算。整数版本更直白:相等即基于同一代,不等即过期。校验和更适合检测传输损坏,而不是协调并发编辑。

一次更新的闭环

闭环至少包含四步。读取时把业务字段和版本一起交给调用方。调用方编辑期间,版本原样保留,不参与业务计算。提交时把原版本放进条件。成功则返回新版本;失败则返回冲突,并尽量带上当前服务器版本和足够合并的字段。调用方重新基于新版本提交,或者提示用户处理差异。少了任何一步,乐观锁都只是半截。只在服务端记住版本、页面却不传回,服务端就不知道这次提交基于哪一版。只在冲突时返回“请重试”、却不给新版本,客户端只能盲目再读再写,中间又可能被第三人改掉。

HTTP 里可以用实体标签表达版本。读取响应带上当前标签,更新请求带上前提条件。前提不符时返回冲突。这个语义和数据库里的版本条件是同一件事,不要在网关层忽略前提、到了数据库又做一次无条件更新。反过来,只在网关比较缓存里的标签也不够,权威比较必须和写入落在同一条原子语句里。先读版本再更新,中间仍有窗口。

影响行数为零之后,不要立刻在应用里循环重试写入。那次写入基于的业务内容已经过期,自动把旧正文再套上新版本写回去,恰恰完成了覆盖。自动重试只适用于“重新读取之后,业务操作仍然有意义”的情况。例如把计数加一,可以读到最新值再加。把用户提交的整份表单原样再写一次,则必须经过合并或再次确认。

冲突时告诉用户什么

冲突提示要能让人做决定。至少说明:保存没有生效,当前页面基于的版本已经旧了,服务器上存在更新的版本。若能安全展示差异,就指出哪些字段在两边不同。标题以服务器为准、正文保留本地修改,这种按字段合并只在字段互不依赖时成立。开始时间和结束时间若分别被两个人修改,各自看起来都合法,合在一起可能结束早于开始。合并规则必须是领域规则,不能是通用的字段级覆盖。

有些变更无法自动合并,例如两个人改了同一段正文。此时应保留用户本地未提交的编辑,避免刷新把输入框冲掉,同时展示服务器版本,让用户手工拷贝或选择放弃本地修改。后台系统可以提供“仍要覆盖”的入口,但那是新的一次写入,基于最新版本,并应留下审计:谁在知道冲突后覆盖了谁。默默覆盖不叫解决冲突。

错误码要稳定。冲突、资源不存在、权限不足、参数不合法,是不同的后续动作。客户端把它们都显示成“保存失败,请重试”,会让用户在无权修改时反复提交,或在资源已删除时不断刷新版本。服务端日志则记录主键、提交携带的版本、当前版本和调用方,便于判断是正常的双人编辑,还是客户端缓存了过旧的页面。

和事务、隔离级别放在一起

乐观锁通常跑在普通的读已提交隔离下。事务里先做别的查询,最后按版本更新目标行。更新失败,整个事务回滚,前面的写入也不能留。不要在更新失败后捕获异常再提交,否则配套的流水、待发送消息会带着一次没成功的业务变更发出去。版本条件满足时,更新语句本身是原子的,不需要先给行加排他锁再比较。若事务很长,版本在事务开始不久就读了,直到结束才更新,中间别人改过就会失败。缩短事务,把用户思考时间放在事务外面,是乐观锁能工作的前提。人在表单上停留的几分钟里不该占着数据库事务。

悲观锁把冲突提前变成等待。它要求先锁再改,锁持有期间别人不能改。等待有上限,否则会堵出锁链。乐观锁把冲突推迟到提交,平时不阻塞读者。二者可以共存:大多数请求走版本校验,管理端的批量修复在短事务里对少数行加锁。不要在同一个接口里对同一行又加锁又忽略版本,那会让调用方以为自己仍受到版本保护。

删除也要带版本。用户甲打开编辑页,用户乙删除记录,甲再保存。若删除是按主键进行的,甲的更新可能把行“救”回来,或者更新影响零行后被误当成冲突并尝试重建。删除条件带上版本,或者使用逻辑删除并推进版本,甲会得到明确的“已被删除”。重建应是单独的命令,而不是更新失败后的副作用。

重试的边界

客户端自动重试要有次数和间隔上限,并只在确认操作可合并时进行。无间隔的连续重试会在热点行上形成风暴。用户主动再次保存,则是新的一次提交,仍然带上他确认过的版本。服务端对同一调用方的重试可以做幂等:同一次保存请求因为网络超时被发了两次,应返回同一个结果,而不是先成功、后一次因为版本已变成新值而报告冲突。这里的幂等键和乐观锁版本解决的是不同问题。幂等键折叠网络重放,版本防止不同意图互相覆盖。两个都需要。

版本号会增长。整数宽度要够用,或者在协议里使用不会在业务生命周期内回头的类型。回绕之后,旧客户端可能恰好持有相同的数字,校验会错误地通过。这种情况在更新极其频繁的行上才要紧,但也说明热点行本来就不宜只靠应用层版本硬扛。

缓存会把旧版本重新引入。页面缓存、接口缓存若在写入后仍返回旧版本,下一个编辑者会基于过期版本提交,于是无故冲突。更新成功后应失效对应缓存,或者让缓存条目包含版本并由写入方对比。反向代理若缓存了带实体标签的响应,还要正确转发前提条件,不能用缓存响应回答一个要求匹配标签的更新请求。

测试时至少覆盖这些路径:单人连续更新能推进版本;两人交错更新时后者收到冲突;冲突后按新版本重放能够成功;删除与更新交错不会复活数据;同一保存请求的网络重放不会变成第二次业务修改;版本不匹配时事务中的其他写入全部回滚。只测单线程更新,乐观锁等于没有被验证。

收束

乐观锁的闭环是:把版本交给读者,写入时原子地比较并推进版本,冲突时返回足够判断的信息,调用方在新版本上合并或放弃,配套事务随失败一起回滚。它防止的是丢失更新,不是所有并发问题。热点写入、需要严格串行的扣减、人在事务里长时间停留,都不适合只用这一套。版本、提示、重试和幂等各自就位之后,并发编辑才从偶发的静默覆盖变成可解释、可恢复的冲突。

见字如晤

图解