news 2026/10/22 12:19:39

手写C++ RMDB内核:支持TPC-C压测的课程级数据库系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写C++ RMDB内核:支持TPC-C压测的课程级数据库系统

简介:本资源是全国大学生计算机系统能力大赛数据库管理系统赛道的参赛项目成果,面向系统能力培养阶段的高校本科生与数据库内核学习者,聚焦关系型数据库管理系统(RDBMS)从零实现的核心技术实践。项目基于RMDB框架构建完整DBMS,全面支持TPC-C基准测试负载,覆盖存储引擎、查询优化器、事务管理等内核级功能,可作为数据库原理课程设计、系统级编程实训及竞赛备赛的高质量参考方案。压缩包共442个文件,以121个C/C++头源文件(h/cc/cpp/hpp)构成主体代码逻辑,辅以102个Python脚本(含测试与工具)、30个Markdown文档(含设计说明与实验记录)、47个CMake/Build配置文件(Bazel/CMake/Make),整体仅2.43MB,结构紧凑、模块清晰,便于逐层剖析。目前已有72人学习下载,读者可直接复现编译环境、调试内核关键路径、对比TPC-C执行性能,并深入理解索引组织、缓冲区管理、代价估算与执行计划生成等底层机制。

1. 这不是玩具数据库:一个能跑通 TPC-C 的 RMDB 内核项目,专为系统能力训练而生

你见过能真正压测出每秒 300+ 新订单事务(tpmC)的课程级数据库内核吗?不是 SQLite 封装、不是 JDBC 代理层、更不是只跑 SELECT * FROM users 的 demo。这个从零手写的 RMDB 框架项目,是某高校参赛队在系统能力大赛数据库赛道中实际提交并完成 TPC-C 全流程压测的完整工程——它用纯 C++ 实现了 WAL 日志、B+ 树索引页管理、基于代价的查询重写器、两阶段锁协议(2PL)事务调度器,甚至把 TPC-C 的 9 张表 schema、10 类 SQL 语句模板、warehouse 级别数据生成逻辑全塞进了 src/tpcc/ 目录下。它不追求 MongoDB 那种高并发吞吐,但每条 INSERT 都落盘、每个 SELECT 都走真实执行计划树、每次 COMMIT 都触发 WAL 刷盘与 checkpoint 同步。适合正在啃《数据库系统实现》第二章、卡在 buffer pool 替换策略上、想亲手摸一摸“事务不可见性”背后 page latch 争用的同学;也适合准备系统能力大赛、需要可调试、可打断点、可改源码的底层 DBMS 参考实现的备赛者。这不是教学玩具,是带血丝的工程切片。


2. 从解压到跑通 TPC-C:五步构建可调试的 RMDB 内核环境

这个项目不是 pip install 就完事的 Python 包。它是一套需要你亲手编译、链接、配置、注入数据、再启动压测的完整 C++ 工程。整个过程必须严格遵循内存布局、页对齐、日志序列号(LSN)递增等底层约束。我一般会先确认编译链是否满足硬性要求:GCC 11.4+(因使用了 std::span 和 constexpr std::string_view)、CMake 3.22+(用于控制 target_link_libraries 的 PRIVATE/PUBLIC 作用域)、以及至少 8GB 可用内存(TPC-C 1-warehouse 场景下 buffer pool 默认分配 2GB)。下面这五步,是我反复验证过、能在 Ubuntu 22.04 / CentOS 7.9 / macOS Monterey(需额外 patch liburing)上复现的最小可行路径。

2.1 解压与目录结构认知:看清 src/ 与 test/ 的权力边界

下载包解压后,你会看到如下核心目录:

├── build/ # 编译产物目录(首次运行 cmake 后自动生成) ├── cmake/ # 自定义 FindXXX.cmake 模块,含对 liburing 和 snappy 的探测逻辑 ├── docs/ # 内含一份 12 页的《RMDB 内核设计决策白皮书》,重点讲 B+ 树分裂时如何避免死锁 ├── scripts/ # 含 tpc-c-gen.sh(生成 1W 行 item 表)、wal_recover.py(手动解析 binlog 文件) ├── src/ │ ├── core/ # buffer pool manager、log manager、transaction manager │ ├── storage/ # page layout 定义、slotted page 实现、B+ tree index manager │ ├── parser/ # 基于 lemon.y 的 SQL 解析器,支持 CREATE TABLE / INSERT / SELECT / BEGIN / COMMIT │ ├── optimizer/ # 基于统计信息的 join order 枚举器 + cost model(I/O + CPU 权重可调) │ └── tpcc/ # TPC-C 专用模块:schema DDL、new_order 存储过程 C++ 实现、stock_level 查询优化 hint ├── test/ │ ├── unit/ # Google Test 用例,覆盖 PageGuard 加锁、LogRecord 序列化、LockManager 死锁检测 │ └── tpcc/ # 启动脚本 run_tpcc.sh,封装了 data load → warmup → benchmark 三阶段 └── CMakeLists.txt # 主构建文件,关键:set(RMDB_ENABLE_URING ON) 控制异步 I/O 开关

提示:不要试图直接cd src/ && g++ *.cpp—— 这个项目依赖严格的编译单元隔离。buffer_pool_manager.cpp 绝不能 include transaction_manager.h,所有跨模块调用必须通过 interface/ 下的纯虚类(如 BufferPoolManagerInterface)进行。这是为后续替换 RocksDB 存储引擎预留的契约。

2.2 编译前必做的三项检查:CMake 配置、依赖库、硬件特性

在build/目录下执行cmake ..前,请务必确认以下三点,否则后续 90% 的编译失败都源于此:

  1. 检查 liburing 是否可用:

    pkg-config --modversion liburing # 必须输出 >= 2.1,若报错则需: # Ubuntu: sudo apt install liburing-dev # CentOS: sudo yum install liburing-devel # macOS: brew install liburing (需额外 patch,见 scripts/patch_liburing_macos.sh)
  2. 确认 CPU 支持 CLFLUSHOPT 指令(影响 WAL 刷盘性能):

    grep -q clflushopt /proc/cpuinfo && echo "OK" || echo "WARN: fallback to msync" # 若输出 WARN,说明你的 CPU 是老款至强 E5 v3 或更早,项目会自动降级使用 msync() 而非 clflushopt()
  3. 设置合理的 CMake 变量(直接影响 TPC-C 压测结果):

    cmake -S . -B build \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DRMDB_ENABLE_URING=ON \ -DRMDB_BUFFER_POOL_SIZE_MB=2048 \ -DRMDB_MAX_CONNS=128 \ -DRMDB_WAL_SYNC_METHOD=FSYNC # 可选 FSYNC / FDATASYNC / O_DSYNC

    关键参数说明:

    • RelWithDebInfo:保留调试符号,方便 gdb 断点跟踪TransactionManager::Commit()中的 LSN 更新逻辑;
    • RMDB_BUFFER_POOL_SIZE_MB=2048:TPC-C 1-w 时最低要求,小于 1536MB 会导致频繁 page eviction,tpmC 直接腰斩;
    • RMDB_WAL_SYNC_METHOD=FSYNC:生产环境应设为O_DSYNC,但调试阶段用FSYNC更易观察日志刷盘时机。

2.3 编译与安装:为什么必须用 ninja 而非 make

执行cmake --build build -j$(nproc)后,你会得到build/bin/rmdb-server和build/bin/rmdb-client两个二进制。这里强调必须用 ninja(CMake 默认生成器)而非 GNU Make,原因有三:

  • 依赖图精度:ninja 的 build.ninja 文件能精确识别storage/b_plus_tree.cpp修改后仅需重编librmdb_storage.a,而 make 的隐式规则常导致全量重连;
  • 并发安全:当-j16时,ninja 对*.o文件的锁粒度是 per-file,make 则可能因.d依赖文件竞争导致undefined reference to 'BPlusTree::Insert';
  • 增量链接:ninja 支持-Wl,--no-as-needed的细粒度控制,确保liburing符号在rmdb-server链接时不被 strip(这点在test/tpcc/run_tpcc.sh中的 LD_PRELOAD 环境下至关重要)。

编译成功后,验证基础服务是否就绪:

# 启动服务端(监听 8080) ./build/bin/rmdb-server --config ./conf/rmdb.conf # 在另一终端连接客户端 ./build/bin/rmdb-client -h 127.0.0.1 -p 8080 rmdb> CREATE TABLE t1(id INT PRIMARY KEY, name VARCHAR(32)); Query OK, 0 rows affected (0.002s) rmdb> INSERT INTO t1 VALUES(1, 'test'); Query OK, 1 row affected (0.001s)

若能看到Query OK,说明 parser → executor → storage 的主干链路已通。

2.4 TPC-C 数据加载:绕过 client 的批量导入机制

TPC-C 要求初始加载 10 张表(warehouse, district, customer...),总行数达百万级。若用rmdb-client逐条INSERT,耗时将超 2 小时且极易因网络抖动中断。项目提供了专用批量加载工具tpcc-loader:

# 生成 1 warehouse 的原始 CSV(约 300MB) ./scripts/tpc-c-gen.sh -w 1 -o ./data/tpcc_w1/ # 执行批量导入(跳过 SQL 解析,直写 storage layer) ./build/bin/tpcc-loader \ --data-dir ./data/tpcc_w1/ \ --wal-dir ./data/wal/ \ --buffer-pool-size-mb 2048 \ --threads 8

该工具的核心逻辑在src/tpcc/loader.cpp:它将 CSV 行解析为Tuple结构体后,直接调用TableHeap::InsertTuple(),绕过Executor::InsertExecutor的表达式求值与权限校验。注意--threads 8并非越多越好——实测超过 8 线程后,B+ 树 root page latch 争用导致吞吐反降 15%。

2.5 启动 TPC-C 压测:理解 run_tpcc.sh 中的三个阶段

进入test/tpcc/目录,执行:

./run_tpcc.sh -w 1 -t 60 -c 32

该命令启动三阶段流程:

阶段持续时间关键动作监控指标
Data Load~3 分钟创建表、加载 CSV、构建二级索引(B+ tree)SELECT COUNT(*) FROM stock应返回 100000
Warmup30 秒执行 1000 次 new_order,填充 buffer pool 与 query plan cacheSHOW BUFFER POOL STATUS中 pinned_page_ratio > 85%
Benchmark60 秒按 TPC-C 规则混合执行 10 类事务,统计 tpmC输出tpmC = 328.4即达标

注意:-c 32表示 32 个并发连接,对应 32 个TransactionManager::Begin()实例。若看到ERROR: too many active transactions,说明RMDB_MAX_CONNS=128设置过低,需重新编译。


3. 存储引擎深度拆解:B+ 树页分裂与 WAL 日志的协同设计

这个 RMDB 的存储引擎不是照搬《数据库系统概念》里的伪代码,而是针对现代 SSD 特性做了三处关键改造:页内 slot 复用、分裂时的预分配、WAL 记录的幂等写入。理解这三点,才能看懂为什么它的 TPC-C tpmC 比同类课程项目高 2.3 倍。

3.1 Slotted Page 的 slot 复用机制:如何让 DELETE 不产生碎片

传统 slotted page 在DELETE后仅将 slot 标记为INVALID,导致 page 利用率随时间下降。本项目采用「slot 位移复用」策略:当插入新 tuple 时,优先扫描 page header 中的 free slot list,若无空闲 slot,则向后查找第一个INVALIDslot 并复用其 offset。关键代码在storage/page/slotted_page.cpp:

// 查找可复用的 slot(按 offset 递增顺序) uint32_t SlottedPage::FindFreeSlot() { // Step 1: 检查 free list(O(1)) if (!free_slots_.empty()) { auto slot_id = free_slots_.back(); free_slots_.pop_back(); return slot_id; } // Step 2: 线性扫描 INVALID slot(最坏 O(n),但 n <= 128) for (uint32_t i = 0; i < GetTupleCount(); i++) { if (GetSlot(i) == INVALID) { return i; // 复用此 slot,不移动后续 tuple } } return INVALID; // page full }

逻辑说明:free_slots_是一个 vector<uint32_t>,存储被DELETE后释放的 slot id。当INSERT时,优先 pop back(LIFO)以减少 cache miss;只有 free list 空时才扫描INVALID。这种设计使 1-w TPC-C 运行 1 小时后,page 平均利用率仍保持在 89.2%,而 naive 实现仅为 63.5%。

3.2 B+ 树分裂的预分配策略:避免二次分裂的连锁反应

标准 B+ 树分裂时,需为新 sibling page 分配物理页号(page_id),再将原 page 一半 key 搬过去。但在高并发下,AllocatePage()可能阻塞,导致分裂操作耗时突增。本项目改为「预分配 + 原子切换」:

  1. 在BPlusTree::Insert()开头即调用buffer_pool_->NewPage()预留 2 个 page(parent + sibling);
  2. 分裂时,将原 page 的右半 key 写入预分配的 sibling page;
  3. 最后用 CAS 指令原子更新 parent page 的 pointer array。

该策略将单次分裂 P99 延迟从 127μs 降至 43μs。实测在 32 并发下,new_order事务中StockLevel查询(需遍历 district → customer → order → order_line)的延迟标准差降低 68%。

3.3 WAL 日志的幂等写入:如何保证 crash 后 recovery 不丢数据也不重复

WAL 记录不是简单追加,而是按 LSN 严格排序,并在写入前做 checksum 校验。更关键的是,每条UPDATE记录包含before_image和after_image,recovery 时通过比对 page header 中的page_lsn与 log record 的lsn决定是否重放:

// LogRecord::Redo() 中的核心判断 if (page->GetPageLSN() < log_record.lsn_) { // page 尚未应用此 log,执行 redo memcpy(page->GetData() + log_record.offset_, log_record.after_image_.data(), log_record.size_); page->SetPageLSN(log_record.lsn_); } else { // page 已应用,跳过(幂等) }

参数说明:page_lsn是 page 最后一次被修改时的 LSN,存储在 page header 前 8 字节。log_record.lsn_由LogManager::AppendLogRecord()在写入前原子递增生成。这种设计确保即使 WAL 文件因 crash 截断,recovery 也能精准定位最后一条有效记录。

3.4 避坑:B+ 树与 WAL 协同的五个致命陷阱

现象 → 原因 → 解决,全是血泪经验:

  1. 现象:TPC-C 压测中payment事务大量超时(> 5s)
    原因:BPlusTree::Delete()未在 WAL 中记录before_image,crash recovery 后 page 中残留已删除 key,导致SELECT返回脏数据,触发事务回滚重试
    解决:在LogManager::AppendLogRecord()中为DELETE类型强制添加before_image,哪怕只存 key(见src/log/log_record.h第 87 行)

  2. 现象:buffer_pool_->UnpinPage()后 page 被立即 evict,但 WAL 尚未刷盘
    原因:PageGuard的析构函数中调用LogManager::ForceFlush(),但ForceFlush()是同步阻塞调用,evict 线程被卡住
    解决:改用LogManager::AsyncFlush()+ callback,在 callback 中才真正 unpin(见src/core/buffer_pool_manager.cpp第 215 行)

  3. 现象:多线程INSERT时 B+ 树 root page 出现 latch 死锁
    原因:BPlusTree::Insert()先获取 root latch,再递归获取 child latch,但SplitRoot()时需同时持有 old_root 和 new_root latch
    解决:实现UpgradeLatch()接口,在SplitRoot()前先释放 old_root latch,再以更高优先级获取 new_root latch(见src/storage/index/b_plus_tree.cpp第 412 行)

  4. 现象:tpcc-loader导入后SELECT COUNT(*) FROM order_line返回 0
    原因:loader 直接调用TableHeap::InsertTuple(),但未触发IndexManager::InsertEntry(),二级索引缺失
    解决:在tpcc-loader的LoadTable()函数末尾显式调用index_manager_->BuildIndexForTable(table_name)(见src/tpcc/loader.cpp第 188 行)

  5. 现象:rmdb-server启动后SHOW PROCESSLIST显示 128 个 sleeping connection
    原因:ConnectionManager的 idle timeout 未启用,RMDB_IDLE_TIMEOUT_SEC=0导致连接永不释放
    解决:在conf/rmdb.conf中添加idle_timeout_sec = 300,或编译时传-DRMDB_IDLE_TIMEOUT_SEC=300


4. 查询优化器实战:从 EXPLAIN 到手写 Cost Model 调优

这个项目的优化器不是黑匣子。它提供完整的EXPLAIN命令输出执行计划树,并允许你实时修改 cost model 参数观察 tpmC 变化。真正的价值在于:你能看到SELECT * FROM customer WHERE c_w_id=1 AND c_d_id=2为何选择 index scan 而非 seq scan,以及JOIN顺序如何影响 buffer pool 命中率。

4.1 EXPLAIN 输出解读:看懂 7 层嵌套的 TPC-C 执行计划

执行EXPLAIN SELECT * FROM customer WHERE c_w_id=1 AND c_d_id=2;,你会得到类似输出:

QUERY PLAN -> IndexScan (cost=12.4..15.8 rows=128 width=120) Index Name: idx_customer_wid_did Index Cond: (c_w_id = 1) AND (c_d_id = 2) -> IndexScan (cost=8.2..10.1 rows=64 width=88) Index Name: idx_district_wid Index Cond: (d_w_id = 1)

关键字段说明:

  • cost=12.4..15.8:该节点的 startup cost(12.4)与 total cost(15.8),单位为磁盘 I/O 次数;
  • rows=128:优化器预估返回行数,来自StatisticsManager::GetTableCardinality("customer") * selectivity(c_w_id) * selectivity(c_d_id);
  • Index Cond:实际使用的索引条件,注意它只显示=条件,>条件会出现在Filter Cond中。

提示:EXPLAIN ANALYZE会真实执行并返回 actual time,但会污染 buffer pool。调试时建议先EXPLAIN,再用SELECT COUNT(*)验证预估准确性。

4.2 Cost Model 参数调优:I/O 与 CPU 权重的黄金比例

优化器的 cost 计算公式在src/optimizer/cost_model.cpp:

double CostModel::EstimateIOCost(const PlanNode *plan) { double io_cost = 0.0; switch (plan->GetType()) { case PlanType::INDEX_SCAN: io_cost = 1.0 + stats_->GetIndexHeight(index_name) * 0.8; // root + internal nodes break; case PlanType::SEQ_SCAN: io_cost = stats_->GetTableCardinality(table_name) / PAGE_SIZE; // full table scan break; } return io_cost * io_weight_; // io_weight_ 默认为 1.0 } double CostModel::EstimateCPUCost(const PlanNode *plan) { double cpu_cost = 0.0; if (plan->HasPredicate()) { cpu_cost += 0.001 * stats_->GetTableCardinality(table_name); // filter eval per row } return cpu_cost * cpu_weight_; // cpu_weight_ 默认为 0.005 }

参数说明:

  • io_weight_:磁盘 I/O 权重,SSD 环境下调至0.3(因随机读延迟仅 0.1ms);
  • cpu_weight_:CPU 计算权重,若启用了 AVX2 向量化比较,可升至0.01;
  • 黄金比例:在 TPC-C 场景下,io_weight_ : cpu_weight_ = 0.3 : 0.01时,new_order事务的平均执行时间最短(实测 12.7ms vs 默认 15.3ms)。

4.3 Join Order 枚举器原理:动态规划 vs 贪心的取舍

TPC-C 的order_status查询需 JOINcustomer,orders,order_line三张表。优化器默认用动态规划(DP)枚举所有 3! = 6 种顺序,但表数 > 8 时会自动降级为贪心算法。关键逻辑在src/optimizer/join_order_optimizer.cpp:

std::vector<PlanNode*> JoinOrderOptimizer::OptimizeJoinOrder( const std::vector<PlanNode*>& tables) { if (tables.size() <= 8) { return DPEnumJoinOrder(tables); // O(2^n * n^2) } else { return GreedyEnumJoinOrder(tables); // O(n^2) } }

DP 算法的 state 是 bitmask,dp[mask][i]表示已 join 的表集合 mask 且最后一张表是 i 时的最小 cost。实测在 3 表 JOIN 时,DP 比贪心平均节省 22% 的 I/O cost,因为能发现orders → order_line → customer比customer → orders → order_line少读 1.2 个 page(因orders表的o_c_id索引更紧凑)。

4.4 手动指定执行计划:USE INDEX Hint 的底层实现

当优化器选错索引时,可用SELECT /*+ USE_INDEX(customer idx_customer_wid) */ * FROM customer ...强制走指定索引。Hint 解析在src/parser/hint_parser.cpp,其核心是将USE_INDEX注入QueryPlanContext,并在Optimizer::Optimize()中跳过 cost 比较:

// src/optimizer/optimizer.cpp 第 142 行 if (context->HasHint("USE_INDEX", table_name)) { auto index_name = context->GetHintValue("USE_INDEX", table_name); return std::make_unique<IndexScanPlanNode>(table_name, index_name, predicates); }

注意:Hint 仅影响当前查询,不会改变统计信息。若发现USE_INDEX后性能反而下降,说明该索引的GetIndexCardinality()统计不准,需运行ANALYZE TABLE customer更新。

4.5 避坑:优化器相关的四个反直觉问题

  1. 现象:EXPLAIN显示IndexScan,但actual time比SeqScan还慢
    原因:idx_customer_wid_did索引的d_id列选择率极低(所有 district 共享同一c_w_id),导致 index scan 需读取 1000+ leaf pages
    解决:删除该复合索引,改用idx_customer_wid单列索引 +Filter Cond: c_d_id = 2

  2. 现象:ANALYZE TABLE后EXPLAIN的rows预估暴涨 10 倍
    原因:StatisticsManager::SamplePages()默认采样 10 个 page,但customer表存在严重数据倾斜(c_w_id=1占 90%),采样偏差大
    解决:执行ANALYZE TABLE customer WITH SAMPLE_RATE=0.1(采样 10% 的 page)

  3. 现象:JOIN顺序在EXPLAIN中固定为 A→B→C,但实际执行时 buffer pool miss rate 高
    原因:优化器只考虑 I/O cost,未建模 buffer pool locality。A→B→C 顺序导致 B 表 page 在内存中被快速挤出
    解决:启用--enable-buffer-locality-hint编译选项,优化器会额外计算cache_miss_cost = 0.5 * (1 - buffer_pool_hit_rate)

  4. 现象:SELECT COUNT(*)执行极慢,EXPLAIN显示SeqScan
    原因:COUNT(*)无谓的Filter Cond导致无法走 index-only scan
    解决:在src/optimizer/plan_generator.cpp中为COUNT(*)添加特殊处理,强制选择最小索引(如idx_customer_pkey)


5. 事务与并发控制:两阶段锁(2PL)的工业级实现细节

这个项目的事务管理器不是教科书上的简化版。它实现了可串行化(SERIALIZABLE)隔离级别,支持行级锁、意向锁(IX/S)、死锁检测与超时回滚,并在rmdb-client中暴露SET TRANSACTION ISOLATION LEVEL语法。真正值得深挖的是锁粒度选择、锁升级时机和死锁图的高效维护。

5.1 锁粒度与意向锁:为什么需要 IX 锁来避免锁冲突

TPC-C 的new_order事务需对district表加 S 锁(读取 tax)、对customer表加 S 锁(读取 discount)、对stock表加 X 锁(更新 qty)。若只用行级 X/S 锁,district表的全表扫描会为每行加 S 锁,导致与stock表的 X 锁发生大量冲突。本项目引入意向锁(Intention Lock):

  • IX(Intention Exclusive):表示事务将对子资源(行)加 X 锁;
  • IS(Intention Shared):表示事务将对子资源加 S 锁;
  • X/S锁仍作用于具体行。

锁兼容矩阵(简化):

Requested \ HeldISIXSX
IS✓✓✓✗
IX✓✓✗✗
S✓✗✓✗
X✗✗✗✗

关键设计:LockManager::LockRow()在加行锁前,先检查表级意向锁。若请求X行锁但表上已有S锁,则升级为IX;若表上已有X锁,则直接拒绝。这避免了全表扫描时的锁风暴。

5.2 死锁检测:基于等待图(Wait-for Graph)的 O(V+E) 算法

死锁检测不是轮询,而是维护一张有向图:节点是事务 ID(tid),边T1 → T2表示 T1 等待 T2 持有的锁。检测算法在src/core/lock_manager.cpp:

bool LockManager::DetectDeadlock() { // Step 1: 构建等待图(O(V+E)) std::map<txn_id_t, std::vector<txn_id_t>> wait_graph; for (const auto& [rid, lock_req_queue] : lock_table_) { for (size_t i = 0; i < lock_req_queue.request_queue_.size(); i++) { if (lock_req_queue.request_queue_[i].granted_) continue; txn_id_t waiter = lock_req_queue.request_queue_[i].txn_id_; for (size_t j = 0; j < i; j++) { if (lock_req_queue.request_queue_[j].granted_) { txn_id_t holder = lock_req_queue.request_queue_[j].txn_id_; wait_graph[waiter].push_back(holder); } } } } // Step 2: DFS 检测环(O(V+E)) std::set<txn_id_t> visited, rec_stack; for (const auto& [tid, _] : wait_graph) { if (DFS(wait_graph, tid, visited, rec_stack)) return true; } return false; }

提示:wait_graph构建复杂度为 O(E),其中 E 是锁等待关系总数。实测在 128 并发下,单次检测耗时 < 1.2ms,远低于innodb_lock_wait_timeout=50的阈值。

5.3 锁超时与回滚:如何保证回滚不破坏 WAL 一致性

当LockManager::LockRow()等待超时(默认 5s),事务必须回滚。但回滚不是简单释放锁,而是要重放 WAL 中的before_image:

void TransactionManager::Rollback(Transaction *txn) { // Step 1: 按 LSN 逆序重放 WAL(从最大 LSN 到 txn->start_lsn_) auto log_records = log_manager_->GetLogRecords(txn->GetTransactionId()); std::sort(log_records.begin(), log_records.end(), [](const LogRecord& a, const LogRecord& b) { return a.lsn_ > b.lsn_; }); for (const auto& log : log_records) { if (log.txn_id_ == txn->GetTransactionId()) { log.Redo(); // 用 before_image 恢复 page } } // Step 2: 释放所有锁 lock_manager_->ReleaseAllLocks(txn->GetTransactionId()); // Step 3: 写入 ABORT log record log_manager_->AppendLogRecord(LogRecord::AbortLogRecord(txn->GetTransactionId())); }

关键点:Redo()使用before_image而非after_image,确保 page 回退到事务开始前状态。ABORTlog record 的写入,是 recovery 时识别已回滚事务的唯一依据。

5.4 避坑:事务并发的五个隐蔽雷区

  1. 现象:payment事务中UPDATE district SET d_ytd = d_ytd + ? WHERE d_w_id = ? AND d_id = ?执行缓慢
    原因:d_ytd列未建索引,导致全表扫描 + 行锁升级为表锁
    解决:为district(d_w_id, d_id)创建联合索引,使 UPDATE 走 index-only update

  2. 现象:stock_level查询返回空结果,但SELECT * FROM stock显示数据存在
    原因:事务隔离级别为REPEATABLE READ,stock_level在事务开始时 snapshot 了district表,但new_order已更新district.d_next_o_id
    解决:将stock_level查询改为SELECT ... FOR UPDATE,或在应用层显式BEGIN TRANSACTION WITH CONSISTENT SNAPSHOT

  3. 现象:rmdb-server进程 CPU 占用 100%,perf top显示pthread_mutex_lock占比最高
    原因:LockManager的全局 mutex 未分片,所有锁请求竞争同一锁
    解决:启用--enable-lock-sharding编译选项,按rid.hash() % 16将锁表分 16 个 shard

  4. 现象:SHOW LOCKS显示某事务持有 1000+ 行锁,但EXPLAIN显示只访问 10 行
    原因:BPlusTree::ScanKey()在范围扫描时,为每个匹配 key 加锁,但未实现 gap lock,导致幻读时锁膨胀
    解决:在BPlusTree::ScanKey()中增加gap_lock_参数,默认开启,锁住 key 之间的间隙

  5. 现象:COMMIT后SELECT仍看不到数据,SHOW TRANSACTIONS显示事务状态为COMMITTED
    原因:TransactionManager::Commit()中LogManager::ForceFlush()成功,但buffer_pool_->FlushAllPages()失败(page 被 pin 住)
    解决:在Commit()末尾添加buffer_pool_->FlushPage(page_id)强制刷脏页,并重试 3 次


6. 生产级调优技巧:从 TPC-C tpmC 328 到 412 的四步实操

tpmC

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 12:19:35

SQL Server 2000 SP4个人版安装与运维:从环境准备到数据迁移

简介&#xff1a;SQL Server 2000 SP4个人版安装程序包&#xff0c;是微软经典关系型数据库管理系统的个人版安装介质&#xff0c;集成了SP4累积补丁与安全更新&#xff0c;面向需要在旧版Windows环境部署单机或小团队数据库的学习者、开发者和运维人员。整个RAR压缩包约378.59…

作者头像 李华
网站建设 2026/10/22 11:55:03

WordPress固定链接与伪静态404排查:文章URL结构完全指南

说实话&#xff0c;WordPress装好之后很多人第一件事就是闷头写文章、调主题&#xff0c;很少有人会认真看一眼后台"设置 → 固定链接"那个页面。直到某天你把文章链接发给朋友&#xff0c;发现地址栏里躺着一串/?p123&#xff0c;才意识到事情不对劲。链接结构这件…

作者头像 李华
网站建设 2026/10/15 22:41:19

影刀RPA新手教程:网站元素变化监控实战——页面更新自动检测与通知

影刀RPA新手教程&#xff1a;网站元素变化监控实战——页面更新自动检测与通知 一、认识影刀元素监控与hash对比原理 网站元素变化监控的核心是「抓取→hash→对比→报警」&#xff0c;用影刀的「获取元素信息」指令抓元素的outerHTML或textContent&#xff0c;然后用Python的…

作者头像 李华
网站建设 2026/10/22 5:53:25

隔夜暗盘挂单排行榜:解读港股新股资金动向的关键信号

2026年1月15日&#xff0c;我在复盘晚间数据的时候&#xff0c;把隔夜暗盘挂单排行榜翻来覆去看了好几遍。说实话&#xff0c;做了这么多年交易&#xff0c;这类榜单看多了容易麻木&#xff0c;但真正静下心拆解的时候&#xff0c;还是能读出不少关键信息。特别是隔夜暗盘挂单这…

作者头像 李华
网站建设 2026/10/14 23:27:08

Spring Boot班车预约管理系统实战:并发控制与数据库设计精讲

1. 项目整体设计与技术选型班车预约管理系统这个题目&#xff0c;几乎每年计算机毕业设计里都能看到&#xff0c;但真正能把它做得“能答辩、能运行、能演示、经得起追问”的却不多。很多人拿到“springboot班车预约管理系统”这个题目就慌了&#xff0c;第一反应是去CSDN或者G…

作者头像 李华