4.3 并发控制与多线程模型


4.3 并发控制与多线程模型:秩序的成本账

本节摘要:并发不是 RocksDB 的某个模块,而是贯穿读写路径的记账规则:谁先谁后由序列号裁定,共享状态由一组粒度递减的同步原语把守,复合写入靠乐观验证兜底。本节先立「正确性」的合同条款,再盘点锁谱系与无锁结构各自的守护范围,接着拆开后台线程池的分工——刷盘与压缩如何并行、怎么配额,最后并入多机部署视角:单机引擎在分布式体系里扮演什么角色、哪些并发问题必须上交上层。

一笔糊涂账与三个线程

构造一个最小事故现场。三个线程几乎同时动作:线程甲执行写入,把某账户余额改成新值;线程乙发起读取同一个键;线程丙先建了快照,随后线程甲的写入才完成。三个问题随之而来:乙该读到新值还是旧值?丙的快照里有没有甲的这笔写入?如果甲的写入还在日志缓冲里没落盘,进程此刻崩溃,乙却读到了新值,算不算违约?

这三个问题的答案构成引擎的并发合同。RocksDB 的选择是快照隔离,合同条款有三条:每次读取都基于某个序列号刻度的数据视图执行;该视图严格包含刻度之前的全部已提交写入、排除刻度之后的一切写入,无论后者物理上落到哪一步;写操作的序列号在进入内存表的瞬间原子分配,成为全局唯一、不可篡改的先后凭证。注意序列号不是钟表时间,是引擎内部单调递增的整数——「谁对谁可见」这个看似混乱的问题,被降维成一次数值比较。并发控制的第一重智慧就在这里:把时空协调问题变成整数序问题,争议就消失了。

锁的谱系:按热度分账管理

合同要有执行者。引擎内部被争抢的共享状态,热度天差地别,用的锁也各不相同——按热度分账,是它同步设计的核心思路。

全局互斥锁守的是最冷、但最不能出错的账本:数据库实例级的元数据。手动触发刷盘时要一口气完成「冻结当前内存表、排队进刷盘队列、换新内存表、更新存活文件清单」这一串动作,任何交错都会让元数据自相矛盾,这类操作整体锁进临界区。它不追求性能,只承诺正确。列族级别另有一把锁,守各列族自己的冻结队列与当前版本指针,这是第 3 章列族隔离能力在并发侧的落点。

读写锁守的是热账本里「读远多于写」的那部分:当前版本的文件清单。每次点查、每次扫描都要读它,而修改它的只有压缩完成这类低频事件。若用普通互斥锁,压缩一完成,成百上千个读请求跟着排队。换成读写锁后,读读并发彻底放开,读写冲突的成本收敛到后台维护的执行窗口内——冲突没有消失,只是被挪到了付款能力最强的账期上。

无锁结构守的是最烫手的两条路径:内存分配与跳表插入。并发内存池给每个线程发本地缓存块,块用完了才用原子比较交换指令去全局池里抢一块,全程无锁;跳表的指针更新全部走原子指令,多个线程同时插节点也不会破坏链式结构。无锁设计不是消灭竞争,而是把竞争引导到一条可预测、可恢复的原子操作上——对每微秒挨几百次插入的内存表,这套设计是生死线,不是锦上添花。

乐观并发:先动手,后对账

同步原语解决了单条读写的秩序,复合写入还有一层难题:一个批量写横跨多个键甚至多个列族,如何保证它要么整体生效、要么整体消失,且中途不被别人插队?

悲观的做法是提前锁住涉及的所有键;乐观的做法反过来——先假设没人跟我冲突,动手干,干完再对账。RocksDB 的乐观事务走的就是这条路:提交前记下自己要写的键集合,全部写完后再核对这些键有没有被别人动过;没被动过,提交成功;被动过,说明撞车,放弃重来。整个过程没有任何锁等待,冲突率低的负载几乎白赚并行度。代价在冲突率高的场景显形:撞车率一高,反复重试的功全白做,吞吐反而不如老实排队。所以选型的分界线是冲突率——业务上确定「这些键基本没人抢」用乐观事务;抢手的热点键多、或者跨线程协调复杂,用悲观事务,让引擎提前加锁,把等待明码标价。

用一张时序图看乐观事务的完整对账流程:

后台线程池:刷盘与压缩的分工

前台并发的秩序立住了,后台并发的分工同样要记账。引擎把耗时的整理工作交给两个线程池:一个专管刷盘——把冻结内存表铸成零层文件;一个专管压缩——把零层往更深层级搬运归并。两个池默认可能共享同一个线程配额,这就是调优的第一个抓手:刷盘是压缩的上游,刷盘卡住会连锁堵住写入,关键负载应该给刷盘留专用线程,别让压缩把刷盘饿死。

线程配额怎么给?经验公式是从介质带宽倒推:压缩是重 IO 活,想让它吃满 NVMe 的写带宽,线程数至少要覆盖「单线程打不满带宽」的差距,四到八个是常见起点。再往上叠两个放大器:其一,压缩任务内部可以再切片,一个大任务拆成多个并行子块同时搬,特别适合超大层级的全量归并;其二,刷盘与压缩都可以按列族并行,多列族负载天然获得并发度。配完之后必须盯两个指标验证:后台任务队列长度(长期大于零说明配额不足)和停顿计数(上涨说明消化能力被写速超越了)。

线程与队列的配置落在代码上就几行,但每行都是账目分配的决策:

// 后台线程配额:刷盘专用 1 线程,压缩池 4 线程 options.max_background_jobs = 5; options.max_subcompactions = 4; // 单个压缩任务内部并行切片数 // 把线程池分成 flush 与 compaction 两本账(环境初始化时设置) // 建议先用默认配额压测,再按「队列长度」与「停顿计数」两个指标微调

多机视角:哪些账必须上交上层

最后把边界画清楚。RocksDB 是嵌入式单机库,它对「多线程」负责到进程边界为止,跨机器的账一律上交上层系统。三笔账尤其要分清:其一,主从复制——引擎只保证本机持久与一致,主从之间的日志同步、追平、切换是上层复制层的职责,引擎能帮的忙是提供变更订阅与快照导出,让上层拿到一致的复制起点;其二,分布式事务——单机内的原子性与隔离由第 7 章的事务机制负责,跨机器的原子性需要两阶段提交等协议,由 TiKV 这类上层系统在引擎之上实现;其三,数据分片——把键空间切成多份、每份一个引擎实例(常见做法是每分片一个列族或一个独立实例目录),分片路由、再均衡、扩缩容全是上层的地图。理解这个边界,才不会问出「为什么引擎不帮我做主从切换」这类问题——它连网络都不碰。

本节要点

  • 并发合同是快照隔离:序列号定先后,读看刻度内的版本,正确性是整数比较而不是调度运气;
  • 锁按热度分账:全局互斥锁守冷元数据、读写锁守版本清单、无锁结构守内存表热路径;
  • 乐观事务先动手后对账,冲突率低是前提;冲突率高就换悲观事务,把等待明码标价;
  • 后台双池分工,刷盘别被压缩饿死;配额从介质带宽倒推,用队列长度与停顿计数验证;
  • 引擎的秩序到进程边界为止,复制、分布式事务、分片三笔账属于上层系统。

读写与并发的机制账全部理清。下一章进入全书的重头戏:这些机制背后的搬运工——压缩,以及它名下最大的三笔账:写放大、读放大、空间放大。


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