postgresql更新时出现“覆盖丢失”是因为其默认隔离级别不保证“读-改-写”原子性,需用version字段实现乐观锁:update带版本校验且检查row_count是否为1。

PostgreSQL 更新时为什么会出现“覆盖丢失”?
当两个事务同时读取同一行、各自修改后提交,后提交者会直接覆盖前者的变更——PostgreSQL 默认不阻止这种行为,因为它的隔离级别(即使是 READ COMMITTED)不保证“读-改-写”原子性。这不是 bug,而是设计选择:它把冲突检测和处理逻辑交还给应用层。
用 version 字段实现乐观锁的正确写法
核心是让 UPDATE 带上版本校验条件,并检查是否真的更新成功。不能只靠 WHERE version = ?,还要验证 ROW_COUNT 是否为 1。
- 表结构中必须有
version(INT或BIGINT),初始值为 0,每次成功更新后 +1 - 应用执行更新时,SQL 必须形如:
UPDATE accounts SET balance = 150, version = version + 1 WHERE id = 123 AND version = 42;
- 执行后立刻检查返回的受影响行数:若为 0,说明已有其他事务抢先更新,当前操作应失败并重试或报错
- 不要在应用里先
SELECT version再拼 SQL——这中间可能被并发修改,必须用单条UPDATE ... WHERE ... version = ?原子判断
SELECT FOR UPDATE 和乐观锁不是一回事
SELECT FOR UPDATE 是悲观锁,会阻塞其他事务对同一行的写操作,直到本事务结束。它解决的是“写-写冲突”,但会降低并发吞吐,且无法避免应用层逻辑错误(比如读出值、本地算错、再写入)。而乐观锁不加锁,靠版本号发现冲突,更适合读多写少、冲突概率低的场景。
- 用
SELECT FOR UPDATE时,必须确保整个事务中持有该锁,且不能跨事务分段操作 - 如果业务中存在“先查再算再更”的长流程,又不想阻塞别人,乐观锁 +
version是更安全的选择 - 注意:在
REPEATABLE READ隔离级别下,SELECT FOR UPDATE可能触发序列化失败(could not serialize access due to concurrent update),而乐观锁不会
容易被忽略的细节:时钟无关、无需 UUID、别漏掉 RETURNING
乐观锁的 version 必须是单调递增整数,不能依赖系统时间(NOW() 或 CLOCK_TIMESTAMP()),否则在高并发或时钟回拨时会失效。也不需要引入 UUID 或复杂哈希——简单 INT 最可靠。
- 推荐在
UPDATE语句末尾加上RETURNING version,这样一次查询就能拿到新版本号,避免再查一次 - 如果用 ORM(如 SQLAlchemy、MyBatis),确认它支持基于
version的乐观锁配置,而不是只做字段映射 - 测试时一定要模拟并发:用两个
psql会话手动执行带相同旧version的UPDATE,观察哪个成功、哪个返回 0 行
真正难的不是写那行 UPDATE,而是让所有修改该行的路径都走同一套版本校验逻辑——漏掉一个接口、一个后台任务、一个迁移脚本,整个乐观锁就形同虚设。










