我清楚记得第一次在9.5的release notes里看见INSERT ... ON CONFLICT时的反应:终于不用再靠规则+触发器+异常捕获那套歪门邪道来做UPSERT了。从那时起,这套实现就一直是PostgreSQL并发写入场景里的顶梁柱。十年过去,网上仍然有人问"PostgreSQL下载哪个版本""9.5是不是太老了",其实版本号从来不是关键,关键是理解内核里那套机制——从9.5的投机插入到后来各版本对分区表、约束、MERGE的演进,理解透了,你才能在18上写出不踩坑的业务逻辑。
这篇文章我会从源码执行路径的角度,把这套UPSERT机制拆开讲清楚。不是念文档,而是带你走一遍:一条INSERT ON CONFLICT DO UPDATE在9.5的解析器里怎么变成计划,在执行器里怎么探测冲突,怎么用speculative insertion做到"不用回滚就能处理冲突",以及后面9.5到18这十几年里,哪些能力变了、哪些坑一直存在。适合正在用PostgreSQL做高并发写入、数据同步、报表累加的开发者,也适合想开始读PG源码但不知道从哪下手的同学。
1. 9.5之前"UPSERT"是一场灾难:为什么ON CONFLICT是刚需
1.1 传统做法的三宗罪
在9.5之前,你要是想在PostgreSQL里实现"有则更新,无则插入",大概只有四条路:先SELECT再INSERT/UPDATE、用CTE做UPDATE ... RETURNING当不存在时再insert、靠规则系统改写、或者干脆捕获唯一约束异常。这四条路看着都能跑,生产环境里全部有硬伤。
最典型的是先查再写:两个并发请求同时读到"记录不存在",然后都去插入,其中一个一定会被唯一约束炸回来。你得在外层再包一个EXCEPTION WHEN unique_violation THEN UPDATE,这等于把"并发正确性"完全丢给异常处理。异常捕获的问题在于:事务块会变成aborted子事务,日志里全是错误信息,性能也差——因为每次唯一约束冲突都要做一次完整的索引探测和错误构造。
CTE方案稍微好看一点,但它不是原子的。理论上你可以用一个WITH语句做到"存在就跳过",但两个并发事务仍然可能在同一个间隙里都判断为"不存在",然后一起插入。规则系统更不用说了,规则重写只执行一遍,遇到批量多行插入时会非常尴尬,比如一行冲突、一行不冲突时,整个语句的语义会变得极难维护。说到底,这些方案都没办法在数据库内核层面回答一个问题:同一时刻有两个会话都想插入同一个键,到底谁赢、输了的那方该怎么办。
1.2 "冲突"不是一个错误,而是一个分支
9.5给出的答案本质上是一次理念转变:唯一键冲突不再是一个需要抛异常的事情,而是插入操作里的一个正常分支。一个插入语句在探测到索引冲突之后,可以选择什么都不做,也可以选择转成一条更新语句去更新冲突的旧行。因为整个决策发生在执行器内部,所以它是原子的——并发会话之间通过索引锁和行锁互相等待,最后到手的结果是一致的。
这个理念转变带来了一个几乎所有人都忽略的好处:你不再需要为了UPSERT把业务拆成两条SQL。一条语句里可以同时包含"插入的新值"和"如何基于新值更新旧值"两套逻辑,数据库自己负责协调。这也是后来逻辑复制、数据仓库增量同步等场景能大规模使用PostgreSQL的一个基础前提。
2. 9.5语法与语义拆解:DO NOTHING、DO UPDATE、conflict_target
2.1 三种写法和它们的边界
9.5的ON CONFLICT语法本身不长,但语义边界很微妙。先看最常见的三种:
-- 方式一:不做任何处理 INSERT INTO t (id, val) VALUES (1, 'a') ON CONFLICT DO NOTHING; -- 方式二:指定冲突目标,只针对这个唯一键冲突时忽略 INSERT INTO t (id, val) VALUES (1, 'a') ON CONFLICT (id) DO NOTHING; -- 方式三:指定冲突目标,冲突时更新 INSERT INTO t (id, val) VALUES (1, 'a') ON CONFLICT (id) DO UPDATE SET val = EXCLUDED.val;注意第一行ON CONFLICT DO NOTHING后面没有冲突目标,它表示"任意唯一约束冲突都忽略"。这种方式可以用于防重复,但无法指定"只在主键冲突时忽略、在另一个唯一键冲突时报错"。如果你要精准控制,必须给出conflict_target。
conflict_target支持几种形式:列名列表、ON CONSTRAINT constraint_name、或者一个唯一索引表达式。它本质上不是随便写两个列就能匹配的——PostgreSQL要求目标必须对应一个已经存在的唯一索引或排除约束。你写了一个没有对应唯一索引的列,执行时会直接报no unique or exclusion constraint matching the ON CONFLICT specification,哪怕这个列其实可以用来判断主键冲突,数据库也不会帮你推断。
2.2 conflict_target为什么必须踩着唯一索引走
很多初学者不理解为什么要这个限制。直接说吧:ON CONFLICT的冲突探测不是靠"比较值",而是靠索引探测。执行器插入元组之后,要把元组插入所有相关索引。当插入到唯一索引时,如果发现索引页里已经存在相同键值的另一个元组,就说明冲突了。这样设计的好处是效率极高,因为你不需要额外扫表,也不需要用户提供一套"如何进行冲突判断"的逻辑;坏处是,没有唯一索引的列本身就没有资格作为冲突判断的仲裁者。
另外,conflict_target对应的唯一索引不能是部分索引。文档里明确说,它必须是一个"non-partial"的唯一索引。如果你建的是CREATE UNIQUE INDEX ... WHERE status='active'这种部分索引,用它来做ON CONFLICT目标会直接报错。原因是部分索引只能覆盖表的一部分行,对于那些不满足索引谓词的行,索引里根本没有对应条目,于是"冲突"这个判断就失效了。
2.3 EXCLUDED伪表和旧值语义
DO UPDATE子句里有两个东西可以引用:一个是旧行,也就是原表里那个已经存在的冲突行;另一个是EXCLUDED,表示"如果没有冲突将被插入的那一行",也就是VALUES里提供的值。这个设计把"旧值"和"新值"的区分放到了SQL层面,读起来非常直观:
INSERT INTO stats (user_id, total) VALUES (100, 1) ON CONFLICT (user_id) DO UPDATE SET total = stats.total + EXCLUDED.total, updated_at = now();这里stats.total是旧值,EXCLUDED.total是这次要累加的量。注意一点:即使你使用了DO UPDATE,不一定会更新每一列——你写的哪些列出现在SET里,哪些列就被更新。这给后面"避免无意义更新"留下了操作空间。
还有一个隐藏语义:当冲突发生时,旧行已经被当前事务以FOR UPDATE的语义锁住了。也就是说,在有并发的情况下,插入方会等待另一个事务提交或回滚后,再拿到最新版本的旧行去做更新。这个等待过程极度依赖死锁检测,热点键上如果处理不当,死锁是家常便饭,后面的实战章节我会专门讲。
3. 源码级核心机制:一条UPSERT语句在9.5里经历了什么
3.1 从gram.y到InsertStmt:冲突子句如何变成计划参数
在PostgreSQL源码里,任何SQL都要经过词法、语法、分析、重写、计划、执行这几关。ON CONFLICT也不例外。语法文件src/backend/parser/gram.y里为INSERT语句新增了opt_on_conflict规则,它最终会填进InsertStmt结构体中的onConflictClause字段。这个字段的类型是OnConflictClause *,里面保存了三个信息:
action:是DO NOTHING还是DO UPDATE;infer:冲突目标,也就是前面说的唯一索引仲裁条件;targetList:DO UPDATE时要更新的列和表达式。
走到计划阶段时,优化器会把InsertStmt改造成一个ModifyTable计划节点,这个节点上挂着onConflictAction、arbiterIndexes、updateColsLists等信息。arbiterIndexes是一个Oid数组,里面是计划器根据conflict_target到系统目录里匹配出来的唯一索引的OID。计划阶段有一个重要动作:它会用ShareLock锁住匹配到的索引对象,防止在语句执行期间索引被并发DROP或重建。这个细节很少人注意,但你回过头看,很多"UPSERT执行到一半索引没了"的诡异错误,其实在这里就已经把好后路断掉了。
3.2 ExecInsert与arbiter索引探测冲突
执行阶段的主场在src/backend/executor/nodeModifyTable.c。插入一条带ON CONFLICT的行时,执行器不会先跑一遍SELECT去看有没有冲突,而是按正常插入流程走:先把元组放进堆页面,然后挨个插入索引。这个流程和普通INSERT最大的差别在于:插入唯一索引时,如果检测到重复键,普通INSERT会抛duplicate key value violates unique constraint错误;而ON CONFLICT模式下,这个错误不再是终点,而是转到了一个内部处理分支。
这个"内部处理分支"的实现在execIndexing.c里。它会把唯一索引冲突的关键信息——冲突的索引、冲突元组在堆里的tid——传给上层。nodeModifyTable.c拿到这个信息后,会根据action决定:
DO NOTHING:直接结束,不插入、不更新,返回一个空的插入结果;DO UPDATE:切换到更新逻辑,先锁定冲突行,再构造一条UPDATE元组执行。
整个过程中,你肉眼看到的表现是"要么插入了,要么更新了,要么什么都没干"。但如果你看性能监控,会发现这种模式下不会产生像异常捕获那样的大量错误日志,这是一个很实在的收益。
3.3 speculative insertion:避免无谓WAL回滚的钥匙
9.5的实现里最核心的机制就是投机插入(speculative insertion)。这大概是整个ON CONFLICT最精妙的地方。
普通INSERT流程中,一个元组先插入堆,再插入索引,然后这条插入会被记入WAL。如果唯一索引冲突,后面要回滚,WAL里已经写下的插入记录就白白浪费了。9.5的做法是:当语句带了ON CONFLICT时,先用heapam.c里的heap_insert函数以特殊模式插入元组。这个模式下,元组会被打上一个speculative token,它在系统表里的可见性是被"吊起来"的——其他事务能看到这是一个未确认的元组,但不把它当作正式数据。
关键点是:投机插入不会立即写WAL,也不会立即终结整个事务。执行器先带着这个"幽灵元组"去尝试插入唯一索引。如果索引探测发现没有冲突,就调用heap_finish_speculative把投机元组转为正式元组,并补写WAL。如果发现冲突,就调用heap_abort_speculative直接丢弃这个投机元组,不走完整的回滚路径。因为投机元组的插入和清除都在内存和缓冲池层面完成,没有产生WAL,也没有把事务标记为aborted,所以它的开销远低于"插进去再回滚"。
听上去有点绕,但你可以把它理解成"先拿一支笔试探性地在纸上描了一下,发现和旁边格子重叠了,就直接把笔划擦掉,而不是撕掉整页纸重新写"。这个设计让高频UPSERT场景下的日志量、回滚段压力、索引维护成本都变得可控,也是后来很多性能测试里UPSERT比"先查后插"快一个数量级的原因。
3.4 并发更新:EvalPlanQual与行锁重检查
UPSERT最难的其实不是单条语句,而是多个会话同时UPSERT同一个键。比如两个会话同时执行INSERT ... ON CONFLICT (id) DO UPDATE SET cnt = cnt + 1 WHERE id = 1,会发生什么?
第一个会话插入失败后,要拿旧行去更新,它需要给旧行加FOR UPDATE行锁。如果第二个会话也进入了冲突处理分支,它同样想拿同一行的行锁,于是它只能等待第一个会话提交。这是符合预期的——数据库会把并发更新串行化,保证最后计数不会丢。
但这里有个细节:第二个会话等到锁之后,它刚才看到的旧行版本可能已经过期了(如果第一个会话做了更新)。执行器不能直接拿过期的版本去计算cnt + 1,否则就丢了第一个会话的累加。为此,PostgreSQL在9.5里引入了对冲突更新场景的EvalPlanQual(EPQ)检查。所谓EPQ,就是SQL标准里READ COMMITTED下的"更新,如果行被并发修改,则重新读取新行版本再判断"。ON CONFLICT DO UPDATE用到的是同一套机制:冲突分支拿到的旧行,如果发现已经被别的事务更新,就重新读取一次该行的最新提交版本,然后再执行DO UPDATE的计算。
这套机制保证了最后的cnt是两次cnt+1之后的值,而不是两次都基于初始值,最后只加了一次。理解这一点很重要,很多并发测试如果只在应用层做压力测试,往往看不出问题,但你一旦把两路请求同时发过去,就会发现计数不符。那不是数据库bug,是你没有意识到"UPSERT的更新天然要带EPQ重读"。
3.5 源码阅读路径速查
如果你真的想亲手打开源码验证,我建议按这个顺序看,会顺很多:
| 源文件 | 关键点 | 作用 |
|---|---|---|
src/backend/parser/gram.y | opt_on_conflict规则 | 语法解析,生成OnConflictClause |
src/include/nodes/parsenodes.h | InsertStmt,OnConflictClause | 语法树结构定义 |
src/backend/optimizer/plan/createplan.c | ModifyTable节点构造 | 把ON CONFLICT信息挂到计划节点 |
src/backend/executor/nodeModifyTable.c | ExecInsert | 执行插入主流程、冲突分支切换 |
src/backend/executor/execIndexing.c | ExecInsertIndexTuples | 索引探测,收集唯一索引冲突 |
src/backend/access/heap/heapam.c | heap_insert,heap_finish_speculative,heap_abort_speculative | 投机插入的底层实现 |
这个顺序就是一条SQL从文本变成变更的完整链路。我读源码时习惯先在ExecInsert附近打断点,因为它往上一层层是计划器和解析器,往下一层层是索引和堆,非常像一个总线节点。
4. 从9.5到18:这十年UPSERT发生了什么
4.1 分区表与UPSERT的逐步解禁
9.5发布时,PostgreSQL还没有声明式分区的概念。那时候人们用继承表做分区,而ON CONFLICT和继承表有一种别扭的关系:理论上可以把ON CONFLICT写到继承表上,但子表各自有独立索引,跨子表做唯一约束在标准继承场景下就不受保护。
10版本引入声明式分区后,情况更复杂了。因为分区表本身没有物理存储,所有数据都落在子表里,唯一约束能不能跨子表,取决于分区键和唯一键的关系。比如你按region分区,但唯一键是user_id,那同一个user_id可能被插入到两个region分区里,各自的唯一索引都能通过,这时ON CONFLICT就不会触发——因为冲突根本不在同一个索引页面中出现。
这些年PostgreSQL一直在补这块短板。分区表上逐步支持了唯一索引、外键,也允许把ON CONFLICT用在一级分区上。但直到最近几个版本,跨分区级别的唯一冲突仍然很难像普通表那样丝滑。所以我给生产环境的建议一直是:如果你要用分区表做UPSERT,请把分区键作为唯一约束的一部分,或者明确接受"冲突判断只能发生在分区内"这个约束。这是从9.5到18都在强调的一个边界。
4.2 arbiter的扩展与约束匹配能力
9.5的实现里,conflict_target的匹配逻辑比较严格:必须是完整覆盖目标列的唯一索引。后来版本逐步放宽了一些细节,比如支持用ON CONSTRAINT指定系统目录里命名约束,也允许对索引表达式做判定。这些变化让UPSERT不再只能针对简单的单列主键,也能用在复合唯一键、排除约束(exclusion constraint)上。
排除约束是个冷门但很有意思的能力。普通唯一索引只保证等值不重复,排除约束可以做到"时间区间不重叠""二维坐标不重复"这类复杂规则。在9.5里,如果排除约束触发冲突,ON CONFLICT分支也能捕获。这个能力在预订系统、库存占用等场景里非常有用。不过要注意,排除约束冲突的处理逻辑比唯一索引更复杂,因为判定冲突需要真的去读对比元组,而不是简单比键值。这也是为什么很多业务宁可多建一个普通唯一索引,也不用排除约束做UPSERT的仲裁目标。
4.3 MERGE的登场:从ON CONFLICT到SQL标准
15版本引入了MERGE语句,它可以在一条语句里混合INSERT、UPDATE、DELETE,是SQL标准里"真正"的Upsert。很多人问:有了MERGE,ON CONFLICT是不是该退役了?
我的看法是:两者定位不同。ON CONFLICT专门为"插入时撞唯一索引"这种高频场景优化,路径短、效率高、语义直白,非常适合写入密集型业务;MERGE更像一个通用的"目标表+源数据"同步工具,适合做ETL、数据对账,它的语法更宽泛,但性能上不一定比得上ON CONFLICT的窄路径。这一点在18版本里依然成立。社区偶尔有讨论要不要在MERGE和ON CONFLICT之间做更多融合,但我个人在实际性能测试中,简单的累加场景下ON CONFLICT仍然是最稳的选择。
4.4 周边生态:vacuum、逻辑复制与大规模写入
ON CONFLICT DO UPDATE本质上是"先失败再更新",它会在目标表上产生更新版本。频繁UPSERT一个窄表,会让表的dead tuples(死元组)膨胀速度非常快。所以9.5之后,PostgreSQL在vacuum调度上做了很多配套改进:包括自动vacuum的work item拆分、索引清理效率优化,以及在13版本后对autovacuum参数在"频繁更新小表"场景下的默认行为调整。
逻辑复制场景里,UPSERT也扮演了重要角色。标准INSERT经过逻辑复制到订阅端后,如果订阅端已经有同主键行,会导致复制冲突。解决办法之一就是在订阅端用ON CONFLICT DO NOTHING或DO UPDATE来吞掉冲突。这在10版本逻辑复制刚出来时就是常见配置。后续版本又不断给逻辑复制加了冲突检测和跳过机制,但ON CONFLICT仍然是订阅端最常见的"兜底方案"。
从9.5到18,语法本身几乎没有变化,变的是它跑在什么表结构上(普通表、分区表、逻辑复制订阅端)、能匹配什么约束(唯一索引、排除约束)、以及周围基础设施对它的友好程度。内核机制已经相当稳定,真正需要你关注的其实是自己的业务模型是否踩中了它天生的那些边界。
5. 实战:我在生产环境里见过的UPSERT翻车现场
5.1 无意义更新导致xid爆炸和表膨胀
这是最常见的一个坑。很多人写成这样:
INSERT INTO t (id, cnt) VALUES (100, 1) ON CONFLICT (id) DO UPDATE SET cnt = EXCLUDED.cnt;这段SQL的意图可能是"如果存在就把它设置成1"。但问题是,即使旧行的cnt已经是1,DO UPDATE分支依然会执行更新,生成一个新版本,并且消耗一个新的事务ID(xid)。在高并发写入同一个热行时,这一行会被反复更新成相同内容,每秒钟产生几十上百个死元组,表和索引一路膨胀,vacuum根本追不上。
正确做法是用WHERE做哨兵:
INSERT INTO t (id, cnt) VALUES (100, 1) ON CONFLICT (id) DO UPDATE SET cnt = 1 WHERE t.cnt IS DISTINCT FROM 1;拿IS DISTINCT FROM而不是<>,是为了处理NULL和相等比较时的边界。这样当旧值已经等于目标值时,WHERE为假,更新分支不执行,也就不会制造死元组。你可以在执行计划里看到:这时冲突分支会变成"not updated",热度下降了不止一个量级。
5.2 热点唯一键上的并发更新死锁
UPSERT在高并发下比普通UPDATE更容易触发死锁,原因在于冲突处理时的"逆向锁顺序"。举个我实际遇到过的例子:两个会话都执行同一段批量UPSERT,会话A按id=1,2的顺序处理,会话B按id=2,1的顺序处理。A先拿到1的行锁,B先拿到2的行锁,随后A去拿2、B去拿1,互相等待,PostgreSQL死锁检测器介入,其中一方报错回滚。
这个问题本质上可以通过"所有会话都按同样的顺序处理key"来规避。如果业务做不到全局排序,至少要在UPSERT语句层面保证一组记录的处理顺序稳定。还有一个细节:ON CONFLICT DO UPDATE的锁等待发生在冲突行的FOR UPDATE上,如果同一行被连续密集更新,lock_timeout设得短一点可以避免整个连接卡死。我这里给个通用建议:线上环境把lock_timeout设置成比如5秒,至少让问题暴露成一条带canceling statement due to user request的错误,而不是无限期等锁。
5.3 与序列、逻辑复制交互时的注意事项
在高频UPSERT场景里使用serial或IDENTITY列时,你会观察到序列值出现很多"空洞",因为九成的插入请求可能在冲突后被丢弃或转成更新,但序列已经先取走了值。这是正常现象,不是bug。如果你下游有ETL要求id连续,那就不该用数据库序列,而应该在应用层生成业务唯一键。
逻辑复制的订阅端如果用ON CONFLICT DO NOTHING避免主键冲突,要小心一个副作用:它会把真正的数据差异也一起吞掉。比如发布端同一个主键更新了新值,订阅端的冲突处理只做了DO NOTHING,导致订阅端数据滞后且无感知。对需要强一致的数据,正确做法是DO UPDATE,并且把WHERE条件设计成"只有真正需要更新时才更新",这样才能既解决复制冲突又保证数据最终一致。
5.4 升级版本时的行为差异检查清单
从9.5一路升级到17、18这些版本时,ON CONFLICT的SQL写法几乎没有破坏性变化,真正的风险来自周边:
- 如果你的表从继承表改成了声明式分区,ON CONFLICT的仲裁目标需要重新建立唯一索引;
- 如果启用了并行插入、并行查询,注意EPQ重读在并行计划下的行为边界;
- 如果使用了本地临时表、跨数据库外部表,某些组合下ON CONFLICT不可用或行为不一致;
EXCLUDED伪表在触发器、规则、CLUSTER等周边功能里的表现,升级前最好跑一遍回归测试;- 涉及
lastval()和RETURNING时,冲突分支的返回值可能不符合你预期。
我遇到过最坑的升级问题不是ON CONFLICT本身,而是旧版本里靠"异常捕获+UPDATE"实现UPSERT的存储过程,在升级后因为事务状态处理差异导致部分冲突被吞。这类东西只能通过全面的集成测试暴露。
6. 现在还想研究源码,应该从哪里下手
6.1 从EXPLAIN看到计划层的行为差异
没有读代码习惯的同学,可以先从执行计划下手。在9.5及之后的版本里执行:
EXPLAIN (VERBOSE, COSTS OFF) INSERT INTO t (id, val) VALUES (1, 'x') ON CONFLICT (id) DO UPDATE SET val = EXCLUDED.val;你会发现INSERT走的是Insert on t的ModifyTable节点,但输出里不会直接告诉你冲突仲裁索引是谁。想看计划器到底选中了哪个索引,可以临时打开:
SET debug_print_plan = on; SET client_min_messages = debug;再执行UPSERT,服务器日志/客户端会打出计划树的完整结构,里面会包含onConflictAction=ONCONFLICT_UPDATE和arbiterIndexes的OID。看到OID后,你在系统目录里反查:
SELECT relname FROM pg_class WHERE oid = '<arbiter_index_oid>'::oid;就能确定计划器选择的仲裁索引。这个方法我在很多次"为什么ON CONFLICT没触发"的问题调查里用过,非常快。
6.2 核心函数阅读顺序建议
如果你想看源码,我建议按这个顺序读,第一章看语法结构,第二章看执行器,第三章看堆管理,基本就能扫完主干。
先从gram.y找出所有带on_conflict的规则,理解语法树里都有什么。然后跳到nodeModifyTable.c,找到ExecInsert。这个函数很长,你不用一次读完,只看它怎么判断onConflictAction不为ONCONFLICT_NONE时的分支。接着去execIndexing.c找ExecInsertIndexTuples,看它如何返回冲突路径。最后到heapam.c里找带speculative字样的函数,理解什么情况下调用heap_finish_speculative、什么时候调用heap_abort_speculative。
这样的阅读顺序不依赖IDE,用grep和ctags就够了。源码版本建议直接拉master或者17/18的branch,因为9.5的代码结构和现在已经有很大差异,但核心的ExecInsert、heapam几个函数的骨架变化不大。
6.3 用gdb断点验证投机插入
想更直观地感受speculative insertion,可以自己编译一个DEBUG模式的PostgreSQL,然后用gdb挂在后端进程上,在heap_finish_speculative和heap_abort_speculative分别打断点。
先开一个正常表,单独插入一行。另一个会话执行INSERT ... ON CONFLICT DO UPDATE,命中已经存在的行。你会清楚看到断点首先落在heap_finish_speculative之后的某个地方,但这里不会直接执行finish——因为插入流程要先探测索引。只有当唯一索引探测表明没有冲突时,才会执行heap_finish_speculative;一旦探测到冲突,代码会走heap_abort_speculative。
这个调试过程能让你直观理解"为什么9.5设计时非要引入投机插入":如果没有投机机制,唯一索引冲突会导致整个堆插入被回滚,而回滚和投机abort根本不是一个成本量级。亲手跑一次断点,比看十遍文档都记得牢。
PostgreSQL的ON CONFLICT从9.5一路走到18,语法上几乎没有变化,这说明它当初的设计边界是对的。真正需要你花时间理解的,是那套藏在"冲突分支"背后的索引探测、投机插入、EPQ重读和锁等待机制。我在实际项目里用这套机制扛过每天几亿次累加写入,也见过因为无意义更新把表撑到几百GB的翻车现场。把它当做一个精密的并发原语来尊重,而不是一句"有则更新无则插入"的语法糖,你就能少踩很多看不见的坑。