• CMU15-445 数据库系统播客:多线程下索引锁存的并发控制

CMU15-445 数据库系统播客:多线程下索引锁存的并发控制

2025-08-16 12:32:18 栏目:宝塔面板 36 阅读

数据库管理系统(DBMS)在处理并发操作时,需要一套并发控制协议来确保共享对象的“正确”结果。这种“正确性”可以从两个方面来理解:

  • 逻辑正确性 (Logical Correctness) :指线程能够看到它应该看到的数据。例如,如果一个B+树索引插入了一个键值,该线程立即读取该键值时,它应该能看到它,而不会得到一个假阴性(false negative)结果。
  • 物理正确性 (Physical Correctness) :指数据结构的内部表示是健全的。这意味着在多线程读写数据时,数据结构的完整性(例如指针和引用)必须得到维护,以避免指向无效内存位置,从而导致程序崩溃(seg fault)等问题。本次讨论主要关注的是物理正确性。

锁 (Locks) 与锁存 (Latches) 的区别

在数据库领域,"锁" 和 "锁存" 是两个不同的概念,用于保护不同层面的数据。

锁 (Locks)

  • 保护对象 :保护数据库的 逻辑内容 ,如元组(tuples)、一组元组或整个表、数据库,使其免受其他事务的并发访问和修改。
  • 持有时间 :通常在 整个事务期间 持有。这意味着如果一个事务正在修改某个数据,它会持有锁直到事务完成。
  • 回滚需求 :需要支持 回滚 机制。如果事务在修改过程中崩溃,DBMS必须能够撤销已做的更改。
  • 模式类型 :有多种锁模式,如共享 (Shared)、排他 (Exclusive)、更新 (Update)、意向 (Intention) 等。
  • 死锁处理 :依赖于外部的 锁管理器 (Lock Manager) 或事务管理器 (Transaction Manager) 来检测和解决死锁,例如通过等待-超时 (Waits-for, Timeout) 或事务中止 (Aborts)。

锁存 (Latches)

  • 保护对象 :保护DBMS内部数据结构(如索引、缓冲区池中的页面表等)的 关键代码段 (critical sections) 及其 物理完整性 ,使其免受其他线程的并发访问和修改。
  • 持有时间 :只在 操作的持续期间 持有,通常是极短的时间。例如,当线程需要更新一个页面时,它会获取该页面的锁存,进行更改,然后立即释放锁存。
  • 回滚需求 : 不需要支持回滚 。因为锁存保护的操作通常是原子性的,如果无法获取锁存,操作就不会执行,因此没有需要回滚的更改。
  • 模式类型 :主要有两种模式—— 读模式 (Read Mode) 和 写模式 (Write Mode) 。

读模式 (Read Mode) :允许多个线程同时共享读锁存,因为读操作不会修改数据结构。

写模式 (Write Mode) :是排他性锁存,只允许一个线程持有。当一个线程持有写锁存时,其他线程不能读取或修改该对象。

  • 死锁处理 : 不提供死锁检测或解决机制 。死锁的避免完全取决于 编程纪律 (Coding Discipline) ,即通过良好的设计和实现来确保死锁不会发生。

锁存的实现方式

锁存的实现通常基于现代CPU提供的原子比较-交换 (compare-and-swap, CAS) 指令。主要有以下几种方法:

阻塞式操作系统互斥锁 (Blocking OS Mutex)

  • 实现方式 :这是最简单的方式,直接使用操作系统提供的互斥锁(如C++标准库中的 std::mutex)。在Linux中,futex(fast user-space mutex)是一种常见的实现,它首先尝试在用户空间通过自旋锁获取,如果失败,则下沉到内核空间获取更昂贵的互斥锁,并可能导致线程被调度器挂起。
  • 优点 :使用简单,无需额外的编码。
  • 缺点 : 非可伸缩性 ,每次加锁/解锁操作可能需要约25纳秒,因为涉及操作系统的调度开销,对于高竞争系统来说,性能会成为问题。

测试-设置自旋锁 (Test-and-Set Spin Latch / TAS)

  • 实现方式 :由DBMS自己实现,通常使用CPU的单指令原子比较-交换操作。线程会反复尝试设置一个内存位置(例如,将一个布尔值设置为 true),直到成功获取。如果无法获取,线程会在一个 while 循环中“自旋”重试。
  • 优点 : 效率极高 ,加锁/解锁操作通常是单个CPU指令。
  • 缺点 : 非可伸缩性且不缓存友好 。在高竞争环境下,线程会不断自旋,消耗CPU周期但未做有效工作,导致CPU利用率飙升。同时,这可能引起缓存一致性问题,因为多个线程在不同CPU上轮询同一个缓存行。
  • 优化 :开发者可以根据工作负载决定是否在自旋失败后短暂地让出CPU(yield),或在尝试一定次数后中止操作。由于DBMS对数据结构的使用场景有更深入的了解,因此可以比操作系统做得更好。

读写锁存 (Reader-Writer Latch)

  • 实现方式 :通常在自旋锁的基础上实现。它通过管理读/写队列和计数器来区分读写请求。
  • 优点 : 允许并发读取 ,这对于读密集型工作负载能显著提高性能,因为多个读取线程可以共享资源而无需等待。
  • 缺点 : 增加了复杂性 。DBMS需要管理读写队列以避免写线程饥饿 (starvation),同时相比简单的自旋锁,它需要更多的元数据和存储开销。

哈希表 (Hash Table) 的锁存

哈希表相对容易支持并发访问,因为线程访问数据结构的方式有限。

  • 单向访问 :所有线程在从一个槽位移动到下一个槽位时都沿同一个方向(自上而下),且一次只访问一个页面或槽位。这使得 死锁不可能发生 。
  • 重整大小 :在调整哈希表大小时,通常会获取整个表的 全局锁存 (例如在头部页面上),以防止其他线程在此期间读写表。

哈希表的锁存策略主要有两种。

页面锁存 (Page Latches)

  • 特点 :每个页面有自己的读写锁存,保护整个页面内容。
  • 权衡 :需要存储的锁存数量较少(每个页面一个),但 可能会降低并行性 ,因为即使两个线程操作不同槽位,如果它们在同一页面内,也可能无法同时运行。

槽锁存 (Slot Latches)

  • 特点 :每个槽位都有自己的锁存。
  • 权衡 : 允许更高的并行性 ,因为锁存粒度更细。但 增加了存储和计算开销 ,因为线程在扫描时需要为每个访问的槽位获取锁存。

B+树 (B+Tree) 的锁存

B+树的并发控制更为复杂,因为它不仅涉及节点内容的修改,还涉及树结构的动态变化(如分裂和合并)。

B+树并发控制主要需要解决两个问题:

  1. 多个线程同时修改同一个节点的内容。
  2. 一个线程遍历树时,另一个线程可能正在分裂或合并节点,导致页面位置改变或指针失效。

解决这些问题的一种经典技术称为 锁存爬行 (Latch Crabbing) 或 锁存耦合 (Latch Coupling) 。

锁存爬行 (Latch Crabbing) 的基本思想

锁存爬行的基本思想是:

  1. 获取父节点的锁存。
  2. 获取子节点的锁存。
  3. 如果确定子节点是“安全”的,则释放父节点的锁存。

“安全”节点定义 :一个节点被认为是安全的,当对其进行更新操作时,它不会导致分裂 (split) 或合并 (merge)。

  • 对于 插入 操作,如果节点未满(不会分裂),则认为是安全的。
  • 对于 删除 操作,如果节点内的键值数量多于一半(不会合并),则认为是安全的。

查找 (Find) 操作

  • 查找操作只涉及读取,因此可以全程使用 读锁存 。
  • 从根节点开始,获取子节点的读锁存后,可以立即释放父节点的读锁存,因为读操作不会改变树的结构。

插入 (Insert) / 删除 (Delete) 操作

  • 从根节点开始,获取 写锁存 。
  • 在获取子节点锁存后, 检查该子节点是否安全 。如果安全,则可以释放所有祖先节点上持有的写锁存。
  • 如果不安全,则需要继续持有祖先节点的锁存,直到找到安全的子节点或到达叶节点。
  • 释放锁存的顺序 :从性能角度考虑,应尽快释放更高层级(更接近根)的锁存,因为它覆盖的节点范围更广,能提高并行性。

根节点争用 (Root Contention) 问题

锁存爬行的一个问题是,每次插入或删除操作都必须在根节点上获取 写锁存 。由于写锁存是排他性的,这意味着在任何给定时间,只有一个线程可以持有根节点的写锁存,从而形成了一个 单点瓶颈 (single point of contention) ,严重限制了高并发系统的并行性。

乐观锁存 (Optimistic Latching)

为了解决根节点争用问题,出现了一种更优化的锁存算法,通常称为 乐观锁存 (Optimistic Latching) 或 Baron-Schlock-Nick 算法

核心思想 : 乐观假设 在实际数据库系统中,B+树节点通常很大(如8KB或16KB),因此大部分插入或删除操作不会导致节点分裂或合并。基于这个假设,算法会 乐观地假定 目标叶节点是安全的。

具体操作

  1. 初始遍历 :线程从根节点开始, 全程使用读锁存 进行锁存爬行,直到到达目标叶节点。
  2. 获取叶节点写锁存 :到达叶节点后,线程尝试获取该叶节点的 写锁存 。
  3. 检查安全并执行 :如果此时发现叶节点是安全的(例如,有足够的空间进行插入而无需分裂,或删除后仍超过半满而无需合并),则线程可以直接在该叶节点上进行修改,然后释放锁存。
  4. 失败重启 :如果发现叶节点不安全(例如,需要分裂或合并),则说明乐观假设失败。此时,线程会 释放所有已获得的锁存,并中止当前操作 。然后,它会 从头开始,并使用悲观策略 ,即采用传统的锁存爬行方法,从根节点开始全程获取 写锁存 。
  • 优点 :显著减少了根节点和中间节点的写锁存持有时间, 提高了并发性 。
  • 缺点 :如果乐观假设经常失败(例如在节点频繁分裂或合并的负载下),那么每次重新启动并重新遍历树都会导致 浪费工作 (wasted work) ,反而可能比悲观策略慢。但在大多数实际场景中,这种乐观策略表现良好。

叶节点遍历 (Leaf Node Scans) 与死锁

在B+树中,如果只进行自上而下的遍历,死锁是不可能发生的,因为所有线程都沿一个方向前进。然而,当引入 叶节点扫描 (Leaf Node Scans) 时(即在叶节点层级从左到右或从右到左遍历),情况会变得复杂,因为此时线程可能需要获取不同方向的锁存,从而导致 潜在的死锁 。

例如,一个线程可能正在从左向右扫描并持有右侧节点的读锁存,而另一个线程从右向左扫描并持有左侧节点的读锁存,它们可能会同时尝试获取对方持有的锁存,从而发生死锁。

如何确保不会死锁 :

  • 锁存不提供死锁检测或避免功能 。
  • 依赖编程纪律 :解决这个问题的唯一方法是依靠 编程纪律 (Coding Discipline) 。
  • “无等待”模式 (No-Wait Mode) :当一个线程尝试获取叶节点上的锁存但该锁存不可用时,它会 立即中止操作 (释放所有已持有的锁存),然后 重新开始操作 。
  • 原因 :由于锁存是低级别的机制,它没有关于其他线程正在做什么的全局信息。因此,与其尝试推断或等待,最简单有效的方法就是立即中止并重试。虽然这可能导致在极端情况下线程“饥饿”或浪费周期,但在实践中,这通常是最佳策略。

延迟父节点更新 (Delayed Parent Updates) / Blink-Tree 优化

正常的B+树节点溢出时,需要更新三个节点:被分裂的叶节点、新创建的叶节点以及它们的父节点(用于添加新的分隔键)。这种多节点的更新需要复杂的锁存管理,可能导致更多的锁争用和重启。

Blink-Tree 优化 引入了一种技术,当叶节点溢出时, 延迟更新其父节点 。

实现方式 :

  1. 当叶节点分裂时,线程只更新叶节点本身,并创建一个新的相邻节点。
  2. 它不会立即更新父节点。相反,它会在树的 全局信息表 中记录一个待处理的更新,表明父节点需要添加一个新的分隔键。
  3. 下一次有线程以写模式获取该父节点的锁存时,它会首先检查并应用这个延迟的更新,从而使树结构变得一致。
  • 优点 :这避免了在分裂时重新启动乐观锁存操作,因为它不需要立即获取并持有父节点的写锁存。
  • 正确性 :这种方法仍然能保证正确性,因为即使父节点尚未更新,查找操作也可以通过遍历当前的指针(包括可能的兄弟节点指针,如果存在)来找到正确的数据。

总结

总而言之,使数据结构具有线程安全性在实践中是出了名的困难。我们讨论了哈希表和B+树的并发控制,但其核心思想是通用的:

  • 强制单向操作 (如哈希表的自顶向下扫描,避免死锁)。
  • 在遇到死锁潜力时立即中止并重试操作 (如B+树的叶节点扫描)。
  • 乐观地假设快速路径 (如乐观锁存,大部分操作只需读锁存)。

这些并发控制技术在整个计算机科学和系统领域都有广泛应用。商业数据库系统通常会自己实现这些核心数据结构,以便根据其特定的目标操作环境进行优化。

本文地址:https://www.yitenyun.com/317.html

搜索文章

Tags

数据库 API FastAPI Calcite 电商系统 MySQL Web 应用 异步数据库 数据同步 ACK 双主架构 循环复制 TIME_WAIT 运维 负载均衡 JumpServer SSL 堡垒机 跳板机 HTTPS JumpServer安装 堡垒机安装 Linux安装JumpServer Deepseek 宝塔面板 Linux宝塔 Docker 生命周期 esxi esxi6 root密码不对 无法登录 web无法登录 HexHub 序列 核心机制 Windows Windows server net3.5 .NET 安装出错 服务器 管理口 HTTPS加密 宝塔面板打不开 宝塔面板无法访问 查看硬件 Linux查看硬件 Linux查看CPU Linux查看内存 Windows宝塔 Mysql重置密码 Oracle 处理机制 InnoDB 数据库锁 无法访问宝塔面板 开源 PostgreSQL 存储引擎 监控 连接控制 机制 Serverless 无服务器 语言 服务器性能 ES 协同 技术 Spring Redis 异步化 缓存方案 缓存架构 缓存穿透 分页查询 索引 group by 高可用 Undo Log GreatSQL 连接数 SQL 动态查询 机器学习 日志文件 MIXED 3 响应模型 R2DBC SVM Embedding 优化 万能公式 R edis 线程 数据 主库 自定义序列化 RocketMQ 长轮询 配置 Postgres OTel Iceberg 工具 云原生 Netstat Linux 服务器 端口 Linux 安全 ​Redis 推荐模型 SQLark scp Linux的scp怎么用 scp上传 scp下载 scp命令 AI 助手 共享锁 PG DBA 向量数据库 大模型 openHalo 存储 SQLite-Web SQLite 数据库管理工具 Recursive Hash 字段 电商 系统 OB 单机版 查询 Ftp 架构 锁机制 • 索引 • 数据库 流量 Rsync 修改DNS Centos7如何修改DNS redo log 重做日志 数据分类 加密 磁盘架构 聚簇 非聚簇 sftp 服务器 参数 向量库 Milvus 线上 库存 预扣 人工智能 推荐系统 场景 同城 双活 信息化 智能运维 MySQL 9.3 防火墙 黑客 业务 Python 高效统计 今天这篇文章就跟大家 Doris SeaTunnel MVCC 数据备份 传统数据库 向量化 不宕机 mini-redis INCR指令 缓存 窗口 函数 Redisson 锁芯 RDB AOF INSERT COMPACT 网络架构 网络配置 分库 分表 prometheus Alert PostGIS 启动故障 事务 Java 开发 Canal Web 崖山 新版本 filelock MongoDB 数据结构 IT运维 B+Tree ID 字段 分布式 集中式 ZODB 核心架构 订阅机制 引擎 性能 数据脱敏 加密算法 Go 数据库迁移 容器 数据类型 虚拟服务器 虚拟机 内存 读写 网络故障 容器化 DBMS 管理系统 频繁 Codis 模型 Redis 8.0 OAuth2 Token JOIN QPS 高并发 微软 SQL Server AI功能 Pottery 聚簇索引 非聚簇索引 原子性 工具链 国产数据库 发件箱模式 自动重启 部署 Entity SpringAI Testcloud 云端自动化 速度 服务器中毒 事务隔离 Web 接口 分页方案 排版 排行榜 排序 SSH Caffeine CP 行业 趋势 数据页 数据集成工具 MCP 开放协议 悲观锁 乐观锁 StarRocks 数据仓库 Redka sqlmock LRU 大表 业务场景 分布式架构 分布式锁​ 1 分页 AIOPS dbt 数据转换工具 优化器 池化技术 连接池 单点故障 仪表盘 网络 Order 意向锁 记录锁 EasyExcel MySQL8 InfluxDB 事务同步 日志 IT 字典 RAG HelixDB 对象 双引擎 订单 播客 单线程 主从复制 代理 Crash 代码 Ansible 编程 UUIDv7 主键 UUID ID 恢复数据 LLM 语句 Pump Valkey Valkey8.0 ReadView 线程安全 产业链 兼容性 数据字典 Weaviate List 类型 失效 MGR 分布式集群 解锁 调优 表空间 Next-Key 分布式锁 Zookeeper 慢SQL优化 关系数据库 GitHub Git 查询规划 矢量存储 数据库类型 AI代理 RR 互联网 国产 用户 算法 千万级 快照读 当前读 视图 神经系统 count(*) count(主键) 行数 CAS 技巧 拦截器 动态代理 多线程 并发控制 恢复机制 闪回