达梦数据库核心机制解析:redo、undo、MVCC与检查点如何协同
发布时间:2026/9/18 6:16:06 作者:尧图编辑部 阅读量:1,286

上周生产库突然“卡死”数据库日志里刷了一堆checkpoint相关的等待恢复用了快二十分钟。业务方反复追问数据不是一直在落盘吗为什么恢复要这么久这个问题其实牵扯到达梦数据库最核心的一整套机制——redo日志、undo回滚段、MVCC版本链、检查点推进、内存脏页刷盘它们互相咬合共同决定了你看到的所有性能数据、恢复时长和隔离级别行为。我接触达梦数据库也有一段时间了从最开始当“Oracle的平替”用到后来发现它在国产化替代场景里确实有自己的一套实现逻辑。这篇文章不打算讲安装部署也不打算讲SQL语法而是把这些决定数据库“生死存亡”的核心机制从头到尾捋一遍。适合正在做达梦运维的DBA、准备从Oracle或MySQL迁过来的开发、以及所有想知道“数据库为什么慢、为什么恢复久、为什么读到旧数据”的人。1. 一条UPDATE语句在达梦里走过的完整路径很多人看数据库内核文章最容易犯的毛病是“只见树木不见森林”——单独看redo能看懂单独看undo也能看懂但组合起来就糊涂了。所以我先不讲单个机制而是拿一条最普通的UPDATE语句完整走一遍它在达梦内部的路径。1.1 先写日志还是先改数据WAL的基本逻辑数据库最基础的要求是“事务一旦提交数据就不能丢”。但如果你每次提交都把数据页直接写进磁盘性能会差到没法用——随机IO太慢了。于是所有主流关系型数据库都采用了同一个策略预写日志Write-Ahead LoggingWAL。WAL的核心原则只有一句话先写redo日志再写数据页。事务提交时数据库保证redo日志已经落盘但数据页可以继续留在内存里晚点再刷。这样即使实例突然崩溃数据页丢了也没关系因为redo日志里有完整的变更记录启动时重放一遍就能把数据“补”回来。达梦数据库完全遵循这个逻辑。它的redo日志物理文件在数据目录下默认以dm_rlog开头比如dm_rlog01.log、dm_rlog02.log这样。你可以在达梦的初始化参数里看到日志文件数量和大小这个后面细说。1.2 redo、undo、数据页三者的写入顺序为什么不能乱现在看一条UPDATE语句的完整路径。假设表里有一行数据id1, name张三你执行UPDATE t SET name李四 WHERE id1。数据库内部大概做这几件事先在Buffer Pool缓冲区里找到包含id1这个数据行的数据页。如果不在内存里就从磁盘读进来。在undo表空间里分配一个回滚段写入“前镜像”——也就是修改前的name张三这条信息。这个undo记录本身也是要落地的它也是数据页的一种。在Buffer Pool里修改数据页把name改成李四。此时内存中的页面和磁盘上的页面已经不一样了这个页面就变成了“脏页”。生成一条redo日志把上面两步的物理变更都记录下来哪个数据页的哪个偏移位置被改成了什么、undo页面又改了哪里。事务提交时把redo日志刷到磁盘上。此时数据页刷不刷都行。后台机制检查点、异步刷盘等在合适的时候把脏页写回磁盘。这六步的顺序不是随便定的第2步必须在第3步之前原因很直接如果在修改数据页之前没有先记录undo信息事务一旦需要回滚或者崩溃恢复时需要撤销未提交的修改就找不到原始数据了。第4步的redo必须覆盖第2步和第3步的全部变更否则崩溃恢复时可能只恢复了数据页的改动、却丢掉了undo段的恢复记录整个数据库的一致性就崩了。理解了这个链路你就掌握了达梦数据库的一根主线redo保证“做了的事不会丢”undo保证“没做完的事能撤销”数据页只是最终的结果载体。接下来几节分别拆开讲。2. redo日志崩溃恢复的真正地基2.1 redo日志中记录的是什么redo日志记录的是物理层面的变更结果不是SQL语句。什么意思它不会记“某人执行了UPDATE t SET name李四”而是记“数据页x的第y个偏移位置值从张三变成了李四”。这有点像录视频而不是写剧本——关注的是最终画面不是情节。每个redo记录都有一个全局递增的序列号达梦里叫LSNLog Sequence Number。LSN的作用非常关键它是崩溃恢复的“进度尺”。数据库启动恢复时只需要从某个LSN开始往后重放redo就能把数据恢复到崩溃前的状态。redo在写入时也不是一条一条直接落盘的。事务执行过程中生成的redo先写进内存里的日志缓冲区提交时才刷盘。这里有一个核心矛盾刷盘越频繁数据越安全但性能越差。达梦的RLOG_APPEND_MODE参数就是干这个用的它控制日志刷盘的模式实时刷和批量刷的吞吐量能差出好几倍。2.2 组提交与提交延迟如果你的应用有很多小事务每个事务提交都要同步刷一次磁盘那么磁盘的fsync次数会非常高性能瓶颈几乎全卡在日志刷盘上。针对这个问题达梦做了组提交Group Commit多个事务的提交操作合并成一次日志刷盘让一次IO服务多个事务。组提交的代价是单个事务的提交延迟会略有增加但整体吞吐量大幅提升。对于OLTP业务来说这个取舍基本是赚的。实际操作中如果你发现达梦的系统负载不高、但事务提交特别慢可以先看看redo日志是不是在单线程刷盘、RLOG_APPEND_MODE参数是不是设置得太保守。另外达梦每个redo日志文件写满后会切换到下一个文件全部写满之后会覆盖最早的文件非归档模式下。这里有个坑如果redo日志文件配得太小系统会频繁切换日志文件每次切换都可能触发检查点或归档操作对整个数据库产生周期性的性能抖动。你在日志里如果看到规律性的“日志切换导致等待”优先考虑加大redo日志文件尺寸而不是盲目调其他参数。2.3 归档模式下redo的特殊待遇达梦开启归档模式后ARCH_INI参数相关配置redo日志文件在被覆盖之前会由归档进程复制一份到归档目录。归档日志的意义在于当你把数据文件恢复到过去某个时间点时光有数据文件备份不够还需要从那次备份后到崩溃前的所有归档日志配合起来做“不完全恢复”。这里给个判断经验如果归档目录所在磁盘IO性能跟不上redo的生成速度数据库会出现“归档等待”的状态表现为写入STALL、连接堆积。遇到这种问题优先把归档目录放到独立的、更快的磁盘上再考虑调整redo文件大小来降低日志切换频率。生产环境里我见过好几次因为归档和redo放在同一个盘上导致的性能事故属于比较典型的低级错误。3. undo回滚与读一致性的隐藏功臣如果说redo是数据库的“后悔药”那undo就是事务的“时光机”。redo保证已提交事务的数据不丢失undo则负责让未提交事务的修改能被撤销同时为MVCC提供历史版本数据。3.1 undo记录里存的到底是什么undo记录保存的是“修改前的旧值”。拿前面的例子来说把name从张三改成李四时undo里记的是张三。一个数据行被反复修改多次时undo记录会串成一条版本链每个版本都指向更早的一个版本。这条链正是后面MVCC构造历史快照的关键。很多人容易忽略的一点是undo记录本身也是数据页修改undo页同样会产生redo日志。也就是说在崩溃恢复时redo不仅要重放数据页的变更还要重放undo页的变更。只有把undo页恢复出来系统才能找到“哪些事务还没提交”的完整信息从而执行回滚操作。这个逻辑解答了一个很常见的疑问——为什么说“undo是受redo保护的”因为它本质上也是数据。达梦的undo表空间是系统自动管理的。UNDO_RETENTION参数控制undo保留时间单位是秒。这个参数直接决定MVCC能“看到”多远的旧版本。3.2 为什么崩溃恢复要先重放redo再回滚undo数据库崩溃恢复的顺序不是先回滚未提交事务而是先redo所有变更、再undo未提交的事务。为什么不先撤销未提交的修改因为你在恢复开始时根本不知道哪些事务提交了、哪些没提交——这些信息在日志里而且必须通过重放redo才能建立完整的事务状态列表。打个比方恢复过程就像考古修复现场。你先通过redo把现场完整“复原”到崩溃那一刻的样子不管是提交的还是没提交的都恢复然后才拿着事务清单去甄别哪些事务已经提交了保留哪些事务还没提交用undo把它们撤销掉。这个“先整体复原、再逐个甄别”的顺序是数据库恢复算法的通用套路达梦也不例外。理解了这个顺序你就能读懂达梦启动日志里那些“Redo started”“Undo started”之类的信息到底在干什么。3.3 undo表空间膨胀的排查与处理生产环境里最常见的undo问题就是表空间膨胀。undo记录不能永远保留事务提交后它产生的undo记录理论上就可以被清理了。但如果存在长事务事务运行期间产生的undo记录必须一直保留到事务结束否则该事务自己回滚时就没有数据可用了。还有一类情况如果UNDO_RETENTION设置得很大且系统里查询类会话长时间持有一个MVCC快照也不释放旧版本数据就一直不能被清理undo表空间就只增不减。这种问题从AWR或动态性能视图里能看到undo表空间使用率持续高位的迹象。处理建议分两步第一步检查是否有长时间运行的查询或未提交事务把它们处理掉让undo能被正常回收第二步再评估UNDO_RETENTION是否设置得过于激进。不要上来就扩容undo表空间那只是把症状往后推根本问题没解决迟早还会爆。4. MVCC为什么有人在达梦上看到了“幻读”又有人说不会MVCC全称多版本并发控制Multi-Version Concurrency Control核心思路是读写不互相阻塞。写事务修改数据时读事务去读这个数据的旧版本彼此各看各的谁也不用等谁。达梦的MVCC实现和MySQL InnoDB的思路非常接近但很多从Oracle过来的人会感到有点别扭因为它的可见性判断逻辑更依赖undo版本链。4.1 快照读和当前读两个完全不同的世界MVCC把查询分成了两类。普通SELECT走的是快照读它读取的是基于某个时间点生成的快照看到的是那一个瞬间的数据版本不感知之后发生的修改。而UPDATE、DELETE、SELECT ... FOR UPDATE这类语句走的是当前读它们必须读取数据的最新已提交版本并且在这个行上加锁防止其他事务同时修改。这个区分极其重要因为开发人员踩的坑大多来自这里。同一个事务里如果你先执行了一个普通SELECT之后另一个事务修改并提交了同一行数据你再执行普通SELECT在READ COMMITTED级别下可能看到新值在READ REPEATABLE级别下可能还是旧值但如果你执行的是SELECT ... FOR UPDATE你拿到的一定是最新已提交版本。这就导致一个很诡异的场景同一事务里快照读和当前读看到的同一行数据竟然不一样。4.2 undo版本链上的CR块构造快照读是怎么看到旧版本的全靠undo版本链。当一个数据页被多个事务反复修改时每一条undo记录都保存了一个旧版本。读取时数据库根据当前事务的快照信息哪些事务在我开始前已经提交、哪些还在活跃状态沿着版本链往前找找到一个“在快照时刻是可见的”版本然后在内存里构造出一个历史版本的数据页这个页就是CR块Consistent Read block一致读块。整个过程对应用是透明的你执行一条SELECT数据库在后台可能已经悄悄构造了好几个历史版本。构造CR块需要undo记录存在如果undo记录已经被清理掉了数据库就无法完成这个操作。你可能会在应用日志里看到类似“snapshot too old”的报错本质上就是undo保留时间不够、旧版本被过早清掉了。优化方向有两个加大UNDO_RETENTION或者检查应用是否持有过长时间的MVCC快照。4.3 SELECT ... FOR UPDATE 到底读不读MVCC快照直接给结论FOR UPDATE属于当前读不走MVCC快照。你执行SELECT * FROM t WHERE id1 FOR UPDATE时数据库读取的是该行最新的已提交版本并且会在这个行上加锁通常是行级排他锁。其他事务如果想修改这一行就得等你提交或回滚。为什么会有人产生“FOR UPDATE读取了MVCC吗”这样的疑问原因在于很多人在测试时看到FOR UPDATE能读到“其他事务已经提交的新值”而普通SELECT读到的却是旧值于是产生困惑。实际上这恰恰说明了MVCC里“快照读”和“当前读”的区别前者看历史后者看现实。FOR UPDATE在达梦里的行为和它在Oracle、MySQL里的行为基本一致属于标准的锁定读。4.4 达梦和MySQL在MVCC上的差异简表对比维度达梦数据库MySQL InnoDB默认隔离级别READ COMMITTED读提交REPEATABLE READ可重复读快照读实现基于undo版本链构造CR块基于undo版本链构造Read View当前读实现读取最新已提交版本并加锁读取最新已提交版本并加锁版本链存放位置undo表空间undo日志回滚段锁等待检测支持出现死锁会报错支持出现死锁会报错注意默认隔离级别这一行。达梦默认是读提交这意味着同一个事务里两条普通SELECT之间如果其他事务提交了修改第二条SELECT可能会看到新值。这跟MySQL默认的可重复读行为不一样。从MySQL迁到达梦的应用如果代码里隐式依赖了“事务内多次读取结果一致”这个特性迁移之后可能会出现“莫名其妙读到新值”的诡异现象。开发侧的适配重点就是搞清楚每一类查询到底走的是快照读还是当前读。5. 检查点决定崩溃恢复要重放多少redo前面说崩溃恢复会从某个LSN开始重放redo那这个“开始位置”是谁定的答案是检查点Checkpoint。检查点机制的本质就是在日志序列上刻画一条“安全线”这条线之前的所有数据页修改都已经被刷到磁盘上了所以恢复时不需要往前重放只需要从这条线开始往后重放redo即可。5.1 为什么不能直接等到刷盘才安全有人会问既然检查点要刷脏页为什么不在事务提交时直接把数据页刷下去省得恢复答案是性能和经验的权衡。数据页的刷盘是随机IO而redo日志的刷盘是顺序IO。顺序IO比随机IO快一到两个数量级。让用户的每次提交都承受随机IO的代价OLTP系统基本就瘫痪了。检查点的存在就是为了“错峰”平时让redo顺序写、扛住提交压力后台再慢慢把脏页刷下去让这个“安全线”不断往前推进。5.2 全量检查点、增量检查点与日志截断达梦检查点分为两种思路全量检查点会把所有脏页全部刷下去日志的安全线可以直接推进到当前最新LSN增量检查点则是分批刷盘每次只刷一部分脏页避免一次性产生巨大的IO尖峰。CKPT_INTERVAL控制检查点触发的间隔时间CKPT_DIRTY_PAGES控制每次检查点刷盘的脏页数量上限。这两个参数配合起来决定了一个检查点周期内刷多少页、花多长时间。日志截断跟检查点位置直接相关。非归档模式下redo日志文件只能覆盖掉检查点之前的日志段。如果检查点迟迟不推进redo日志的文件空间就无法释放最终会导致数据库停止写入。你可能遇到过“数据库突然连不上”“写入卡死”这类现象其中有一种可能性就是大量的脏页堆积检查点刷不过来redo日志被填满后数据库不再接受新事务。这种场景下问题不在应用而在刷盘能力和日志空间配置。5.3 检查点频率怎么调快了刷盘费IO慢了恢复费时间运维调优检查点参数本质上是在两个坏结果之间选一个轻的。检查点太频繁脏页刚被修改就急着刷盘系统的大量IO都消耗在重复刷差不多的页面上整体性能下降磁盘压力大。检查点太稀疏崩溃恢复时需要的redo区间就很长恢复时间MTTR成倍增加业务中断时间变长。我的调优经验是如果业务对恢复时间比较敏感比如金融、交易类宁可让检查点频繁一点牺牲一些日常IO如果业务允许崩溃后花较长时间恢复、但对日常性能要求高那可以把检查点间隔调大一些让日常IO压力降下来。不要一上来就追求“极端”先用默认参数跑一阵观察日志里的检查点执行频率、脏页累计量、redo日志切换频率三个指标再决定往哪个方向调。5.4 检查点与备份、正常停机的关系还有一个容易被忽视的关联点正常关闭数据库时达梦会执行一次全量检查点。所以在正常shutdown之后所有脏页都已经落盘下次启动时不需要做崩溃恢复启动速度很快。而如果你用kill -9之类的方式强制杀掉进程相当于制造了一次“异常崩溃”下次启动就必须从上次检查点位置开始恢复redo。做物理备份的时候也同理。如果备份过程能跟一个检查点结合起来备份集的一致性会更好恢复时也更简单。日常维护中我喜欢在备份脚本前加一个日志归档触发或手动触发检查点这样备份文件之间的时间点更干净后续做时间点恢复时不容易出现日志断档。6. 内存缓存与刷盘性能与安全的平衡术6.1 Buffer Pool的组织方式与脏页的产生达梦把数据文件的内容缓存在内存的Buffer Pool里读写都优先走内存。查询时如果目标数据页在Buffer Pool里直接读内存不在则从磁盘加载同时可能淘汰一些旧页面。你执行一条UPDATE时修改发生在Buffer Pool里的数据页上。此时内存里的页面版本比磁盘新这个页面就是“脏页”。脏页不会立刻写回磁盘而是等着后台机制来刷。这套缓冲机制把磁盘随机IO挡在了应用路径之外是数据库能扛住高并发写入的根本原因。Buffer Pool是否够用直接影响业务性能。达梦里BUFFER_NUM决定缓冲区页数BUFFER_POOLS决定缓冲区分区数量。如果Buffer Pool明显偏小命中率低大量查询都要穿透到磁盘IO整体性能会被拖垮。判断方法很朴素看数据库的缓冲区命中率指标如果长期低于90%说明内存给少了。6.2 脏页刷盘的几种触发机制脏页刷盘不是一个单一动作而是由多路机制共同触发的检查点刷盘这是最核心的机制由CKPT_INTERVAL和CKPT_DIRTY_PAGES驱动定期把指定数量的脏页刷到磁盘。脏页比例触发当脏页在Buffer Pool中的占比超过某个阈值时系统会启动异步刷盘防止脏页堆积过多。日志切换触发redo日志切换时有时会联动触发检查点确保日志空间可以安全复用。正常停机触发shutdown时把所有脏页都强制刷下去保证下次启动干净利落。6.3 为什么数据库不直接同步写磁盘你可能会想既然脏页刷盘这么麻烦为什么不直接同步写磁盘一了百了原因前面提过性能和体验不允许。但这里要再说深一层同步写盘解决不了崩溃恢复问题。即使你每次都把数据页写进磁盘也不能保证磁盘上数据页的写入顺序和逻辑事务顺序完全一致。数据库在运行时可能同时改写几十个页面如果写到一半崩溃了磁盘上的数据页可能处于“一部分是新的、一部分是旧的”这种中间状态反而连最基本的一致性都保证不了。WAL加缓冲、再通过redo做恢复才能让数据库在任意崩溃点都有办法“找回自己”。所以大可不必担心内存里的脏页没刷下去数据会丢——只要redo日志在数据就能恢复redo日志本身才是那个绝对不能丢的东西。6.4 热点块冲突与并发写入优化Buffer Pool的另一个常见性能问题是热点块冲突。多个会话频繁修改同一个数据页比如并发更新同一行或同一范围内的数据会导致这些会话在Buffer Pool访问这个页面时互相排队。有时候你看到数据库CPU不高、IO也不忙但应用就是慢很可能就是这种锁竞争。这类问题的典型特征是等待事件集中在buffer相关等待上。处理方式不一定是加内存而是调整BUFFER_POOLS分区数让并发访问分散到更多的缓冲区内同时检查应用是否存在明显的数据热点比如疯狂更新同一行累计字段的写法这是更值得优化的方向。7. 故障恢复实验从实例崩溃到数据库起来的全过程最后把前面的机制串起来走一遍完整的故障恢复链路。虽然实验环境会“崩溃”但理解了整个过程之后你再看到生产库日志里的恢复信息心里就有底了。7.1 一次崩溃后达梦启动时到底做了什么实例崩溃后重启达梦内部按照固定顺序完成恢复定位检查点位置从控制文件和数据字典中找到最近一次检查点记录的LSN。恢复就从这里开始所以检查点越新需要重放的redo越少恢复越快。重放redo日志前滚从检查点LSN开始按顺序重放redo把崩溃时可能还没有落到磁盘的已提交事务“补写”出来。这一步会恢复数据页也会恢复undo页。识别未提交事务此时系统已经知道崩溃时有哪些事务处于活动状态没提交也没回滚。回滚未提交事务回滚借助undo记录把这些未提交的修改恢复成原状。这一步结束后数据库的一致性和崩溃前应用层看到的状态完全对齐。正常打开数据库对外提供服务。这个过程听起来简单实际耗时取决于两个因素检查点到日志尾部的距离redo重放量和未提交事务涉及的数据量undo回滚量。前者靠检查点频率控制后者靠应用的事务长度控制。7.2 通过日志确认恢复过程达梦实例启动时会在运行日志通常在安装目录的log子目录下文件名类似dm_DMSERVER_实例名.log中打印恢复过程的关键行。你可以搜索“checkpoint”“redo”“undo”等关键字确认恢复起点和结束位置。如果有“Redo ended successfully”之类的输出说明前滚阶段正常完成。曾经有个生产环境启动极慢我抓日志后确认是检查点太稀疏崩溃前积累了海量redo需要重放。调整CKPT_INTERVAL之后恢复时间从十几分钟降到了两分钟以内。这种经验说明恢复慢不一定是硬件问题多数情况下是参数配置和应用事务模式共同作用的结果。7.3 达梦、Oracle、MySQL在核心机制上的对照机制达梦数据库OracleMySQL InnoDB日志类型redo日志物理变更redo log物理变更redo log物理变更回滚数据undo表空间自动管理undo表空间自动管理undo log属于InnoDB内部默认隔离级别READ COMMITTEDREAD COMMITTEDREPEATABLE READMVCC实现基于undo版本链基于undo版本链 SCN快照基于undo版本链 Read View检查点形式参数控制支持增量检查点增量检查点 日志切换联动异步刷盘 检查点推进LSN崩溃恢复顺序先redo后undo先redo后undo先redo后undo这个对照表对做数据库迁移的人非常有用。你会发现达梦的整体结构和Oracle更接近语法、系统视图、DBA习惯上都能找到Oracle的影子但在隔离级别和MVCC的具体行为上又和MySQL有不少交集。迁移时不要只翻译SQL语法隔离级别差异、undo保留策略、检查点参数的初始值都值得重新评估一遍。我个人在实际调优中养成的习惯是不管换什么数据库先花半天时间把它的redo、undo、MVCC、检查点、缓冲机制这五件事想透彻再去看参数和报错。很多看似复杂的问题追到根上都落在这五个机制之间的联动关系上。达梦也是这样它的内在逻辑并不神秘只要顺着“写日志-改内存-刷脏页-做检查点-崩了能恢复”这条主线去理解绝大多数现象都能解释得通。