7.1 事务模型


7.1 事务模型:两条路径的成本归属

本节摘要:事务不是引擎的出厂配置,而是在现成地基上搭出来的合约层。本节先盘三样地基——批量写入的原子性、全局序列号、快照刻度,再对比两条实现路径:悲观事务用键级锁把冲突变成等待,乐观事务用提交时验证把冲突变成重试。成本记在谁的头上、冲突检测为什么可以只查热区、死锁怎么防、选型看哪三个维度,本节逐笔算清。

三样现成的地基

动笔写事务之前,先纠正一个常见误解:引擎并不是「加了事务功能」,而是把本来就有的三样材料组装成了合同条款。

第一样,批量写入的原子性。一批跨键操作打进同一个写入批次,引擎保证它们要么整体生效、要么整体不存在——这解决了「原子」,但没解决「隔离」:批次执行期间,别人看不见半成品,可别人先改了你正要改的键怎么办,批量写入不管。

第二样,全局序列号。第 4 章讲过,每个写入在进入内存表前领取一个严格递增的号码,它是全引擎唯一的先后凭证。事务要的「谁先谁后」仲裁,材料是现成的。

第三样,快照刻度。创建快照就是记下当前最大序列号,读取只看刻度之下的版本。隔离的视觉基础也有了。

三样材料摆齐后,事务剩下的唯一难题是:两个事务同时想改同一个键,怎么裁决? 两条路径由此分岔。

悲观路径:先锁后动,冲突记成等待

悲观路径的假设是冲突常见:与其撞车后重来,不如动手前就把要碰的键锁住。引擎维护一个独立的锁管理器,键级粒度——锁的是「user_1024」这个键本身,不是页、不是表,锁冲突面被压到最小。事务执行中第一次要改某键时获取该键的锁,直到提交或回滚才释放;别人的同键操作在锁上排队。

这条路径有三个账目细节值得单独记。其一,锁表本身是内存里的分段子表,获取锁是常数级的内存操作,锁本身不贵,贵的是等待。其二,等待必须限时:每个加锁请求都带超时,超时就返回失败让业务重试——「无限等」在存储系统里不是忠诚是事故。其三,死锁的预防靠约定而非检测:强制所有事务按键的字典序加锁,A 先于 B,就不可能出现「你等我、我等你」的环——这是用零运行时代换掉整个死锁检测线程的典型交易,代价只是业务侧要保证按键有序地访问。

用一段代码看悲观事务的骨架:

// 打开悲观事务库 TransactionDB* txn_db; TransactionDB::Open(options, txn_db_options, path, &txn_db); // 事务一:转账的扣款方 Transaction* t1 = txn_db->BeginTransaction(write_opts, txn_opts); t1->GetForUpdate(read_opts, "account:A", &balance); // 读前加锁 t1->Put("account:A", new_balance); t1->Put("account:B", new_balance); // 按字典序先A后B t1->Commit(); // 提交后锁批量释放

乐观路径:先动后对账,冲突记成重试

乐观路径的假设相反:冲突罕见,为罕见的场景预先加锁是浪费。事务全程无锁执行,所有改动暂存在私有批次里,提交时做一次性对账——检查自己读过的键有没有在别人那里被改过:有,提交失败,业务重试整个事务;没有,私有批次整体生效。

它便宜在检测范围:冲突核查不需要翻遍全库。一个键的旧版本在压缩时会被清理到只剩有限的历史,自快照以来的新改动只会出现在内存表和零层文件这两个热区里——所以核查范围就是这两个热区,内存操作为主,亚毫秒级。这个「只查热区」的剪枝,是乐观模型敢说零锁开销的底气。

它的账目风险也明摆着:冲突率一高,重试就是纯粹的功亏一篑。冲突率百分之五的系统,平均每个事务多跑半遍;冲突率百分之五十,吞吐直接腰斩还没人负责。所以乐观路径的生命线是一条业务纪律——事务要短。拖得越长,撞车窗口越大,这是它和悲观路径在工程习惯上最深的分野。

选型分界线:三个维度

两条路径怎么选,把三个问题问清就有答案。

一问冲突率。同一批热点键被并发改写概率高(秒杀库存、账户余额),选悲观——等待可控且可预测,尾延迟有保障;键空间分散、写入互不相干(会话更新、流水记录),选乐观——零锁开销白赚。

二问事务长短。毫秒级短事务适合乐观,重试成本低;秒级以上的长流程(读一批、算一轮、写一批)适合悲观——乐观路径下长事务的冲突窗口大到重试率不可接受。

三问要不要跨列族原子。两列族间的整体生效,悲观路径的锁天然覆盖;乐观路径做跨列族对账的成本会随涉及键数线性涨。

现实业务常常是混合形态:低冲突的浏览记录走乐观,高冲突的扣减走悲观。引擎允许同一实例混开两种事务库,按业务模块分而治之——一致性强度按需采购,不必全站统一最高档。

一次跨列族转账的推演

悲观事务的死锁预防值得用一场推演称重。设定:账户甲在列族 A,账户乙在列族 B,转账事务要从甲扣一百、给乙加一百——一次天然的跨列族原子操作。

按字典序加锁的约定,事务先锁列族 A 里的「account:甲」,再锁列族 B 里的「account:乙」。同一时刻另一个反向转账事务(乙转甲)也想锁这两个键:它同样按字典序,先伸手锁「account:甲」——发现被前一个事务持有,排队等待。会不会死锁?不会:两个事务都按同一全局顺序伸手,后来者一定在最小键处排队,环形等待在结构上就不可能出现。这就是「用排序换检测」的全部秘密——死锁检测线程、超时回滚、等待图,这些重型武器在单机事务里都省掉了,省下的复杂度换成一条纪律:同一事务内按键有序地访问。

推演再补一刀:如果业务确实无法保证按键有序呢?比如按用户请求的先后动态决定先锁谁。此时打开引擎的死锁检测开关——带等待超时的检测路径,代价是锁管理器要维护等待关系。开关默认关闭是性能考量,打开是语义兜底:两条路都通,选哪条取决于业务能不能守住排序纪律,而不是哪个参数看起来高级。

本节要点

  • 事务的三样地基是现成的:批量原子性、全局序列号、快照刻度,唯一难题是同键并发写怎么裁决;
  • 悲观路径把冲突记成等待:键级锁、等待限时、按字典序加锁防死锁,尾延迟可预测;
  • 乐观路径把冲突记成重试:提交时只查内存表与零层两个热区,零锁但要求事务短;
  • 选型三问:冲突率高选悲观、事务长选悲观、键空间分散事务短选乐观,可按模块混开;
  • 无论哪条路径,一致性强度按需采购——全站最高档是最贵的浪费。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U