在高并发交易系统里,最常见的性能瓶颈往往不是SQL写得多慢,而是大量会话在抢同一行记录。典型的例子就是秒杀场景:库存只有一行数据,成百上千个事务同时执行UPDATE去扣减库存,结果就是行锁队列排成长龙,等待事件里全是enq TX - row lock contention,响应时间被锁等待彻底拖垮。Oracle 23ai带来的Lock-Free Reservation(无锁预约)特性,就是针对这类热点行更新冲突给出的官方解法。本文将围绕它的原理、用法和适用边界展开讨论。

一、热点行更新为什么慢:传统行锁的竞争机制
要理解无锁预约的价值,得先弄清楚行锁冲突是怎么发生的。Oracle的UPDATE语句在定位到目标行后,会在该行的ITL槽上标记事务信息并持有行级锁,直到事务提交或回滚。当第二个事务也要更新同一行时,它必须等待第一个事务结束,于是被挂起进入锁等待队列。这个等待本身不是bug,而是保证数据一致性的必要手段。
问题在于热点行的并发度太高。假设一个商品库存行每秒被更新500次,而每个事务持锁时间为50毫秒,理论上这一行的串行吞吐上限只有每秒20次左右,剩下的480次全部在排队。更糟的是,长队列会放大 latch 和 buffer busy waits,热点行所在的数据块会成为一个瓶颈点,即使应用层做了限流,数据库内部的压力依然存在。
传统上有几种缓解手段:把一行拆成多行分桶扣减(增加应用复杂度)、使用全局序列配合异步合并更新(引入最终一致性)、或者借助Redis在应用层预扣(架构复杂度陡增)。这些方案都在用不同方式回避行锁,而Lock-Free Reservation的思路是:既然冲突不可避免,那就让数据库自己来处理冲突,而且是用代价最小的方式处理。
二、Lock-Free Reservation的核心原理
Lock-Free Reservation的本质是“先记账、后应用”。当一条DML语句更新的是一个启用了预约模式的数值列,并且只做简单的加减运算时,Oracle不会立即去修改数据块、也不会持有行锁,而是在PGA中记录一个预约量(reservation),然后立即向客户端返回成功。此时这条语句在数据库内部尚未真正落盘,数据行的当前值保持不变。
真正的数据修改发生在COMMIT时刻。提交时数据库会把这些预约增量集中起来,串行地应用到目标行上。由于应用动作极其轻量(一次加法运算加上块修改),即使需要串行化,单位时间能处理的提交量也远高于传统方式下持锁执行完整UPDATE的吞吐量。换句话说,它没有消除串行化点,而是把串行化的代价压缩到了极致,并且把锁的持有时间从“整个事务周期”缩短到“提交瞬间”。
启用方式是在建表或ALTER TABLE时,对目标列声明RESERVABLE关键字,并指定溢出处理策略:
-- 建表时启用无锁预约列
CREATE TABLE product_stock (
product_id NUMBER PRIMARY KEY,
stock_qty NUMBER DEFAULT 0 NOT NULL
RESERVABLE (MAXVALUE OVERFLOW DISCARD),
version_no NUMBER
);
-- 查看列的预约属性
SELECT column_name, data_type, reservable
FROM user_tab_columns
WHERE table_name = 'PRODUCT_STOCK';
-- 会话级别开启预约模式(前提是初始化参数已允许)
ALTER SESSION ENABLE RESERVABLE;
上面的RESERVABLE子句中,MAXVALUE OVERFLOW DISCARD表示当增量累加后超过列允许的最大值时,该笔增量会被丢弃。对于扣减库存的场景,还可以配合CHECK约束或MINVALUE策略,避免库存被扣成负数。需要注意的是,同一张表里可以有多个reservable列,但主键列和某些特殊类型列不能声明为reservable。
三、使用限制与DML写法要求
无锁预约并非对所有UPDATE都生效,它只支持非常特定的语法形态。SET子句必须是col = col + 常量或col = col - 常量这种对reservable列的增量式更新,WHERE条件必须能通过唯一键或主键精确定位到一行。如果写成了stock_qty = 100这种绝对值赋值,或者WHERE条件命中多行、用了子查询参与计算,语句会退回传统的行锁执行路径,达不到无锁效果。
-- 正确写法:增量更新 + 主键定位单行,走无锁预约 UPDATE product_stock SET stock_qty = stock_qty - 1 WHERE product_id = 1001; -- 错误写法:绝对值赋值,退化为传统行锁 UPDATE product_stock SET stock_qty = 0 WHERE product_id = 1001; -- 同一事务里可以多次对同一行的预约列追加增量 UPDATE product_stock SET stock_qty = stock_qty - 1 WHERE product_id = 1001; UPDATE product_stock SET stock_qty = stock_qty - 2 WHERE product_id = 1001; COMMIT;
第二个值得注意的行为是同一事务内可以追加预约。上面示例中两条UPDATE针对同一行,两条都会登记预约,提交时合并为减3一次性应用。这在业务上对应“一个订单包含多个商品明细且来自同一行库存”的场景,非常自然。而如果事务回滚,登记的预约会被一并取消,不会产生任何残留影响,一致性由数据库保证。
还有一个容易踩坑的地方:因为预约在提交前不修改实际数据,事务内自己UPDATE之后再SELECT这一行,读到的仍是旧值,只有提交后才反映增量。这一点和传统行为不同,应用代码不能依赖“本事务内立即可见自己修改”的假设。此外,外键关联列、位图索引列、虚拟列等场景也存在限制,启用前应查阅官方文档确认列属性兼容性。
四、与传统行锁方案的效果对比
从压测数据看,热点行场景下的收益非常直观。传统UPDATE模式下,几百个并发会话抢一行,锁等待时间随并发数线性甚至超线性增长,吞吐量很快封顶;而开启预约模式后,UPDATE语句本身几乎瞬时返回,所有压力集中到提交阶段,由数据库内部高效串行处理,整体TPS通常能提升一个数量级以上,锁等待事件几乎消失。
但也要清醒认识到它的代价。第一,它只适合增量语义的数值列,比如库存、余额、计数器,不适合需要读取当前值再做复杂判断的业务逻辑。第二,溢出策略决定了边界条件的处理方式,DISCARD意味着静默丢弃增量,业务层需要通过返回的批处理错误信息或其他机制感知失败,编写逻辑时要多加留意。第三,预约信息在实例故障恢复场景下依赖 redo 和恢复机制保证,跨节点的RAC环境下行为细节也建议在上线前充分验证。
总结一下选型思路:如果你的热点冲突来自于纯粹的数量增减,比如秒杀扣库存、点赞计数、积分账户变动,Lock-Free Reservation是当前数据库层面最优雅的解法,不需要改架构、不需要引入中间件;如果业务需要“先读值、判断条件、再更新”的完整事务语义,那么它并不适用,还是应该考虑分桶拆行或者队列串行化等传统方案。在升级到Oracle 23ai之后,建议先用测试环境跑一轮压测,把预约模式下的提交吞吐和错误处理路径都验证清楚,再逐步推广到生产热点表上。
Oracle 23ai无锁预约热点行更新修改时间:2026-09-08 12:16:58