ITPub博客

首页 > 数据库 > MySQL > MySQL 8.4 Writeset 并行复制详解:last_committed 与 sequence_number 的计算机制

MySQL 8.4 Writeset 并行复制详解:last_committed 与 sequence_number 的计算机制

原创 MySQL 作者:贺子_DBA时代 时间:2026-11-12 00:27:57 0 删除 编辑

本文基于 MySQL 8.4 官方文档编写,重点厘清一个 极易踩坑的版本差异:在 MySQL 8.4 中, binlog_transaction_dependency_tracking 和 transaction_write_set_extraction 两个参数 已经被移除,服务器默认即启用 WRITESET 行为,不再需要(也无法)手动"开启"。


目录

    1. last_committed 与 sequence_number 到底是什么
    1. 澄清:sequence_number ≠ GTID 里的 uuid:序号
    1. writeset 模式下主库如何计算 last_committed
    1. binlog_transaction_dependency_history_size 的影响
      1. 在 MySQL 8.4 上如何"开启"与验证

        一、先纠正一个最常见的误区

        网上绝大多数关于 writeset 并行复制的文章都还停留在 MySQL 8.0 时代,会教你这样配置:

        -- MySQL 8.0 时代的写法(在 8.4 中已经失效!)
        SET GLOBAL binlog_transaction_dependency_tracking = 'WRITESET';
        SET GLOBAL transaction_write_set_extraction = 'XXHASH64';

        这两条在 MySQL 8.4 中会直接报错,因为这两个系统变量已经被移除了。

        根据官方文档 《What Is New in MySQL 8.4 since MySQL 8.0》:

        binlog_transaction_dependency_tracking:在 MySQL 8.0.35 和 8.2.0 中被标记为弃用(deprecated),在 MySQL 8.4.0 中被 移除。该变量及其功能已内建到服务器内部,不再提供替代变量。 从 MySQL 8.4 起,当使用多线程复制时,主库始终使用 writeset 来生成 binlog 中的依赖信息,其效果等同于在旧版本中把 binlog_transaction_dependency_tracking 设置为 WRITESET。

        同样, transaction_write_set_extraction 也已移除,writeset 的哈希算法 固定为 XXHASH64,不再可配置。

        所以结论是:

        版本

        需要手动配置吗

        说明

        MySQL 5.7

        需要

        默认 COMMIT_ORDER,需显式开启 WRITESET

        MySQL 8.0

        需要

        默认 COMMIT_ORDER,需显式设置 binlog_transaction_dependency_tracking=WRITESET

        MySQL 8.4

        不需要

        变量已移除,服务器 默认就使用 WRITESET 行为

        这也是为什么你的问题"writeset 怎么开启"在 8.4 语境下的正确答案是: 不需要开启,它已经是默认行为。你唯一还能配置的相关参数,就只剩下 binlog_transaction_dependency_history_size 了。


        二、last_committed 与 sequence_number 到底是什么

        在 binlog 中,每个事务都会被打上两个 逻辑时间戳(logical timestamp),它们写在事务开头那条 GTID 事件上。这两个值就是 sequence_number 和 last_committed。

        sequence_number:事务在 binlog 中的全局递增编号

        • 某个 binlog 文件中的 第一个事务, sequence_number = 1;
        • 第二个事务 = 2,依此类推;
        • 每换一个新的 binlog 文件,编号会从 1 重新开始。

        它本质就是一个单调递增的计数器,标识"这个事务是当前 binlog 里的第几个事务"。

        last_committed:当前事务依赖的最后一个事务编号

        last_committed 指的是 与当前事务发生冲突的、最近提交的那个事务的 sequence_number。它恒满足:

        last_committed < sequence_number

        它的语义是: 当前事务必须等待 sequence_number = last_committed 的那个事务执行完之后,才能被执行。

        两者如何配合实现并行复制

        从库的 MTS(多线程复制)协调线程读的就是这两个值,判断哪些事务可以并行回放:

        • 并行原则:两个事务如果拥有 相同的 last_committed,说明它们彼此之间没有依赖关系(都不依赖对方),就可以被并行执行。
        • 串行约束:如果事务 B 的 last_committed 指向了事务 A 的 sequence_number,那么 B 必须排在 A 之后执行。

        用一个例子说明,假设主库 binlog 中有这样一串事务:

        seq=1  last_committed=0   ──┐
        seq=2  last_committed=1     │  → 这 4 个事务 last_committed 相同,
        seq=3  last_committed=1     │    说明彼此无冲突,从库可以并行执行
        seq=4  last_committed=1   ──┘
        seq=5  last_committed=4   ──→ 依赖 seq=4,必须等 4 执行完

        last_committed 的值越小(越靠前),意味着该事务可以越早、越并行地被回放;反之 last_committed 越接近 sequence_number,并行度越低。

        直观理解: last_committed 本质上是一个"依赖屏障"——它之前的那些事务里,凡是没被它依赖的,它都可以跟它们并行;它真正必须等的,是那个"拦路"的冲突事务。


        三、澄清:sequence_number ≠ GTID 里的 uuid:序号

        这是最容易混淆的一处,必须单独拎出来讲清楚: binlog 里的 sequence_number 和 GTID 里的 uuid:序号 是两码事,只是碰巧都叫"sequence number"。

        对比表


        binlog 的 sequence_number(逻辑时间戳)

        GTID 里的 uuid:序号

        全称/出处

        事务在 binlog 中的逻辑时间戳,写在 GTID 事件头里

        GTID 的后半部分,格式 server_uuid:transaction_id

        作用域

        每个 binlog 文件内从 1 递增, 换文件就归零

        整个实例生命周期内单调递增,跨 binlog 文件持续

        用途

        配合 last_committed 做 MTS 并行复制的依赖判断

        全局唯一标识一个事务,用于故障切换、主从一致性校验

        是否重复

        会(不同 binlog 文件里都有 seq=1 的事务)

        不会( uuid:序号 全局唯一)

        谁能修改

        服务器内部计算,用户不可控

        事务提交时服务器自动分配

        它们写在同一个地方,容易看混

        用 mysqlbinlog 看,一个事务会同时出现这两样东西:

        #230101 12:00:00 server id 1  end_log_pos 1000
        #GTID [commit=...]
        SET @@SESSION.GTID_NEXT= 'a1b2c3d4-...-1234567890ab:17'/*!*/;
        # last_committed=3 sequence_number=18
        • a1b2c3d4-...:17 里的 17 是 GTID 的 transaction_id(这个实例上第 17 个事务),跟 binlog 文件名无关;
        • sequence_number=18 是 逻辑时间戳,是"当前这个 binlog 文件里的第 18 个事务",可能前一个 binlog 文件末尾也是 seq=18。

        为什么它们不相等

        假设主库切了 binlog 文件:

        mysql-bin.000001  ... GTID :100  →  sequence_number=1(文件内第 1 个)
        mysql-bin.000002  ... GTID :101  →  sequence_number=1(又是文件内第 1 个!)

        GTID 的序号从 100 连续涨到 101(全局唯一),但 binlog 的 sequence_number 在换文件后 重新归零到 1。这正是官方文档强调的:sequence_number 在每个 binlog 文件中编号重新从 1 开始。

        一句话记住

        • sequence_number(binlog 逻辑时间戳) → 只服务于并行复制的依赖判断,跟 last_committed 是一对;
        • GTID 的 uuid:序号 → 事务的全球唯一身份证,用于复制拓扑的定位和一致性。

        两者都叫 sequence,但一个管"这个文件里排第几",一个管"整个服务器上排第几、而且永不重复"。


        四、writeset 模式下主库如何计算 last_committed

        在 MySQL 8.4 中,依赖跟踪采用 WRITESET 算法(这是默认且唯一的行为)。要理解 last_committed 的计算,先理解"writeset(写集)"这个概念。

        什么是 writeset

        每个事务在提交时,会为它 修改过的每一行计算一组哈希值,构成这个事务的"写集"(write set)。官方文档对哈希的来源定义如下:

        事务中的每一行会向该事务的写集添加一个或多个哈希, 每个唯一键(unique key)对应一个哈希。如果该行没有任何非空的唯一键,则使用 整行数据的哈希。写集同时包含删除的行和插入的行;对于更新的行, 旧行和新行都会被包含。

        这些哈希由「库名 + 表名 + 唯一键/主键值」通过 XXHASH64 算法计算得出,用于唯一标识"这一行被改动了"。

        冲突判定规则

        两个事务 如果写集有交集(存在相同的哈希值),就判定为冲突,也就是它们修改了同一行,必须串行执行;写集完全不相交,则判定为独立,可以并行。

        主库维护的历史映射表

        主库在内存中维护一张 writeset 历史映射表,记录"最近修改过某一行数据的是哪个事务"。表的结构大致是:

        row_hash  →  sequence_number(最后一次修改该行的事务编号)

        历史映射表的官方源码里  m_writeset_history 的类型定义是:

        using Writeset_history = ankerl::unordered_dense::map<uint64, int64>;

        它是一个以  行哈希(uint64)为 key 的 map。map 的特性决定了: 同一个 key(同一行)只能有一个条目,不可能存在两条相同 key 的映射。

        last_committed 的计算过程

        当一个事务提交时,主库按以下步骤计算它的 last_committed:

        1. 初始化:把 last_committed 设为一个"历史起点值",记为 m_writeset_history_start,即当前内存历史映射表中 最小的 sequence_number。
        1. 遍历该事务的写集,对写集中的每一个行哈希:
          • 如果这个哈希 在历史表中不存在(说明这行之前没被改过),就把 <hash, 当前事务的 sequence_number> 插入历史表;
          • 如果这个哈希 在历史表中已经存在(说明之前有事务改过同一行),则取 last_committed = max(last_committed, 历史表中该 hash 对应的 sequence_number),并把该 hash 对应的 sequence_number 更新为当前事务的编号。
        1. 与 COMMIT_ORDER 结果取交集:如果事务满足"所有涉及的表都有主键或唯一索引"等条件,则最终取 writeset 算法算出的值和 COMMIT_ORDER 算法算出的值中较大的那个作为最终的 last_committed(等价于两者中更保守的那个,保证不遗漏依赖)。

        核心逻辑一句话总结:

        last_committed = 该事务写集里每一行,在历史表中"上一次被哪个事务改过"的那个 sequence_number 的最大值。

        也就是说,它依赖的,就是"改过同一批行的、编号最大的那个历史事务"。

        一个具体例子

        假设有 4 个事务依次提交,都改同一张 orders 表:

        事务

        修改的行(简化)

        sequence_number

        计算出的 last_committed

        T1

        主键 id=1

        1

        0(历史为空,无依赖)

        T2

        主键 id=2

        2

        0(id=2 从未被改过,与 T1 无冲突)

        T3

        主键 id=1

        3

        1(id=1 上次被 T1 改过,依赖 T1)

        T4

        主键 id=3

        4

        0(id=3 从未被改过)

        结果:T2、T4 与 T1 的 last_committed 都是 0,彼此无冲突,从库可以把它们和 T1 一起并行回放;而 T3 因为又改了 id=1,必须等 T1 完成。 writeset 的威力就在这里:哪怕主库并发很低、这些事务根本没有发生组提交,也能通过"行级冲突分析"创造并行机会。


        五、binlog_transaction_dependency_history_size 的影响

        binlog_transaction_dependency_history_size 是 MySQL 8.4 中 唯一保留的、可配置的 writeset 相关参数。

        参数定义

        属性

        值

        作用范围

        Global

        是否动态

        是

        默认值

        25000

        最小值

        1

        最大值

        1000000

        单位

        行哈希(row hash)的个数

        官方定义:

        该变量设置 内存中保留的行哈希数量的上限,这些哈希用于查找"最近修改过某一行的事务"。一旦达到这个数量,历史记录就会被 清除(purge)。

        binlog_transaction_dependency_history_size 管的那张内存历史表,它的 存活范围就是一个 binlog 文件的生命周期。每当 binlog 发生 轮转(rotate)、开启一个新的 binlog 文件时,这张历史表会 被清空重建。

        所以任何时候,表里存的全都是 当前这一个 binlog 文件里的  row_hash → sequence_number 映射。而同一个 binlog 文件内, sequence_number 是严格从 1 递增、 不重复的——因此映射表的 key(sequence_number)天然唯一,不存在"分属不同 binlog 的重复编号"混在一起的情况。


        它对 last_committed 计算的影响

        这个参数直接决定了主库那张"历史映射表"能装多少条目,进而影响 last_committed 的取值:

        1. 影响历史表的存活窗口

        历史表存的是 <row_hash → sequence_number> 映射。它保留的哈希越多,"改过同一行的事务"能被追溯得越久。一旦历史被清除,那些较早事务的依赖信息就丢了。

        2. 触发"序列化点"(serialization point)

        官方文档明确指出,writeset 算法存在 周期性的序列化点,其中一个触发条件就是:

        如果自上一个序列化点以来,提交的事务累计产生的 唯一哈希数达到了 binlog_transaction_dependency_history_size,就会强制插入一个序列化点。

        序列化点的含义是: 它之后的所有事务,都被视为与它之前的所有事务冲突。这会导致跨越序列化点的事务 last_committed 被"拉高",并行度瞬时下降。

        3. 历史被清除后的依赖丢失

        当历史表满了、旧条目被清除后,如果一个新事务修改的行,其"上一次修改事务"已经被清除出历史表,那么 writeset 算法 无法再检测到这个依赖。此时会退回到更保守的 COMMIT_ORDER 方式来计算 last_committed(取两者中较大值),确保正确性不受影响,但并行度会降低。

        参数大小的权衡

        取值

        效果

        代价

        调大(如 500000+)

        历史窗口更长,能更久地追溯到"上次改同一行的事务",并行度更高,序列化点更少

        占用更多内存(每个哈希约几十字节量级)

        调小(如 1000)

        省内存

        历史很快被清除,依赖频繁丢失 → 频繁退化到 COMMIT_ORDER,序列化点更密集,并行度下降

        实践建议:

        • 对于 写热点分散、大表、高并发的业务,可以适当调大,让 writeset 的历史窗口覆盖更长的提交序列,充分释放并行度;
        • 内存紧张的环境,保持默认 25000 通常足够;
        • 需要关注的是,如果业务大量修改 无主键表或 带外键的表(见下一节),调大这个参数收益有限,因为 writeset 算法本身就没法完整工作。

        六、几个必须知道的退化场景

        writeset 算法并非对所有事务都生效。官方文档列出的情况,会让主库 退回使用 COMMIT_ORDER 来计算依赖,也就是退化成"按组提交顺序"的保守模式:

        1. 空写集或部分写集的事务(例如事务里没有修改行);
        1. 修改没有主键或唯一索引的表——没有唯一键可哈希,只能用整行哈希,且此类表易退化为 COMMIT_ORDER;
        1. 更新了外键关系中的父表;
        1. DDL 语句——本身就是序列化点;
        1. 上一节提到的 历史表被清除 / 达到 history_size 阈值。

        这些场景的共同后果是: 相关事务的 last_committed 会被按 COMMIT_ORDER 方式计算(值偏大、偏保守),并行度下降。这也解释了为什么"单库大量无主键表"的业务,即使开了 8 个 worker,并行回放效果仍然很差——不是配置错,是业务数据模型不支持 writeset。


        七、在 MySQL 8.4 上如何"开启"与验证

        无需"开启",只需验证默认行为已生效

        在 8.4 上,你不需要设置任何东西来启用 writeset。可以这样确认两个已移除的变量确实不存在:

        -- 会报错:Unknown system variable
        SELECT @@binlog_transaction_dependency_tracking;
        SELECT @@transaction_write_set_extraction;

        而唯一保留的参数可以直接查看和调整:

        -- 查看当前历史大小(默认 25000)
        SELECT @@global.binlog_transaction_dependency_history_size;
        -- 如需调大,动态设置
        SET GLOBAL binlog_transaction_dependency_history_size = 100000;

        验证并行复制的效果

        从库侧仍需配置 MTS 相关参数(这些在 8.4 中依然需要):

        -- 从库:启用逻辑时钟并行 + 多 worker
        SET GLOBAL replica_parallel_type = 'LOGICAL_CLOCK';
        SET GLOBAL replica_parallel_workers = 8;
        SET GLOBAL replica_preserve_commit_order = ON;

        用 mysqlbinlog 直观查看 last_committed

        要验证主库确实在为事务打依赖标签,可以用 mysqlbinlog 解析 binlog:

        mysqlbinlog --base64-output=DECODE-ROWS -v mysql-bin.000001

        在 GTID 事件里能看到类似输出:

        # at 1000
        #GTID ... 
        SET @@SESSION.GTID_NEXT= 'xxxxxxxx-...:1'/*!*/;
        # last_committed=0 sequence_number=1

        观察不同事务的 last_committed 是否大量相同(相同即可并行)、 sequence_number 是否连续递增,即可判断 writeset 是否在正常工作。


        八、总结

        问题

        答案

        writeset 怎么开启?

        MySQL 8.4 无需开启,服务器默认即 WRITESET 行为; binlog_transaction_dependency_tracking 和 transaction_write_set_extraction 已被移除

        sequence_number 是什么?

        每个 binlog 文件中事务的全局递增编号,第一个事务为 1,换文件重新从 1 计( 注意:不是 GTID 里的 uuid:序号)

        last_committed 是什么?

        与当前事务冲突的、最近提交事务的 sequence_number,恒小于 sequence_number,代表"依赖屏障"

        主库怎么算 last_committed?

        遍历事务写集,在内存历史表中查找"上次改同一行的事务编号",取最大值;再与 COMMIT_ORDER 结果取保守值

        history_size 有什么影响?

        决定历史表能保留多少个行哈希,影响依赖可追溯的窗口长度,并在达到阈值时强制插入序列化点、清空历史

        核心记住三点即可:

        1. 8.4 的 writeset 是"内置默认",不是"需要开启的开关";
        1. last_committed 的语义是"我能和谁并行、必须等谁",值越小并行度越高;
        1. binlog_transaction_dependency_history_size 是你在 8.4 上唯一还能调的 writeset 相关参数,它管的是"主库能记住多远的行级依赖"。

        参考文档


        来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/29654823/viewspace-3188656/ ,如需转载,请注明出处,否则将追究法律责任。

        下一篇: 没有了~
        请登录后发表评论 登录
        全部评论
        10年数据库管理员从业经历,熟悉Oracle、mysql、tidb、oceanbase、redis、 mongodb 、python等技术栈,持有11g ocm 、mysql 5.7 ocp 、tidb 5.0 ptcp、tidb 6.0 ptca、oceanbase obcp等

        注册时间:2014-05-12

        • 博文量
          328
        • 访问量
          2153020