
从一次 UPDATE 看懂达梦事务并发控制MVCC、Lock、Latch 与 WAL1. 从一笔转账开始ACID 在这里分别解决什么2. Undo、Redo 与 WAL为什么提交成功不等于数据页已经落盘Undo让未完成的修改能够后退Redo让已经提交的修改能够重新做一遍WAL 的核心是保证写入顺序STEAL 与 NO-FORCE3. 崩溃恢复哪些事务保留哪些事务撤销Analysis先确定宕机现场Redo重复必要的历史Undo撤销未完成事务Checkpoint 不是备份4. MVCC读取看到的不是“当前值”而是“对自己可见的值”快照并不是复制整个数据库5. 隔离级别、提交序号与历史版本清理READ COMMITTED 与 SERIALIZABLE 的差别为什么还需要 CMTSEQPurge 与长事务6. MVCC 不等于没有锁写写冲突仍然需要协调S、X、IS、IX 锁达梦的 TID 锁死锁如何出现7. Lock 与 Latch 不是一回事8. 一次 UPDATE 在内核中大致经历什么结语最近在学习达梦事务并发控制时我先后接触到了 TRX、MVCC、Lock 等概念。单独看每一个概念并不算难但真正把它们放到一起时很容易变成一堆彼此割裂的名词。理解这套机制最好的方式不是继续逐条背定义而是跟着一笔事务完整走一遍数据怎样被修改其他事务此时能看到什么两个事务同时更新一行时如何处理提交后为什么不必立刻刷数据页以及数据库宕机后又怎样恢复。这篇文章就沿着这条主线整理一下我对达梦事务并发控制机制的理解。1. 从一笔转账开始假设数据库中有两个账户账户 A1000 元 账户 B1000 元现在事务 T1 要从 A 向 B 转账 100 元UPDATEaccountSETbalance900WHEREidA;UPDATEaccountSETbalance1100WHEREidB;COMMIT;执行这笔转账时数据库至少要处理下面几类问题A 已经扣款、B 还没入账时事务报错如何恢复T1 尚未提交时其他事务能否看到 A900、B1000 的中间状态T1 和另一个事务同时更新账户 A 时谁先执行数据库已经返回提交成功但数据页还没落盘便发生宕机数据是否会丢失。这些问题分别对应事务的原子性、隔离性、并发冲突控制和持久性也正好把 Undo、Redo、MVCC、Lock、Latch 与 WAL 串联起来。ACID 在这里分别解决什么事务通常用 ACID 描述。原子性要求一笔事务要么全部成功要么全部撤销。转账不能只扣 A、不增加 B。为了在失败后恢复旧值数据库需要保存修改前的信息也就是 Undo。一致性要求事务把数据库从一个合法状态带到另一个合法状态。例如转账前后 A 与 B 的余额之和都应是 2000。需要注意的是一致性并不是由某一个内核模块单独保证的它还依赖业务逻辑、约束、原子性、隔离性和持久性共同实现。隔离性解决并发事务互相干扰的问题。T1 尚未完成时T2 不应随意看到它的中间结果。现代数据库通常不会简单地让所有读取都等待写事务结束而是通过 MVCC 让读取访问合适的历史版本。持久性要求数据库既然已经向应用返回COMMIT成功事务结果就不能在重启后消失。这里主要依赖 Redo 日志和 WAL 规则。2. Undo、Redo 与 WAL为什么提交成功不等于数据页已经落盘数据库执行UPDATE时通常不会直接修改磁盘文件而是先把数据页读入缓冲池在内存中完成修改。被修改但尚未写回磁盘的数据页称为脏页。磁盘数据页 ↓ 读入 缓冲池中的数据页 ↓ 修改 内存中的脏页 ↓ 后台刷盘 磁盘数据页这样做可以减少随机磁盘写提高事务处理性能但也带来了两个问题。Undo让未完成的修改能够后退假设 T1 已经把 A 从 1000 改为 900但尚未修改 B 就发生异常。数据库需要知道 A 原来是多少才能把事务完整回滚。Undo 保存的就是修改前的信息可以理解为记录的前镜像当前值A.balance 900 Undo A.balance 1000因此Undo 的主要作用是“向后恢复”。它不仅服务于用户主动执行ROLLBACK还会用于事务异常回滚、死锁牺牲事务回滚以及 MVCC 对历史版本的访问。Redo让已经提交的修改能够重新做一遍假设 T1 已经提交内存中的余额也变成了 A900、B1100但对应数据页还没写入磁盘。这时如果服务器断电内存内容会消失而磁盘上的数据页仍然是旧值。Redo 保存的是修改后应当达到的结果。数据库重启时可以根据日志重新执行相应修改把已经提交但尚未落盘的数据补回来。可以简单记成Undo记录修改前的状态用于撤销 Redo记录修改后的动作用于重做WAL 的核心是保证写入顺序WAL 是 Write-Ahead Logging即预写日志。它并不是说每次修改内存数据页之前都必须先把日志同步写到磁盘而是要求恢复所依赖的日志在关键时刻必须先于数据页持久化。其中有两条最重要的顺序对应 Redo 日志落盘 早于 脏数据页落盘以及提交所需日志落盘 早于 向应用返回 COMMIT 成功只要提交日志已经可靠落盘即使数据页仍留在内存中数据库也可以返回提交成功。因为即使随后发生宕机重启时仍然能通过 Redo 恢复这次提交。这也是数据库提交事务时通常不需要把所有相关数据页立刻刷盘的原因。STEAL 与 NO-FORCE理解缓冲池策略时经常会看到 STEAL 和 NO-FORCE。STEAL表示允许未提交事务修改过的脏页提前写入磁盘。这样可以缓解缓冲池压力但磁盘上可能暂时存在未提交数据因此恢复时需要 Undo。NO-FORCE表示事务提交时不强制把它修改过的全部数据页立即刷盘。这样能降低提交开销但磁盘上可能缺少已经提交的数据因此恢复时需要 Redo。主流 OLTP 数据库通常倾向于使用STEAL NO-FORCE本质上是用更复杂的日志与恢复机制换取正常运行阶段更高的并发和更低的 I/O 开销。3. 崩溃恢复哪些事务保留哪些事务撤销数据库宕机时磁盘上的状态往往并不整齐某些已提交事务的数据页还没落盘某些未提交事务的脏页已经提前落盘还有一些修改已经完整写入磁盘。理解这类恢复过程时可以借助 ARIES 的经典框架Analysis → Redo → Undo这里更适合把它看作理解崩溃恢复的通用模型不必简单地认为达梦内部实现与 ARIES 论文中的每一步完全一致。Analysis先确定宕机现场分析阶段主要判断宕机时有哪些事务仍处于活动状态哪些事务已经提交哪些数据页可能需要重做日志应从什么位置开始扫描。Redo重复必要的历史随后数据库正向扫描日志把需要存在的修改重新应用到数据页。经典 ARIES 思路通常会先“重复历史”也就是把宕机前已经发生的修改重新构造出来再在后续阶段撤销未提交事务。实际重做时数据库还会结合数据页上的日志序号等信息判断某条 Redo 是否已经体现在页面中避免无意义的重复写。Undo撤销未完成事务分析阶段确认的未提交事务需要沿着 Undo 链从后往前撤销直到回到事务开始前的状态。最终结果应当是已经提交的事务全部保留 尚未提交的事务全部消除Checkpoint 不是备份如果数据库每次恢复都从最早的日志开始扫描运行时间越长恢复成本就越高。因此系统需要 Checkpoint 记录恢复所需的阶段性信息例如活跃事务、脏页和日志推进位置。Checkpoint 更像恢复路标而不是完整备份。现代数据库也不一定会在检查点时把所有脏页一次性刷完常见做法是模糊检查点在数据库继续运行的同时记录恢复信息。检查点频率实际上是在正常运行开销与恢复时间目标之间做平衡过于频繁会增加运行期压力间隔过长则可能让崩溃恢复扫描更多日志。4. MVCC读取看到的不是“当前值”而是“对自己可见的值”MVCC 是 Multi-Version Concurrency Control多版本并发控制。它的关键并不是“数据库完全不加锁”而是同一条记录可以存在多个版本。写事务生成新版本时读事务仍然可以根据自己的快照访问旧版本因此普通读取通常不必等待写事务结束。假设账户 A 原来为 1000事务 T1 将其更新为 900但尚未提交。可以把记录结构抽象成数据页中的当前版本 A 900 TID T1 RPTR ─────────────┐ ↓ Undo 中的历史版本 A 1000其中TID表示产生当前版本的事务RPTR指向相应的回滚记录Undo 中保存被覆盖前的内容多条回滚记录继续连接就形成了版本链。此时另一个事务 T2 查询账户 A。它首先会读到数据页中的当前版本 A900但通过 TID 判断出该版本由仍未结束的 T1 产生对自己不可见于是沿着 RPTR 回溯最终读取历史版本 A1000。于是同一时刻可能出现T1 看到自己写入的 900 T2 仍然看到已经提交的旧值 1000这就是 MVCC 能够降低读写阻塞的基础。当然这里说的是普通快照读显式加锁查询、DDL、特殊访问方式以及资源竞争仍然可能产生等待。快照并不是复制整个数据库所谓快照并不是在事务开始时复制一份完整数据库而是保存一组用于判断版本可见性的事务状态。达梦中将其称为活动事务视图TRX_VIEW其中会涉及当前活跃事务集合MIN_ACTIVE_ID最小活动事务号NEXT_ID创建快照时下一个可分配事务号当前事务自己的事务号。假设某个快照如下SELF_ID 27 MIN_ACTIVE_ID 20 NEXT_ID 30 TRX_VIEW {20, 25, 28}对于不同事务产生的版本可以大致这样判断TRX_ID 31事务号不小于NEXT_ID说明版本在快照之后产生不可见TRX_ID 27当前事务自己产生的版本可见TRX_ID 15早于最小活动事务通常可直接认为在快照前已经结束可见TRX_ID 25位于活动事务集合中创建快照时尚未提交不可见TRX_ID 23处于事务号范围内但不在活动集合中说明创建快照前已经结束可见。如果当前版本不可见数据库就继续沿 RPTR 查找前一个版本直到找到符合快照规则的记录。5. 隔离级别、提交序号与历史版本清理READ COMMITTED 与 SERIALIZABLE 的差别理解读提交和串行化的关键可以落在快照创建时机上。在READ COMMITTED下每条 SQL 执行时都会重新获取快照SELECTbalanceFROMaccountWHEREidA;-- 其他事务在这里提交修改SELECTbalanceFROMaccountWHEREidA;两次查询可能分别看到 1000 和 900。它保证每条语句不会读取其他事务尚未提交的数据但同一个事务中的不同语句可能看到不同的已提交结果。在SERIALIZABLE下事务会维持更稳定的事务级视图。其他事务之后提交的修改通常不会直接进入当前事务已经建立的快照范围因此并发冲突更容易导致事务报错并由应用重试。可以这么记忆READ COMMITTED更接近一条 SQL 一张快照 SERIALIZABLE更接近一个事务一张快照为什么还需要 CMTSEQ事务号通常反映事务启动和分配身份的顺序却不一定反映真正的提交顺序。例如 T100 先启动但执行很久T101 后启动却可能先提交。因此还需要理解提交序号相关信息CMTSEQ事务提交时取得或更新的提交序号SNAP_CMTSEQ创建事务或 SQL 快照时取得的提交序号CMTARR根据事务 ID 查询提交序号的全局结构TRX_VIEW_MODE控制相关可见性判断方式。核心思路是比较版本提交序号与快照提交序号版本 CMTSEQ SNAP_CMTSEQ说明该版本在快照建立前已经提交因而可以被当前快照看到反之则说明它在快照之后提交。事务 ID 解决的是“这是谁产生的版本”提交序号进一步解决“它究竟在什么时候提交”。Purge 与长事务每次更新都保留历史版本Undo 链会不断增长1000 ← 900 ← 850 ← 800 ← 750 ...数据库不可能永久保存所有历史版本因此需要通过 Purge 清理已经不再被任何活动快照需要的 Undo 记录。清理的前提是确认当前没有事务还需要访问那个历史版本。这也是长事务容易带来问题的原因。一个持续数小时的查询一直持有旧快照数据库就可能不得不保留大量旧版本进而造成长事务 ↓ Undo 无法及时清理 ↓ Undo 空间增长、版本链变长 ↓ 查询回溯和后台清理压力上升6. MVCC 不等于没有锁写写冲突仍然需要协调MVCC 擅长解决读写并发但它不能让两个事务同时随意覆盖同一条记录。假设两个事务都要修改账户 AT11000 → 900 T21000 → 800普通读取可以沿版本链读取旧值但更新操作必须建立在当前有效的最新版本上。如果 T2 绕过 T1 的当前版本直接根据旧版本修改就可能覆盖 T1 已完成的更新形成更新丢失。所以可以这样区分读取旧版本由 MVCC 处理 修改当前版本由 Lock 协调S、X、IS、IX 锁数据库常见的锁模式包括锁模式含义S共享访问多个只读事务可以并存X排他访问用于独占修改IS意向共享表示准备在下级对象加共享锁IX意向排他表示准备在下级对象加排他锁意向锁的价值在于快速表达层次化加锁状态。例如事务准备修改表中的某一行时可以先在表级取得 IX其他事务不必遍历整张表的所有行锁就能判断是否可以申请整表 S 或 X 锁。兼容关系可以概括为IS 可以与 IS、IX、S 共存但不能与 X 共存IX 可以与 IS、IX 共存但不能与 S、X 共存S 可以与 IS、S 共存但不能与 IX、X 共存X 与其他模式都冲突。达梦的 TID 锁达梦事务启动时会取得唯一递增的事务号 TID并建立与事务相关的 TID 锁。记录被修改后其 TID 字段会指向产生当前版本的事务。例如记录 A.TID T100此时 T200 也想修改 A发现当前最新版本由尚未结束的 T100 产生就需要等待 T100 对应的 TID 锁。等 T100 提交或回滚TID 锁释放等待事务再继续处理。这种设计可以把等待关系理解为不是单纯等待“某一行上的传统锁对象” 而是等待“产生当前版本的事务结束”一个事务即使修改了很多记录也可以通过事务身份统一协调写写冲突。死锁如何出现假设T1 已经修改 A准备修改 B T2 已经修改 B准备修改 A于是 T1 等待 T2T2 又等待 T1形成闭环这就是死锁。数据库需要分析事务等待关系一旦检测到环就选择其中一个事务作为牺牲者回滚使其他事务继续执行。在 DSC 集群中等待关系还可能跨节点出现因此除了本地锁还会涉及全局锁协调以及跨节点死锁检测。7. Lock 与 Latch 不是一回事Lock 面向事务之间的逻辑并发而 Latch 面向数据库内核中的共享内存结构。简单来说Lock这个事务现在能不能修改这条记录 Latch这个线程现在能不能安全修改这个数据页一个数据页内部不仅有业务记录还可能包含页头、行目录、空闲空间信息、TID、RPTR 等结构。如果两个工作线程同时修改同一个页面而没有短期互斥就可能破坏页内结构。因此线程访问或修改数据页时需要短暂取得 Latch。Latch 模式包括S共享读取X独占修改F与刷盘协调相关的模式尤其涉及 DSC 数据页刷盘场景。Lock 和 Latch 最关键的差别在于持有时间。Lock 可能持续整个事务直到COMMIT或ROLLBACKLatch 通常只保护一小段内核操作完成页内检查或修改后就应尽快释放。因此线程不能长期拿着页面 Latch 等待另一个事务否则会把页面上的其他访问也一起堵住甚至放大死锁问题。8. 一次 UPDATE 在内核中大致经历什么把前面的概念放到一次更新操作中整体流程就清晰了。假设 T1 正在修改账户 AT2 也执行UPDATEaccountSETbalancebalance-50WHEREidA;T2 首先需要取得数据页的 X Latch以便安全检查和修改页内结构。随后它读取记录上的 TID判断当前版本由哪个事务产生以及这个版本是否对自己可见。如果记录可以直接修改T2 会完成以下操作生成 Undo保存旧版本 生成 Redo记录新修改 更新内存中的数据页 修改记录的 TID 与 RPTR 释放页面 Latch如果当前版本由尚未结束的冲突事务 T1 产生T2 不能沿 RPTR 找一个旧版本直接更新因为更新必须以最新有效状态为基础。正确的处理方式是记住冲突事务 T1 释放页面 Latch 等待 T1 的 TID Lock“先释放 Latch再等待 Lock”非常重要。Latch 只负责短时间保护页面事务等待则交给 Lock 处理。当 T1 结束后有两种情况T1 回滚当前修改被 Undo 撤销T2 被唤醒后重新检查并继续更新T1 提交T1 的版本成为有效最新版本T2 再根据隔离级别决定后续处理。在READ COMMITTED下语句可以重新获取快照并基于 T1 已提交的新值继续执行在SERIALIZABLE下这类并发更新更容易被判定为串行化冲突需要应用回滚并重试事务。最后把一笔事务从开始到结束串起来大致可以表示为1. BEGIN分配事务 ID 2. 按隔离级别创建快照 3. 查询时根据 TID、事务视图和版本链判断可见性 4. 更新前取得页面 Latch 5. 生成 Undo 和 Redo 6. 修改缓冲池中的数据页并更新 TID、RPTR 7. 释放 Latch 8. 遇到写写冲突时等待对方 TID Lock 9. COMMIT 时按 WAL 规则先保证日志持久化 10. 返回提交成功并释放事务锁 11. 后台择机把脏页写入磁盘 12. Purge 清理不再被活动快照需要的历史版本 13. 如果中途宕机通过分析、重做和撤销恢复一致状态结语事务并发控制并不是 MVCC、锁和日志各自独立工作而是一套相互配合的机制Undo 让事务能够撤销也为 MVCC 保存历史版本 Redo 让已经提交的修改可以在宕机后重做 WAL 保证恢复所需日志先于数据页和提交结果持久化 MVCC 让读事务找到自己应该看到的版本 Lock 负责协调事务之间的长期冲突 Latch 负责保护内核线程对数据页的短期操作 Purge 负责回收已经不再需要的历史版本我认为真正理解这部分内容的关键是始终区分三个层次版本层面当前事务应该看到哪个版本由 MVCC、Undo 和事务视图决定事务层面多个事务能否同时修改同一对象由 Lock 和 TID 锁协调内核结构层面线程能否安全操作同一个页面由 Latch 保护。再加上 Redo、WAL 与崩溃恢复数据库才能在并发性能、事务正确性和故障可恢复性之间取得平衡。