第 8 / 23 篇

第 7 章:资源版本冲突是正常控制流

看见过不等于仍然成立

控制器读取 Deployment 后,需要一些时间计算期望变化。在这段时间里,用户可能添加注解,内建控制器可能更新状态,另一个工具可能调整配置。Update 请求携带的 resourceVersion 用于判断客户端是否还基于当前版本写入。服务器返回 Conflict,不是在说 API 不稳定,而是在拒绝一个可能覆盖更新的旧写入。

本项目对 Deployment 使用 RetryOnConflict。每次尝试都在闭包内重新 GET,再核对 owner UID 和托管字段,最后 Update。重试闭包若只包含 Update,却复用闭包外读取的对象,会反复提交同一个旧版本,直到重试耗尽。退避次数再多也无法弥补读取位置错误。

resourceVersion 是服务器定义的并发标识,不应当作可计算的整数序列使用。控制器只需要将读取到的值原样带回条件写入。generation 表示期望配置版本,resourceVersion 表示对象存储变更,两者不能互换:一次状态更新通常改变后者,却不代表用户提交了新 spec。

在真实 API 上制造一次竞争

真实冲突测试先让实例收敛,再把副本数从一改为二。控制器读取待更新 Deployment 后,测试钩子启动另一条客户端路径,写入一个注解,使资源版本前进。随后用旧对象执行一次 Update,必须得到真实 API server 的 409,而不是由 fake client 人工返回的错误。

控制器自己的首次 Update 此时也会冲突。RetryOnConflict 重新读取带注解的新对象,重新应用副本目标,再成功写回。最终断言副本数为二,而且另一写入者的注解仍在。前者证明重试没有丢失本轮目标,后者证明它没有用旧对象覆盖并发变化。

钩子只用于确定性安排测试时序,正式 CLI 构造不会设置它。测试没有伪造服务器返回值,也没有替换客户端存储。这样可以同时获得可重复的竞争窗口和真实服务器的版本检查,避免依靠大量并发循环碰运气。

冲突重试的正确边界

重试闭包里适合放可重复读取与条件写入,不适合塞入无法确认结果的外部扣费请求。Conflict 说明当前 Kubernetes 更新没有接受,并不能撤销闭包前面已经发生的外部副作用。如果每次重试都先创建云资源,就可能把一个元数据冲突放大为多次计费。

本项目的外部登记使用稳定名字和 ownerUID 检查,并且在 Deployment 重试闭包之外处理。它仍不是现实云 API 的完整模型,但足以让读者看清哪些步骤可以安全重做。未来接入真实外部系统时,应把幂等创建、结果查询和 Kubernetes 状态提交分开设计。

冲突次数也不应该无限增长。client-go 的有限重试处理短时竞争,耗尽后把错误返回给队列,等待下一轮。这样一个持续竞争对象不会永久占住单 worker。生产系统还应记录冲突计数及对象信息,以识别两个控制器互相覆盖同一字段的配置错误。

重新读取后仍要重新判断

获取新版本不只是为了换一个 resourceVersion。对象可能已经进入删除阶段,owner reference 也可能被改变,用户 spec 可能提交了新的目标。每一轮都必须重新检查当前条件。否则程序虽然通过乐观锁,仍可能在语义上做错事。

当前代码的 Deployment 重试使用本轮捕获的主对象 spec,因此在主对象中途变化时,旧目标可能短暂被应用。状态写入前的 generation 检查会阻止错误确认,后续协调再推进新目标。它保证最终收敛和状态真实性,没有保证每个中间瞬间都跳过旧目标。若业务不允许这种短暂过渡,需要更强的版本栅栏或发布协议。

这种边界应在评审中说清楚。一个系统可以诚实地承诺“旧配置可能短暂出现,但不会被当成新配置确认”,而不能因为测试最终通过,就声称所有更新都是原子的。

不要把元数据并发变成业务锁

给主资源加一条“处理中”注解,不能自动获得跨进程互斥。读改写之间仍存在竞争,进程崩溃后锁也可能遗留。Kubernetes Lease 和 leader election 有专门语义,但它们也不让外部调用拥有全局恰好一次属性。本项目不实现多副本控制器,避免把一个未证明的协调锁扩展为高可用承诺。

练习时让并发写入者修改控制器自己拥有的副本数,而非注解。你会看到重试成功仍可能把该写入覆盖回期望值,因为字段所有权本来属于控制器。并发保护保证不盲目覆盖未知版本,最终谁决定字段值仍由 API 契约决定。

评审案例:状态更新为什么也会冲突

主对象status不是单独存储的一份无关文档。用户更新spec或控制器自己改变finalizer,都可能让基于旧resourceVersion的UpdateStatus遭遇竞争。因此状态写入也放在重新读取的有限重试闭包中,而非假定只要分了子资源就无需并发保护。

重新读取后必须再次检查generation。否则第一次状态提交因冲突失败,第二次读到新spec,却沿用旧结论并确认新generation,竞态仍然存在。并发保护应该同时检查存储版本与业务版本,二者分别回答“对象是否被改过”和“结论基于哪份意图”。

测试中的另一写入者保留注解,说明元数据可以合法并存。若竞争持续来自同一托管字段,则应先修复所有权设计,而不是增加重试次数。把冲突视作诊断信号,能让乐观锁帮助暴露系统关系,而不是变成无限掩盖争夺的循环。