ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PostgreSQL 数据正确性实战(第 4 篇):ON CONFLICT 两次都成功,旧订单为什么覆盖新状态

PostgreSQL 数据正确性实战(第 4 篇):ON CONFLICT 两次都成功,旧订单为什么覆盖新状态 订单版本 105 的“已支付”先写入版本 103 的“已取消”因网络重试后到。两次 UPSERT 都成功结果却退回旧状态。ON CONFLICT能原子裁决“写哪一行”不能替业务定义“哪个事件更新”。唯一键幂等、业务顺序和消息 Exactly-Once 是三种不同保证。一次没有报错的数据事故先建立一张订单当前状态表。示例中的source_version由订单源系统生成并且在同一order_id内单调递增。DROPTABLEIFEXISTSorder_current;CREATETABLEorder_current(order_idbigintPRIMARYKEY,statustextNOTNULL,source_versionbigintNOTNULL,event_time timestamptzNOTNULL,is_deletedbooleanNOTNULLDEFAULTfalse,payload jsonbNOTNULLDEFAULT{}::jsonb);先到的新事件版本为 105INSERTINTOorder_current(order_id,status,source_version,event_time,payload)VALUES(9001,paid,105,2026-08-30 10:0508,{channel:app})ONCONFLICT(order_id)DOUPDATESETstatusEXCLUDED.status,source_versionEXCLUDED.source_version,event_timeEXCLUDED.event_time,payloadEXCLUDED.payload;随后积压的旧事件版本 103 到达INSERTINTOorder_current(order_id,status,source_version,event_time,payload)VALUES(9001,cancelled,103,2026-08-30 10:0308,{reason:timeout})ONCONFLICT(order_id)DOUPDATESETstatusEXCLUDED.status,source_versionEXCLUDED.source_version,event_timeEXCLUDED.event_time,payloadEXCLUDED.payload;SELECTorder_id,status,source_versionFROMorder_currentWHEREorder_id9001;实际结果是order_id | status | source_version ------------------------------------- 9001 | cancelled | 103这个结果完全符合 SQL唯一索引找到order_id 9001的冲突行DO UPDATE无条件覆盖它。数据库并不知道 105 和 103 是业务版本更不会猜测paid与cancelled谁更新。正确性首先取决于“版本”是否真的可比较在修 SQL 前必须先确定顺序来源。可用的source_version至少要满足在同一业务键内可比较105 确实晚于 103由掌握业务事实的源端生成而不是目标消费者收到消息时临时生成INSERT、UPDATE、DELETE 使用同一套版本规则重放同一事件时版本不变不能每消费一次就加一。常见替代物并不天然满足这些条件候选顺序能否直接作为业务版本主要边界源端事务序号或行版本通常可以要确认按业务键单调且删除事件也能取得数据库提交时间谨慎长事务的业务发生时间、提交时间与捕获时间可能不同事件event_time通常不够时钟漂移、精度相同、客户端伪造都会产生并列或倒序Kafka offset仅在限定条件下只在同一分区内有序换分区或多主题后不能全局比较消费者到达时间不可以它描述网络和调度不描述业务事实如果源端无法提供可比较版本目标端的一条精巧 SQL 也无法凭空恢复真实顺序。此时应先保留事件历史再通过源端快照或业务规则做归并而不是把到达顺序包装成正确性。把“更新”写成带前置条件的原子裁决清空测试数据后用版本条件重放同一组事件TRUNCATEorder_current;INSERTINTOorder_currentAScurrent(order_id,status,source_version,event_time,payload)VALUES(9001,paid,105,2026-08-30 10:0508,{channel:app})ONCONFLICT(order_id)DOUPDATESETstatusEXCLUDED.status,source_versionEXCLUDED.source_version,event_timeEXCLUDED.event_time,is_deletedfalse,payloadEXCLUDED.payloadWHEREEXCLUDED.source_versioncurrent.source_version;INSERTINTOorder_currentAScurrent(order_id,status,source_version,event_time,payload)VALUES(9001,cancelled,103,2026-08-30 10:0308,{reason:timeout})ONCONFLICT(order_id)DOUPDATESETstatusEXCLUDED.status,source_versionEXCLUDED.source_version,event_timeEXCLUDED.event_time,is_deletedfalse,payloadEXCLUDED.payloadWHEREEXCLUDED.source_versioncurrent.source_versionRETURNINGWITH(OLDASold_row,NEWASnew_row)old_row.source_versionASprevious_version,new_row.source_versionASaccepted_version;第二条语句成功结束但RETURNING返回 0 行。再查当前表仍是版本 105order_id | status | source_version ---------------------------------- 9001 | paid | 105这里有两个容易被忽略的 PostgreSQL 语义ON CONFLICT DO UPDATE在并发下保证每个候选行得到 INSERT 或 UPDATE 的原子结果不需要客户端先查后写。冲突行会先被锁定DO UPDATE ... WHERE最后才判断条件为假时不更新而且该行不会出现在RETURNING结果中。所以“SQL 没报错”只能说明语句执行完成。应用必须把返回 0 行识别为版本条件拒绝而不能一律计作“成功更新一行”。PG 18 支持在RETURNING中显式命名OLD、NEW。真正发生更新时可以同时记录旧版本与新版本首次插入时OLD列为空。它适合生成审计信息但条件为假时仍没有返回行。两个并发会话怎样裁决顺序执行只能证明条件表达式正确还要验证并发窗口。先清空表然后分别打开会话 A 和会话 B。会话 A 写入版本 105但暂不提交BEGIN;INSERTINTOorder_currentAScurrent(order_id,status,source_version,event_time)VALUES(9001,paid,105,clock_timestamp())ONCONFLICT(order_id)DOUPDATESETstatusEXCLUDED.status,source_versionEXCLUDED.source_version,event_timeEXCLUDED.event_time,is_deletedfalseWHEREEXCLUDED.source_versioncurrent.source_versionRETURNINGsource_version;-- 暂停在这里不提交会话 B 写入版本 103BEGIN;INSERTINTOorder_currentAScurrent(order_id,status,source_version,event_time)VALUES(9001,cancelled,103,clock_timestamp())ONCONFLICT(order_id)DOUPDATESETstatusEXCLUDED.status,source_versionEXCLUDED.source_version,event_timeEXCLUDED.event_time,is_deletedfalseWHEREEXCLUDED.source_versioncurrent.source_versionRETURNINGsource_version;预期现象会话 B 等待会话 A 的冲突结果。A 执行COMMIT后B 继续执行但版本条件为假并返回 0 行。B 再提交最终版本仍是 105。交换先后顺序也成立103 先落库105 后到时满足条件并升级到 105。这个实验能证明“同一主键的冲突与版本比较位于数据库原子路径内”它不能证明源端版本本身可靠也不能证明跨订单业务约束正确。与之相比客户端的“先查再写”存在竞态会话 A 读取当前版本 100 ── 判断 105 可写 ───── 写入 105 会话 B 读取当前版本 100 ──── 判断 103 可写 ───────── 写入 103即使两次判断各自都正确判断与写入之间的窗口仍可能让旧版本最后覆盖。单纯提高事务隔离级别也不是业务版本协议的替代品。相同版本不是一个可以含糊过去的边界条件使用意味着同版本重放会被拒绝。这通常正是幂等需要的行为。但若同一个order_id source_version出现不同状态或不同载荷不能随便改成让后到者覆盖那意味着上游版本契约冲突。生产系统至少应区分incoming_version current_version乱序旧事件incoming_version current_version且内容一致重复投递incoming_version current_version但内容不一致数据契约违规应告警并隔离incoming_version current_version接受更新。一条普通 UPSERT 很难在条件为假时同时返回当前行并完成上述分类。可将事件标识与内容摘要先写入审计表或用存储过程封装裁决和分类。若拒绝后再执行一次SELECT诊断查询到的是随后时刻的状态只适合观测不应把它误认为与刚才裁决严格同一瞬间的证据。DELETE 不能绕过版本协议直接消费物理删除有一个危险后果版本 103 的迟到 DELETE 可能删掉版本 105即使给 DELETE 加版本条件删除成功后也丢失了“我已经见过版本 105”这条水位线版本 102 以后仍可能把该行复活。更稳妥的当前表模型是版本化墓碑INSERTINTOorder_currentAScurrent(order_id,status,source_version,event_time,is_deleted,payload)VALUES(9001,deleted,106,2026-08-30 10:0608,true,{})ONCONFLICT(order_id)DOUPDATESETstatusEXCLUDED.status,source_versionEXCLUDED.source_version,event_timeEXCLUDED.event_time,is_deletedEXCLUDED.is_deleted,payloadEXCLUDED.payloadWHEREEXCLUDED.source_versioncurrent.source_version;墓碑保留版本 106随后到达的 105 或更旧事件都无法复活订单。物理清理只能在以下条件同时满足后进行源端和所有下游确认不会再重放低于墓碑版本的事件保留期覆盖消息系统、CDC 作业和备份恢复的最大回放窗口清理前存在可恢复的历史或快照先按小批次灰度观察锁、WAL、复制延迟与复活告警一旦发现版本倒退、异常复活或下游缺口立即停止扩大清理范围。墓碑被物理删除后单靠当前表无法恢复版本屏障因此“回滚清理 SQL”并不等于恢复语义真正的恢复点是历史事件、源端快照或备份。Exactly-Once 为什么仍然救不了旧状态覆盖“事件只处理一次”和“最终留下最新业务状态”不是同一个命题传输层Kafka offset / Flink checkpoint ↓ 约束重复传输与恢复位置 写入层唯一键 ON CONFLICT ↓ 约束同一业务键落在哪一行 顺序层source_version 条件 ↓ 约束哪个业务版本获胜 事实层源端对账 历史审计 ↓ 证明目标结果符合业务事实即使每条消息恰好执行一次若 105 本来就比 103 先到最后一次无条件写入仍会留下 103。反过来即使框架发生重放只要同版本内容一致且版本条件正确当前状态仍可以保持单调。因此不要用一个“Exactly-Once”标签覆盖四层不同证据。生产验证不能只看异常率上线版本条件前先从只读证据开始统计每个业务键的版本倒退、相同版本内容冲突以及源端最大版本与目标当前版本的差异。确认版本契约后再按业务分片或租户小流量启用条件更新。至少记录以下指标指标它能证明什么它不能证明什么accepted_total满足条件并真正插入或更新接受的版本一定符合源端事实stale_rejected_total存在迟到或乱序事件乱序来自网络、上游还是错误版本equal_duplicate_total出现同版本重放同版本载荷一定一致equal_conflict_total上游版本契约被破坏哪一份载荷才正确源端/目标最大版本差发现缺失或滞后候选仅凭最大值不能证明中间版本完整墓碑后复活数检测删除语义失守历史清理是否已经可逆验收测试应覆盖正常顺序、105→103 乱序、同版本重复、同版本异内容、并发写入、删除后旧事件、消费重启和历史重放。技术验收是最终版本始终等于已接收的最大合法版本业务验收还要抽样对比源端订单事实确认状态映射本身没有错。若启用条件后拒绝量异常上升不要立即放宽成。先停止扩大流量保留拒绝事件和当前版本证据核查版本生成、消息分区和回放范围。回滚到无条件覆盖会重新打开数据倒退窗口只能作为明确评估后的应急选择不能当作无风险撤销。四种方案的边界方案并发原子性防旧版本覆盖保留历史适用场景无条件ON CONFLICT DO UPDATE是否否后到即新的严格有序输入客户端先SELECT再 UPDATE否看似可以否不应作为并发正确性方案带版本条件的 UPSERT是是否有可靠单调版本的当前状态表不可变事件表 当前状态投影投影需单独保证可以是需要审计、重算与复杂冲突处理还要注意ON CONFLICT DO UPDATE的冲突裁决依赖可用的唯一索引或非延迟唯一约束排他约束不能作为它的 arbiter。同一条语句也不能让多条输入重复影响同一目标行否则会触发 cardinality violation。批量同步前应先保证每个业务键最多有一个候选版本。面试时怎么讲可以用四句话回答ON CONFLICT解决唯一键冲突和原子写入不理解业务事件的新旧。当前状态表要保存源端按业务键单调的版本并在DO UPDATE ... WHERE中完成原子比较。条件拒绝、相同版本冲突和 DELETE 墓碑都必须可观测否则“没报错”不等于写对。Exactly-Once、唯一键、版本裁决和源端对账分别覆盖传输、落行、顺序和事实四层不能互相替代。实验清理DROPTABLEIFEXISTSorder_current;本实验面向 PostgreSQL 18.6。并发等待时不要在共享环境长时间悬挂事务测试结束后确认两个会话均已COMMIT或ROLLBACK。官方资料PostgreSQL 18INSERT 与 ON CONFLICTPostgreSQL 18事务隔离与并发命令行为PostgreSQL 18RETURNING 数据PostgreSQL 18唯一索引PostgreSQL 18.6 源码标签 REL_18_6
返回列表